Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d5b3bfd9 | ||
|
|
f15b76fba8 | ||
|
|
61f457736e | ||
|
|
5de1447ff5 |
@@ -130,39 +130,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
"Install it with: pip install sentence-transformers"
|
||||
)
|
||||
|
||||
# Note: We use CPU even when GPU/MPS is available because:
|
||||
# 1. The reranker model (MiniLM) is tiny (~22M params)
|
||||
# 2. Batch sizes are small (~100-200 pairs)
|
||||
# 3. Data transfer overhead to GPU outweighs compute benefit
|
||||
# 4. CPU inference is actually faster for this workload
|
||||
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
|
||||
|
||||
# Determine device and device_map based on hardware and installed packages.
|
||||
# When accelerate is installed but no GPU/MPS is available, transformers can
|
||||
# incorrectly use lazy loading (meta tensors) which fails on .to(device).
|
||||
# We use device_map="cpu" in that case to force direct CPU loading.
|
||||
import torch
|
||||
|
||||
try:
|
||||
import accelerate # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
accelerate_available = True
|
||||
except ImportError:
|
||||
accelerate_available = False
|
||||
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
|
||||
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
device_map = None
|
||||
elif accelerate_available:
|
||||
device = "cpu"
|
||||
device_map = "cpu" # Force direct CPU loading to avoid meta tensors
|
||||
else:
|
||||
device = "cpu"
|
||||
device_map = None
|
||||
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False, "device_map": device_map},
|
||||
)
|
||||
self._model = CrossEncoder(self.model_name)
|
||||
|
||||
# Initialize shared executor (limited workers naturally limits concurrency)
|
||||
if LocalSTCrossEncoder._executor is None:
|
||||
|
||||
@@ -128,37 +128,11 @@ class LocalSTEmbeddings(Embeddings):
|
||||
)
|
||||
|
||||
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
|
||||
|
||||
# Determine device and device_map based on hardware and installed packages.
|
||||
# When accelerate is installed but no GPU/MPS is available, transformers can
|
||||
# incorrectly use lazy loading (meta tensors) which fails on .to(device).
|
||||
# We use device_map="cpu" in that case to force direct CPU loading.
|
||||
import torch
|
||||
|
||||
try:
|
||||
import accelerate # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
accelerate_available = True
|
||||
except ImportError:
|
||||
accelerate_available = False
|
||||
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
|
||||
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
device_map = None
|
||||
elif accelerate_available:
|
||||
device = "cpu"
|
||||
device_map = "cpu" # Force direct CPU loading to avoid meta tensors
|
||||
else:
|
||||
device = "cpu"
|
||||
device_map = None
|
||||
|
||||
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
|
||||
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
|
||||
self._model = SentenceTransformer(
|
||||
self.model_name,
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False, "device_map": device_map},
|
||||
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
|
||||
)
|
||||
|
||||
self._dimension = self._model.get_sentence_embedding_dimension()
|
||||
|
||||
@@ -473,6 +473,29 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
_current_schema.set(tenant_context.schema_name)
|
||||
return tenant_context.schema_name
|
||||
|
||||
async def _handle_access_count_update(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Handler for access count update tasks.
|
||||
|
||||
Args:
|
||||
task_dict: Dict with 'node_ids' key containing list of node IDs to update
|
||||
|
||||
Raises:
|
||||
Exception: Any exception from database operations (propagates to execute_task for retry)
|
||||
"""
|
||||
node_ids = task_dict.get("node_ids", [])
|
||||
if not node_ids:
|
||||
return
|
||||
|
||||
pool = await self._get_pool()
|
||||
# Convert string UUIDs to UUID type for faster matching
|
||||
uuid_list = [uuid.UUID(nid) for nid in node_ids]
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
f"UPDATE {fq_table('memory_units')} SET access_count = access_count + 1 WHERE id = ANY($1::uuid[])",
|
||||
uuid_list,
|
||||
)
|
||||
|
||||
async def _handle_batch_retain(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Handler for batch retain tasks.
|
||||
@@ -774,7 +797,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
Args:
|
||||
task_dict: Task dictionary with 'type' key and other payload data
|
||||
Example: {'type': 'batch_retain', 'bank_id': '...', 'contents': [...]}
|
||||
Example: {'type': 'access_count_update', 'node_ids': [...]}
|
||||
"""
|
||||
task_type = task_dict.get("type")
|
||||
operation_id = task_dict.get("operation_id")
|
||||
@@ -799,7 +822,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Continue with processing if we can't check status
|
||||
|
||||
try:
|
||||
if task_type == "batch_retain":
|
||||
if task_type == "access_count_update":
|
||||
await self._handle_access_count_update(task_dict)
|
||||
elif task_type == "batch_retain":
|
||||
await self._handle_batch_retain(task_dict)
|
||||
elif task_type == "refresh_mental_models":
|
||||
await self._handle_refresh_mental_models(task_dict)
|
||||
@@ -2262,6 +2287,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
text=sr.retrieval.text,
|
||||
context=sr.retrieval.context or "",
|
||||
event_date=sr.retrieval.occurred_start,
|
||||
access_count=sr.retrieval.access_count,
|
||||
is_entry_point=(sr.id in [ep.node_id for ep in tracer.entry_points]),
|
||||
parent_node_id=None, # In parallel retrieval, there's no clear parent
|
||||
link_type=None,
|
||||
@@ -2273,6 +2299,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
final_weight=sr.weight,
|
||||
)
|
||||
|
||||
# Step 8: Queue access count updates for visited nodes
|
||||
visited_ids = list(set([sr.id for sr in scored_results[:50]])) # Top 50
|
||||
if visited_ids:
|
||||
await self._task_backend.submit_task(
|
||||
{
|
||||
"type": "access_count_update",
|
||||
"bank_id": bank_id,
|
||||
"node_ids": visited_ids,
|
||||
}
|
||||
)
|
||||
log_buffer.append(f" [7] Queued access count updates for {len(visited_ids)} nodes")
|
||||
|
||||
# Log fact_type distribution in results
|
||||
fact_type_counts = {}
|
||||
for sr in top_scored:
|
||||
|
||||
@@ -441,7 +441,7 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
|
||||
# Concise extraction prompt (default) - selective, high-quality facts
|
||||
CONCISE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
|
||||
|
||||
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions, and other output MUST be in the SAME language as the input. Do not translate to another language.
|
||||
LANGUAGE RULE (CRITICAL): Output facts in the EXACT SAME language as the input text. If input is Japanese, output Japanese. If input is Chinese, output Chinese. NEVER translate to English. Preserve original language completely.
|
||||
|
||||
{fact_types_instruction}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ async def insert_facts_batch(
|
||||
contexts = []
|
||||
fact_types = []
|
||||
confidence_scores = []
|
||||
access_counts = []
|
||||
metadata_jsons = []
|
||||
chunk_ids = []
|
||||
document_ids = []
|
||||
@@ -60,6 +61,7 @@ async def insert_facts_batch(
|
||||
fact_types.append(fact.fact_type)
|
||||
# confidence_score is only for opinion facts
|
||||
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
|
||||
access_counts.append(0) # Initial access count
|
||||
metadata_jsons.append(json.dumps(fact.metadata))
|
||||
chunk_ids.append(fact.chunk_id)
|
||||
# Use per-fact document_id if available, otherwise fallback to batch-level document_id
|
||||
@@ -74,16 +76,16 @@ async def insert_facts_batch(
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
$8::text[], $9::text[], $10::float[], $11::int[], $12::jsonb[], $13::text[], $14::text[], $15::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags_json)
|
||||
)
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id,
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
@@ -101,6 +103,7 @@ async def insert_facts_batch(
|
||||
contexts,
|
||||
fact_types,
|
||||
confidence_scores,
|
||||
access_counts,
|
||||
metadata_jsons,
|
||||
chunk_ids,
|
||||
document_ids,
|
||||
|
||||
@@ -162,7 +162,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
entry_points = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -216,7 +216,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
|
||||
mu.mentioned_at, mu.embedding, mu.fact_type,
|
||||
mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type,
|
||||
mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight, ml.link_type, ml.from_unit_id
|
||||
FROM {fq_table("memory_links")} ml
|
||||
|
||||
@@ -45,7 +45,7 @@ async def _find_semantic_seeds(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -168,7 +168,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
f"""
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
COUNT(*)::float AS score
|
||||
FROM {fq_table("unit_entities")} seed_ue
|
||||
@@ -193,7 +193,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
f"""
|
||||
SELECT DISTINCT ON (mu.id)
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight + 1.0 AS score
|
||||
FROM {fq_table("memory_links")} ml
|
||||
|
||||
@@ -449,7 +449,7 @@ async def fetch_memory_units_by_ids(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, embedding, fact_type, document_id, chunk_id, tags
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND fact_type = $2
|
||||
|
||||
@@ -116,7 +116,7 @@ async def retrieve_semantic(
|
||||
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -180,7 +180,7 @@ async def retrieve_bm25(
|
||||
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -237,7 +237,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
NULL::float AS bm25_score,
|
||||
'semantic' AS source,
|
||||
@@ -249,7 +249,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
AND (1 - (embedding <=> $1::vector)) >= 0.3
|
||||
{tags_clause}
|
||||
)
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked
|
||||
WHERE rn <= $4
|
||||
@@ -281,7 +281,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
NULL::float AS bm25_score,
|
||||
'semantic' AS source,
|
||||
@@ -294,7 +294,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
{tags_clause}
|
||||
),
|
||||
bm25_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
NULL::float AS similarity,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $5)) AS bm25_score,
|
||||
'bm25' AS source,
|
||||
@@ -306,12 +306,12 @@ async def retrieve_semantic_bm25_combined(
|
||||
{tags_clause}
|
||||
),
|
||||
semantic AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked WHERE rn <= $4
|
||||
),
|
||||
bm25 AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM bm25_ranked WHERE rn <= $4
|
||||
)
|
||||
@@ -386,7 +386,7 @@ async def retrieve_temporal_combined(
|
||||
entry_points = await conn.fetch(
|
||||
f"""
|
||||
WITH ranked_entries AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, embedding <=> $1::vector) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
@@ -406,7 +406,7 @@ async def retrieve_temporal_combined(
|
||||
AND (1 - (embedding <=> $1::vector)) >= $6
|
||||
{tags_clause}
|
||||
)
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags, similarity
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags, similarity
|
||||
FROM ranked_entries
|
||||
WHERE rn <= 10
|
||||
""",
|
||||
@@ -486,7 +486,7 @@ async def retrieve_temporal_combined(
|
||||
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight, ml.link_type, ml.from_unit_id,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_links")} ml
|
||||
@@ -610,7 +610,7 @@ async def retrieve_temporal(
|
||||
|
||||
entry_points = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -691,7 +691,7 @@ async def retrieve_temporal(
|
||||
# Batch fetch all neighbors for this batch of nodes
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
|
||||
ml.weight, ml.link_type, ml.from_unit_id,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_links")} ml
|
||||
@@ -1023,7 +1023,7 @@ async def _get_temporal_entry_points(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
embedding, fact_type, document_id, chunk_id,
|
||||
access_count, embedding, fact_type, document_id, chunk_id,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
|
||||
@@ -65,6 +65,31 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
|
||||
return 1.0 / (1.0 + math.log1p(normalized_age))
|
||||
|
||||
|
||||
def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float:
|
||||
"""
|
||||
Calculate frequency weight based on access count.
|
||||
|
||||
Frequently accessed memories are weighted higher.
|
||||
Uses logarithmic scaling to avoid over-weighting.
|
||||
|
||||
Args:
|
||||
access_count: Number of times the memory was accessed
|
||||
max_boost: Maximum multiplier for frequently accessed memories
|
||||
|
||||
Returns:
|
||||
Weight between 1.0 and max_boost
|
||||
"""
|
||||
import math
|
||||
|
||||
if access_count <= 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic scaling: log(access_count + 1) / log(10)
|
||||
# This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0
|
||||
normalized = math.log(access_count + 1) / math.log(10)
|
||||
return 1.0 + min(normalized, max_boost - 1.0)
|
||||
|
||||
|
||||
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
|
||||
"""
|
||||
Calculate a single temporal anchor point from a temporal range.
|
||||
|
||||
@@ -85,6 +85,7 @@ class NodeVisit(BaseModel):
|
||||
text: str = Field(description="Memory unit text content")
|
||||
context: str = Field(description="Memory unit context")
|
||||
event_date: datetime | None = Field(default=None, description="When the memory occurred")
|
||||
access_count: int = Field(description="Number of times accessed before this search")
|
||||
|
||||
# How this node was reached
|
||||
is_entry_point: bool = Field(description="Whether this is an entry point")
|
||||
|
||||
@@ -136,6 +136,7 @@ class SearchTracer:
|
||||
text: str,
|
||||
context: str,
|
||||
event_date: datetime | None,
|
||||
access_count: int,
|
||||
is_entry_point: bool,
|
||||
parent_node_id: str | None,
|
||||
link_type: Literal["temporal", "semantic", "entity"] | None,
|
||||
@@ -154,6 +155,7 @@ class SearchTracer:
|
||||
text: Memory unit text
|
||||
context: Memory unit context
|
||||
event_date: When the memory occurred
|
||||
access_count: Access count before this search
|
||||
is_entry_point: Whether this is an entry point
|
||||
parent_node_id: Node that led here (None for entry points)
|
||||
link_type: Type of link from parent
|
||||
@@ -192,6 +194,7 @@ class SearchTracer:
|
||||
text=text,
|
||||
context=context,
|
||||
event_date=event_date,
|
||||
access_count=access_count,
|
||||
is_entry_point=is_entry_point,
|
||||
parent_node_id=parent_node_id,
|
||||
link_type=link_type,
|
||||
|
||||
@@ -46,6 +46,7 @@ class RetrievalResult:
|
||||
mentioned_at: datetime | None = None
|
||||
document_id: str | None = None
|
||||
chunk_id: str | None = None
|
||||
access_count: int = 0
|
||||
embedding: list[float] | None = None
|
||||
tags: list[str] | None = None # Visibility scope tags
|
||||
|
||||
@@ -70,6 +71,7 @@ class RetrievalResult:
|
||||
mentioned_at=row.get("mentioned_at"),
|
||||
document_id=row.get("document_id"),
|
||||
chunk_id=row.get("chunk_id"),
|
||||
access_count=row.get("access_count", 0),
|
||||
embedding=row.get("embedding"),
|
||||
tags=row.get("tags"),
|
||||
similarity=row.get("similarity"),
|
||||
@@ -154,6 +156,7 @@ class ScoredResult:
|
||||
"mentioned_at": self.retrieval.mentioned_at,
|
||||
"document_id": self.retrieval.document_id,
|
||||
"chunk_id": self.retrieval.chunk_id,
|
||||
"access_count": self.retrieval.access_count,
|
||||
"embedding": self.retrieval.embedding,
|
||||
"tags": self.retrieval.tags,
|
||||
"semantic_similarity": self.retrieval.similarity,
|
||||
|
||||
@@ -124,6 +124,31 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
|
||||
return 1.0 / (1.0 + math.log1p(normalized_age))
|
||||
|
||||
|
||||
def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float:
|
||||
"""
|
||||
Calculate frequency weight based on access count.
|
||||
|
||||
Frequently accessed memories are weighted higher.
|
||||
Uses logarithmic scaling to avoid over-weighting.
|
||||
|
||||
Args:
|
||||
access_count: Number of times the memory was accessed
|
||||
max_boost: Maximum multiplier for frequently accessed memories
|
||||
|
||||
Returns:
|
||||
Weight between 1.0 and max_boost
|
||||
"""
|
||||
import math
|
||||
|
||||
if access_count <= 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic scaling: log(access_count + 1) / log(10)
|
||||
# This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0
|
||||
normalized = math.log(access_count + 1) / math.log(10)
|
||||
return 1.0 + min(normalized, max_boost - 1.0)
|
||||
|
||||
|
||||
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
|
||||
"""
|
||||
Calculate a single temporal anchor point from a temporal range.
|
||||
|
||||
@@ -95,6 +95,7 @@ class MemoryUnit(Base):
|
||||
mentioned_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
|
||||
fact_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="world")
|
||||
confidence_score: Mapped[float | None] = mapped_column(Float)
|
||||
access_count: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||
unit_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata", JSONB, server_default=sql_text("'{}'::jsonb")
|
||||
) # User-defined metadata (str->str)
|
||||
@@ -130,6 +131,7 @@ class MemoryUnit(Base):
|
||||
Index("idx_memory_units_document_id", "document_id"),
|
||||
Index("idx_memory_units_event_date", "event_date", postgresql_ops={"event_date": "DESC"}),
|
||||
Index("idx_memory_units_bank_date", "bank_id", "event_date", postgresql_ops={"event_date": "DESC"}),
|
||||
Index("idx_memory_units_access_count", "access_count", postgresql_ops={"access_count": "DESC"}),
|
||||
Index("idx_memory_units_fact_type", "fact_type"),
|
||||
Index("idx_memory_units_bank_fact_type", "bank_id", "fact_type"),
|
||||
Index(
|
||||
|
||||
@@ -116,65 +116,16 @@ def llm_config():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def embeddings(tmp_path_factory, worker_id):
|
||||
"""
|
||||
Session-scoped embeddings fixture with filelock to prevent race conditions.
|
||||
def embeddings():
|
||||
|
||||
When pytest-xdist runs multiple workers in parallel, they all try to load
|
||||
models from the HuggingFace cache simultaneously, which can cause race
|
||||
conditions and meta tensor errors. We use a filelock to serialize model
|
||||
initialization across workers.
|
||||
"""
|
||||
# Get shared temp dir for coordination between xdist workers
|
||||
if worker_id == "master":
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp()
|
||||
else:
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
||||
return LocalSTEmbeddings()
|
||||
|
||||
lock_file = root_tmp_dir / "embeddings_init.lock"
|
||||
|
||||
emb = LocalSTEmbeddings()
|
||||
|
||||
# Serialize model initialization across workers
|
||||
with filelock.FileLock(str(lock_file)):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(emb.initialize())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return emb
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def cross_encoder(tmp_path_factory, worker_id):
|
||||
"""
|
||||
Session-scoped cross-encoder fixture with filelock to prevent race conditions.
|
||||
def cross_encoder():
|
||||
|
||||
When pytest-xdist runs multiple workers in parallel, they all try to load
|
||||
models from the HuggingFace cache simultaneously, which can cause race
|
||||
conditions and meta tensor errors. We use a filelock to serialize model
|
||||
initialization across workers.
|
||||
"""
|
||||
# Get shared temp dir for coordination between xdist workers
|
||||
if worker_id == "master":
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp()
|
||||
else:
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
||||
|
||||
lock_file = root_tmp_dir / "cross_encoder_init.lock"
|
||||
|
||||
ce = LocalSTCrossEncoder()
|
||||
|
||||
# Serialize model initialization across workers
|
||||
with filelock.FileLock(str(lock_file)):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(ce.initialize())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return ce
|
||||
return LocalSTCrossEncoder()
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def query_analyzer():
|
||||
|
||||
@@ -275,165 +275,6 @@ async def test_retain_japanese_content(memory, request_context):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_english_content_stays_english(memory, request_context):
|
||||
"""
|
||||
Test that English content is NOT incorrectly translated to Japanese or Chinese.
|
||||
|
||||
This test specifically catches the bug where the language instruction in the
|
||||
CONCISE extraction prompt mentioned Japanese/Chinese explicitly, which primed
|
||||
the LLM to sometimes output facts in those languages even for English input.
|
||||
|
||||
See: https://github.com/vectorize-io/hindsight/issues/181
|
||||
"""
|
||||
bank_id = f"test_english_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# English content about a developer
|
||||
english_content = """
|
||||
John Smith is a software engineer at TechCorp in Seattle.
|
||||
He specializes in machine learning and has been working on
|
||||
recommendation systems for the past three years.
|
||||
Last month, he launched a new feature that improved click-through rates by 25%.
|
||||
He prefers working in Python and uses PyTorch for model training.
|
||||
"""
|
||||
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=english_content,
|
||||
context="Team profile",
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Retained {len(unit_ids)} facts from English content")
|
||||
assert len(unit_ids) > 0, "Should have extracted facts from English content"
|
||||
|
||||
# Recall with English query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me about John Smith",
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should recall facts about John Smith"
|
||||
|
||||
# Verify facts are NOT in Japanese or Chinese
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text}")
|
||||
|
||||
# Count Japanese characters (hiragana, katakana)
|
||||
japanese_chars = sum(
|
||||
1 for char in fact.text
|
||||
if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff")
|
||||
)
|
||||
|
||||
# Count Chinese/CJK characters (excluding those also used in Japanese)
|
||||
# Note: Kanji/CJK ideographs overlap between Chinese and Japanese
|
||||
cjk_chars = sum(1 for char in fact.text if "\u4e00" <= char <= "\u9fff")
|
||||
|
||||
# For English input, there should be minimal CJK characters
|
||||
# Allow for occasional edge cases (e.g., proper nouns) but not full translation
|
||||
total_chars = len(fact.text)
|
||||
cjk_ratio = cjk_chars / max(total_chars, 1)
|
||||
|
||||
assert cjk_ratio < 0.1, (
|
||||
f"English content was incorrectly translated to CJK language! "
|
||||
f"CJK ratio: {cjk_ratio:.1%}, Japanese chars: {japanese_chars}, CJK chars: {cjk_chars}. "
|
||||
f"Fact: {fact.text}"
|
||||
)
|
||||
|
||||
logger.info("English content test passed - facts stayed in English")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_italian_content_stays_italian(memory, request_context):
|
||||
"""
|
||||
Test that Italian content is NOT incorrectly translated to Japanese or Chinese.
|
||||
|
||||
Similar to the English test, this catches the bug where non-CJK languages
|
||||
could be incorrectly translated due to biased language instruction.
|
||||
|
||||
See: https://github.com/vectorize-io/hindsight/issues/181
|
||||
"""
|
||||
bank_id = f"test_italian_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Italian content about a chef
|
||||
italian_content = """
|
||||
Marco Rossi è uno chef italiano che lavora in un ristorante a Milano.
|
||||
È specializzato nella cucina toscana e ha vinto tre premi gastronomici.
|
||||
Il mese scorso ha aperto un nuovo ristorante nel centro della città.
|
||||
Preferisce usare ingredienti freschi e locali per i suoi piatti.
|
||||
"""
|
||||
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=italian_content,
|
||||
context="Profilo dello chef",
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Retained {len(unit_ids)} facts from Italian content")
|
||||
assert len(unit_ids) > 0, "Should have extracted facts from Italian content"
|
||||
|
||||
# Recall with Italian query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Dimmi di Marco Rossi", # "Tell me about Marco Rossi"
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should recall facts about Marco Rossi"
|
||||
|
||||
# Verify facts are NOT in Japanese or Chinese - should stay in Italian
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text}")
|
||||
|
||||
# Count CJK characters
|
||||
cjk_chars = sum(1 for char in fact.text if "\u4e00" <= char <= "\u9fff")
|
||||
japanese_chars = sum(
|
||||
1 for char in fact.text
|
||||
if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff")
|
||||
)
|
||||
|
||||
total_chars = len(fact.text)
|
||||
cjk_ratio = (cjk_chars + japanese_chars) / max(total_chars, 1)
|
||||
|
||||
assert cjk_ratio < 0.1, (
|
||||
f"Italian content was incorrectly translated to CJK language! "
|
||||
f"CJK ratio: {cjk_ratio:.1%}. Fact: {fact.text}"
|
||||
)
|
||||
|
||||
# Verify facts contain Italian words (basic sanity check)
|
||||
all_text = " ".join(f.text for f in result.results).lower()
|
||||
italian_indicators = ["marco", "rossi", "chef", "ristorante", "milano", "cucina", "italiano", "italiana"]
|
||||
has_italian = any(word in all_text for word in italian_indicators)
|
||||
|
||||
# Allow English translation as acceptable (not ideal but not the bug)
|
||||
english_indicators = ["chef", "restaurant", "milan", "italian", "cooking"]
|
||||
has_english = any(word in all_text for word in english_indicators)
|
||||
|
||||
assert has_italian or has_english, (
|
||||
f"Expected facts to be in Italian or English, but got neither. Facts: {all_text}"
|
||||
)
|
||||
|
||||
logger.info("Italian content test passed - facts not translated to CJK")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_language_entities(memory, request_context):
|
||||
"""
|
||||
|
||||
@@ -45,10 +45,6 @@ chrono = "0.4"
|
||||
walkdir = "2.5"
|
||||
dirs = "5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
# For integration tests with blocking HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
|
||||
@@ -67,7 +67,7 @@ run_test_output() {
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Cleaning up test bank..."
|
||||
"$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y 2>/dev/null || true
|
||||
"$HINDSIGHT_CLI" bank delete "$TEST_BANK" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -115,32 +115,8 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED=
|
||||
# Test 14: Clear memories
|
||||
run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 15: Health check
|
||||
run_test_output "health check" "healthy" "$HINDSIGHT_CLI" health || FAILED=1
|
||||
|
||||
# Test 16: List memories (new command)
|
||||
run_test "list memories" "$HINDSIGHT_CLI" memory list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 17: List tags
|
||||
run_test "list tags" "$HINDSIGHT_CLI" tag list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 18: List mental models
|
||||
run_test "list mental models" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 19: Create mental model
|
||||
run_test "create mental model" "$HINDSIGHT_CLI" mental-model create "$TEST_BANK" "Test Model" "A test mental model" || FAILED=1
|
||||
|
||||
# Test 20: List mental models (should have one now)
|
||||
run_test_output "list mental models with model" "Test Model" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 21: Bank graph
|
||||
run_test "bank graph" "$HINDSIGHT_CLI" bank graph "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 22: List operations
|
||||
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 23: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
|
||||
# Test 15: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" || FAILED=1
|
||||
|
||||
echo ""
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
|
||||
@@ -316,266 +316,6 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional API methods for complete CLI coverage
|
||||
// ============================================================================
|
||||
|
||||
impl ApiClient {
|
||||
// --- Mental Model Methods ---
|
||||
|
||||
pub fn list_mental_models(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
subtype: Option<&str>,
|
||||
tags: Option<Vec<String>>,
|
||||
tags_match: Option<&str>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let tags_match_enum = match tags_match {
|
||||
Some("all") => Some(types::TagsMatch::All),
|
||||
Some("any_strict") => Some(types::TagsMatch::AnyStrict),
|
||||
Some("all_strict") => Some(types::TagsMatch::AllStrict),
|
||||
_ => Some(types::TagsMatch::Any),
|
||||
};
|
||||
let response = self.client.list_mental_models(
|
||||
bank_id,
|
||||
subtype,
|
||||
tags.as_ref(),
|
||||
tags_match_enum,
|
||||
None,
|
||||
).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateMentalModelRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_mental_model(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.delete_mental_model(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
request: &types::UpdateMentalModelRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.update_mental_model(bank_id, model_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_mental_models(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
subtype: Option<&str>,
|
||||
tags: Option<Vec<String>>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let subtype_enum = match subtype {
|
||||
Some("structural") => Some(types::Subtype::Structural),
|
||||
Some("emergent") => Some(types::Subtype::Emergent),
|
||||
Some("pinned") => Some(types::Subtype::Pinned),
|
||||
Some("learned") => Some(types::Subtype::Learned),
|
||||
_ => None,
|
||||
};
|
||||
let request = types::RefreshMentalModelsRequest {
|
||||
subtype: subtype_enum,
|
||||
tags,
|
||||
};
|
||||
let response = self.client.refresh_mental_models(bank_id, None, &request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.refresh_mental_model(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_mental_model_versions(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_mental_model_versions(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model_version(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
version: i64,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model_version(bank_id, model_id, version, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Memory Methods ---
|
||||
|
||||
pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_memory(bank_id, memory_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Bank Methods ---
|
||||
|
||||
pub fn create_bank(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateBankRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_or_update_bank(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_bank(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateBankRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.update_bank(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_mission(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
mission: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let request = types::CreateBankRequest {
|
||||
name: None,
|
||||
mission: Some(mission.to_string()),
|
||||
background: None,
|
||||
disposition: None,
|
||||
};
|
||||
let response = self.client.update_bank(bank_id, None, &request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_graph(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
type_filter: Option<&str>,
|
||||
limit: Option<i64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::GraphDataResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_graph(bank_id, limit, type_filter, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Tag Methods ---
|
||||
|
||||
pub fn list_tags(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
q: Option<&str>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::ListTagsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_tags(bank_id, limit, offset, q, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Chunk Methods ---
|
||||
|
||||
pub fn get_chunk(&self, chunk_id: &str, _verbose: bool) -> Result<types::ChunkResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_chunk(chunk_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Operation Methods ---
|
||||
|
||||
pub fn get_operation(&self, bank_id: &str, operation_id: &str, _verbose: bool) -> Result<types::OperationStatusResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_operation_status(bank_id, operation_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Health Methods ---
|
||||
|
||||
pub fn health(&self, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.health_endpoint_health_get().await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn metrics(&self, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.metrics_endpoint_metrics_get().await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types from the generated client for use in commands
|
||||
pub use types::{
|
||||
BankProfileResponse,
|
||||
|
||||
@@ -222,226 +222,6 @@ pub fn update_background(
|
||||
}
|
||||
}
|
||||
|
||||
/// Set bank mission
|
||||
pub fn mission(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
mission_text: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Setting mission..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.set_mission(bank_id, mission_text, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(profile) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Mission updated successfully");
|
||||
println!();
|
||||
println!("{}", profile.mission);
|
||||
} else {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new bank
|
||||
pub fn create(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: Option<String>,
|
||||
mission_text: Option<String>,
|
||||
skepticism: Option<i64>,
|
||||
literalism: Option<i64>,
|
||||
empathy: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Creating bank..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
use hindsight_client::types;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
let disposition = if skepticism.is_some() || literalism.is_some() || empathy.is_some() {
|
||||
Some(types::DispositionTraits {
|
||||
skepticism: NonZeroU64::new(skepticism.unwrap_or(3) as u64).unwrap(),
|
||||
literalism: NonZeroU64::new(literalism.unwrap_or(3) as u64).unwrap(),
|
||||
empathy: NonZeroU64::new(empathy.unwrap_or(3) as u64).unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::CreateBankRequest {
|
||||
name,
|
||||
mission: mission_text,
|
||||
background: None,
|
||||
disposition,
|
||||
};
|
||||
|
||||
let response = client.create_bank(bank_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(profile) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Bank '{}' created successfully", bank_id));
|
||||
println!();
|
||||
ui::print_disposition(&profile);
|
||||
} else {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update bank properties (partial update)
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: Option<String>,
|
||||
mission_text: Option<String>,
|
||||
skepticism: Option<i64>,
|
||||
literalism: Option<i64>,
|
||||
empathy: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && mission_text.is_none() && skepticism.is_none() && literalism.is_none() && empathy.is_none() {
|
||||
anyhow::bail!("At least one field must be provided (--name, --mission, --skepticism, --literalism, --empathy)");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating bank..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
use hindsight_client::types;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
let disposition = if skepticism.is_some() || literalism.is_some() || empathy.is_some() {
|
||||
Some(types::DispositionTraits {
|
||||
skepticism: NonZeroU64::new(skepticism.unwrap_or(3) as u64).unwrap(),
|
||||
literalism: NonZeroU64::new(literalism.unwrap_or(3) as u64).unwrap(),
|
||||
empathy: NonZeroU64::new(empathy.unwrap_or(3) as u64).unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::CreateBankRequest {
|
||||
name,
|
||||
mission: mission_text,
|
||||
background: None,
|
||||
disposition,
|
||||
};
|
||||
|
||||
let response = client.update_bank(bank_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(profile) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Bank '{}' updated successfully", bank_id));
|
||||
println!();
|
||||
ui::print_disposition(&profile);
|
||||
} else {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get memory graph data
|
||||
pub fn graph(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
type_filter: Option<String>,
|
||||
limit: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching graph data..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_graph(bank_id, type_filter.as_deref(), Some(limit), verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Memory Graph: {}", bank_id));
|
||||
|
||||
println!(" {} {}", ui::dim("Nodes:"), ui::gradient_start(&result.nodes.len().to_string()));
|
||||
println!(" {} {}", ui::dim("Edges:"), ui::gradient_end(&result.edges.len().to_string()));
|
||||
println!();
|
||||
|
||||
// Show sample of nodes
|
||||
if !result.nodes.is_empty() {
|
||||
println!("{}", ui::gradient_text("─── Sample Nodes ───"));
|
||||
for node in result.nodes.iter().take(5) {
|
||||
let fact_type = node.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let id = node.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
println!(" {} [{}]", ui::dim(id), fact_type);
|
||||
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
|
||||
let preview: String = text.chars().take(60).collect();
|
||||
let ellipsis = if text.len() > 60 { "..." } else { "" };
|
||||
println!(" {}{}", preview, ellipsis);
|
||||
}
|
||||
}
|
||||
if result.nodes.len() > 5 {
|
||||
println!(" {} more...", ui::dim(&format!("+ {}", result.nodes.len() - 5)));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("{}", ui::dim("Use JSON output for full graph data: -o json"));
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//! Chunk commands for retrieving document chunks.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
/// Get a specific chunk by ID
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
chunk_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching chunk..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_chunk(chunk_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Chunk: {}", chunk_id));
|
||||
|
||||
println!(" {} {}", ui::dim("ID:"), result.chunk_id);
|
||||
println!(" {} {}", ui::dim("Index:"), result.chunk_index);
|
||||
println!(" {} {}", ui::dim("Document:"), result.document_id);
|
||||
println!(" {} {}", ui::dim("Bank:"), result.bank_id);
|
||||
println!(" {} {}", ui::dim("Created:"), result.created_at);
|
||||
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Content ───"));
|
||||
println!();
|
||||
println!("{}", result.chunk_text);
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hindsight_client::types::ChunkResponse;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_response_deserialization() {
|
||||
let json = r#"{
|
||||
"chunk_id": "chunk-123",
|
||||
"bank_id": "test-bank",
|
||||
"document_id": "doc-456",
|
||||
"chunk_index": 0,
|
||||
"chunk_text": "This is the chunk content.",
|
||||
"created_at": "2024-01-15T10:00:00Z"
|
||||
}"#;
|
||||
|
||||
let result: ChunkResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.chunk_id, "chunk-123");
|
||||
assert_eq!(result.bank_id, "test-bank");
|
||||
assert_eq!(result.document_id, "doc-456");
|
||||
assert_eq!(result.chunk_index, 0);
|
||||
assert_eq!(result.chunk_text, "This is the chunk content.");
|
||||
assert_eq!(result.created_at, "2024-01-15T10:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_response_multiline_content() {
|
||||
let json = r#"{
|
||||
"chunk_id": "chunk-456",
|
||||
"bank_id": "test-bank",
|
||||
"document_id": "doc-789",
|
||||
"chunk_index": 5,
|
||||
"chunk_text": "Line 1\nLine 2\nLine 3",
|
||||
"created_at": "2024-01-15T11:00:00Z"
|
||||
}"#;
|
||||
|
||||
let result: ChunkResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.chunk_index, 5);
|
||||
assert!(result.chunk_text.contains('\n'));
|
||||
assert_eq!(result.chunk_text.lines().count(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
//! Health and metrics commands.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
// Local type for health response
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
database: Option<String>,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
/// Check API health
|
||||
pub fn health(
|
||||
client: &ApiClient,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Checking health..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.health(verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: HealthResponse = serde_json::from_value(value.clone())
|
||||
.unwrap_or(HealthResponse {
|
||||
status: "unknown".to_string(),
|
||||
database: None,
|
||||
version: None,
|
||||
});
|
||||
|
||||
let status_str = if result.status == "healthy" {
|
||||
ui::gradient_start(&result.status)
|
||||
} else {
|
||||
ui::gradient_end(&result.status)
|
||||
};
|
||||
|
||||
ui::print_section_header("Health Check");
|
||||
println!(" {} {}", ui::dim("Status:"), status_str);
|
||||
|
||||
if let Some(db_status) = &result.database {
|
||||
let db_str = if db_status == "connected" {
|
||||
ui::gradient_start(db_status)
|
||||
} else {
|
||||
ui::gradient_end(db_status)
|
||||
};
|
||||
println!(" {} {}", ui::dim("Database:"), db_str);
|
||||
}
|
||||
|
||||
if let Some(version) = &result.version {
|
||||
println!(" {} {}", ui::dim("Version:"), version);
|
||||
}
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Prometheus metrics
|
||||
pub fn metrics(
|
||||
client: &ApiClient,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching metrics..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.metrics(verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header("Prometheus Metrics");
|
||||
println!("{}", result);
|
||||
} else {
|
||||
// For JSON/YAML, wrap in an object
|
||||
let wrapped = serde_json::json!({ "metrics": result });
|
||||
output::print_output(&wrapped, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_health_response_deserialization() {
|
||||
let json = r#"{
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"version": "0.3.0"
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: HealthResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.status, "healthy");
|
||||
assert_eq!(result.database, Some("connected".to_string()));
|
||||
assert_eq!(result.version, Some("0.3.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_response_minimal() {
|
||||
let json = r#"{"status": "healthy"}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: HealthResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.status, "healthy");
|
||||
assert_eq!(result.database, None);
|
||||
assert_eq!(result.version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_response_unhealthy() {
|
||||
let json = r#"{
|
||||
"status": "unhealthy",
|
||||
"database": "disconnected"
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: HealthResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.status, "unhealthy");
|
||||
assert_eq!(result.database, Some("disconnected".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -10,30 +10,8 @@ use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
// Local types for serde_json::Value deserialization
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MemoryUnitDetail {
|
||||
id: String,
|
||||
text: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: Option<String>,
|
||||
document_id: Option<String>,
|
||||
context: Option<String>,
|
||||
occurred_start: Option<String>,
|
||||
occurred_end: Option<String>,
|
||||
entities: Option<Vec<EntityRef>>,
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EntityRef {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// Helper function to parse budget string to Budget enum
|
||||
fn parse_budget(budget: &str) -> Budget {
|
||||
match budget.to_lowercase().as_str() {
|
||||
@@ -43,183 +21,6 @@ fn parse_budget(budget: &str) -> Budget {
|
||||
}
|
||||
}
|
||||
|
||||
/// List memory units with pagination and optional filters
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
type_filter: Option<String>,
|
||||
query: Option<String>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching memories..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_memories(
|
||||
bank_id,
|
||||
type_filter.as_deref(),
|
||||
query.as_deref(),
|
||||
Some(limit),
|
||||
Some(offset),
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Memories: {} (showing {}-{})", bank_id, offset + 1, offset + result.items.len() as i64));
|
||||
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No memories found."));
|
||||
} else {
|
||||
for item in &result.items {
|
||||
let fact_type = item.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let type_t = match fact_type {
|
||||
"world" => 0.0,
|
||||
"experience" => 0.5,
|
||||
"opinion" => 1.0,
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
let id = item.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::gradient(&format!("[{}]", fact_type.to_uppercase()), type_t),
|
||||
ui::dim(id)
|
||||
);
|
||||
|
||||
// Truncate text if too long
|
||||
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||
let text_preview: String = text.chars().take(100).collect();
|
||||
let ellipsis = if text.len() > 100 { "..." } else { "" };
|
||||
println!(" {}{}", text_preview, ellipsis);
|
||||
}
|
||||
|
||||
if let Some(doc_id) = item.get("document_id").and_then(|v| v.as_str()) {
|
||||
println!(" {} {}", ui::dim("doc:"), ui::dim(doc_id));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!(" {} {} total", ui::dim("Total:"), result.total);
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific memory unit by ID
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
memory_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching memory..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_memory(bank_id, memory_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: MemoryUnitDetail = serde_json::from_value(value)
|
||||
.with_context(|| "Failed to parse memory response")?;
|
||||
|
||||
let fact_type = result.type_.as_deref().unwrap_or("unknown");
|
||||
let type_t = match fact_type {
|
||||
"world" => 0.0,
|
||||
"experience" => 0.5,
|
||||
"opinion" => 1.0,
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
ui::print_section_header(&format!("Memory: {}", memory_id));
|
||||
|
||||
println!(" {} {}", ui::dim("Type:"), ui::gradient(&fact_type.to_uppercase(), type_t));
|
||||
println!(" {} {}", ui::dim("ID:"), result.id);
|
||||
|
||||
if let Some(doc_id) = &result.document_id {
|
||||
println!(" {} {}", ui::dim("Document:"), doc_id);
|
||||
}
|
||||
|
||||
if let Some(context) = &result.context {
|
||||
println!(" {} {}", ui::dim("Context:"), context);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Content ───"));
|
||||
println!();
|
||||
println!("{}", result.text);
|
||||
|
||||
// Show temporal info if available
|
||||
if result.occurred_start.is_some() || result.occurred_end.is_some() {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Temporal ───"));
|
||||
if let Some(start) = &result.occurred_start {
|
||||
println!(" {} {}", ui::dim("Start:"), start);
|
||||
}
|
||||
if let Some(end) = &result.occurred_end {
|
||||
println!(" {} {}", ui::dim("End:"), end);
|
||||
}
|
||||
}
|
||||
|
||||
// Show entities if available
|
||||
if let Some(entities) = &result.entities {
|
||||
if !entities.is_empty() {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Entities ───"));
|
||||
for entity in entities {
|
||||
println!(" • {} ({})", entity.name, entity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show tags if available
|
||||
if let Some(tags) = &result.tags {
|
||||
if !tags.is_empty() {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Tags ───"));
|
||||
println!(" {}", tags.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a file has a text-based extension
|
||||
fn is_text_file(path: &std::path::Path) -> bool {
|
||||
const TEXT_EXTENSIONS: &[&str] = &[
|
||||
|
||||
@@ -1,721 +0,0 @@
|
||||
//! Mental model commands for managing structured knowledge containers.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
use hindsight_client::types;
|
||||
use serde::Deserialize;
|
||||
|
||||
// Local types for serde_json::Value deserialization
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionListResponse {
|
||||
versions: Vec<VersionItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionItem {
|
||||
version: i64,
|
||||
created_at: String,
|
||||
observations_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionDetailResponse {
|
||||
version: i64,
|
||||
created_at: String,
|
||||
observations: Option<Vec<ObservationData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObservationData {
|
||||
title: String,
|
||||
content: String,
|
||||
trend: Option<String>,
|
||||
evidence: Option<Vec<EvidenceData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EvidenceData {
|
||||
quote: String,
|
||||
}
|
||||
|
||||
/// List mental models for a bank
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
subtype: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
tags_match: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching mental models..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_mental_models(
|
||||
bank_id,
|
||||
subtype.as_deref(),
|
||||
tags,
|
||||
tags_match.as_deref(),
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Mental Models: {}", bank_id));
|
||||
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No mental models found."));
|
||||
} else {
|
||||
for model in &result.items {
|
||||
let subtype_str = &model.subtype;
|
||||
let obs_count = model.observations.len();
|
||||
|
||||
println!(
|
||||
" {} {} {}",
|
||||
ui::gradient_start(&model.id),
|
||||
ui::dim(&format!("[{}]", subtype_str)),
|
||||
model.name
|
||||
);
|
||||
|
||||
if !model.description.is_empty() {
|
||||
println!(" {}", ui::dim(&model.description));
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} observations, v{}",
|
||||
obs_count,
|
||||
model.version
|
||||
);
|
||||
|
||||
// Show freshness status
|
||||
if let Some(freshness) = &model.freshness {
|
||||
let status = if freshness.is_up_to_date {
|
||||
ui::gradient_start("up to date")
|
||||
} else {
|
||||
ui::gradient_end("needs refresh")
|
||||
};
|
||||
println!(" {}", status);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific mental model
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_mental_model(bank_id, model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(model) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
print_mental_model_detail(&model);
|
||||
} else {
|
||||
output::print_output(&model, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new mental model
|
||||
pub fn create(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
subtype: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
observations_file: Option<PathBuf>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Creating mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Parse observations from file if provided
|
||||
let observations = if let Some(path) = observations_file {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read observations file: {}", path.display()))?;
|
||||
let obs: Vec<types::ObservationInput> = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse observations JSON from: {}", path.display()))?;
|
||||
Some(obs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::CreateMentalModelRequest {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
subtype: subtype.unwrap_or_else(|| "pinned".to_string()),
|
||||
tags: tags.unwrap_or_default(),
|
||||
observations,
|
||||
};
|
||||
|
||||
let response = client.create_mental_model(bank_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(model) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Mental model '{}' created successfully", model.id));
|
||||
println!();
|
||||
print_mental_model_detail(&model);
|
||||
} else {
|
||||
output::print_output(&model, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a mental model
|
||||
pub fn delete(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
yes: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Confirmation prompt unless -y flag is used
|
||||
if !yes && output_format == OutputFormat::Pretty {
|
||||
let message = format!(
|
||||
"Are you sure you want to delete mental model '{}'? This cannot be undone.",
|
||||
model_id
|
||||
);
|
||||
|
||||
let confirmed = ui::prompt_confirmation(&message)?;
|
||||
|
||||
if !confirmed {
|
||||
ui::print_info("Operation cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Deleting mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.delete_mental_model(bank_id, model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
if result.success {
|
||||
ui::print_success(&format!("Mental model '{}' deleted successfully", model_id));
|
||||
} else {
|
||||
ui::print_error("Failed to delete mental model");
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a mental model's name or description
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && description.is_none() {
|
||||
anyhow::bail!("At least one of --name or --description must be provided");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::UpdateMentalModelRequest { name, description };
|
||||
|
||||
let response = client.update_mental_model(bank_id, model_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(model) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Mental model '{}' updated successfully", model_id));
|
||||
println!();
|
||||
print_mental_model_detail(&model);
|
||||
} else {
|
||||
output::print_output(&model, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh all mental models (or filtered by subtype)
|
||||
pub fn refresh_all(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
subtype: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Submitting refresh request..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.refresh_mental_models(bank_id, subtype.as_deref(), tags, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Refresh operation submitted");
|
||||
println!(" Operation ID: {}", result.operation_id);
|
||||
println!(" Status: {}", result.status);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh a specific mental model
|
||||
pub fn refresh(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Submitting refresh request..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.refresh_mental_model(bank_id, model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Refresh submitted for model '{}'", model_id));
|
||||
println!(" Operation ID: {}", result.operation_id);
|
||||
println!(" Status: {}", result.status);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// List version history for a mental model
|
||||
pub fn versions(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching versions..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_mental_model_versions(bank_id, model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: VersionListResponse = serde_json::from_value(value)
|
||||
.with_context(|| "Failed to parse version list response")?;
|
||||
|
||||
ui::print_section_header(&format!("Version History: {}", model_id));
|
||||
|
||||
if result.versions.is_empty() {
|
||||
println!(" {}", ui::dim("No versions found."));
|
||||
} else {
|
||||
for version in &result.versions {
|
||||
let obs_count = version.observations_count.unwrap_or(0);
|
||||
println!(
|
||||
" {} v{} - {} observations",
|
||||
ui::gradient_start(&format!("v{}", version.version)),
|
||||
version.version,
|
||||
obs_count
|
||||
);
|
||||
println!(" {}", ui::dim(&version.created_at));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific version of a mental model
|
||||
pub fn version(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
version_num: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching version..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_mental_model_version(bank_id, model_id, version_num, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: VersionDetailResponse = serde_json::from_value(value)
|
||||
.with_context(|| "Failed to parse version response")?;
|
||||
|
||||
ui::print_section_header(&format!("{} v{}", model_id, version_num));
|
||||
|
||||
println!(" {} {}", ui::dim("Created:"), result.created_at);
|
||||
println!();
|
||||
|
||||
if let Some(observations) = &result.observations {
|
||||
if observations.is_empty() {
|
||||
println!(" {}", ui::dim("No observations in this version."));
|
||||
} else {
|
||||
for (i, obs) in observations.iter().enumerate() {
|
||||
print_observation_data(i + 1, obs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to print mental model details
|
||||
fn print_mental_model_detail(model: &types::MentalModelResponse) {
|
||||
ui::print_section_header(&model.name);
|
||||
|
||||
let subtype_str = &model.subtype;
|
||||
println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&model.id));
|
||||
println!(" {} {}", ui::dim("Subtype:"), subtype_str);
|
||||
println!(" {} v{}", ui::dim("Version:"), model.version);
|
||||
|
||||
if !model.description.is_empty() {
|
||||
println!(" {} {}", ui::dim("Description:"), &model.description);
|
||||
}
|
||||
|
||||
if !model.tags.is_empty() {
|
||||
println!(" {} {}", ui::dim("Tags:"), model.tags.join(", "));
|
||||
}
|
||||
|
||||
// Freshness status
|
||||
if let Some(freshness) = &model.freshness {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Freshness ───"));
|
||||
let status = if freshness.is_up_to_date {
|
||||
ui::gradient_start("Up to date")
|
||||
} else {
|
||||
ui::gradient_end("Needs refresh")
|
||||
};
|
||||
println!(" {} {}", ui::dim("Status:"), status);
|
||||
|
||||
if let Some(last_refresh) = &freshness.last_refresh_at {
|
||||
println!(" {} {}", ui::dim("Last refresh:"), last_refresh);
|
||||
}
|
||||
|
||||
if freshness.memories_since_refresh > 0 {
|
||||
println!(" {} {}", ui::dim("New memories:"), freshness.memories_since_refresh);
|
||||
}
|
||||
|
||||
if !freshness.reasons.is_empty() {
|
||||
println!(" {} {}", ui::dim("Reasons:"), freshness.reasons.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// Observations
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Observations ───"));
|
||||
println!();
|
||||
|
||||
if model.observations.is_empty() {
|
||||
println!(" {}", ui::dim("No observations yet."));
|
||||
} else {
|
||||
for (i, obs) in model.observations.iter().enumerate() {
|
||||
print_observation(i + 1, obs);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_observation(index: usize, obs: &types::MentalModelObservationResponse) {
|
||||
let trend_str = &obs.trend;
|
||||
let trend_colored = match trend_str.as_str() {
|
||||
"strengthening" => ui::gradient_start(trend_str),
|
||||
"stable" => ui::gradient_mid(trend_str),
|
||||
"weakening" | "stale" => ui::gradient_end(trend_str),
|
||||
_ => trend_str.to_string(),
|
||||
};
|
||||
|
||||
println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored)));
|
||||
println!(" {}", obs.content);
|
||||
|
||||
// Show evidence if available
|
||||
if !obs.evidence.is_empty() {
|
||||
println!(" {} evidence items:", ui::dim(&obs.evidence.len().to_string()));
|
||||
for ev in obs.evidence.iter().take(2) {
|
||||
// Show first 2 evidence items
|
||||
let quote_preview: String = ev.quote.chars().take(60).collect();
|
||||
let ellipsis = if ev.quote.len() > 60 { "..." } else { "" };
|
||||
println!(" • \"{}{}\"", quote_preview, ellipsis);
|
||||
}
|
||||
if obs.evidence.len() > 2 {
|
||||
println!(" {} more...", ui::dim(&format!("+ {}", obs.evidence.len() - 2)));
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_observation_data(index: usize, obs: &ObservationData) {
|
||||
let trend_str = obs.trend.as_deref().unwrap_or("unknown");
|
||||
let trend_colored = match trend_str {
|
||||
"strengthening" => ui::gradient_start(trend_str),
|
||||
"stable" => ui::gradient_mid(trend_str),
|
||||
"weakening" | "stale" => ui::gradient_end(trend_str),
|
||||
_ => trend_str.to_string(),
|
||||
};
|
||||
|
||||
println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored)));
|
||||
println!(" {}", obs.content);
|
||||
|
||||
// Show evidence if available
|
||||
if let Some(evidence) = &obs.evidence {
|
||||
if !evidence.is_empty() {
|
||||
println!(" {} evidence items:", ui::dim(&evidence.len().to_string()));
|
||||
for ev in evidence.iter().take(2) {
|
||||
// Show first 2 evidence items
|
||||
let quote_preview: String = ev.quote.chars().take(60).collect();
|
||||
let ellipsis = if ev.quote.len() > 60 { "..." } else { "" };
|
||||
println!(" • \"{}{}\"", quote_preview, ellipsis);
|
||||
}
|
||||
if evidence.len() > 2 {
|
||||
println!(" {} more...", ui::dim(&format!("+ {}", evidence.len() - 2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_observation_input_serialization() {
|
||||
let obs = types::ObservationInput {
|
||||
title: "Test observation".to_string(),
|
||||
content: "Test content".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&obs).unwrap();
|
||||
assert!(json.contains("Test observation"));
|
||||
assert!(json.contains("Test content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_list_response_deserialization() {
|
||||
let json = r#"{
|
||||
"versions": [
|
||||
{"version": 1, "created_at": "2024-01-10T10:00:00Z", "observations_count": 5},
|
||||
{"version": 2, "created_at": "2024-01-15T10:00:00Z", "observations_count": 8}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: VersionListResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.versions.len(), 2);
|
||||
assert_eq!(result.versions[0].version, 1);
|
||||
assert_eq!(result.versions[1].version, 2);
|
||||
assert_eq!(result.versions[1].observations_count, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_detail_response_deserialization() {
|
||||
let json = r#"{
|
||||
"version": 1,
|
||||
"created_at": "2024-01-10T10:00:00Z",
|
||||
"observations": [
|
||||
{
|
||||
"title": "Test observation",
|
||||
"content": "Test content",
|
||||
"trend": "stable",
|
||||
"evidence": [{"quote": "test evidence"}]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: VersionDetailResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.created_at, "2024-01-10T10:00:00Z");
|
||||
let observations = result.observations.unwrap();
|
||||
assert_eq!(observations.len(), 1);
|
||||
assert_eq!(observations[0].title, "Test observation");
|
||||
assert_eq!(observations[0].trend, Some("stable".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_observation_data_deserialization() {
|
||||
let json = r#"{
|
||||
"title": "Test Title",
|
||||
"content": "Test Content",
|
||||
"trend": "strengthening",
|
||||
"evidence": [
|
||||
{"quote": "Evidence 1"},
|
||||
{"quote": "Evidence 2"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: ObservationData = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.title, "Test Title");
|
||||
assert_eq!(result.content, "Test Content");
|
||||
assert_eq!(result.trend, Some("strengthening".to_string()));
|
||||
let evidence = result.evidence.unwrap();
|
||||
assert_eq!(evidence.len(), 2);
|
||||
assert_eq!(evidence[0].quote, "Evidence 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_mental_model_request() {
|
||||
let request = types::CreateMentalModelRequest {
|
||||
name: "Test Model".to_string(),
|
||||
description: "A test model".to_string(),
|
||||
subtype: "pinned".to_string(),
|
||||
tags: vec!["test".to_string()],
|
||||
observations: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("Test Model"));
|
||||
assert!(json.contains("pinned"));
|
||||
assert!(json.contains("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_mental_model_request() {
|
||||
let request = types::UpdateMentalModelRequest {
|
||||
name: Some("Updated Name".to_string()),
|
||||
description: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("Updated Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_operation_submit_response_deserialization() {
|
||||
let json = r#"{
|
||||
"operation_id": "op-123",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: types::AsyncOperationSubmitResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.operation_id, "op-123");
|
||||
assert_eq!(result.status, "pending");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
pub mod bank;
|
||||
pub mod chunk;
|
||||
pub mod memory;
|
||||
pub mod document;
|
||||
pub mod entity;
|
||||
pub mod explore;
|
||||
pub mod health;
|
||||
pub mod memory;
|
||||
pub mod mental_model;
|
||||
pub mod operation;
|
||||
pub mod tag;
|
||||
pub mod explore;
|
||||
|
||||
@@ -47,55 +47,6 @@ pub fn list(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the status of a specific operation
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
operation_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching operation status..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_operation(agent_id, operation_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Operation: {}", operation_id));
|
||||
|
||||
use hindsight_client::types::Status;
|
||||
let status_str = match &result.status {
|
||||
Status::Completed => ui::gradient_start("completed"),
|
||||
Status::Pending => ui::gradient_mid("pending"),
|
||||
Status::Failed => ui::gradient_end("failed"),
|
||||
Status::NotFound => ui::gradient_end("not_found"),
|
||||
};
|
||||
|
||||
println!(" {} {}", ui::dim("Status:"), status_str);
|
||||
|
||||
if let Some(error) = &result.error_message {
|
||||
println!(" {} {}", ui::dim("Error:"), ui::gradient_end(error));
|
||||
}
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
//! Tag commands for listing tags in a memory bank.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
/// List tags in a bank
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
query: Option<String>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching tags..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_tags(
|
||||
bank_id,
|
||||
query.as_deref(),
|
||||
Some(limit),
|
||||
Some(offset),
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Tags: {}", bank_id));
|
||||
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No tags found."));
|
||||
} else {
|
||||
for (i, tag) in result.items.iter().enumerate() {
|
||||
let t = i as f32 / result.items.len().max(1) as f32;
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::gradient(&tag.tag, t),
|
||||
ui::dim(&format!("({})", tag.count))
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(" {} {} total", ui::dim("Total:"), result.total);
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hindsight_client::types::{ListTagsResponse, TagItem};
|
||||
|
||||
#[test]
|
||||
fn test_tag_item_fields() {
|
||||
// Verify TagItem has the expected fields
|
||||
let tag = TagItem {
|
||||
tag: "test-tag".to_string(),
|
||||
count: 5,
|
||||
};
|
||||
|
||||
assert_eq!(tag.tag, "test-tag");
|
||||
assert_eq!(tag.count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_tags_response_deserialization() {
|
||||
let json = r#"{
|
||||
"items": [
|
||||
{"tag": "user", "count": 10},
|
||||
{"tag": "system", "count": 5}
|
||||
],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"total": 2
|
||||
}"#;
|
||||
|
||||
let result: ListTagsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.items[0].tag, "user");
|
||||
assert_eq!(result.items[0].count, 10);
|
||||
assert_eq!(result.items[1].tag, "system");
|
||||
assert_eq!(result.items[1].count, 5);
|
||||
assert_eq!(result.total, 2);
|
||||
assert_eq!(result.limit, 100);
|
||||
assert_eq!(result.offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tags_response() {
|
||||
let json = r#"{
|
||||
"items": [],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"total": 0
|
||||
}"#;
|
||||
|
||||
let result: ListTagsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
+5
-373
@@ -67,18 +67,14 @@ fn get_before_help() -> &'static str {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Manage banks (list, create, update, profile, stats, mission, graph, delete)
|
||||
/// Manage banks (list, profile, stats)
|
||||
#[command(subcommand)]
|
||||
Bank(BankCommands),
|
||||
|
||||
/// Manage memories (list, get, recall, reflect, retain, clear)
|
||||
/// Manage memories (recall, reflect, retain, delete)
|
||||
#[command(subcommand)]
|
||||
Memory(MemoryCommands),
|
||||
|
||||
/// Manage mental models (list, get, create, update, delete, refresh, versions)
|
||||
#[command(subcommand)]
|
||||
MentalModel(MentalModelCommands),
|
||||
|
||||
/// Manage documents (list, get, delete)
|
||||
#[command(subcommand)]
|
||||
Document(DocumentCommands),
|
||||
@@ -87,24 +83,10 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
Entity(EntityCommands),
|
||||
|
||||
/// Manage tags (list)
|
||||
#[command(subcommand)]
|
||||
Tag(TagCommands),
|
||||
|
||||
/// Manage chunks (get)
|
||||
#[command(subcommand)]
|
||||
Chunk(ChunkCommands),
|
||||
|
||||
/// Manage async operations (list, get, cancel)
|
||||
/// Manage async operations (list, cancel)
|
||||
#[command(subcommand)]
|
||||
Operation(OperationCommands),
|
||||
|
||||
/// Check API health status
|
||||
Health,
|
||||
|
||||
/// Get Prometheus metrics
|
||||
Metrics,
|
||||
|
||||
/// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect
|
||||
#[command(alias = "tui")]
|
||||
Explore,
|
||||
@@ -129,59 +111,7 @@ enum BankCommands {
|
||||
/// List all banks
|
||||
List,
|
||||
|
||||
/// Create a new bank
|
||||
Create {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Bank name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Mission statement
|
||||
#[arg(short = 'm', long)]
|
||||
mission: Option<String>,
|
||||
|
||||
/// Skepticism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
skepticism: Option<i64>,
|
||||
|
||||
/// Literalism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
literalism: Option<i64>,
|
||||
|
||||
/// Empathy trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
empathy: Option<i64>,
|
||||
},
|
||||
|
||||
/// Update bank properties (partial update)
|
||||
Update {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Bank name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Mission statement
|
||||
#[arg(short = 'm', long)]
|
||||
mission: Option<String>,
|
||||
|
||||
/// Skepticism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
skepticism: Option<i64>,
|
||||
|
||||
/// Literalism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
literalism: Option<i64>,
|
||||
|
||||
/// Empathy trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
empathy: Option<i64>,
|
||||
},
|
||||
|
||||
/// Get bank disposition and profile
|
||||
/// Get bank disposition and background
|
||||
Disposition {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
@@ -202,17 +132,7 @@ enum BankCommands {
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Set bank mission
|
||||
Mission {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mission statement
|
||||
mission: String,
|
||||
},
|
||||
|
||||
/// Set or merge bank background (deprecated: use mission instead)
|
||||
#[command(hide = true)]
|
||||
/// Set or merge bank background
|
||||
Background {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
@@ -225,20 +145,6 @@ enum BankCommands {
|
||||
no_update_disposition: bool,
|
||||
},
|
||||
|
||||
/// Get memory graph data
|
||||
Graph {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by fact type (world, experience, opinion)
|
||||
#[arg(short = 't', long)]
|
||||
fact_type: Option<String>,
|
||||
|
||||
/// Maximum nodes to return
|
||||
#[arg(short = 'l', long, default_value = "1000")]
|
||||
limit: i64,
|
||||
},
|
||||
|
||||
/// Delete a bank and all its data
|
||||
Delete {
|
||||
/// Bank ID
|
||||
@@ -252,37 +158,6 @@ enum BankCommands {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MemoryCommands {
|
||||
/// List memory units with pagination
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by fact type (world, experience, opinion)
|
||||
#[arg(short = 't', long)]
|
||||
fact_type: Option<String>,
|
||||
|
||||
/// Full-text search query
|
||||
#[arg(short = 'q', long)]
|
||||
query: Option<String>,
|
||||
|
||||
/// Maximum number of results
|
||||
#[arg(short = 'l', long, default_value = "100")]
|
||||
limit: i64,
|
||||
|
||||
/// Offset for pagination
|
||||
#[arg(short = 's', long, default_value = "0")]
|
||||
offset: i64,
|
||||
},
|
||||
|
||||
/// Get a specific memory unit by ID
|
||||
Get {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Memory unit ID
|
||||
memory_id: String,
|
||||
},
|
||||
|
||||
/// Recall memories using semantic search
|
||||
Recall {
|
||||
/// Bank ID
|
||||
@@ -485,15 +360,6 @@ enum OperationCommands {
|
||||
bank_id: String,
|
||||
},
|
||||
|
||||
/// Get the status of a specific operation
|
||||
Get {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Operation ID
|
||||
operation_id: String,
|
||||
},
|
||||
|
||||
/// Cancel a pending async operation
|
||||
Cancel {
|
||||
/// Bank ID
|
||||
@@ -504,164 +370,6 @@ enum OperationCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MentalModelCommands {
|
||||
/// List mental models for a bank
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by subtype (structural, emergent, pinned, learned, directive)
|
||||
#[arg(long)]
|
||||
subtype: Option<String>,
|
||||
|
||||
/// Filter by tags
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
|
||||
/// Tag matching mode (any, all, any_strict, all_strict)
|
||||
#[arg(long, default_value = "any")]
|
||||
tags_match: Option<String>,
|
||||
},
|
||||
|
||||
/// Get a specific mental model
|
||||
Get {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
},
|
||||
|
||||
/// Create a new mental model (pinned or directive subtype)
|
||||
Create {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Model name
|
||||
name: String,
|
||||
|
||||
/// Model description
|
||||
description: String,
|
||||
|
||||
/// Subtype (pinned or directive)
|
||||
#[arg(long, default_value = "pinned")]
|
||||
subtype: Option<String>,
|
||||
|
||||
/// Tags for the model
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
|
||||
/// Path to JSON file containing initial observations
|
||||
#[arg(long)]
|
||||
observations: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Update a mental model's name or description
|
||||
Update {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
|
||||
/// New name
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// New description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a mental model
|
||||
Delete {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// Refresh all mental models (async operation)
|
||||
RefreshAll {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by subtype
|
||||
#[arg(long)]
|
||||
subtype: Option<String>,
|
||||
|
||||
/// Filter by tags
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Refresh a specific mental model (async operation)
|
||||
Refresh {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
},
|
||||
|
||||
/// List version history for a mental model
|
||||
Versions {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
},
|
||||
|
||||
/// Get a specific version of a mental model
|
||||
Version {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
|
||||
/// Version number
|
||||
version: i64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TagCommands {
|
||||
/// List tags in a bank
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Wildcard search query (e.g., 'user:*')
|
||||
#[arg(short = 'q', long)]
|
||||
query: Option<String>,
|
||||
|
||||
/// Maximum number of results
|
||||
#[arg(short = 'l', long, default_value = "100")]
|
||||
limit: i64,
|
||||
|
||||
/// Offset for pagination
|
||||
#[arg(short = 's', long, default_value = "0")]
|
||||
offset: i64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ChunkCommands {
|
||||
/// Get a specific chunk by ID
|
||||
Get {
|
||||
/// Chunk ID
|
||||
chunk_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(_) = run() {
|
||||
std::process::exit(1);
|
||||
@@ -704,45 +412,20 @@ fn run() -> Result<()> {
|
||||
Commands::Configure { .. } => unreachable!(), // Handled above
|
||||
Commands::Ui => unreachable!(), // Handled above
|
||||
Commands::Explore => commands::explore::run(&client),
|
||||
|
||||
// Health and Metrics
|
||||
Commands::Health => commands::health::health(&client, verbose, output_format),
|
||||
Commands::Metrics => commands::health::metrics(&client, verbose, output_format),
|
||||
|
||||
// Bank commands
|
||||
Commands::Bank(bank_cmd) => match bank_cmd {
|
||||
BankCommands::List => commands::bank::list(&client, verbose, output_format),
|
||||
BankCommands::Create { bank_id, name, mission, skepticism, literalism, empathy } => {
|
||||
commands::bank::create(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::Update { bank_id, name, mission, skepticism, literalism, empathy } => {
|
||||
commands::bank::update(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format),
|
||||
BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format),
|
||||
BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format),
|
||||
BankCommands::Mission { bank_id, mission } => {
|
||||
commands::bank::mission(&client, &bank_id, &mission, verbose, output_format)
|
||||
}
|
||||
BankCommands::Background { bank_id, content, no_update_disposition } => {
|
||||
commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format)
|
||||
}
|
||||
BankCommands::Graph { bank_id, fact_type, limit } => {
|
||||
commands::bank::graph(&client, &bank_id, fact_type, limit, verbose, output_format)
|
||||
}
|
||||
BankCommands::Delete { bank_id, yes } => {
|
||||
commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Memory commands
|
||||
Commands::Memory(memory_cmd) => match memory_cmd {
|
||||
MemoryCommands::List { bank_id, fact_type, query, limit, offset } => {
|
||||
commands::memory::list(&client, &bank_id, fact_type, query, limit, offset, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Get { bank_id, memory_id } => {
|
||||
commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format)
|
||||
}
|
||||
@@ -763,38 +446,6 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Mental Model commands
|
||||
Commands::MentalModel(mm_cmd) => match mm_cmd {
|
||||
MentalModelCommands::List { bank_id, subtype, tags, tags_match } => {
|
||||
commands::mental_model::list(&client, &bank_id, subtype, tags, tags_match, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Get { bank_id, model_id } => {
|
||||
commands::mental_model::get(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Create { bank_id, name, description, subtype, tags, observations } => {
|
||||
commands::mental_model::create(&client, &bank_id, &name, &description, subtype, tags, observations, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Update { bank_id, model_id, name, description } => {
|
||||
commands::mental_model::update(&client, &bank_id, &model_id, name, description, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Delete { bank_id, model_id, yes } => {
|
||||
commands::mental_model::delete(&client, &bank_id, &model_id, yes, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::RefreshAll { bank_id, subtype, tags } => {
|
||||
commands::mental_model::refresh_all(&client, &bank_id, subtype, tags, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Refresh { bank_id, model_id } => {
|
||||
commands::mental_model::refresh(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Versions { bank_id, model_id } => {
|
||||
commands::mental_model::versions(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Version { bank_id, model_id, version } => {
|
||||
commands::mental_model::version(&client, &bank_id, &model_id, version, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Document commands
|
||||
Commands::Document(doc_cmd) => match doc_cmd {
|
||||
DocumentCommands::List { bank_id, query, limit, offset } => {
|
||||
commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format)
|
||||
@@ -807,7 +458,6 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Entity commands
|
||||
Commands::Entity(entity_cmd) => match entity_cmd {
|
||||
EntityCommands::List { bank_id, limit } => {
|
||||
commands::entity::list(&client, &bank_id, limit, verbose, output_format)
|
||||
@@ -820,28 +470,10 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Tag commands
|
||||
Commands::Tag(tag_cmd) => match tag_cmd {
|
||||
TagCommands::List { bank_id, query, limit, offset } => {
|
||||
commands::tag::list(&client, &bank_id, query, limit, offset, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Chunk commands
|
||||
Commands::Chunk(chunk_cmd) => match chunk_cmd {
|
||||
ChunkCommands::Get { chunk_id } => {
|
||||
commands::chunk::get(&client, &chunk_id, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Operation commands
|
||||
Commands::Operation(op_cmd) => match op_cmd {
|
||||
OperationCommands::List { bank_id } => {
|
||||
commands::operation::list(&client, &bank_id, verbose, output_format)
|
||||
}
|
||||
OperationCommands::Get { bank_id, operation_id } => {
|
||||
commands::operation::get(&client, &bank_id, &operation_id, verbose, output_format)
|
||||
}
|
||||
OperationCommands::Cancel { bank_id, operation_id } => {
|
||||
commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format)
|
||||
}
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
//! Integration tests for the hindsight CLI commands.
|
||||
//!
|
||||
//! These tests require a running hindsight API server.
|
||||
//! Set HINDSIGHT_API_URL environment variable to point to the server.
|
||||
//! Tests will be skipped if the server is not available.
|
||||
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if the API server is available
|
||||
fn server_available() -> bool {
|
||||
let api_url = env::var("HINDSIGHT_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
let health_url = format!("{}/health", api_url);
|
||||
|
||||
match reqwest::blocking::get(&health_url) {
|
||||
Ok(resp) => resp.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper macro to skip tests when server is not available
|
||||
macro_rules! skip_if_no_server {
|
||||
() => {
|
||||
if !server_available() {
|
||||
eprintln!("Skipping test: API server not available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the path to the hindsight binary
|
||||
fn hindsight_binary() -> String {
|
||||
env::var("CARGO_BIN_EXE_hindsight")
|
||||
.unwrap_or_else(|_| {
|
||||
// Try common locations
|
||||
let target_debug = "./target/debug/hindsight";
|
||||
let target_release = "./target/release/hindsight";
|
||||
if std::path::Path::new(target_debug).exists() {
|
||||
target_debug.to_string()
|
||||
} else if std::path::Path::new(target_release).exists() {
|
||||
target_release.to_string()
|
||||
} else {
|
||||
"hindsight".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Test bank ID for integration tests - each test needs a unique bank ID
|
||||
/// to avoid parallel test interference
|
||||
fn test_bank_id(test_name: &str) -> String {
|
||||
format!("cli-test-{}-{}", test_name, std::process::id())
|
||||
}
|
||||
|
||||
/// Run a hindsight CLI command
|
||||
fn run_hindsight(args: &[&str]) -> std::process::Output {
|
||||
let api_url = env::var("HINDSIGHT_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
Command::new(hindsight_binary())
|
||||
.env("HINDSIGHT_API_URL", &api_url)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("Failed to execute hindsight command")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["health"]);
|
||||
|
||||
// Should succeed or fail gracefully
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Either succeeded with "healthy" output or has a reasonable error
|
||||
if output.status.success() {
|
||||
// Note: output may contain ANSI color codes, so check for key text
|
||||
assert!(
|
||||
stdout.contains("healthy") || stdout.contains("Health") || stdout.contains("status"),
|
||||
"Expected health check output, got: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_json_output() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["health", "-o", "json"]);
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// Should be valid JSON
|
||||
let result: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.expect(&format!("Expected valid JSON output, got: {}", stdout));
|
||||
|
||||
// Should have status field
|
||||
assert!(result.get("status").is_some(), "Expected status field in health response");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["bank", "list"]);
|
||||
|
||||
// Should succeed (even if no banks exist)
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_list_json_output() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["bank", "list", "-o", "json"]);
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// Should be valid JSON array
|
||||
let _result: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.expect(&format!("Expected valid JSON output, got: {}", stdout));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_create_and_delete() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("create-delete");
|
||||
|
||||
// Create a bank
|
||||
let output = run_hindsight(&[
|
||||
"bank", "create",
|
||||
&bank_id,
|
||||
"--name", "Test Bank",
|
||||
"--mission", "A test bank for CLI integration tests",
|
||||
]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Bank might already exist, which is OK
|
||||
let created = output.status.success();
|
||||
|
||||
// Get bank disposition
|
||||
let output = run_hindsight(&["bank", "disposition", &bank_id]);
|
||||
if created {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank disposition command failed: {} / {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up: delete the bank
|
||||
let output = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
// Deletion should succeed
|
||||
if created {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank delete command failed: {} / {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("memory-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List memories (should be empty for new bank)
|
||||
let output = run_hindsight(&["memory", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if empty)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Memory list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mental_model_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("mm-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List mental models
|
||||
let output = run_hindsight(&["mental-model", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Mental model list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mental_model_create_and_delete() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("mm-create");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// Create a mental model
|
||||
let output = run_hindsight(&[
|
||||
"mental-model", "create",
|
||||
&bank_id,
|
||||
"Test Model",
|
||||
"A test mental model",
|
||||
]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// The create command should succeed
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Mental model create failed: stdout={}, stderr={}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Verify it's in the list
|
||||
let output = run_hindsight(&["mental-model", "list", &bank_id, "-o", "json"]);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Mental model list failed: {}",
|
||||
stdout
|
||||
);
|
||||
|
||||
// Parse JSON and verify model exists
|
||||
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
if let Some(items) = result.get("items").and_then(|v| v.as_array()) {
|
||||
// Check if any model has the name "Test Model"
|
||||
let found = items.iter().any(|item| {
|
||||
item.get("name").and_then(|v| v.as_str()) == Some("Test Model")
|
||||
});
|
||||
assert!(found, "Expected to find 'Test Model' in mental models list: {}", stdout);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("tag-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List tags
|
||||
let output = run_hindsight(&["tag", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if no tags)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Tag list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("entity-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List entities
|
||||
let output = run_hindsight(&["entity", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if no entities)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Entity list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("op-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List operations
|
||||
let output = run_hindsight(&["operation", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if no operations)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Operation list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_stats() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("bank-stats");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// Get stats
|
||||
let output = run_hindsight(&["bank", "stats", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank stats command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_graph() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("bank-graph");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// Get graph
|
||||
let output = run_hindsight(&["bank", "graph", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if empty graph)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank graph command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_update() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("bank-update");
|
||||
|
||||
// Create the bank first
|
||||
let output = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
if output.status.success() {
|
||||
// Update the bank
|
||||
let output = run_hindsight(&[
|
||||
"bank", "update", &bank_id,
|
||||
"--name", "Updated Test Bank",
|
||||
]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank update command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Verify the update
|
||||
let output = run_hindsight(&["bank", "disposition", &bank_id, "-o", "json"]);
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(
|
||||
result.get("name").and_then(|v| v.as_str()),
|
||||
Some("Updated Test Bank")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_yaml_output_formats() {
|
||||
skip_if_no_server!();
|
||||
|
||||
// Test JSON output for bank list
|
||||
let output = run_hindsight(&["bank", "list", "-o", "json"]);
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.expect("Expected valid JSON for bank list");
|
||||
}
|
||||
|
||||
// Test YAML output for bank list
|
||||
let output = run_hindsight(&["bank", "list", "-o", "yaml"]);
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _: serde_yaml::Value = serde_yaml::from_str(&stdout)
|
||||
.expect("Expected valid YAML for bank list");
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ easy-to-use interface on top of the auto-generated OpenAPI client.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
import hindsight_client_api
|
||||
from hindsight_client_api.api import memory_api, banks_api, mental_models_api
|
||||
from hindsight_client_api.api import memory_api, banks_api
|
||||
from hindsight_client_api.models import (
|
||||
recall_request,
|
||||
retain_request,
|
||||
@@ -23,9 +23,6 @@ from hindsight_client_api.models.recall_result import RecallResult
|
||||
from hindsight_client_api.models.reflect_response import ReflectResponse
|
||||
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
|
||||
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
|
||||
from hindsight_client_api.models.mental_model_response import MentalModelResponse
|
||||
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
|
||||
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
@@ -81,7 +78,6 @@ class Hindsight:
|
||||
self._api_client.set_default_header("Authorization", f"Bearer {api_key}")
|
||||
self._memory_api = memory_api.MemoryApi(self._api_client)
|
||||
self._banks_api = banks_api.BanksApi(self._api_client)
|
||||
self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client)
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
@@ -336,256 +332,6 @@ class Hindsight:
|
||||
|
||||
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
|
||||
|
||||
def set_mission(
|
||||
self,
|
||||
bank_id: str,
|
||||
mission: str,
|
||||
) -> BankProfileResponse:
|
||||
"""
|
||||
Set or update the mission for a memory bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
mission: The mission text describing the agent's purpose
|
||||
|
||||
Returns:
|
||||
BankProfileResponse with updated bank profile
|
||||
"""
|
||||
from hindsight_client_api.models import create_bank_request
|
||||
|
||||
request_obj = create_bank_request.CreateBankRequest(mission=mission)
|
||||
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
|
||||
|
||||
def list_mental_models(
|
||||
self,
|
||||
bank_id: str,
|
||||
subtype: Optional[Literal["structural", "emergent", "pinned", "learned", "directive"]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
tags_match: Optional[Literal["any", "all", "exact"]] = None,
|
||||
) -> MentalModelListResponse:
|
||||
"""
|
||||
List mental models for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
subtype: Optional filter by subtype (structural, emergent, pinned, learned, directive)
|
||||
tags: Optional list of tags to filter by
|
||||
tags_match: How to match tags - 'any' (OR), 'all' (AND), or 'exact'
|
||||
|
||||
Returns:
|
||||
MentalModelListResponse with list of mental models
|
||||
"""
|
||||
return _run_async(self._mental_models_api.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
subtype=subtype,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
))
|
||||
|
||||
def get_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Get a specific mental model by ID.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with full mental model details including observations
|
||||
"""
|
||||
return _run_async(self._mental_models_api.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def create_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
name: str,
|
||||
description: str,
|
||||
subtype: Literal["pinned", "directive"] = "pinned",
|
||||
observations: Optional[List[Dict[str, str]]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Create a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
name: Human-readable name for the mental model
|
||||
description: One-liner description for quick scanning
|
||||
subtype: Type of mental model - 'pinned' (LLM-generated observations) or 'directive' (user-provided observations)
|
||||
observations: For directives only - list of observations with 'title' and 'content' keys
|
||||
tags: Optional list of tags for scoped visibility
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with created mental model
|
||||
"""
|
||||
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
|
||||
from hindsight_client_api.models.observation_input import ObservationInput
|
||||
|
||||
obs_list = None
|
||||
if observations:
|
||||
obs_list = [ObservationInput(title=o.get("title", ""), content=o.get("content", "")) for o in observations]
|
||||
|
||||
request_obj = CreateMentalModelRequest(
|
||||
name=name,
|
||||
description=description,
|
||||
subtype=subtype,
|
||||
observations=obs_list,
|
||||
tags=tags or [],
|
||||
)
|
||||
return _run_async(self._mental_models_api.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
create_mental_model_request=request_obj,
|
||||
))
|
||||
|
||||
def update_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Update a mental model's name and/or description.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
name: Optional new name
|
||||
description: Optional new description
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with updated mental model
|
||||
"""
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
|
||||
request_obj = UpdateMentalModelRequest(
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
return _run_async(self._mental_models_api.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
update_mental_model_request=request_obj,
|
||||
))
|
||||
|
||||
def delete_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
):
|
||||
"""
|
||||
Delete a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
DeleteResponse confirming deletion
|
||||
"""
|
||||
return _run_async(self._mental_models_api.delete_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def refresh_mental_models(
|
||||
self,
|
||||
bank_id: str,
|
||||
subtype: Optional[Literal["structural", "emergent", "pinned", "learned"]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AsyncOperationSubmitResponse:
|
||||
"""
|
||||
Submit a background job to refresh mental models for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
subtype: Optional - only refresh models of this subtype
|
||||
tags: Optional - tags to apply to newly created mental models
|
||||
|
||||
Returns:
|
||||
AsyncOperationSubmitResponse with operation_id to track progress
|
||||
"""
|
||||
from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest
|
||||
|
||||
request_obj = RefreshMentalModelsRequest(
|
||||
subtype=subtype,
|
||||
tags=tags,
|
||||
)
|
||||
return _run_async(self._mental_models_api.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
refresh_mental_models_request=request_obj,
|
||||
))
|
||||
|
||||
def refresh_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
) -> AsyncOperationSubmitResponse:
|
||||
"""
|
||||
Submit a background job to refresh content for a specific mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID to refresh
|
||||
|
||||
Returns:
|
||||
AsyncOperationSubmitResponse with operation_id to track progress
|
||||
"""
|
||||
return _run_async(self._mental_models_api.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def list_mental_model_versions(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
):
|
||||
"""
|
||||
List all saved versions of a mental model's observations.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
List of version objects ordered by version descending
|
||||
"""
|
||||
return _run_async(self._mental_models_api.list_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def get_mental_model_version(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
version: int,
|
||||
):
|
||||
"""
|
||||
Get observations from a specific version of a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
version: The version number
|
||||
|
||||
Returns:
|
||||
Version object with observations at that version
|
||||
"""
|
||||
return _run_async(self._mental_models_api.get_mental_model_version(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
version=version,
|
||||
))
|
||||
|
||||
# Async methods (native async, no _run_async wrapper)
|
||||
|
||||
async def aretain_batch(
|
||||
|
||||
@@ -544,205 +544,3 @@ class TestDeleteBank:
|
||||
# Verify bank data is deleted - memories should be gone
|
||||
memories = client.list_memories(bank_id=bank_id)
|
||||
assert memories.total == 0
|
||||
|
||||
|
||||
class TestMentalModels:
|
||||
"""Tests for mental model operations."""
|
||||
|
||||
def test_set_mission(self, client, bank_id):
|
||||
"""Test setting a bank's mission."""
|
||||
response = client.set_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Be a helpful PM tracking sprint progress and team capacity",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.bank_id == bank_id
|
||||
assert response.mission == "Be a helpful PM tracking sprint progress and team capacity"
|
||||
|
||||
def test_create_pinned_mental_model(self, client, bank_id):
|
||||
"""Test creating a pinned mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
response = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Product Roadmap",
|
||||
description="Track product priorities and feature decisions",
|
||||
subtype="pinned",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Product Roadmap"
|
||||
assert response.description == "Track product priorities and feature decisions"
|
||||
assert response.subtype == "pinned"
|
||||
|
||||
def test_create_directive_mental_model(self, client, bank_id):
|
||||
"""Test creating a directive mental model with observations."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
response = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Response Guidelines",
|
||||
description="Rules for responding to users",
|
||||
subtype="directive",
|
||||
observations=[
|
||||
{"title": "Always be polite", "content": "All responses must be courteous and professional"},
|
||||
{"title": "Never share private info", "content": "Do not reveal internal details or user data"},
|
||||
],
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Response Guidelines"
|
||||
assert response.subtype == "directive"
|
||||
assert response.observations is not None
|
||||
assert len(response.observations) == 2
|
||||
|
||||
def test_list_mental_models(self, client, bank_id):
|
||||
"""Test listing mental models."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Model",
|
||||
description="A test mental model",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.list_mental_models(bank_id=bank_id)
|
||||
|
||||
assert response is not None
|
||||
assert response.items is not None
|
||||
assert len(response.items) >= 1
|
||||
|
||||
def test_get_mental_model(self, client, bank_id):
|
||||
"""Test getting a specific mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Retrieve Test Model",
|
||||
description="A model to retrieve",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.id == created.id
|
||||
assert response.name == "Retrieve Test Model"
|
||||
|
||||
def test_update_mental_model(self, client, bank_id):
|
||||
"""Test updating a mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Update Test Model",
|
||||
description="Original description",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
name="Updated Model Name",
|
||||
description="Updated description",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Updated Model Name"
|
||||
assert response.description == "Updated description"
|
||||
|
||||
def test_delete_mental_model(self, client, bank_id):
|
||||
"""Test deleting a mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Delete Test Model",
|
||||
description="A model to delete",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.delete_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.success is True
|
||||
|
||||
def test_refresh_mental_models(self, client, bank_id):
|
||||
"""Test refreshing all mental models (async operation)."""
|
||||
# Set mission first (required for refresh) - this also creates the bank
|
||||
client.set_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Track team progress and decisions",
|
||||
)
|
||||
|
||||
response = client.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.operation_id is not None
|
||||
assert response.status == "queued"
|
||||
|
||||
def test_refresh_mental_model(self, client, bank_id):
|
||||
"""Test refreshing a single mental model (async operation)."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Refresh Single Test",
|
||||
description="A model to refresh individually",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.operation_id is not None
|
||||
assert response.status == "queued"
|
||||
|
||||
def test_list_mental_model_versions(self, client, bank_id):
|
||||
"""Test listing mental model versions."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Versions Test Model",
|
||||
description="A model to test version history",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.list_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
# Newly created model should have version history
|
||||
assert response is not None
|
||||
|
||||
@@ -40,10 +40,6 @@ import type {
|
||||
BankProfileResponse,
|
||||
CreateBankRequest,
|
||||
Budget,
|
||||
MentalModelResponse,
|
||||
MentalModelListResponse,
|
||||
AsyncOperationSubmitResponse,
|
||||
ObservationInput,
|
||||
} from '../generated/types.gen';
|
||||
|
||||
export interface HindsightClientOptions {
|
||||
@@ -312,176 +308,6 @@ export class HindsightClient {
|
||||
|
||||
return this.validateResponse(response, 'getBankProfile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the mission for a memory bank.
|
||||
*/
|
||||
async setMission(bankId: string, mission: string): Promise<BankProfileResponse> {
|
||||
const response = await sdk.createOrUpdateBank({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: { mission },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'setMission');
|
||||
}
|
||||
|
||||
/**
|
||||
* List mental models for a bank.
|
||||
*/
|
||||
async listMentalModels(
|
||||
bankId: string,
|
||||
options?: {
|
||||
subtype?: 'structural' | 'emergent' | 'pinned' | 'learned' | 'directive';
|
||||
tags?: string[];
|
||||
tagsMatch?: 'any' | 'all' | 'exact';
|
||||
}
|
||||
): Promise<MentalModelListResponse> {
|
||||
const response = await sdk.listMentalModels({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
query: {
|
||||
subtype: options?.subtype,
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'listMentalModels');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific mental model by ID.
|
||||
*/
|
||||
async getMentalModel(bankId: string, modelId: string): Promise<MentalModelResponse> {
|
||||
const response = await sdk.getMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'getMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mental model.
|
||||
*/
|
||||
async createMentalModel(
|
||||
bankId: string,
|
||||
options: {
|
||||
name: string;
|
||||
description: string;
|
||||
subtype?: 'pinned' | 'directive';
|
||||
observations?: Array<{ title: string; content: string }>;
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<MentalModelResponse> {
|
||||
const response = await sdk.createMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
subtype: options.subtype,
|
||||
observations: options.observations,
|
||||
tags: options.tags,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'createMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a mental model's name and/or description.
|
||||
*/
|
||||
async updateMentalModel(
|
||||
bankId: string,
|
||||
modelId: string,
|
||||
options: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
): Promise<MentalModelResponse> {
|
||||
const response = await sdk.updateMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
body: {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'updateMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a mental model.
|
||||
*/
|
||||
async deleteMentalModel(bankId: string, modelId: string): Promise<void> {
|
||||
const response = await sdk.deleteMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
this.validateResponse(response, 'deleteMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a background job to refresh mental models for a bank.
|
||||
*/
|
||||
async refreshMentalModels(
|
||||
bankId: string,
|
||||
options?: {
|
||||
subtype?: 'structural' | 'emergent' | 'pinned' | 'learned';
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<AsyncOperationSubmitResponse> {
|
||||
const response = await sdk.refreshMentalModels({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
subtype: options?.subtype,
|
||||
tags: options?.tags,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'refreshMentalModels');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a background job to refresh content for a specific mental model.
|
||||
*/
|
||||
async refreshMentalModel(bankId: string, modelId: string): Promise<AsyncOperationSubmitResponse> {
|
||||
const response = await sdk.refreshMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'refreshMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved versions of a mental model's observations.
|
||||
*/
|
||||
async listMentalModelVersions(bankId: string, modelId: string): Promise<unknown> {
|
||||
const response = await sdk.listMentalModelVersions({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'listMentalModelVersions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get observations from a specific version of a mental model.
|
||||
*/
|
||||
async getMentalModelVersion(bankId: string, modelId: string, version: number): Promise<unknown> {
|
||||
const response = await sdk.getMentalModelVersion({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId, version },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'getMentalModelVersion');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
@@ -497,10 +323,6 @@ export type {
|
||||
BankProfileResponse,
|
||||
CreateBankRequest,
|
||||
Budget,
|
||||
MentalModelResponse,
|
||||
MentalModelListResponse,
|
||||
AsyncOperationSubmitResponse,
|
||||
ObservationInput,
|
||||
};
|
||||
|
||||
// Also export low-level SDK functions for advanced usage
|
||||
|
||||
@@ -412,186 +412,3 @@ describe('TestDeleteBank', () => {
|
||||
expect(memories.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TestMentalModels', () => {
|
||||
test('set mission', async () => {
|
||||
const bankId = randomBankId();
|
||||
const response = await client.setMission(
|
||||
bankId,
|
||||
'Be a helpful PM tracking sprint progress and team capacity'
|
||||
);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.bank_id).toBe(bankId);
|
||||
expect(response.mission).toBe('Be a helpful PM tracking sprint progress and team capacity');
|
||||
});
|
||||
|
||||
test('create pinned mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
const response = await client.createMentalModel(bankId, {
|
||||
name: 'Product Roadmap',
|
||||
description: 'Track product priorities and feature decisions',
|
||||
subtype: 'pinned',
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Product Roadmap');
|
||||
expect(response.description).toBe('Track product priorities and feature decisions');
|
||||
expect(response.subtype).toBe('pinned');
|
||||
});
|
||||
|
||||
test('create directive mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
const response = await client.createMentalModel(bankId, {
|
||||
name: 'Response Guidelines',
|
||||
description: 'Rules for responding to users',
|
||||
subtype: 'directive',
|
||||
observations: [
|
||||
{ title: 'Always be polite', content: 'All responses must be courteous and professional' },
|
||||
{ title: 'Never share private info', content: 'Do not reveal internal details or user data' },
|
||||
],
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Response Guidelines');
|
||||
expect(response.subtype).toBe('directive');
|
||||
expect(response.observations).toBeDefined();
|
||||
expect(response.observations!.length).toBe(2);
|
||||
});
|
||||
|
||||
test('list mental models', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
await client.createMentalModel(bankId, {
|
||||
name: 'Test Model',
|
||||
description: 'A test mental model',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.listMentalModels(bankId);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.items).toBeDefined();
|
||||
expect(response.items!.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('get mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Retrieve Test Model',
|
||||
description: 'A model to retrieve',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.getMentalModel(bankId, created.id);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.id).toBe(created.id);
|
||||
expect(response.name).toBe('Retrieve Test Model');
|
||||
});
|
||||
|
||||
test('update mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Update Test Model',
|
||||
description: 'Original description',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.updateMentalModel(bankId, created.id, {
|
||||
name: 'Updated Model Name',
|
||||
description: 'Updated description',
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Updated Model Name');
|
||||
expect(response.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
test('delete mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Delete Test Model',
|
||||
description: 'A model to delete',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
// Delete should not throw
|
||||
await expect(client.deleteMentalModel(bankId, created.id)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('refresh mental models', async () => {
|
||||
const bankId = randomBankId();
|
||||
|
||||
// Set mission first (required for refresh) - this also creates the bank
|
||||
await client.setMission(bankId, 'Track team progress and decisions');
|
||||
|
||||
const response = await client.refreshMentalModels(bankId, {
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.operation_id).toBeDefined();
|
||||
expect(response.status).toBe('queued');
|
||||
});
|
||||
|
||||
test('refresh mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Refresh Single Test',
|
||||
description: 'A model to refresh individually',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.refreshMentalModel(bankId, created.id);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.operation_id).toBeDefined();
|
||||
expect(response.status).toBe('queued');
|
||||
});
|
||||
|
||||
test('list mental model versions', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Versions Test Model',
|
||||
description: 'A model to test version history',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.listMentalModelVersions(bankId, created.id);
|
||||
|
||||
// Newly created model should have version history
|
||||
expect(response).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ Support for external streaming platforms like Kafka for scale-out processing is
|
||||
| **batch_retain** | `retain_batch` with `async=True` | Processes large content batches in the background |
|
||||
| **form_opinion** | After each `reflect` call | Extracts and stores new opinions formed during reflection |
|
||||
| **reinforce_opinion** | After `retain` | Updates opinion confidence based on new supporting evidence |
|
||||
| **access_count_update** | After `recall` | Tracks which memories are accessed for relevance scoring |
|
||||
| **regenerate_observations** | Bank profile update | Regenerates entity observations when disposition changes |
|
||||
|
||||
## Async Retain Example
|
||||
|
||||
Reference in New Issue
Block a user