Compare commits

..
4 Commits
Author SHA1 Message Date
Nicolò Boschi 85d5b3bfd9 tests 2026-01-19 18:34:32 +01:00
Nicolò Boschi f15b76fba8 docs 2026-01-19 17:12:55 +01:00
Nicolò Boschi 61f457736e doc 2026-01-19 16:44:45 +01:00
Nicolò Boschi 5de1447ff5 feat: new 'worker' service 2026-01-19 16:32:18 +01:00
35 changed files with 153 additions and 3833 deletions
@@ -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.
+2
View File
@@ -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(
+4 -53
View File
@@ -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():
-159
View File
@@ -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):
"""
-4
View File
@@ -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
+3 -27
View File
@@ -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
-260
View File
@@ -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,
-220
View File
@@ -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,
-96
View File
@@ -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);
}
}
-157
View File
@@ -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()));
}
}
-199
View File
@@ -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] = &[
-721
View File
@@ -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");
}
}
+2 -6
View File
@@ -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;
-49
View File
@@ -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,
-119
View File
@@ -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
View File
@@ -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)
}
-483
View File
@@ -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
-178
View File
@@ -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