Compare commits

..
Author SHA1 Message Date
Nicolò Boschi e560d5f22d fix: sometimes memories gets extracted in the wrong language 2026-01-22 16:24:13 +01:00
Nicolò Boschi 9a080a0fd5 fixes 2026-01-22 13:53:54 +01:00
Nicolò Boschi 876deb2e36 fixes 2026-01-22 13:06:09 +01:00
Nicolò Boschi 9f10db6422 fixes 2026-01-22 12:01:40 +01:00
Nicolò Boschi 8e37acc5f6 initial commit 2026-01-21 18:43:38 +01:00
Nicolò Boschi 431783dc35 bunch of fixes 2026-01-21 16:55:14 +01:00
Nicolò Boschi db0fed246d new mm 2026-01-21 16:17:50 +01:00
Nicolò Boschi 833e260cd7 fixes 2026-01-21 09:11:03 +01:00
Nicolò Boschi 9d188942b1 chore: run benchmarks with reflect mode 2026-01-20 10:40:19 +01:00
Nicolò Boschi 19e3743683 chore: run benchmarks with reflect mode 2026-01-20 10:40:19 +01:00
261 changed files with 11537 additions and 24580 deletions
-60
View File
@@ -875,66 +875,6 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-upgrade:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for git clone of tags
- name: Fetch tags
run: git fetch --tags
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Install hindsight-dev dependencies
working-directory: ./hindsight-dev
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Install current hindsight-api
working-directory: ./hindsight-api
run: uv sync --frozen --index-strategy unsafe-best-match
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Run upgrade tests
working-directory: ./hindsight-dev
run: uv run pytest upgrade_tests/ -v --tb=short
verify-generated-files:
runs-on: ubuntu-latest
env:
+3 -2
View File
@@ -7,7 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. Memories are organized as:
- **World facts**: General knowledge ("The sky is blue")
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
- **Mental models**: Consolidated knowledge synthesized from facts ("User prefers functional programming patterns")
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
- **Observations**: Complex mental models derived from reflection
## Development Commands
@@ -100,7 +101,7 @@ cd hindsight-control-plane && npm run dev
Main operations:
- **Retain**: Store memories, extracts facts/entities/relationships
- **Recall**: Retrieve memories via 4 parallel strategies (semantic, BM25, graph, temporal) + reranking
- **Reflect**: Disposition-aware reasoning using memories and mental models.
- **Reflect**: Deep analysis forming new opinions/observations (disposition-aware)
### Database
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
@@ -1,53 +0,0 @@
"""Add consolidated_at column to memory_units for incremental consolidation tracking.
This allows consolidation to track progress at the memory level rather than
using a bank-level watermark. If consolidation crashes, already-processed
memories won't be reprocessed.
Revision ID: s4n5o6p7q8r9
Revises: r3m4n5o6p7q8
Create Date: 2025-01-22
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "s4n5o6p7q8r9"
down_revision: str | Sequence[str] | None = "r3m4n5o6p7q8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Add consolidated_at column to memory_units
op.execute(
f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS consolidated_at TIMESTAMPTZ DEFAULT NULL
"""
)
# Create index for efficient querying of unconsolidated memories
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
ON {schema}memory_units (bank_id, created_at)
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidated_at")
@@ -1,134 +0,0 @@
"""Rename mental_model fact_type to observation and reflections table to mental_models
Revision ID: t5o6p7q8r9s0
Revises: s4n5o6p7q8r9
Create Date: 2026-01-26
This migration implements the terminology rename:
1. mental_model (fact_type in memory_units) -> observation
2. reflections table -> mental_models table
The new terminology:
- Observations: Consolidated knowledge synthesized from facts (was mental_model)
- Mental Models: Stored reflect responses (was reflections)
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "t5o6p7q8r9s0"
down_revision: str | Sequence[str] | None = "s4n5o6p7q8r9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Rename mental_model -> observation and reflections -> mental_models."""
schema = _get_schema_prefix()
# 1. Update fact_type values: mental_model -> observation
op.execute(f"""
UPDATE {schema}memory_units
SET fact_type = 'observation'
WHERE fact_type = 'mental_model'
""")
# 2. Update the CHECK constraint - remove mental_model, keep observation
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
""")
# 3. Rename the index for observations (was for mental_models)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_observations
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'observation'
""")
# 4. Update the unconsolidated index to not filter by fact_type since observations
# are now the consolidated type
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
ON {schema}memory_units (bank_id, created_at)
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
""")
# 5. Rename reflections table to mental_models
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO mental_models")
# 6. Rename indexes for mental_models (was reflections)
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_mental_models_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_mental_models_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_mental_models_tags")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_mental_models_text_search")
# 7. Rename foreign key constraint
op.execute(f"""
ALTER TABLE {schema}mental_models
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT fk_mental_models_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
def downgrade() -> None:
"""Reverse: observation -> mental_model and mental_models -> reflections."""
schema = _get_schema_prefix()
# 1. Rename mental_models table back to reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}mental_models RENAME TO reflections")
# 2. Rename indexes back
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_bank_id RENAME TO idx_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_embedding RENAME TO idx_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_tags RENAME TO idx_reflections_tags")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_text_search RENAME TO idx_reflections_text_search")
# 3. Rename foreign key back
op.execute(f"""
ALTER TABLE {schema}reflections
DROP CONSTRAINT IF EXISTS fk_mental_models_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}reflections
ADD CONSTRAINT fk_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# 4. Update fact_type values: observation -> mental_model
op.execute(f"""
UPDATE {schema}memory_units
SET fact_type = 'mental_model'
WHERE fact_type = 'observation'
""")
# 5. Update the CHECK constraint back
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
""")
# 6. Rename index back
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_observations")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'mental_model'
""")
@@ -1,41 +0,0 @@
"""Change mental_models.id from UUID to TEXT
Revision ID: u6p7q8r9s0t1
Revises: t5o6p7q8r9s0
Create Date: 2026-01-27
This migration changes the mental_models.id column from UUID to TEXT
to support user-defined text identifiers like 'team-communication' instead of UUIDs.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "u6p7q8r9s0t1"
down_revision: str | Sequence[str] | None = "t5o6p7q8r9s0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Change mental_models.id from UUID to TEXT."""
schema = _get_schema_prefix()
# Change the id column type from UUID to TEXT
# Existing UUIDs will be converted to their string representation
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT USING id::TEXT")
def downgrade() -> None:
"""Revert mental_models.id from TEXT to UUID."""
schema = _get_schema_prefix()
# Note: This will fail if any id values are not valid UUIDs
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE UUID USING id::UUID")
@@ -1,50 +0,0 @@
"""Add max_tokens and trigger columns to mental_models
Revision ID: v7q8r9s0t1u2
Revises: u6p7q8r9s0t1
Create Date: 2026-01-27
This migration adds:
- max_tokens column: token limit for content generation during refresh
- trigger column: JSONB for trigger settings (e.g., refresh_after_consolidation)
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "v7q8r9s0t1u2"
down_revision: str | Sequence[str] | None = "u6p7q8r9s0t1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add max_tokens and trigger columns to mental_models."""
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS max_tokens INT NOT NULL DEFAULT 2048
""")
# trigger column stores trigger settings as JSONB
# Default: refresh_after_consolidation = false (not "real time")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS trigger JSONB NOT NULL DEFAULT '{{"refresh_after_consolidation": false}}'::jsonb
""")
def downgrade() -> None:
"""Remove max_tokens and trigger columns from mental_models."""
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS max_tokens")
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS trigger")
+227 -232
View File
@@ -92,7 +92,7 @@ class RecallRequest(BaseModel):
query: str
types: list[str] | None = Field(
default=None,
description="List of fact types to recall: 'world', 'experience', 'observation'. Defaults to world and experience if not specified. "
description="List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. "
"Note: 'opinion' is accepted but ignored (opinions are excluded from recall).",
)
budget: Budget = Budget.MID
@@ -535,22 +535,6 @@ class ReflectFact(BaseModel):
occurred_end: str | None = None
class ReflectDirective(BaseModel):
"""A directive applied during reflect."""
id: str = Field(description="Directive ID")
name: str = Field(description="Directive name")
content: str = Field(description="Directive content")
class ReflectMentalModel(BaseModel):
"""A mental model used during reflect."""
id: str = Field(description="Mental model ID")
text: str = Field(description="Mental model content")
context: str | None = Field(default=None, description="Additional context")
class ReflectToolCall(BaseModel):
"""A tool call made during reflect agent execution."""
@@ -570,14 +554,22 @@ class ReflectLLMCall(BaseModel):
duration_ms: int = Field(description="Execution time in milliseconds")
class ReflectMentalModel(BaseModel):
"""A mental model accessed during reflect."""
id: str = Field(description="Mental model ID")
name: str = Field(description="Mental model name")
type: str = Field(description="Mental model type: entity, concept, event")
subtype: str = Field(description="Mental model subtype: structural, emergent, learned, directive")
observations: list[str] | None = Field(
default=None, description="Observations for directive mental models (subtype='directive')"
)
class ReflectBasedOn(BaseModel):
"""Evidence the response is based on: memories, mental models, and directives."""
"""Evidence the response is based on: memories and mental models."""
memories: list[ReflectFact] = Field(default_factory=list, description="Memory facts used to generate the response")
mental_models: list[ReflectMentalModel] = Field(
default_factory=list, description="Mental models used during reflection"
)
directives: list[ReflectDirective] = Field(default_factory=list, description="Directives applied during reflection")
class ReflectTrace(BaseModel):
@@ -585,6 +577,10 @@ class ReflectTrace(BaseModel):
tool_calls: list[ReflectToolCall] = Field(default_factory=list, description="Tool calls made during reflection")
llm_calls: list[ReflectLLMCall] = Field(default_factory=list, description="LLM calls made during reflection")
mental_models: list[ReflectMentalModel] = Field(
default_factory=list,
description="Mental models used during reflection (includes directives with subtype='directive')",
)
class ReflectResponse(BaseModel):
@@ -599,6 +595,15 @@ class ReflectResponse(BaseModel):
{"id": "123", "text": "AI is used in healthcare", "type": "world"},
{"id": "456", "text": "I discussed AI applications last week", "type": "experience"},
],
"mental_models": [
{
"id": "mm-1",
"name": "AI Technology",
"type": "concept",
"subtype": "structural",
"description": "Understanding of AI capabilities",
}
],
},
"structured_output": {
"summary": "AI is transformative",
@@ -608,14 +613,6 @@ class ReflectResponse(BaseModel):
"trace": {
"tool_calls": [{"tool": "recall", "input": {"query": "AI"}, "duration_ms": 150}],
"llm_calls": [{"scope": "agent_1", "duration_ms": 1200}],
"observations": [
{
"id": "obs-1",
"name": "AI Technology",
"type": "concept",
"subtype": "structural",
}
],
},
}
}
@@ -1019,7 +1016,7 @@ class BankStatsResponse(BaseModel):
"failed_operations": 0,
"last_consolidated_at": "2024-01-15T10:30:00Z",
"pending_consolidation": 0,
"total_observations": 45,
"total_mental_models": 45,
}
}
)
@@ -1036,8 +1033,8 @@ class BankStatsResponse(BaseModel):
failed_operations: int
# Consolidation stats
last_consolidated_at: str | None = Field(default=None, description="When consolidation last ran (ISO format)")
pending_consolidation: int = Field(default=0, description="Number of memories not yet processed into observations")
total_observations: int = Field(default=0, description="Total number of observations")
pending_consolidation: int = Field(default=0, description="Number of memories not yet processed into mental models")
total_mental_models: int = Field(default=0, description="Total number of mental models")
# Mental Model models
@@ -1098,21 +1095,12 @@ class UpdateDirectiveRequest(BaseModel):
# =========================================================================
# Mental Models (stored reflect responses)
# Reflections Models
# =========================================================================
class MentalModelTrigger(BaseModel):
"""Trigger settings for a mental model."""
refresh_after_consolidation: bool = Field(
default=False,
description="If true, refresh this mental model after observations consolidation (real-time mode)",
)
class MentalModelResponse(BaseModel):
"""Response model for a mental model (stored reflect response)."""
class ReflectionResponse(BaseModel):
"""Response model for a reflection."""
id: str
bank_id: str
@@ -1120,24 +1108,22 @@ class MentalModelResponse(BaseModel):
source_query: str
content: str
tags: list[str] = Field(default_factory=list)
max_tokens: int = Field(default=2048)
trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger)
last_refreshed_at: str | None = None
created_at: str | None = None
reflect_response: dict | None = Field(
default=None,
description="Full reflect API response payload including based_on facts and observations",
description="Full reflect API response payload including based_on facts and mental_models",
)
class MentalModelListResponse(BaseModel):
"""Response model for listing mental models."""
class ReflectionListResponse(BaseModel):
"""Response model for listing reflections."""
items: list[MentalModelResponse]
items: list[ReflectionResponse]
class CreateMentalModelRequest(BaseModel):
"""Request model for creating a mental model."""
class CreateReflectionRequest(BaseModel):
"""Request model for creating a reflection."""
model_config = ConfigDict(
json_schema_extra={
@@ -1146,44 +1132,34 @@ class CreateMentalModelRequest(BaseModel):
"source_query": "How does the team prefer to communicate?",
"tags": ["team"],
"max_tokens": 2048,
"trigger": {"refresh_after_consolidation": False},
}
}
)
name: str = Field(description="Human-readable name for the mental model")
name: str = Field(description="Human-readable name for the reflection")
source_query: str = Field(description="The query to run to generate content")
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
max_tokens: int = Field(default=2048, ge=256, le=8192, description="Maximum tokens for generated content")
trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger, description="Trigger settings")
class CreateMentalModelResponse(BaseModel):
"""Response model for mental model creation."""
class CreateReflectionResponse(BaseModel):
"""Response model for reflection creation."""
operation_id: str = Field(description="Operation ID to track progress")
class UpdateMentalModelRequest(BaseModel):
"""Request model for updating a mental model."""
class UpdateReflectionRequest(BaseModel):
"""Request model for updating a reflection."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Updated Team Communication Preferences",
"source_query": "How does the team prefer to communicate?",
"max_tokens": 4096,
"tags": ["team", "communication"],
"trigger": {"refresh_after_consolidation": True},
}
}
)
name: str | None = Field(default=None, description="New name for the mental model")
source_query: str | None = Field(default=None, description="New source query for the mental model")
max_tokens: int | None = Field(default=None, ge=256, le=8192, description="Maximum tokens for generated content")
tags: list[str] | None = Field(default=None, description="Tags for scoped visibility")
trigger: MentalModelTrigger | None = Field(default=None, description="Trigger settings")
name: str | None = Field(default=None, description="New name for the reflection")
class OperationResponse(BaseModel):
@@ -1215,8 +1191,11 @@ class OperationResponse(BaseModel):
class ConsolidationResponse(BaseModel):
"""Response model for consolidation trigger endpoint."""
operation_id: str = Field(description="ID of the async consolidation operation")
deduplicated: bool = Field(default=False, description="True if an existing pending task was reused")
status: str = Field(description="Status of the consolidation (completed or queued)")
processed: int = Field(description="Number of memories processed")
created: int = Field(description="Number of mental models created")
updated: int = Field(description="Number of mental models updated")
message: str = Field(description="Human-readable summary")
class OperationsListResponse(BaseModel):
@@ -1312,7 +1291,7 @@ class AsyncOperationSubmitResponse(BaseModel):
class FeaturesInfo(BaseModel):
"""Feature flags indicating which capabilities are enabled."""
observations: bool = Field(description="Whether observations (auto-consolidation) are enabled")
mental_models: bool = Field(description="Whether mental models (auto-consolidation) are enabled")
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: bool = Field(description="Whether the background worker is enabled")
@@ -1325,7 +1304,7 @@ class VersionResponse(BaseModel):
"example": {
"api_version": "1.0.0",
"features": {
"observations": False,
"mental_models": False,
"mcp": True,
"worker": True,
},
@@ -1414,7 +1393,6 @@ def create_app(
poll_interval_ms=config.worker_poll_interval_ms,
batch_size=config.worker_batch_size,
max_retries=config.worker_max_retries,
tenant_extension=getattr(memory, "_tenant_extension", None),
)
poller_task = asyncio.create_task(poller.run())
logging.info(f"Worker poller started (worker_id={worker_id})")
@@ -1573,7 +1551,7 @@ def _register_routes(app: FastAPI):
return VersionResponse(
api_version="1.0.0",
features=FeaturesInfo(
observations=config.enable_observations,
mental_models=config.enable_mental_models,
mcp=config.mcp_enabled,
worker=config.worker_enabled,
),
@@ -1887,48 +1865,25 @@ def _register_routes(app: FastAPI):
tags_match=request.tags_match,
)
# Build based_on (memories + mental_models + directives) if facts are requested
# Build based_on (memories + mental_models) if facts are requested
based_on_result: ReflectBasedOn | None = None
if request.include.facts is not None:
memories = []
mental_models = []
directives = []
for fact_type, facts in core_result.based_on.items():
if fact_type == "directives":
# Directives have different structure (id, name, content)
for directive in facts:
directives.append(
ReflectDirective(
id=directive.id,
name=directive.name,
content=directive.content,
)
for fact in facts:
memories.append(
ReflectFact(
id=fact.id,
text=fact.text,
type=fact.fact_type,
context=fact.context,
occurred_start=fact.occurred_start,
occurred_end=fact.occurred_end,
)
elif fact_type == "mental_models":
# Mental models are MemoryFact with type "mental_models"
for fact in facts:
mental_models.append(
ReflectMentalModel(
id=fact.id,
text=fact.text,
context=fact.context,
)
)
else:
for fact in facts:
memories.append(
ReflectFact(
id=fact.id,
text=fact.text,
type=fact.fact_type,
context=fact.context,
occurred_start=fact.occurred_start,
occurred_end=fact.occurred_end,
)
)
based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models, directives=directives)
)
based_on_result = ReflectBasedOn(memories=memories)
# Build trace (tool_calls + llm_calls + observations) if tool_calls is requested
# Build trace (tool_calls + llm_calls + mental_models) if tool_calls is requested
trace_result: ReflectTrace | None = None
if request.include.tool_calls is not None:
include_output = request.include.tool_calls.output
@@ -1943,9 +1898,33 @@ def _register_routes(app: FastAPI):
for tc in core_result.tool_trace
]
llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace]
# Build map of directive observations by id
directive_observations = {d.id: d.rules for d in core_result.directives_applied}
# Build mental models from tool trace (get_mental_model outputs)
trace_mental_models: list[ReflectMentalModel] = []
seen_model_ids: set[str] = set()
for tc in core_result.tool_trace:
if tc.tool == "get_mental_model" and tc.output.get("found") and "model" in tc.output:
model = tc.output["model"]
model_id = model.get("id")
if model_id and model_id not in seen_model_ids:
seen_model_ids.add(model_id)
model_subtype = model.get("subtype", "structural")
trace_mental_models.append(
ReflectMentalModel(
id=model_id,
name=model.get("name", ""),
type=model.get("type", "concept"),
subtype=model_subtype,
observations=directive_observations.get(model_id)
if model_subtype == "directive"
else None,
)
)
trace_result = ReflectTrace(
tool_calls=tool_calls,
llm_calls=llm_calls,
mental_models=trace_mental_models,
)
return ReflectResponse(
@@ -2079,30 +2058,53 @@ def _register_routes(app: FastAPI):
)
total_documents = doc_count_result["count"] if doc_count_result else 0
# Get consolidation stats from memory-level tracking
consolidation_stats = await conn.fetchrow(
# Get consolidation stats
bank_row = await conn.fetchrow(
f"""
SELECT
MAX(consolidated_at) as last_consolidated_at,
COUNT(*) FILTER (WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')) as pending
FROM {fq_table("memory_units")}
SELECT last_consolidated_at
FROM {fq_table("banks")}
WHERE bank_id = $1
""",
bank_id,
)
last_consolidated_at = consolidation_stats["last_consolidated_at"] if consolidation_stats else None
pending_consolidation = consolidation_stats["pending"] if consolidation_stats else 0
last_consolidated_at = bank_row["last_consolidated_at"] if bank_row else None
# Count total observations (consolidated knowledge)
observation_count_result = await conn.fetchrow(
# Count memories pending consolidation (created after last_consolidated_at)
if last_consolidated_at:
pending_consolidation_result = await conn.fetchrow(
f"""
SELECT COUNT(*) as count
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND created_at > $2
AND fact_type IN ('experience', 'world')
""",
bank_id,
last_consolidated_at,
)
else:
# If never consolidated, count all experience/world memories
pending_consolidation_result = await conn.fetchrow(
f"""
SELECT COUNT(*) as count
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
pending_consolidation = pending_consolidation_result["count"] if pending_consolidation_result else 0
# Count total mental models
mental_model_count_result = await conn.fetchrow(
f"""
SELECT COUNT(*) as count
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND fact_type = 'observation'
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
total_observations = observation_count_result["count"] if observation_count_result else 0
total_mental_models = mental_model_count_result["count"] if mental_model_count_result else 0
# Format results
nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats}
@@ -2135,7 +2137,7 @@ def _register_routes(app: FastAPI):
failed_operations=failed_operations,
last_consolidated_at=(last_consolidated_at.isoformat() if last_consolidated_at else None),
pending_consolidation=pending_consolidation,
total_observations=total_observations,
total_mental_models=total_mental_models,
)
except (AuthenticationError, HTTPException):
@@ -2242,18 +2244,18 @@ def _register_routes(app: FastAPI):
# =========================================================================
# =========================================================================
# MENTAL MODELS ENDPOINTS (stored reflect responses)
# REFLECTIONS ENDPOINTS
# =========================================================================
@app.get(
"/v1/default/banks/{bank_id}/mental-models",
response_model=MentalModelListResponse,
summary="List mental models",
"/v1/default/banks/{bank_id}/reflections",
response_model=ReflectionListResponse,
summary="List reflections",
description="List user-curated living documents that stay current.",
operation_id="list_mental_models",
tags=["Mental Models"],
operation_id="list_reflections",
tags=["Reflections"],
)
async def api_list_mental_models(
async def api_list_reflections(
bank_id: str,
tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
@@ -2261,9 +2263,9 @@ def _register_routes(app: FastAPI):
offset: int = Query(0, ge=0),
request_context: RequestContext = Depends(get_request_context),
):
"""List mental models for a bank."""
"""List reflections for a bank."""
try:
mental_models = await app.state.memory.list_mental_models(
reflections = await app.state.memory.list_reflections(
bank_id=bank_id,
tags=tags_filter,
tags_match=tags_match,
@@ -2271,83 +2273,74 @@ def _register_routes(app: FastAPI):
offset=offset,
request_context=request_context,
)
return MentalModelListResponse(items=[MentalModelResponse(**m) for m in mental_models])
return ReflectionListResponse(items=[ReflectionResponse(**r) for r in reflections])
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models: {error_detail}")
logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
response_model=MentalModelResponse,
summary="Get mental model",
description="Get a specific mental model by ID.",
operation_id="get_mental_model",
tags=["Mental Models"],
"/v1/default/banks/{bank_id}/reflections/{reflection_id}",
response_model=ReflectionResponse,
summary="Get reflection",
description="Get a specific reflection by ID.",
operation_id="get_reflection",
tags=["Reflections"],
)
async def api_get_mental_model(
async def api_get_reflection(
bank_id: str,
mental_model_id: str,
reflection_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get a mental model by ID."""
"""Get a reflection by ID."""
try:
mental_model = await app.state.memory.get_mental_model(
reflection = await app.state.memory.get_reflection(
bank_id=bank_id,
mental_model_id=mental_model_id,
reflection_id=reflection_id,
request_context=request_context,
)
if mental_model is None:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
return MentalModelResponse(**mental_model)
if reflection is None:
raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
return ReflectionResponse(**reflection)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/mental-models",
response_model=CreateMentalModelResponse,
summary="Create mental model",
description="Create a mental model by running reflect with the source query in the background. "
"/v1/default/banks/{bank_id}/reflections",
response_model=CreateReflectionResponse,
summary="Create reflection",
description="Create a reflection by running reflect with the source query in the background. "
"Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. "
"Use the operations endpoint to check completion status.",
operation_id="create_mental_model",
tags=["Mental Models"],
operation_id="create_reflection",
tags=["Reflections"],
)
async def api_create_mental_model(
async def api_create_reflection(
bank_id: str,
body: CreateMentalModelRequest,
body: CreateReflectionRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Create a mental model (async - returns operation_id)."""
"""Create a reflection (async - returns operation_id)."""
try:
# 1. Create the mental model with placeholder content
mental_model = await app.state.memory.create_mental_model(
result = await app.state.memory.submit_async_create_reflection(
bank_id=bank_id,
name=body.name,
source_query=body.source_query,
content="Generating content...",
tags=body.tags if body.tags else None,
max_tokens=body.max_tokens,
trigger=body.trigger.model_dump() if body.trigger else None,
request_context=request_context,
)
# 2. Schedule a refresh to generate the actual content
result = await app.state.memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=mental_model["id"],
request_context=request_context,
)
return CreateMentalModelResponse(operation_id=result["operation_id"])
return CreateReflectionResponse(operation_id=result["operation_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
@@ -2356,32 +2349,32 @@ def _register_routes(app: FastAPI):
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models: {error_detail}")
logger.error(f"Error in POST /v1/default/banks/{bank_id}/reflections: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh",
response_model=AsyncOperationSubmitResponse,
summary="Refresh mental model",
description="Submit an async task to re-run the source query through reflect and update the content.",
operation_id="refresh_mental_model",
tags=["Mental Models"],
"/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh",
response_model=ReflectionResponse,
summary="Refresh reflection",
description="Re-run the source query through reflect and update the content.",
operation_id="refresh_reflection",
tags=["Reflections"],
)
async def api_refresh_mental_model(
async def api_refresh_reflection(
bank_id: str,
mental_model_id: str,
reflection_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Refresh a mental model by re-running its source query (async)."""
"""Refresh a reflection by re-running its source query."""
try:
result = await app.state.memory.submit_async_refresh_mental_model(
reflection = await app.state.memory.refresh_reflection(
bank_id=bank_id,
mental_model_id=mental_model_id,
reflection_id=reflection_id,
request_context=request_context,
)
return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
if reflection is None:
raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
return ReflectionResponse(**reflection)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -2389,69 +2382,65 @@ def _register_routes(app: FastAPI):
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
f"Error in POST /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh: {error_detail}"
f"Error in POST /v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
response_model=MentalModelResponse,
summary="Update mental model",
description="Update a mental model's name and/or source query.",
operation_id="update_mental_model",
tags=["Mental Models"],
"/v1/default/banks/{bank_id}/reflections/{reflection_id}",
response_model=ReflectionResponse,
summary="Update reflection",
description="Update a reflection's name.",
operation_id="update_reflection",
tags=["Reflections"],
)
async def api_update_mental_model(
async def api_update_reflection(
bank_id: str,
mental_model_id: str,
body: UpdateMentalModelRequest,
reflection_id: str,
body: UpdateReflectionRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Update a mental model."""
"""Update a reflection."""
try:
mental_model = await app.state.memory.update_mental_model(
reflection = await app.state.memory.update_reflection(
bank_id=bank_id,
mental_model_id=mental_model_id,
reflection_id=reflection_id,
name=body.name,
source_query=body.source_query,
max_tokens=body.max_tokens,
tags=body.tags,
trigger=body.trigger.model_dump() if body.trigger else None,
request_context=request_context,
)
if mental_model is None:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
return MentalModelResponse(**mental_model)
if reflection is None:
raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
return ReflectionResponse(**reflection)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
summary="Delete mental model",
description="Delete a mental model.",
operation_id="delete_mental_model",
tags=["Mental Models"],
"/v1/default/banks/{bank_id}/reflections/{reflection_id}",
summary="Delete reflection",
description="Delete a reflection.",
operation_id="delete_reflection",
tags=["Reflections"],
)
async def api_delete_mental_model(
async def api_delete_reflection(
bank_id: str,
mental_model_id: str,
reflection_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Delete a mental model."""
"""Delete a reflection."""
try:
deleted = await app.state.memory.delete_mental_model(
deleted = await app.state.memory.delete_reflection(
bank_id=bank_id,
mental_model_id=mental_model_id,
reflection_id=reflection_id,
request_context=request_context,
)
if not deleted:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
return {"status": "deleted"}
except (AuthenticationError, HTTPException):
raise
@@ -2459,7 +2448,7 @@ def _register_routes(app: FastAPI):
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
@@ -3183,20 +3172,20 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/observations",
"/v1/default/banks/{bank_id}/mental-models",
response_model=DeleteResponse,
summary="Clear all observations",
description="Delete all observations for a memory bank. This is useful for resetting the consolidated knowledge.",
operation_id="clear_observations",
summary="Clear all mental models",
description="Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.",
operation_id="clear_mental_models",
tags=["Banks"],
)
async def api_clear_observations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Clear all observations for a bank."""
async def api_clear_mental_models(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Clear all mental models for a bank."""
try:
result = await app.state.memory.clear_observations(bank_id, request_context=request_context)
result = await app.state.memory.clear_mental_models(bank_id, request_context=request_context)
return DeleteResponse(
success=True,
message=f"Cleared {result.get('deleted_count', 0)} observations",
message=f"Cleared {result.get('deleted_count', 0)} mental models",
deleted_count=result.get("deleted_count", 0),
)
except (AuthenticationError, HTTPException):
@@ -3205,24 +3194,30 @@ def _register_routes(app: FastAPI):
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/consolidate",
response_model=ConsolidationResponse,
summary="Trigger consolidation",
description="Run memory consolidation to create/update observations from recent memories.",
description="Run memory consolidation to create/update mental models from recent memories.",
operation_id="trigger_consolidation",
tags=["Banks"],
)
async def api_trigger_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Trigger consolidation for a bank (async)."""
"""Trigger consolidation for a bank."""
try:
result = await app.state.memory.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
result = await app.state.memory.run_consolidation(bank_id, request_context=request_context)
processed = result.get("processed", 0)
created = result.get("created", 0)
updated = result.get("updated", 0)
return ConsolidationResponse(
operation_id=result["operation_id"],
deduplicated=result.get("deduplicated", False),
status="completed",
processed=processed,
created=created,
updated=updated,
message=f"Consolidation completed: {processed} memories processed, {created} mental models created, {updated} updated",
)
except (AuthenticationError, HTTPException):
raise
+190 -10
View File
@@ -1,4 +1,4 @@
"""Hindsight MCP Server implementation using FastMCP (HTTP transport)."""
"""Hindsight MCP Server implementation using FastMCP."""
import json
import logging
@@ -8,7 +8,8 @@ from contextvars import ContextVar
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
@@ -51,15 +52,194 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
# Use stateless_http=True for Claude Code compatibility
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
tools=None, # All tools
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
)
@mcp.tool()
async def retain(
content: str,
context: str = "general",
async_processing: bool = True,
bank_id: str | None = None,
) -> str:
"""
Store important information to long-term memory.
register_mcp_tools(mcp, memory, config)
Use this tool PROACTIVELY whenever the user shares:
- Personal facts, preferences, or interests
- Important events or milestones
- User history, experiences, or background
- Decisions, opinions, or stated preferences
- Goals, plans, or future intentions
- Relationships or people mentioned
- Work context, projects, or responsibilities
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
contents = [{"content": content, "context": context}]
if async_processing:
# Queue for background processing and return immediately
result = await memory.submit_async_retain(
bank_id=target_bank, contents=contents, request_context=RequestContext()
)
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
else:
# Wait for completion
await memory.retain_batch_async(
bank_id=target_bank,
contents=contents,
request_context=RequestContext(),
)
return f"Memory stored successfully in bank '{target_bank}'"
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
return f"Error: {str(e)}"
@mcp.tool()
async def recall(query: str, max_tokens: int = 4096, bank_id: str | None = None) -> str:
"""
Search memories to provide personalized, context-aware responses.
Use this tool PROACTIVELY to:
- Check user's preferences before making suggestions
- Recall user's history to provide continuity
- Remember user's goals and context
- Personalize responses based on past interactions
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens in the response (default: 4096)
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
from hindsight_api.engine.memory_engine import Budget
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=RequestContext(),
)
# Use model's JSON serialization
return recall_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return f'{{"error": "{e}", "results": []}}'
@mcp.tool()
async def reflect(query: str, context: str | None = None, budget: str = "low", bank_id: str | None = None) -> str:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
WHEN TO USE THIS TOOL:
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
thinks through the question using everything the bank knows and its personality traits.
EXAMPLES OF GOOD QUERIES:
- "What patterns have emerged in how I approach debugging?"
- "Based on my past decisions, what architectural style do I prefer?"
- "What might be the best approach for this problem given what you know about me?"
- "How should I prioritize these tasks based on my goals?"
HOW IT DIFFERS FROM RECALL:
- recall: Returns raw facts matching your search (fast lookup)
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
Args:
query: The question or topic to reflect on
context: Optional context about why this reflection is needed
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
from hindsight_api.engine.memory_engine import Budget
# Map string budget to enum
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
reflect_result = await memory.reflect_async(
bank_id=target_bank,
query=query,
budget=budget_enum,
context=context,
request_context=RequestContext(),
)
return reflect_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error reflecting: {e}", exc_info=True)
return f'{{"error": "{e}", "text": ""}}'
@mcp.tool()
async def list_banks() -> str:
"""
List all available memory banks.
Use this tool to discover what memory banks exist in the system.
Each bank is an isolated memory store (like a separate "brain").
Returns:
JSON list of banks with their IDs, names, dispositions, and missions.
"""
try:
banks = await memory.list_banks(request_context=RequestContext())
return json.dumps({"banks": banks}, indent=2)
except Exception as e:
logger.error(f"Error listing banks: {e}", exc_info=True)
return f'{{"error": "{e}", "banks": []}}'
@mcp.tool()
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
Banks are auto-created with default settings if they don't exist.
Args:
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
name: Optional human-friendly name for the bank
mission: Optional mission describing who the agent is and what they're trying to accomplish
"""
try:
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name/mission if provided
if name is not None or mission is not None:
await memory.update_bank(
bank_id,
name=name,
mission=mission,
request_context=RequestContext(),
)
# Fetch updated profile
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Serialize disposition if it's a Pydantic model
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
profile["disposition"] = profile["disposition"].model_dump()
return json.dumps(profile, indent=2)
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
return mcp
+34 -32
View File
@@ -39,11 +39,6 @@ ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
ENV_REFLECT_LLM_MODEL = "HINDSIGHT_API_REFLECT_LLM_MODEL"
ENV_REFLECT_LLM_BASE_URL = "HINDSIGHT_API_REFLECT_LLM_BASE_URL"
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
ENV_CONSOLIDATION_LLM_MODEL = "HINDSIGHT_API_CONSOLIDATION_LLM_MODEL"
ENV_CONSOLIDATION_LLM_BASE_URL = "HINDSIGHT_API_CONSOLIDATION_LLM_BASE_URL"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
@@ -87,16 +82,20 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# Observation thresholds
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
ENV_OBSERVATION_TOP_ENTITIES = "HINDSIGHT_API_OBSERVATION_TOP_ENTITIES"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
# Observations settings (consolidated knowledge from facts)
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
# Mental models settings
ENV_ENABLE_MENTAL_MODELS = "HINDSIGHT_API_ENABLE_MENTAL_MODELS"
ENV_CONSOLIDATION_SIMILARITY_THRESHOLD = "HINDSIGHT_API_CONSOLIDATION_SIMILARITY_THRESHOLD"
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
# Optimization flags
@@ -165,17 +164,21 @@ DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
# Observation thresholds
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
DEFAULT_OBSERVATION_TOP_ENTITIES = 5 # Max entities to process per retain batch
# Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "verbose"
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
# Mental models defaults
DEFAULT_ENABLE_MENTAL_MODELS = False # Mental models disabled by default (experimental)
DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD = 0.75 # Minimum similarity to consider a learning related
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
# Database migrations
@@ -290,11 +293,6 @@ class HindsightConfig:
reflect_llm_model: str | None
reflect_llm_base_url: str | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
consolidation_llm_model: str | None
consolidation_llm_base_url: str | None
# Embeddings
embeddings_provider: str
embeddings_local_model: str
@@ -325,16 +323,20 @@ class HindsightConfig:
recall_connection_budget: int
mental_model_refresh_concurrency: int
# Observation thresholds
observation_min_facts: int
observation_top_entities: int
# Retain settings
retain_max_completion_tokens: int
retain_chunk_size: int
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_custom_instructions: str | None
retain_observations_async: bool
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
# Mental models settings
enable_mental_models: bool
consolidation_similarity_threshold: float
consolidation_batch_size: int
# Optimization flags
@@ -383,10 +385,6 @@ class HindsightConfig:
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL) or None,
reflect_llm_base_url=os.getenv(ENV_REFLECT_LLM_BASE_URL) or None,
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL) or None,
consolidation_llm_base_url=os.getenv(ENV_CONSOLIDATION_LLM_BASE_URL) or None,
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
@@ -422,6 +420,11 @@ class HindsightConfig:
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
# Observation thresholds
observation_min_facts=int(os.getenv(ENV_OBSERVATION_MIN_FACTS, str(DEFAULT_OBSERVATION_MIN_FACTS))),
observation_top_entities=int(
os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))
),
# Retain settings
retain_max_completion_tokens=int(
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
@@ -434,13 +437,16 @@ class HindsightConfig:
retain_extraction_mode=_validate_extraction_mode(
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
),
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_observations_async=os.getenv(
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
).lower()
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
# Mental models settings
enable_mental_models=os.getenv(ENV_ENABLE_MENTAL_MODELS, str(DEFAULT_ENABLE_MENTAL_MODELS)).lower()
== "true",
consolidation_similarity_threshold=float(
os.getenv(ENV_CONSOLIDATION_SIMILARITY_THRESHOLD, str(DEFAULT_CONSOLIDATION_SIMILARITY_THRESHOLD))
),
consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
),
@@ -525,10 +531,6 @@ class HindsightConfig:
reflect_provider = self.reflect_llm_provider or self.llm_provider
reflect_model = self.reflect_llm_model or self.llm_model
logger.info(f"LLM (reflect): provider={reflect_provider}, model={reflect_model}")
if self.consolidation_llm_provider or self.consolidation_llm_model:
consolidation_provider = self.consolidation_llm_provider or self.llm_provider
consolidation_model = self.consolidation_llm_model or self.llm_model
logger.info(f"LLM (consolidation): provider={consolidation_provider}, model={consolidation_model}")
logger.info(f"Embeddings: provider={self.embeddings_provider}")
logger.info(f"Reranker: provider={self.reranker_provider}")
logger.info(f"Graph retriever: {self.graph_retriever}")
@@ -1,13 +1,13 @@
"""Consolidation engine for automatic observation creation from memories.
"""Consolidation engine for automatic mental model creation from memories.
The consolidation engine runs as a background job after retain operations complete.
It processes new memories and either:
- Creates new observations from novel facts
- Updates existing observations when new evidence supports/contradicts/refines them
- Creates new mental models from novel facts
- Updates existing mental models when new evidence supports/contradicts/refines them
Observations are stored in memory_units with fact_type='observation' and include:
Mental models are stored in memory_units with fact_type='mental_model' and include:
- proof_count: Number of supporting memories
- source_memory_ids: Array of memory UUIDs that contribute to this observation
- source_memory_ids: Array of memory UUIDs that contribute to this mental model
- history: JSONB tracking changes over time
"""
@@ -89,18 +89,16 @@ async def run_consolidation_job(
max_memories_per_batch = config.consolidation_batch_size
# Check if consolidation is enabled
if not config.enable_observations:
if not config.enable_mental_models:
logger.debug(f"Consolidation disabled for bank {bank_id}")
return {"status": "disabled", "bank_id": bank_id}
pool = memory_engine._pool
# Get bank profile
async with pool.acquire() as conn:
async with memory_engine._pool.acquire() as conn:
# Get bank profile and last_consolidated_at
t0 = time.time()
bank_row = await conn.fetchrow(
f"""
SELECT bank_id, name, mission
SELECT bank_id, name, mission, last_consolidated_at
FROM {fq_table("banks")}
WHERE bank_id = $1
""",
@@ -112,51 +110,31 @@ async def run_consolidation_job(
return {"status": "bank_not_found", "bank_id": bank_id}
mission = bank_row["mission"] or "General memory consolidation"
last_consolidated_at = bank_row["last_consolidated_at"]
perf.record_timing("fetch_bank", time.time() - t0)
# Count total unconsolidated memories for progress logging
total_count = await conn.fetchval(
f"""
SELECT COUNT(*)
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
if total_count == 0:
logger.debug(f"No new memories to consolidate for bank {bank_id}")
return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0}
logger.info(f"[CONSOLIDATION] bank={bank_id} total_unconsolidated={total_count}")
perf.log(f"[1] Found {total_count} pending memories to consolidate")
# Process each memory with individual commits for crash recovery
stats = {
"memories_processed": 0,
"observations_created": 0,
"observations_updated": 0,
"observations_merged": 0,
"actions_executed": 0,
"skipped": 0,
}
batch_num = 0
while True:
batch_num += 1
batch_start = time.time()
# Fetch next batch of unconsolidated memories
async with pool.acquire() as conn:
t0 = time.time()
# Fetch memories created after last_consolidated_at (exclude mental_model type)
t0 = time.time()
if last_consolidated_at:
memories = await conn.fetch(
f"""
SELECT id, text, fact_type, occurred_start, occurred_end, event_date, tags, mentioned_at
SELECT id, text, fact_type, occurred_start, event_date, tags
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND created_at > $2
AND fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $3
""",
bank_id,
last_consolidated_at,
max_memories_per_batch,
)
else:
memories = await conn.fetch(
f"""
SELECT id, text, fact_type, occurred_start, event_date, tags
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $2
@@ -164,16 +142,45 @@ async def run_consolidation_job(
bank_id,
max_memories_per_batch,
)
perf.record_timing("fetch_memories", time.time() - t0)
perf.record_timing("fetch_memories", time.time() - t0)
if not memories:
break # No more unconsolidated memories
logger.debug(f"No new memories to consolidate for bank {bank_id}")
# Update timestamp anyway to prevent reprocessing
await _update_last_consolidated_at(conn, bank_id)
return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0}
for memory in memories:
mem_start = time.time()
logger.info(
f"[CONSOLIDATION] bank={bank_id} memories={len(memories)} "
f"batch_size={max_memories_per_batch} since={last_consolidated_at or 'beginning'}"
)
perf.log(f"[1] Found {len(memories)} pending memories to consolidate")
# Process the memory (uses its own connection internally)
async with pool.acquire() as conn:
# Process each memory sequentially
# Important: We process ALL pending memories before updating the watermark
# to avoid losing memories when many have the same timestamp
stats = {
"memories_processed": 0,
"mental_models_created": 0,
"mental_models_updated": 0,
"mental_models_merged": 0,
"actions_executed": 0, # Total actions (can be > memories_processed due to multiple actions per fact)
"skipped": 0,
}
# Track processed memory IDs to avoid reprocessing
processed_ids: set[uuid.UUID] = set()
batch_num = 0
while memories:
batch_num += 1
batch_start = time.time()
for memory in memories:
if memory["id"] in processed_ids:
continue
mem_start = time.time()
result = await _process_memory(
conn=conn,
memory_engine=memory_engine,
@@ -183,149 +190,118 @@ async def run_consolidation_job(
request_context=request_context,
perf=perf,
)
mem_time = time.time() - mem_start
perf.record_timing("process_memory_total", mem_time)
# Mark memory as consolidated (committed immediately)
await conn.execute(
processed_ids.add(memory["id"])
stats["memories_processed"] += 1
action = result.get("action")
if action == "created":
stats["mental_models_created"] += 1
stats["actions_executed"] += 1
elif action == "updated":
stats["mental_models_updated"] += 1
stats["actions_executed"] += 1
elif action == "merged":
stats["mental_models_merged"] += 1
stats["actions_executed"] += 1
elif action == "multiple":
# Multiple actions from one fact (tag routing)
stats["mental_models_created"] += result.get("created", 0)
stats["mental_models_updated"] += result.get("updated", 0)
stats["mental_models_merged"] += result.get("merged", 0)
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
batch_time = time.time() - batch_start
perf.log(
f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
f"(avg {batch_time / len(memories):.3f}s/memory)"
)
# Fetch next batch of memories (excluding already processed)
t0 = time.time()
if last_consolidated_at:
memories = await conn.fetch(
f"""
UPDATE {fq_table("memory_units")}
SET consolidated_at = NOW()
WHERE id = $1
SELECT id, text, fact_type, occurred_start, event_date, tags
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND created_at > $2
AND fact_type IN ('experience', 'world')
AND id != ALL($4)
ORDER BY created_at ASC
LIMIT $3
""",
memory["id"],
bank_id,
last_consolidated_at,
max_memories_per_batch,
list(processed_ids),
)
mem_time = time.time() - mem_start
perf.record_timing("process_memory_total", mem_time)
stats["memories_processed"] += 1
action = result.get("action")
if action == "created":
stats["observations_created"] += 1
stats["actions_executed"] += 1
elif action == "updated":
stats["observations_updated"] += 1
stats["actions_executed"] += 1
elif action == "merged":
stats["observations_merged"] += 1
stats["actions_executed"] += 1
elif action == "multiple":
stats["observations_created"] += result.get("created", 0)
stats["observations_updated"] += result.get("updated", 0)
stats["observations_merged"] += result.get("merged", 0)
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
# Log progress periodically
if stats["memories_processed"] % 10 == 0:
logger.info(
f"[CONSOLIDATION] bank={bank_id} progress: "
f"{stats['memories_processed']}/{total_count} memories processed"
else:
memories = await conn.fetch(
f"""
SELECT id, text, fact_type, occurred_start, event_date, tags
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND fact_type IN ('experience', 'world')
AND id != ALL($3)
ORDER BY created_at ASC
LIMIT $2
""",
bank_id,
max_memories_per_batch,
list(processed_ids),
)
perf.record_timing("fetch_memories", time.time() - t0)
batch_time = time.time() - batch_start
# Update last_consolidated_at only after ALL memories are processed
t0 = time.time()
await _update_last_consolidated_at(conn, bank_id)
perf.record_timing("update_watermark", time.time() - t0)
# Build summary
perf.log(
f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
f"(avg {batch_time / len(memories):.3f}s/memory)"
f"[3] Results: {stats['memories_processed']} memories → "
f"{stats['actions_executed']} actions "
f"({stats['mental_models_created']} created, "
f"{stats['mental_models_updated']} updated, "
f"{stats['mental_models_merged']} merged, "
f"{stats['skipped']} skipped)"
)
# Build summary
perf.log(
f"[3] Results: {stats['memories_processed']} memories -> "
f"{stats['actions_executed']} actions "
f"({stats['observations_created']} created, "
f"{stats['observations_updated']} updated, "
f"{stats['observations_merged']} merged, "
f"{stats['skipped']} skipped)"
# Add timing breakdown
timing_parts = []
if "recall" in perf.timings:
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
if "llm" in perf.timings:
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
if "embedding" in perf.timings:
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
if "db_write" in perf.timings:
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
if timing_parts:
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
perf.flush()
return {"status": "completed", "bank_id": bank_id, **stats}
async def _update_last_consolidated_at(conn: "Connection", bank_id: str) -> None:
"""Update the bank's last_consolidated_at timestamp."""
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET last_consolidated_at = $1
WHERE bank_id = $2
""",
datetime.now(timezone.utc),
bank_id,
)
# Add timing breakdown
timing_parts = []
if "recall" in perf.timings:
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
if "llm" in perf.timings:
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
if "embedding" in perf.timings:
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
if "db_write" in perf.timings:
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
if timing_parts:
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
# Trigger mental model refreshes for models with refresh_after_consolidation=true
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
bank_id=bank_id,
request_context=request_context,
perf=perf,
)
stats["mental_models_refreshed"] = mental_models_refreshed
perf.flush()
return {"status": "completed", "bank_id": bank_id, **stats}
async def _trigger_mental_model_refreshes(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
perf: ConsolidationPerfLog | None = None,
) -> int:
"""
Trigger refreshes for mental models with refresh_after_consolidation=true.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
perf: Performance logging
Returns:
Number of mental models scheduled for refresh
"""
pool = memory_engine._pool
# Find mental models with refresh_after_consolidation=true
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, name
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
""",
bank_id,
)
if not rows:
return 0
if perf:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
# Submit refresh tasks for each mental model
refreshed_count = 0
for row in rows:
mental_model_id = row["id"]
try:
await memory_engine.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
refreshed_count += 1
logger.info(
f"[CONSOLIDATION] Triggered refresh for mental model {mental_model_id} "
f"(name: {row['name']}) in bank {bank_id}"
)
except Exception as e:
logger.warning(f"[CONSOLIDATION] Failed to trigger refresh for mental model {mental_model_id}: {e}")
return refreshed_count
async def _process_memory(
conn: "Connection",
@@ -340,13 +316,13 @@ async def _process_memory(
Process a single memory for consolidation using a SINGLE LLM call.
This function:
1. Finds related observations (can be empty)
1. Finds related mental models (can be empty)
2. Uses ONE LLM call to extract durable knowledge AND decide on actions
3. Executes array of actions (can be multiple creates/updates)
The LLM handles all cases:
- No related observations: returns create action(s) with extracted durable knowledge
- Related observations exist: returns update/create actions based on tag routing
- No related models: returns create action(s) with extracted durable knowledge
- Related models exist: returns update/create actions based on tag routing
- Purely ephemeral fact: returns empty array (skip)
Returns:
@@ -356,9 +332,9 @@ async def _process_memory(
memory_id = memory["id"]
fact_tags = memory.get("tags") or []
# Find related observations using the full recall system (NO tag filtering)
# Find related mental models using the full recall system (NO tag filtering)
t0 = time.time()
related_observations = await _find_related_observations(
related_mental_models = await _find_related_mental_models(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
@@ -368,13 +344,13 @@ async def _process_memory(
if perf:
perf.record_timing("recall", time.time() - t0)
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
# Single LLM call handles ALL cases (with or without existing models)
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
fact_tags=fact_tags,
mental_models=related_mental_models, # Can be empty list
mission=mission,
)
if perf:
@@ -395,11 +371,7 @@ async def _process_memory(
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
mental_models=related_mental_models,
perf=perf,
)
results.append(result)
@@ -410,11 +382,8 @@ async def _process_memory(
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
@@ -446,26 +415,13 @@ async def _execute_update_action(
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
observations: list[dict[str, Any]],
source_fact_tags: list[str] | None = None,
source_occurred_start: datetime | None = None,
source_occurred_end: datetime | None = None,
source_mentioned_at: datetime | None = None,
mental_models: list[dict[str, Any]],
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute an update action on an existing observation.
Execute an update action on an existing mental model.
Updates the observation text, adds to history, increments proof_count,
and updates temporal fields:
- occurred_start: uses LEAST to keep the earliest start time
- occurred_end: uses GREATEST to keep the most recent end time
- mentioned_at: uses GREATEST to keep the most recent mention time
SECURITY: Merges source fact's tags into the observation's existing tags.
This ensures all contributors can see the observation they contributed to.
For example, if Lisa's observation (tags=['user_lisa']) is updated with
Mike's fact (tags=['user_mike']), the observation will have both tags.
Updates the mental model text, adds to history, and increments proof_count.
"""
learning_id = action.get("learning_id")
new_text = action.get("text")
@@ -474,8 +430,8 @@ async def _execute_update_action(
if not learning_id or not new_text:
return {"action": "skipped", "reason": "missing_learning_id_or_text"}
# Find the observation
model = next((m for m in observations if str(m["id"]) == learning_id), None)
# Find the mental model
model = next((m for m in mental_models if str(m["id"]) == learning_id), None)
if not model:
return {"action": "skipped", "reason": "learning_not_found"}
@@ -494,17 +450,6 @@ async def _execute_update_action(
source_ids = list(model.get("source_memory_ids", []))
source_ids.append(memory_id)
# SECURITY: Merge source fact's tags into existing observation tags
# This ensures all contributors can see the observation they contributed to
existing_tags = set(model.get("tags", []) or [])
source_tags = set(source_fact_tags or [])
merged_tags = list(existing_tags | source_tags) # Union of both tag sets
if source_tags and source_tags != existing_tags:
logger.debug(
f"Security: Merging tags for observation {learning_id}: "
f"existing={list(existing_tags)}, source={list(source_tags)}, merged={merged_tags}"
)
# Generate new embedding for updated text
t0 = time.time()
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
@@ -512,11 +457,7 @@ async def _execute_update_action(
if perf:
perf.record_timing("embedding", time.time() - t0)
# Update the observation
# - occurred_start: LEAST keeps the earliest start time across all source facts
# - occurred_end: GREATEST keeps the most recent end time across all source facts
# - mentioned_at: GREATEST keeps the most recent mention time
# - tags: merged from existing + source fact (for visibility)
# Update the mental model
t0 = time.time()
await conn.execute(
f"""
@@ -526,11 +467,7 @@ async def _execute_update_action(
history = $3,
source_memory_ids = $4,
proof_count = $5,
tags = $10,
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($9, mentioned_at))
updated_at = now()
WHERE id = $6
""",
new_text,
@@ -539,20 +476,16 @@ async def _execute_update_action(
source_ids,
len(source_ids),
uuid.UUID(learning_id),
source_occurred_start,
source_occurred_end,
source_mentioned_at,
merged_tags,
)
# Create links from memory to observation
# Create links from memory to mental model
await _create_memory_links(conn, memory_id, uuid.UUID(learning_id))
if perf:
perf.record_timing("db_write", time.time() - t0)
logger.debug(f"Updated observation {learning_id} with memory {memory_id}")
logger.debug(f"Updated mental model {learning_id} with memory {memory_id}")
return {"action": "updated", "observation_id": learning_id}
return {"action": "updated", "mental_model_id": learning_id}
async def _execute_create_action(
@@ -561,48 +494,36 @@ async def _execute_create_action(
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
source_fact_tags: list[str] | None = None,
event_date: datetime | None = None,
occurred_start: datetime | None = None,
occurred_end: datetime | None = None,
mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute a create action for a new observation.
Execute a create action for a new mental model.
Creates a new observation with the specified text.
Creates a new mental model with the specified text and tags.
The text comes directly from the classify LLM - no second LLM call needed.
Tags are determined algorithmically (not by LLM):
- Observations always inherit their source fact's tags
- This ensures visibility scope is maintained (security)
"""
text = action.get("text")
# Tags are determined algorithmically - always use source fact's tags
# This ensures private memories create private observations
tags = source_fact_tags or []
tags = action.get("tags", [])
if not text:
return {"action": "skipped", "reason": "missing_text"}
# Use text directly from classify - skip the redundant LLM call
result = await _create_observation_directly(
result = await _create_mental_model_directly(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_id=memory_id,
observation_text=text, # Text already processed by classify LLM
mental_model_text=text, # Text already processed by classify LLM
tags=tags,
event_date=event_date,
occurred_start=occurred_start,
occurred_end=occurred_end,
mentioned_at=mentioned_at,
perf=perf,
)
logger.debug(f"Created observation {result.get('observation_id')} from memory {memory_id} (tags: {tags})")
logger.debug(f"Created mental model {result.get('mental_model_id')} from memory {memory_id} (tags: {tags})")
return result
@@ -610,28 +531,98 @@ async def _execute_create_action(
async def _create_memory_links(
conn: "Connection",
memory_id: uuid.UUID,
observation_id: uuid.UUID,
mental_model_id: uuid.UUID,
) -> None:
"""
Placeholder for observation link creation.
Create links between a source memory and its mental model.
Observations do NOT get any memory_links copied from their source facts.
Instead, retrieval uses source_memory_ids to traverse:
- Entity connections: observation → source_memory_ids → unit_entities
- Semantic similarity: observations have their own embeddings
- Temporal proximity: observations have their own temporal fields
This:
1. Creates bidirectional semantic links between memory and mental model
2. Copies existing memory_links from the source memory to the mental model
3. Copies entity links from the source memory to the mental model
This avoids data duplication and ensures observations are always
connected via their source facts' relationships.
This enables graph traversal to find related memories via their mental models.
The memory_id and observation_id parameters are kept for interface
compatibility but no links are created.
Note: Uses EXISTS checks to handle the case where source memory was deleted
by a concurrent operation between fetching and link creation.
"""
# No links are created - observations rely on source_memory_ids for traversal
pass
mu_table = fq_table("memory_units")
ml_table = fq_table("memory_links")
ue_table = fq_table("unit_entities")
# 1. Bidirectional link between memory and mental model
# Only insert if both units exist (handles concurrent deletion)
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
SELECT $1, $2, 'semantic', 1.0
WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
ON CONFLICT DO NOTHING
""",
memory_id,
mental_model_id,
)
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
SELECT $1, $2, 'semantic', 1.0
WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
# 2. Copy outgoing memory_links from source memory to mental model
# If source memory links to X, mental model should also link to X
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
SELECT $1, ml.to_unit_id, ml.link_type, ml.entity_id, ml.weight
FROM {ml_table} ml
WHERE ml.from_unit_id = $2 AND ml.to_unit_id != $1
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.to_unit_id)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
# 3. Copy incoming memory_links from source memory to mental model
# If X links to source memory, X should also link to mental model
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
SELECT ml.from_unit_id, $1, ml.link_type, ml.entity_id, ml.weight
FROM {ml_table} ml
WHERE ml.to_unit_id = $2 AND ml.from_unit_id != $1
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.from_unit_id)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
# 4. Copy entity links from source memory to mental model
await conn.execute(
f"""
INSERT INTO {ue_table} (unit_id, entity_id)
SELECT $1, ue.entity_id
FROM {ue_table} ue
WHERE ue.unit_id = $2
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
ON CONFLICT DO NOTHING
""",
mental_model_id,
memory_id,
)
async def _find_related_observations(
async def _find_related_mental_models(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
@@ -639,10 +630,10 @@ async def _find_related_observations(
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
Find observations related to the given query using the full recall system.
Find mental models related to the given query using the full recall system.
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
potentially related observations regardless of scope, so the LLM can
potentially related mental models regardless of scope, so the LLM can
decide on tag routing (same scope update vs cross-scope create).
This leverages:
@@ -652,37 +643,37 @@ async def _find_related_observations(
- Graph traversal (connected via entity links)
Returns:
List of related observations with their tags for LLM tag routing
List of related mental models with their tags for LLM tag routing
"""
# Use recall to find related observations
# NO tags parameter - we want ALL observations regardless of scope
# Use low max_tokens since we only need observations, not memories
# Use recall to find related mental models
# NO tags parameter - we want ALL mental models regardless of scope
# Use low max_tokens since we only need mental models, not memories
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=5000, # Token budget for observations
fact_type=["observation"], # Only retrieve observations
max_tokens=5000, # Token budget for mental models
fact_type=["mental_model"], # Only retrieve mental models
request_context=request_context,
_quiet=True, # Suppress logging
# NO tags parameter - intentionally get ALL observations
# NO tags parameter - intentionally get ALL mental models
)
# If no observations returned, return empty list
# When fact_type=["observation"], results come back in `results` field
# If no mental models returned, return empty list
# When fact_type=["mental_model"], results come back in `results` field
if not recall_result.results:
return []
# Trust recall's relevance filtering - fetch full data for each observation
# Trust recall's relevance filtering - fetch full data for each mental model
results = []
for obs in recall_result.results:
# Fetch full observation data from DB to get history, source_memory_ids, tags
for mm in recall_result.results:
# Fetch full mental model data from DB to get history, source_memory_ids, tags
row = await conn.fetchrow(
f"""
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
FROM {fq_table("memory_units")}
WHERE id = $1 AND bank_id = $2 AND fact_type = 'observation'
WHERE id = $1 AND bank_id = $2 AND fact_type = 'mental_model'
""",
uuid.UUID(obs.id),
uuid.UUID(mm.id),
bank_id,
)
@@ -711,35 +702,32 @@ async def _find_related_observations(
async def _consolidate_with_llm(
memory_engine: "MemoryEngine",
fact_text: str,
observations: list[dict[str, Any]],
fact_tags: list[str],
mental_models: list[dict[str, Any]],
mission: str,
) -> list[dict[str, Any]]:
"""
Single LLM call to extract durable knowledge and decide on consolidation actions.
This handles ALL cases:
- No related observations: extracts durable knowledge, returns create action
- Related observations exist: compares and returns update/create actions
- No related mental models: extracts durable knowledge, returns create action
- Related models exist: compares and returns update/create actions
- Purely ephemeral fact: returns empty array
Note: Tags are NOT handled by the LLM. They are determined algorithmically:
- CREATE: observation inherits source fact's tags
- UPDATE: observation merges source fact's tags with existing tags
Returns:
List of actions, each being:
- {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
- {"action": "create", "text": "...", "reason": "..."}
- {"action": "create", "tags": [...], "text": "...", "reason": "..."}
- [] if fact is purely ephemeral (no durable knowledge)
"""
# Format observations WITH their tags (or "None" if empty)
if observations:
observations_text = "\n".join(
f'- ID: {obs["id"]}, Tags: {json.dumps(obs["tags"])}, Text: "{obs["text"]}" (proof_count: {obs["proof_count"]})'
for obs in observations
# Format mental models WITH their tags (or "None" if empty)
if mental_models:
mental_models_text = "\n".join(
f'- ID: {mm["id"]}, Tags: {json.dumps(mm["tags"])}, Text: "{mm["text"]}" (proof_count: {mm["proof_count"]})'
for mm in mental_models
)
else:
observations_text = "None (this is a new topic - create if fact contains durable knowledge)"
mental_models_text = "None (this is a new topic - create if fact contains durable knowledge)"
# Only include mission section if mission is set and not the default
mission_section = ""
@@ -753,7 +741,8 @@ Focus on DURABLE knowledge that serves this mission, not ephemeral state.
user_prompt = CONSOLIDATION_USER_PROMPT.format(
mission_section=mission_section,
fact_text=fact_text,
observations_text=observations_text,
fact_tags=json.dumps(fact_tags),
mental_models_text=mental_models_text,
)
messages = [
@@ -762,7 +751,7 @@ Focus on DURABLE knowledge that serves this mission, not ephemeral state.
]
try:
result = await memory_engine._consolidation_llm_config.call(
result = await memory_engine._llm_config.call(
messages=messages,
skip_validation=True, # Raw JSON response
scope="consolidation",
@@ -792,68 +781,62 @@ Focus on DURABLE knowledge that serves this mission, not ephemeral state.
return []
async def _create_observation_directly(
async def _create_mental_model_directly(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
source_memory_id: uuid.UUID,
observation_text: str,
mental_model_text: str,
tags: list[str] | None = None,
event_date: datetime | None = None,
occurred_start: datetime | None = None,
occurred_end: datetime | None = None,
mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Create an observation directly with pre-processed text (no LLM call).
Create a mental model directly with pre-processed text (no LLM call).
Used when the classify LLM has already provided the learning text.
This avoids the redundant second LLM call.
"""
# Generate embedding for the observation (convert to string for pgvector)
# Generate embedding for the mental model (convert to string for pgvector)
t0 = time.time()
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [observation_text])
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [mental_model_text])
embedding_str = str(embeddings[0]) if embeddings else None
if perf:
perf.record_timing("embedding", time.time() - t0)
# Create the observation as a memory_unit
# Create the mental model as a memory_unit
now = datetime.now(timezone.utc)
obs_event_date = event_date or now
obs_occurred_start = occurred_start or now
obs_occurred_end = occurred_end or now
obs_mentioned_at = mentioned_at or now
obs_tags = tags or []
mm_event_date = event_date or now
mm_occurred_start = occurred_start or now
mm_tags = tags or []
t0 = time.time()
observation_id = uuid.uuid4()
mental_model_id = uuid.uuid4()
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
tags, event_date, occurred_start, occurred_end, mentioned_at
tags, event_date, occurred_start
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, 'mental_model', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8)
RETURNING id
""",
observation_id,
mental_model_id,
bank_id,
observation_text,
mental_model_text,
embedding_str,
[source_memory_id],
obs_tags,
obs_event_date,
obs_occurred_start,
obs_occurred_end,
obs_mentioned_at,
mm_tags,
mm_event_date,
mm_occurred_start,
)
# Create links between memory and observation (includes entity links, memory_links)
await _create_memory_links(conn, source_memory_id, observation_id)
# Create links between memory and mental model (includes entity links, memory_links)
await _create_memory_links(conn, source_memory_id, mental_model_id)
if perf:
perf.record_timing("db_write", time.time() - t0)
logger.debug(f"Created observation {observation_id} from memory {source_memory_id} (tags: {obs_tags})")
logger.debug(f"Created mental model {mental_model_id} from memory {source_memory_id} (tags: {mm_tags})")
return {"action": "created", "observation_id": str(row["id"]), "tags": obs_tags}
return {"action": "created", "mental_model_id": str(row["id"]), "tags": mm_tags}
@@ -1,6 +1,6 @@
"""Prompts for the consolidation engine."""
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (observations) and merge with existing knowledge when appropriate.
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (mental models) and merge with existing knowledge when appropriate.
You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text.
@@ -30,40 +30,62 @@ BAD examples:
- "John likes pizza" -> "Understanding dietary preferences helps..." (TOO ABSTRACT)
- "User is at Room 203" -> "User is currently at Room 203" (EPHEMERAL STATE)
## MERGE RULES (when comparing to existing observations):
## MERGE RULES (when comparing to existing mental models):
1. REDUNDANT: Same information worded differently → update existing
2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
3. UPDATE: New state replacing old state → update with history
## TAG ROUTING RULES:
Tags define visibility scopes. The fact and each mental model have tags (can be empty = global).
| Fact Tags | Model Tags | Action |
|-----------|------------|--------|
| [alice] | [alice] | UPDATE the model (same scope) |
| [alice] | [] | UPDATE the model (global absorbs all scopes) |
| [alice] | [bob] | CREATE new untagged model (cross-scope insight) |
| [] | [alice] | UPDATE the model (untagged facts can update any scope) |
| [] | [] | UPDATE the model (global to global) |
When NO existing model matches the fact's topic: CREATE new model with fact's tags.
## MULTIPLE ACTIONS:
One fact can trigger MULTIPLE actions. For example:
- Update a scoped model [alice] about pizza preferences
- AND update a global model [] about pizza in general
Output an ARRAY of actions (can be empty, one, or many).
## CRITICAL RULES:
- NEVER merge facts about DIFFERENT people
- NEVER merge unrelated topics (food preferences vs work vs hobbies)
- When merging contradictions, capture the CHANGE (before → after)
- Keep observations focused on ONE specific topic per person
- The "text" field MUST contain durable knowledge, not ephemeral state
- Do NOT include "tags" in output - tags are handled automatically"""
- Keep mental models focused on ONE specific topic per person
- Cross-scope insights (alice's fact about bob's topic) become UNTAGGED (global)
- The "text" field MUST contain durable knowledge, not ephemeral state"""
CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowledge.
{mission_section}
NEW FACT: {fact_text}
FACT TAGS: {fact_tags}
EXISTING OBSERVATIONS:
{observations_text}
EXISTING MENTAL MODELS:
{mental_models_text}
Instructions:
1. First, extract the DURABLE KNOWLEDGE from the fact (not ephemeral state like "user is at X")
2. Then compare with existing observations:
- If an observation covers the same topic: UPDATE it with the new knowledge
- If no observation covers the topic: CREATE a new one
2. Then compare with existing mental models:
- If a model covers the same topic: UPDATE it with the new knowledge
- If no model covers the topic: CREATE a new one
- If fact is about different scope: apply tag routing rules
Output JSON array of actions (ALWAYS an array, even for single action):
[
{{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}},
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
{{"action": "create", "tags": ["tag"], "text": "new durable knowledge", "reason": "..."}}
]
If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
[]
If no observations exist and fact contains durable knowledge:
[{{"action": "create", "text": "durable knowledge text", "reason": "new topic"}}]"""
If no models exist and fact contains durable knowledge:
[{{"action": "create", "tags": {fact_tags}, "text": "durable knowledge text", "reason": "new topic"}}]"""
@@ -130,27 +130,17 @@ 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 based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
# Note: We do NOT use device_map because CrossEncoder internally calls .to(device)
# after loading, which conflicts with accelerate's device_map handling.
import torch
# 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
else:
device = "cpu"
# 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 = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
# Initialize shared executor (limited workers naturally limits concurrency)
@@ -163,101 +153,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
else:
logger.info("Reranker: local provider initialized (using existing executor)")
def _is_xpc_error(self, error: Exception) -> bool:
"""
Check if an error is an XPC connection error (macOS daemon issue).
On macOS, long-running daemons can lose XPC connections to system services
when the process is idle for extended periods.
"""
error_str = str(error).lower()
return "xpc_error_connection_invalid" in error_str or "xpc error" in error_str
def _reinitialize_model_sync(self) -> None:
"""
Clear and reinitialize the cross-encoder model synchronously.
This is used to recover from XPC errors on macOS where the
PyTorch/MPS backend loses its connection to system services.
"""
logger.warning(f"Reinitializing reranker model {self.model_name} due to backend error")
# Clear existing model
self._model = None
# Force garbage collection to free resources
import gc
import torch
gc.collect()
# If using CUDA/MPS, clear the cache
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
try:
torch.mps.empty_cache()
except AttributeError:
pass # Method might not exist in all PyTorch versions
# Reinitialize the model
try:
from sentence_transformers import CrossEncoder
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTCrossEncoder. "
"Install it with: pip install sentence-transformers"
)
# Determine device based on hardware availability
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
else:
device = "cpu"
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
logger.info("Reranker: local provider reinitialized successfully")
def _predict_with_recovery(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Predict with automatic recovery from XPC errors.
This runs synchronously in the thread pool.
"""
max_retries = 1
for attempt in range(max_retries + 1):
try:
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
except Exception as e:
# Check if this is an XPC error (macOS daemon issue)
if self._is_xpc_error(e) and attempt < max_retries:
logger.warning(f"XPC error detected in reranker (attempt {attempt + 1}): {e}")
try:
self._reinitialize_model_sync()
logger.info("Reranker reinitialized successfully, retrying prediction")
continue
except Exception as reinit_error:
logger.error(f"Failed to reinitialize reranker: {reinit_error}")
raise Exception(f"Failed to recover from XPC error: {str(e)}")
else:
# Not an XPC error or out of retries
raise
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs for relevance.
Uses a dedicated thread pool with limited workers to prevent CPU thrashing.
Automatically recovers from XPC errors on macOS by reinitializing the model.
Args:
pairs: List of (query, document) tuples to score
@@ -270,11 +170,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Use dedicated executor - limited workers naturally limits concurrency
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
scores = await loop.run_in_executor(
LocalSTCrossEncoder._executor,
self._predict_with_recovery,
pairs,
lambda: self._model.predict(pairs, show_progress_bar=False),
)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
@@ -128,98 +128,20 @@ class LocalSTEmbeddings(Embeddings):
)
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
import torch
# 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
else:
device = "cpu"
# 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},
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
def _is_xpc_error(self, error: Exception) -> bool:
"""
Check if an error is an XPC connection error (macOS daemon issue).
On macOS, long-running daemons can lose XPC connections to system services
when the process is idle for extended periods.
"""
error_str = str(error).lower()
return "xpc_error_connection_invalid" in error_str or "xpc error" in error_str
def _reinitialize_model_sync(self) -> None:
"""
Clear and reinitialize the embedding model synchronously.
This is used to recover from XPC errors on macOS where the
PyTorch/MPS backend loses its connection to system services.
"""
logger.warning(f"Reinitializing embedding model {self.model_name} due to backend error")
# Clear existing model
self._model = None
# Force garbage collection to free resources
import gc
import torch
gc.collect()
# If using CUDA/MPS, clear the cache
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
try:
torch.mps.empty_cache()
except AttributeError:
pass # Method might not exist in all PyTorch versions
# Reinitialize the model (inline version of initialize() but synchronous)
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTEmbeddings. "
"Install it with: pip install sentence-transformers"
)
# Determine device based on hardware availability
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
else:
device = "cpu"
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
logger.info("Embeddings: local provider reinitialized successfully")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings for a list of texts.
Automatically recovers from XPC errors on macOS by reinitializing the model.
Args:
texts: List of text strings to encode
@@ -228,27 +150,8 @@ class LocalSTEmbeddings(Embeddings):
"""
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
# Try encoding with automatic recovery from XPC errors
max_retries = 1
for attempt in range(max_retries + 1):
try:
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
except Exception as e:
# Check if this is an XPC error (macOS daemon issue)
if self._is_xpc_error(e) and attempt < max_retries:
logger.warning(f"XPC error detected in embedding generation (attempt {attempt + 1}): {e}")
try:
self._reinitialize_model_sync()
logger.info("Model reinitialized successfully, retrying embedding generation")
continue
except Exception as reinit_error:
logger.error(f"Failed to reinitialize model: {reinit_error}")
raise Exception(f"Failed to recover from XPC error: {str(e)}")
else:
# Not an XPC error or out of retries
raise
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
class RemoteTEIEmbeddings(Embeddings):
@@ -647,13 +647,7 @@ class LLMProvider:
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except APIConnectionError as e:
last_exception = e
@@ -803,10 +797,6 @@ class LLMProvider:
content = "".join(content_parts) if content_parts else None
finish_reason = "tool_calls" if tool_calls else "stop"
# Extract token usage
input_tokens = response.usage.input_tokens or 0
output_tokens = response.usage.output_tokens or 0
# Record metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
@@ -814,18 +804,12 @@ class LLMProvider:
model=self.model,
scope=scope,
duration=time.time() - start_time,
input_tokens=input_tokens,
output_tokens=output_tokens,
input_tokens=response.usage.input_tokens or 0,
output_tokens=response.usage.output_tokens or 0,
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except (APIConnectionError, APIStatusError) as e:
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
@@ -946,13 +930,7 @@ class LLMProvider:
success=True,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except genai_errors.APIError as e:
if e.code in (401, 403):
File diff suppressed because it is too large Load Diff
@@ -4,15 +4,17 @@ Reflect agent module for agentic reflection with tools.
The reflect agent uses an iterative loop with tools to:
1. Lookup mental models (existing knowledge)
2. Recall facts (semantic + temporal search)
3. Expand memories (get chunk/document context)
3. Learn new insights (create/update mental models)
4. Expand memories (get chunk/document context)
"""
from .agent import ReflectAgentResult, run_reflect_agent
from .models import ReflectAction, ReflectActionBatch
from .models import MentalModelInput, ReflectAction, ReflectActionBatch
__all__ = [
"run_reflect_agent",
"ReflectAgentResult",
"ReflectAction",
"ReflectActionBatch",
"MentalModelInput",
]
@@ -2,30 +2,24 @@
Reflect agent - agentic loop for reflection with native tool calling.
Uses hierarchical retrieval:
1. search_mental_models - User-curated summaries (highest quality)
2. search_observations - Consolidated knowledge with freshness
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
"""
import asyncio
import json
import logging
import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, ToolCall
from .prompts import FINAL_SYSTEM_PROMPT, _extract_directive_rules, build_final_prompt, build_system_prompt_for_tools
from .tools_schema import get_reflect_tools
def _build_directives_applied(directives: list[dict[str, Any]] | None) -> list[DirectiveInfo]:
"""Build list of DirectiveInfo from directive mental models.
Handles multiple directive formats:
1. New format: directives have direct 'content' field
2. Fallback: directives have 'description' field
"""
"""Build list of DirectiveInfo from directive mental models."""
if not directives:
return []
@@ -33,11 +27,17 @@ def _build_directives_applied(directives: list[dict[str, Any]] | None) -> list[D
for directive in directives:
directive_id = directive.get("id", "")
directive_name = directive.get("name", "")
observations = directive.get("observations", [])
# Get content from 'content' field or fallback to 'description'
content = directive.get("content", "") or directive.get("description", "")
rules = []
for obs in observations:
# Support both Pydantic Observation objects and dicts
if hasattr(obs, "content"):
rules.append(obs.content)
elif isinstance(obs, dict) and obs.get("content"):
rules.append(obs["content"])
result.append(DirectiveInfo(id=directive_id, name=directive_name, content=content))
result.append(DirectiveInfo(id=directive_id, name=directive_name, rules=rules))
return result
@@ -77,66 +77,12 @@ def _is_done_tool(name: str) -> bool:
return _normalize_tool_name(name) == "done"
# Pattern to match done() call as text - handles done({...}) with nested JSON
_DONE_CALL_PATTERN = re.compile(r"done\s*\(\s*\{.*$", re.DOTALL)
# Patterns for leaked structured output in the answer field
_LEAKED_JSON_SUFFIX = re.compile(
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
re.DOTALL | re.IGNORECASE,
)
_LEAKED_JSON_OBJECT = re.compile(
r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$', re.DOTALL
)
_TRAILING_IDS_PATTERN = re.compile(
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
)
def _clean_answer_text(text: str) -> str:
"""Clean up answer text by removing any done() tool call syntax.
Some LLMs output the done() call as text instead of a proper tool call.
This strips out patterns like: done({"answer": "...", ...})
"""
# Remove done() call pattern from the end of the text
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
return cleaned if cleaned else text
def _clean_done_answer(text: str) -> str:
"""Clean up the answer field from a done() tool call.
Some LLMs leak structured output patterns into the answer text, such as:
- JSON code blocks with observation_ids/memory_ids at the end
- Raw JSON objects with these fields
- Plain text like "observation_ids: [...]"
This cleans those patterns while preserving the actual answer content.
"""
if not text:
return text
cleaned = text
# Remove leaked JSON in code blocks at the end
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
# Remove leaked raw JSON objects at the end
cleaned = _LEAKED_JSON_OBJECT.sub("", cleaned).strip()
# Remove trailing ID patterns
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()
return cleaned if cleaned else text
async def _generate_structured_output(
answer: str,
response_schema: dict,
llm_config: "LLMProvider",
reflect_id: str,
) -> tuple[dict[str, Any] | None, int, int]:
) -> dict[str, Any] | None:
"""Generate structured output from an answer using the provided JSON schema.
Args:
@@ -146,8 +92,7 @@ async def _generate_structured_output(
reflect_id: Reflect ID for logging
Returns:
Tuple of (structured_output, input_tokens, output_tokens).
structured_output is None if generation fails.
Structured output dict if successful, None otherwise
"""
try:
from typing import Any as TypingAny
@@ -180,62 +125,41 @@ async def _generate_structured_output(
fields[field_name] = (field_type, default)
if not fields:
logger.warning(f"[REFLECT {reflect_id}] No fields found in response_schema, skipping structured output")
return None, 0, 0
return None
DynamicModel = create_model("StructuredResponse", **fields)
# Include the full schema in the prompt for better LLM guidance
schema_str = json.dumps(response_schema, indent=2)
# Build field descriptions for the prompt
field_descriptions = []
for field_name, field_schema in schema_props.items():
field_type = field_schema.get("type", "string")
field_desc = field_schema.get("description", "")
is_required = field_name in required_fields
req_marker = " (REQUIRED)" if is_required else " (optional)"
field_descriptions.append(f"- {field_name} ({field_type}){req_marker}: {field_desc}")
fields_text = "\n".join(field_descriptions)
# Call LLM with the answer to extract structured data
structured_prompt = f"""Your task is to extract specific information from the answer below and format it as JSON.
structured_prompt = f"""Based on this answer, extract the information into the requested structured format.
ANSWER TO EXTRACT FROM:
\"\"\"
{answer}
\"\"\"
Answer: {answer}
REQUIRED OUTPUT FORMAT - Extract the following fields from the answer above:
{fields_text}
JSON Schema:
JSON Schema to follow:
```json
{schema_str}
```
INSTRUCTIONS:
1. Read the answer carefully and identify the information that matches each field
2. Extract the ACTUAL content from the answer - do NOT leave fields empty if information is present
3. For string fields: use the exact text or a clear summary from the answer
4. For array fields: return a JSON array (e.g., ["item1", "item2"]), NOT a string
5. For required fields: you MUST provide a value extracted from the answer
6. Return ONLY the JSON object, no explanation
Return ONLY a valid JSON object that matches this exact schema. Pay special attention to field types:
- "type": "array" means the value must be a JSON array/list, NOT a string
- "type": "string" means the value must be a string
- "type": "object" means the value must be a JSON object
OUTPUT:"""
Do not include any explanation, only the JSON object."""
structured_result, usage = await llm_config.call(
structured_result = await llm_config.call(
messages=[
{
"role": "system",
"content": "You are a precise data extraction assistant. Extract information from text and return it as valid JSON matching the provided schema. Always extract actual content - never return empty strings for required fields if information is available.",
"content": "Extract structured data from the given answer. Return only valid JSON matching the provided schema exactly.",
},
{"role": "user", "content": structured_prompt},
],
response_format=DynamicModel,
scope="reflect_structured",
skip_validation=True, # We'll handle the dict ourselves
return_usage=True,
)
# Convert to dict
@@ -247,18 +171,12 @@ OUTPUT:"""
# Try to parse as JSON
structured_output = json.loads(str(structured_result))
# Validate that required fields have non-empty values
for field_name in required_fields:
value = structured_output.get(field_name)
if value is None or value == "" or value == []:
logger.warning(f"[REFLECT {reflect_id}] Required field '{field_name}' is empty in structured output")
logger.info(f"[REFLECT {reflect_id}] Generated structured output with {len(structured_output)} fields")
return structured_output, usage.input_tokens, usage.output_tokens
return structured_output
except Exception as e:
logger.warning(f"[REFLECT {reflect_id}] Failed to generate structured output: {e}")
return None, 0, 0
return None
async def run_reflect_agent(
@@ -266,8 +184,8 @@ async def run_reflect_agent(
bank_id: str,
query: str,
bank_profile: dict[str, Any],
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
context: str | None = None,
@@ -275,15 +193,13 @@ async def run_reflect_agent(
max_tokens: int | None = None,
response_schema: dict | None = None,
directives: list[dict[str, Any]] | None = None,
has_mental_models: bool = False,
budget: str | None = None,
) -> ReflectAgentResult:
"""
Execute the reflect agent loop using native tool calling.
The agent uses hierarchical retrieval:
1. search_mental_models - User-curated summaries (try first)
2. search_observations - Consolidated knowledge with freshness
1. search_reflections - User-curated summaries (try first)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
Args:
@@ -291,8 +207,8 @@ async def run_reflect_agent(
bank_id: Bank identifier
query: Question to answer
bank_profile: Bank profile with name and mission
search_reflections_fn: Tool callback for searching reflections (query, max_results) -> result
search_mental_models_fn: Tool callback for searching mental models (query, max_results) -> result
search_observations_fn: Tool callback for searching observations (query, max_results) -> result
recall_fn: Tool callback for recall (query, max_tokens) -> result
expand_fn: Tool callback for expand (memory_ids, depth) -> result
context: Optional additional context
@@ -317,9 +233,7 @@ async def run_reflect_agent(
tools = get_reflect_tools(directive_rules=directive_rules)
# Build initial messages (directives are injected into system prompt at START and END)
system_prompt = build_system_prompt_for_tools(
bank_profile, context, directives=directives, has_mental_models=has_mental_models, budget=budget
)
system_prompt = build_system_prompt_for_tools(bank_profile, context, directives=directives)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
@@ -332,32 +246,13 @@ async def run_reflect_agent(
llm_trace: list[dict[str, Any]] = []
context_history: list[dict[str, Any]] = [] # For final prompt fallback
# Token usage tracking - accumulate across all LLM calls
total_input_tokens = 0
total_output_tokens = 0
# Track available IDs for validation (prevents hallucinated citations)
available_memory_ids: set[str] = set()
available_reflection_ids: set[str] = set()
available_mental_model_ids: set[str] = set()
available_observation_ids: set[str] = set()
def _get_llm_trace() -> list[LLMCall]:
return [
LLMCall(
scope=c["scope"],
duration_ms=c["duration_ms"],
input_tokens=c.get("input_tokens", 0),
output_tokens=c.get("output_tokens", 0),
)
for c in llm_trace
]
def _get_usage() -> TokenUsageSummary:
return TokenUsageSummary(
input_tokens=total_input_tokens,
output_tokens=total_output_tokens,
total_tokens=total_input_tokens + total_output_tokens,
)
return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace]
def _log_completion(answer: str, iterations: int, forced: bool = False):
elapsed_ms = int((time.time() - start_time) * 1000)
@@ -391,36 +286,21 @@ async def run_reflect_agent(
# Force text response on last iteration - no tools
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response, usage = await llm_config.call(
response = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
return_usage=True,
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
llm_trace.append(
{
"scope": "final",
"duration_ms": llm_duration,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -430,7 +310,6 @@ async def run_reflect_agent(
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
@@ -445,16 +324,7 @@ async def run_reflect_agent(
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += result.input_tokens
total_output_tokens += result.output_tokens
llm_trace.append(
{
"scope": f"agent_{iteration + 1}",
"duration_ms": llm_duration,
"input_tokens": result.input_tokens,
"output_tokens": result.output_tokens,
}
)
llm_trace.append({"scope": f"agent_{iteration + 1}", "duration_ms": llm_duration})
except Exception as e:
err_duration = int((time.time() - llm_start) * 1000)
@@ -462,42 +332,27 @@ async def run_reflect_agent(
llm_trace.append({"scope": f"agent_{iteration + 1}_err", "duration_ms": err_duration})
# Guardrail: If no evidence gathered yet, retry
has_gathered_evidence = (
bool(available_memory_ids) or bool(available_mental_model_ids) or bool(available_observation_ids)
bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids)
)
if not has_gathered_evidence and iteration < max_iterations - 1:
continue
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response, usage = await llm_config.call(
response = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
return_usage=True,
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
llm_trace.append(
{
"scope": "final",
"duration_ms": llm_duration,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -507,23 +362,20 @@ async def run_reflect_agent(
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
# No tool calls - LLM wants to respond with text
if not result.tool_calls:
if result.content:
answer = _clean_answer_text(result.content.strip())
answer = result.content.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
structured_output = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1)
return ReflectAgentResult(
@@ -533,42 +385,26 @@ async def run_reflect_agent(
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
# Empty response, force final
prompt = build_final_prompt(query, context_history, bank_profile, context)
llm_start = time.time()
response, usage = await llm_config.call(
response = await llm_config.call(
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
max_completion_tokens=max_tokens,
return_usage=True,
)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
llm_trace.append(
{
"scope": "final",
"duration_ms": llm_duration,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -578,7 +414,6 @@ async def run_reflect_agent(
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
@@ -587,7 +422,7 @@ async def run_reflect_agent(
if done_call:
# Guardrail: Require evidence before done
has_gathered_evidence = (
bool(available_memory_ids) or bool(available_mental_model_ids) or bool(available_observation_ids)
bool(available_memory_ids) or bool(available_reflection_ids) or bool(available_mental_model_ids)
)
if not has_gathered_evidence and iteration < max_iterations - 1:
# Add assistant message and fake tool result asking for evidence
@@ -601,10 +436,9 @@ async def run_reflect_agent(
{
"role": "tool",
"tool_call_id": done_call.id,
"name": done_call.name, # Required by Gemini
"content": json.dumps(
{
"error": "You must search for information first. Use search_mental_models(), search_observations(), or recall() before providing your final answer."
"error": "You must search for information first. Use search_reflections(), search_mental_models(), or recall() before providing your final answer."
}
),
}
@@ -615,13 +449,12 @@ async def run_reflect_agent(
return await _process_done_tool(
done_call,
available_memory_ids,
available_reflection_ids,
available_mental_model_ids,
available_observation_ids,
iteration + 1,
total_tools_called,
tool_trace,
_get_llm_trace(),
_get_usage(),
_log_completion,
reflect_id,
directives_applied=directives_applied,
@@ -644,8 +477,8 @@ async def run_reflect_agent(
tool_tasks = [
_execute_tool_with_timing(
tc,
search_reflections_fn,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
@@ -674,6 +507,15 @@ async def run_reflect_agent(
)
# Track available IDs from tool results (only for successful responses)
if (
normalized_tool_name == "search_reflections"
and isinstance(output, dict)
and "reflections" in output
):
for reflection in output["reflections"]:
if "id" in reflection:
available_reflection_ids.add(reflection["id"])
if (
normalized_tool_name == "search_mental_models"
and isinstance(output, dict)
@@ -683,15 +525,6 @@ async def run_reflect_agent(
if "id" in mm:
available_mental_model_ids.add(mm["id"])
if (
normalized_tool_name == "search_observations"
and isinstance(output, dict)
and "observations" in output
):
for obs in output["observations"]:
if "id" in obs:
available_observation_ids.add(obs["id"])
if normalized_tool_name == "recall" and isinstance(output, dict) and "memories" in output:
for memory in output["memories"]:
if "id" in memory:
@@ -702,7 +535,6 @@ async def run_reflect_agent(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name, # Required by Gemini
"content": json.dumps(output, default=str),
}
)
@@ -711,17 +543,9 @@ async def run_reflect_agent(
input_dict = {"tool": tc.name, **tc.arguments}
input_summary = _summarize_input(tc.name, tc.arguments)
# Extract reason from tool arguments (if provided)
tool_reason = tc.arguments.get("reason")
tool_trace.append(
ToolCall(
tool=tc.name,
reason=tool_reason,
input=input_dict,
output=output,
duration_ms=duration_ms,
iteration=iteration + 1,
tool=tc.name, input=input_dict, output=output, duration_ms=duration_ms, iteration=iteration + 1
)
)
@@ -751,7 +575,6 @@ async def run_reflect_agent(
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
)
@@ -771,13 +594,12 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
async def _process_done_tool(
done_call: "LLMToolCall",
available_memory_ids: set[str],
available_reflection_ids: set[str],
available_mental_model_ids: set[str],
available_observation_ids: set[str],
iterations: int,
total_tools_called: int,
tool_trace: list[ToolCall],
llm_trace: list[LLMCall],
usage: TokenUsageSummary,
log_completion: Callable,
reflect_id: str,
directives_applied: list[DirectiveInfo],
@@ -787,30 +609,19 @@ async def _process_done_tool(
"""Process the done tool call and return the result."""
args = done_call.arguments
# Extract and clean the answer - some LLMs leak structured output into the answer text
raw_answer = args.get("answer", "").strip()
answer = _clean_done_answer(raw_answer) if raw_answer else ""
answer = args.get("answer", "").strip()
if not answer:
answer = "No answer provided."
# Validate IDs (only include IDs that were actually retrieved)
used_memory_ids = [mid for mid in args.get("memory_ids", []) if mid in available_memory_ids]
used_reflection_ids = [rid for rid in args.get("reflection_ids", []) if rid in available_reflection_ids]
used_mental_model_ids = [mid for mid in args.get("mental_model_ids", []) if mid in available_mental_model_ids]
used_observation_ids = [oid for oid in args.get("observation_ids", []) if oid in available_observation_ids]
# Generate structured output if schema provided
structured_output = None
final_usage = usage
if response_schema and llm_config and answer:
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
# Add structured output tokens to usage
final_usage = TokenUsageSummary(
input_tokens=usage.input_tokens + struct_in,
output_tokens=usage.output_tokens + struct_out,
total_tokens=usage.total_tokens + struct_in + struct_out,
)
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
log_completion(answer, iterations)
return ReflectAgentResult(
@@ -820,18 +631,17 @@ async def _process_done_tool(
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=llm_trace,
usage=final_usage,
used_memory_ids=used_memory_ids,
used_reflection_ids=used_reflection_ids,
used_mental_model_ids=used_mental_model_ids,
used_observation_ids=used_observation_ids,
directives_applied=directives_applied,
)
async def _execute_tool_with_timing(
tc: "LLMToolCall",
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
) -> tuple[dict[str, Any], int]:
@@ -840,8 +650,8 @@ async def _execute_tool_with_timing(
result = await _execute_tool(
tc.name,
tc.arguments,
search_reflections_fn,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
@@ -852,8 +662,8 @@ async def _execute_tool_with_timing(
async def _execute_tool(
tool_name: str,
args: dict[str, Any],
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
@@ -861,19 +671,19 @@ async def _execute_tool(
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
if tool_name == "search_mental_models":
if tool_name == "search_reflections":
query = args.get("query")
if not query:
return {"error": "search_reflections requires a query parameter"}
max_results = args.get("max_results") or 5
return await search_reflections_fn(query, max_results)
elif tool_name == "search_mental_models":
query = args.get("query")
if not query:
return {"error": "search_mental_models requires a query parameter"}
max_results = args.get("max_results") or 5
return await search_mental_models_fn(query, max_results)
elif tool_name == "search_observations":
query = args.get("query")
if not query:
return {"error": "search_observations requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
return await search_observations_fn(query, max_tokens)
return await search_mental_models_fn(query, max_tokens)
elif tool_name == "recall":
query = args.get("query")
@@ -895,12 +705,12 @@ async def _execute_tool(
def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
"""Create a summary of tool input for logging, showing all params."""
if tool_name == "search_mental_models":
if tool_name == "search_reflections":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_results = args.get("max_results") or 5
return f"(query={query_preview}, max_results={max_results})"
elif tool_name == "search_observations":
elif tool_name == "search_mental_models":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_tokens = max(args.get("max_tokens") or 5000, 1000)
@@ -919,9 +729,9 @@ def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
answer = args.get("answer", "")
answer_preview = f"'{answer[:30]}...'" if len(answer) > 30 else f"'{answer}'"
memory_ids = args.get("memory_ids", [])
reflection_ids = args.get("reflection_ids", [])
mental_model_ids = args.get("mental_model_ids", [])
observation_ids = args.get("observation_ids", [])
return (
f"(answer={answer_preview}, mem={len(memory_ids)}, mm={len(mental_model_ids)}, obs={len(observation_ids)})"
f"(answer={answer_preview}, mem={len(memory_ids)}, ref={len(reflection_ids)}, mm={len(mental_model_ids)})"
)
return str(args)
@@ -7,28 +7,51 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
class ObservationSection(BaseModel):
"""A section within an observation with its supporting memories."""
class MentalModelObservation(BaseModel):
"""An observation within a mental model with its supporting memories."""
title: str = Field(description="Section header (can be empty for intro)")
text: str = Field(description="Section content - no headers, use lists/tables/bold")
title: str = Field(description="Observation header (can be empty for intro)")
text: str = Field(description="Observation content - no headers, use lists/tables/bold")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
class MentalModelInput(BaseModel):
"""Input for the learn tool to create a mental model placeholder.
The agent only specifies name and description - the actual content/observations
are generated during refresh, similar to pinned models.
"""
name: str = Field(description="Human-readable name for the mental model")
description: str = Field(description="What to track - used as prompt for content generation during refresh")
entity_id: str | None = Field(default=None, description="Optional link to existing entity ID")
class AnswerSection(BaseModel):
"""A section of the answer with its supporting evidence (DEPRECATED)."""
title: str = Field(description="Section header/title")
text: str = Field(description="Section content")
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
model_ids: list[str] = Field(default_factory=list, description="Mental model IDs supporting this section")
class ReflectAction(BaseModel):
"""Single action the reflect agent can take."""
tool: Literal["list_observations", "get_observation", "recall", "expand", "done"] = Field(
description="Tool to invoke: list_observations, get_observation, recall, expand, or done"
tool: Literal["list_mental_models", "get_mental_model", "recall", "learn", "expand", "done"] = Field(
description="Tool to invoke: list_mental_models, get_mental_model, recall, learn, expand, or done"
)
# Tool-specific parameters
observation_id: str | None = Field(default=None, description="Observation ID for get_observation")
model_id: str | None = Field(default=None, description="Mental model ID for get_mental_model")
query: str | None = Field(default=None, description="Search query for recall")
max_tokens: int | None = Field(default=None, description="Max tokens for recall results (default 2048)")
mental_model: MentalModelInput | None = Field(default=None, description="Mental model to create/update for learn")
memory_ids: list[str] | None = Field(default=None, description="Memory unit IDs for expand (batched)")
depth: Literal["chunk", "document"] | None = Field(default=None, description="Expansion depth for expand")
observation_sections: list[ObservationSection] | None = Field(
default=None, description="Observation sections for done action (when output_mode=observations)"
sections: list[AnswerSection] | None = Field(default=None, description="DEPRECATED: Use answer field instead")
observations: list[MentalModelObservation] | None = Field(
default=None, description="Observations for done action (when output_mode=observations)"
)
# Plain text answer fields (for output_mode=answer)
answer: str | None = Field(default=None, description="Plain text answer for done action (no markdown)")
@@ -50,8 +73,7 @@ class ReflectActionBatch(BaseModel):
class ToolCall(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, expand")
reason: str | None = Field(default=None, description="Agent's reasoning for making this tool call")
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
@@ -63,8 +85,6 @@ class LLMCall(BaseModel):
scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
duration_ms: int = Field(description="Execution time in milliseconds")
input_tokens: int = Field(default=0, description="Input tokens used")
output_tokens: int = Field(default=0, description="Output tokens used")
class DirectiveInfo(BaseModel):
@@ -72,15 +92,7 @@ class DirectiveInfo(BaseModel):
id: str = Field(description="Directive mental model ID")
name: str = Field(description="Directive name")
content: str = Field(description="Directive content")
class TokenUsageSummary(BaseModel):
"""Total token usage across all LLM calls."""
input_tokens: int = Field(default=0, description="Total input tokens used")
output_tokens: int = Field(default=0, description="Total output tokens used")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied")
class ReflectAgentResult(BaseModel):
@@ -94,16 +106,13 @@ class ReflectAgentResult(BaseModel):
tools_called: int = Field(default=0, description="Total number of tool calls made")
tool_trace: list[ToolCall] = Field(default_factory=list, description="Trace of all tool calls made")
llm_trace: list[LLMCall] = Field(default_factory=list, description="Trace of all LLM calls made")
usage: TokenUsageSummary = Field(
default_factory=TokenUsageSummary, description="Total token usage across all LLM calls"
)
used_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer")
used_reflection_ids: list[str] = Field(
default_factory=list, description="Validated reflection IDs actually used in answer"
)
used_mental_model_ids: list[str] = Field(
default_factory=list, description="Validated mental model IDs actually used in answer"
)
used_observation_ids: list[str] = Field(
default_factory=list, description="Validated observation IDs actually used in answer"
)
directives_applied: list[DirectiveInfo] = Field(
default_factory=list, description="Directive mental models that affected this reflection"
)
@@ -2,8 +2,8 @@
System prompts for the reflect agent.
The reflect agent uses hierarchical retrieval:
1. search_mental_models - User-curated summaries (highest quality)
2. search_observations - Consolidated knowledge with freshness awareness
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - Consolidated knowledge with freshness awareness
3. recall - Raw facts as ground truth fallback
"""
@@ -125,23 +125,21 @@ def build_system_prompt_for_tools(
bank_profile: dict[str, Any],
context: str | None = None,
directives: list[dict[str, Any]] | None = None,
has_mental_models: bool = False,
budget: str | None = None,
has_reflections: bool = False,
) -> str:
"""
Build the system prompt for tool-calling reflect agent.
The agent uses hierarchical retrieval:
1. search_mental_models - User-curated summaries (try first, if available)
2. search_observations - Consolidated knowledge with freshness
1. search_reflections - User-curated summaries (try first, if available)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
Args:
bank_profile: Bank profile with name and mission
context: Optional additional context
directives: Optional list of directive mental models to inject as hard rules
has_mental_models: Whether the bank has any mental models (skip if not)
budget: Search depth budget - "low", "mid", or "high". Controls exploration thoroughness.
has_reflections: Whether the bank has any reflections (skip if not)
"""
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
@@ -178,25 +176,25 @@ def build_system_prompt_for_tools(
)
# Build retrieval levels based on what's available
if has_mental_models:
if has_reflections:
parts.extend(
[
"You have access to THREE levels of knowledge. Use them in this order:",
"",
"### 1. MENTAL MODELS (search_mental_models) - Try First",
"### 1. REFLECTIONS (search_reflections) - Try First",
"- User-curated summaries about specific topics",
"- HIGHEST quality - manually created and maintained",
"- If a relevant mental model exists and is FRESH, it may fully answer the question",
"- If a relevant reflection exists and is FRESH, it may fully answer the question",
"- Check `is_stale` field - if stale, also verify with lower levels",
"",
"### 2. OBSERVATIONS (search_observations) - Second Priority",
"### 2. MENTAL MODELS (search_mental_models) - Second Priority",
"- Auto-consolidated knowledge from memories",
"- Check `is_stale` field - if stale, ALSO use recall() to verify",
"- Good for understanding patterns and summaries",
"",
"### 3. RAW FACTS (recall) - Ground Truth",
"- Individual memories (world facts and experiences)",
"- Use when: no mental models/observations exist, they're stale, or you need specific details",
"- Use when: no reflections/models exist, they're stale, or you need specific details",
"- This is the source of truth that other levels are built from",
"",
]
@@ -206,15 +204,15 @@ def build_system_prompt_for_tools(
[
"You have access to TWO levels of knowledge. Use them in this order:",
"",
"### 1. OBSERVATIONS (search_observations) - Try First",
"### 1. MENTAL MODELS (search_mental_models) - Try First",
"- Auto-consolidated knowledge from memories",
"- Check `is_stale` field - if stale, ALSO use recall() to verify",
"- Good for understanding patterns and summaries",
"",
"### 2. RAW FACTS (recall) - Ground Truth",
"- Individual memories (world facts and experiences)",
"- Use when: no observations exist, they're stale, or you need specific details",
"- This is the source of truth that observations are built from",
"- Use when: no mental models exist, they're stale, or you need specific details",
"- This is the source of truth that mental models are built from",
"",
]
)
@@ -232,57 +230,16 @@ def build_system_prompt_for_tools(
"",
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
"",
"## Workflow",
]
)
# Add budget guidance
if budget:
budget_lower = budget.lower()
if budget_lower == "low":
parts.extend(
[
"## RESEARCH DEPTH: SHALLOW (Quick Response)",
"- Prioritize speed over completeness",
"- If mental models or observations provide a reasonable answer, stop there",
"- Only dig deeper if the initial results are clearly insufficient",
"- Prefer a quick overview rather than exhaustive details",
"- Answer promptly with available information",
"",
]
)
elif budget_lower == "mid":
parts.extend(
[
"## RESEARCH DEPTH: MODERATE (Balanced)",
"- Balance thoroughness with efficiency",
"- Check multiple sources when the question warrants it",
"- Verify stale data if it's central to the answer",
"- Don't over-explore, but ensure reasonable coverage",
"",
]
)
elif budget_lower == "high":
parts.extend(
[
"## RESEARCH DEPTH: DEEP (Thorough Exploration)",
"- Explore comprehensively before answering",
"- Search across all available knowledge levels",
"- Use multiple query variations to ensure coverage",
"- Verify information across different retrieval levels",
"- Use expand() to get full context on important memories",
"- Take time to synthesize a complete, well-researched answer",
"",
]
)
parts.append("## Workflow")
if has_mental_models:
if has_reflections:
parts.extend(
[
"1. First, try search_mental_models() - check if a curated summary exists",
"2. If no mental model or it's stale, try search_observations() for consolidated knowledge",
"3. If observations are stale OR you need specific details, use recall() for raw facts",
"1. First, try search_reflections() - check if a curated summary exists",
"2. If no reflection or it's stale, try search_mental_models() for consolidated knowledge",
"3. If mental models are stale OR you need specific details, use recall() for raw facts",
"4. Use expand() if you need more context on specific memories",
"5. When ready, call done() with your answer and supporting IDs",
]
@@ -290,8 +247,8 @@ def build_system_prompt_for_tools(
else:
parts.extend(
[
"1. First, try search_observations() - check for consolidated knowledge",
"2. If observations are stale OR you need specific details, use recall() for raw facts",
"1. First, try search_mental_models() - check for consolidated knowledge",
"2. If mental models are stale OR you need specific details, use recall() for raw facts",
"3. Use expand() if you need more context on specific memories",
"4. When ready, call done() with your answer and supporting IDs",
]
@@ -304,7 +261,7 @@ def build_system_prompt_for_tools(
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
"- Put IDs ONLY in the memory_ids/mental_model_ids/observation_ids arrays, not in the answer",
"- Put IDs ONLY in the memory_ids/reflection_ids/mental_model_ids arrays, not in the answer",
]
)
@@ -399,8 +356,8 @@ def build_agent_prompt(
parts.append(
"\n## Instructions\n"
"Start by searching for relevant information using the hierarchical retrieval strategy:\n"
"1. Try search_mental_models() first for curated summaries\n"
"2. Try search_observations() for consolidated knowledge\n"
"1. Try search_reflections() first for curated summaries\n"
"2. Try search_mental_models() for consolidated knowledge\n"
"3. Use recall() for specific details or to verify stale data"
)
@@ -2,8 +2,8 @@
Tool implementations for the reflect agent.
Implements hierarchical retrieval:
1. search_mental_models - User-curated stored reflect responses (highest quality)
2. search_observations - Consolidated knowledge with freshness
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
"""
@@ -20,11 +20,11 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Observation is considered stale if not updated in this many days
# Mental model is considered stale if not updated in this many days
STALE_THRESHOLD_DAYS = 7
async def tool_search_mental_models(
async def tool_search_reflections(
conn: "Connection",
bank_id: str,
query: str,
@@ -35,9 +35,9 @@ async def tool_search_mental_models(
exclude_ids: list[str] | None = None,
) -> dict[str, Any]:
"""
Search user-curated mental models by semantic similarity.
Search user-curated reflections by semantic similarity.
Mental models are high-quality, manually created summaries about specific topics.
Reflections are high-quality, manually created summaries about specific topics.
They should be searched FIRST as they represent the most reliable synthesized knowledge.
Args:
@@ -45,13 +45,13 @@ async def tool_search_mental_models(
bank_id: Bank identifier
query: Search query (for logging/tracing)
query_embedding: Pre-computed embedding for semantic search
max_results: Maximum number of mental models to return
tags: Optional tags to filter mental models
max_results: Maximum number of reflections to return
tags: Optional tags to filter reflections
tags_match: How to match tags - "any" (OR), "all" (AND)
exclude_ids: Optional list of mental model IDs to exclude (e.g., when refreshing a mental model)
exclude_ids: Optional list of reflection IDs to exclude (e.g., when refreshing a reflection)
Returns:
Dict with matching mental models including content and freshness info
Dict with matching reflections including content and freshness info
"""
from ..memory_engine import fq_table
@@ -73,14 +73,14 @@ async def tool_search_mental_models(
params.append(exclude_ids)
next_param += 1
# Search mental models by embedding similarity
# Search reflections by embedding similarity
rows = await conn.fetch(
f"""
SELECT
id, name, content,
id, name, content, reflect_response,
tags, created_at, last_refreshed_at,
1 - (embedding <=> $2::vector) as relevance
FROM {fq_table("mental_models")}
FROM {fq_table("reflections")}
WHERE bank_id = $1 AND embedding IS NOT NULL {filters}
ORDER BY embedding <=> $2::vector
LIMIT $3
@@ -89,7 +89,7 @@ async def tool_search_mental_models(
)
now = datetime.now(timezone.utc)
mental_models = []
reflections = []
for row in rows:
last_refreshed_at = row["last_refreshed_at"]
@@ -102,11 +102,12 @@ async def tool_search_mental_models(
age = now - last_refreshed_at
is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS)
mental_models.append(
reflections.append(
{
"id": str(row["id"]),
"name": row["name"],
"content": row["content"],
"reflect_response": row["reflect_response"],
"tags": row["tags"] or [],
"relevance": round(row["relevance"], 4),
"updated_at": last_refreshed_at.isoformat() if last_refreshed_at else None,
@@ -116,12 +117,12 @@ async def tool_search_mental_models(
return {
"query": query,
"count": len(mental_models),
"mental_models": mental_models,
"count": len(reflections),
"reflections": reflections,
}
async def tool_search_observations(
async def tool_search_mental_models(
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
@@ -133,9 +134,9 @@ async def tool_search_observations(
pending_consolidation: int = 0,
) -> dict[str, Any]:
"""
Search consolidated observations using recall with include_observations.
Search consolidated mental models using recall with include_mental_models.
Observations are auto-generated from memories. Returns freshness info
Mental models are auto-generated from memories. Returns freshness info
so the agent knows if it should also verify with recall().
Args:
@@ -144,22 +145,22 @@ async def tool_search_observations(
query: Search query
request_context: Request context for authentication
max_tokens: Maximum tokens for results (default 5000)
tags: Optional tags to filter observations
tags: Optional tags to filter models
tags_match: How to match tags - "any" (OR), "all" (AND)
last_consolidated_at: When consolidation last ran (for staleness check)
pending_consolidation: Number of memories waiting to be consolidated
Returns:
Dict with matching observations including freshness info
Dict with matching mental models including freshness info
"""
from ..memory_engine import fq_table
# Use recall to search observations (they come back in results field when fact_type=["observation"])
# Use recall to search mental models (they come back in results field when fact_type=["mental_model"])
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["observation"], # Only retrieve observations
max_tokens=max_tokens, # Token budget controls how many observations are returned
fact_type=["mental_model"], # Only retrieve mental models
max_tokens=max_tokens, # Token budget controls how many mental models are returned
enable_trace=False,
request_context=request_context,
tags=tags,
@@ -168,29 +169,29 @@ async def tool_search_observations(
_quiet=True,
)
observations = []
mental_models = []
# When fact_type=["observation"], results come back in `results` field as MemoryFact objects
# When fact_type=["mental_model"], results come back in `results` field as MemoryFact objects
# We need to fetch additional fields (proof_count, source_memory_ids) from the database
if result.results:
obs_ids = [m.id for m in result.results]
mm_ids = [m.id for m in result.results]
# Fetch proof_count and source_memory_ids for these observations
# Fetch proof_count and source_memory_ids for these mental models
pool = await memory_engine._get_pool()
async with pool.acquire() as conn:
obs_rows = await conn.fetch(
mm_rows = await conn.fetch(
f"""
SELECT id, proof_count, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
obs_ids,
mm_ids,
)
obs_data = {str(row["id"]): row for row in obs_rows}
mm_data = {str(row["id"]): row for row in mm_rows}
for m in result.results:
# Get additional data from DB lookup
extra = obs_data.get(m.id, {})
extra = mm_data.get(m.id, {})
proof_count = extra.get("proof_count", 1) if extra else 1
source_ids = extra.get("source_memory_ids", []) if extra else []
# Convert UUIDs to strings
@@ -203,7 +204,7 @@ async def tool_search_observations(
is_stale = True
staleness_reason = f"{pending_consolidation} memories pending consolidation"
observations.append(
mental_models.append(
{
"id": str(m.id),
"text": m.text,
@@ -225,8 +226,8 @@ async def tool_search_observations(
return {
"query": query,
"count": len(observations),
"observations": observations,
"count": len(mental_models),
"mental_models": mental_models,
"freshness": freshness,
}
@@ -246,7 +247,7 @@ async def tool_recall(
Search memories using TEMPR retrieval.
This is the ground truth - raw facts and experiences.
Use when mental models/observations don't exist, are stale, or need verification.
Use when reflections/mental models don't exist, are stale, or need verification.
Args:
memory_engine: Memory engine instance
@@ -265,7 +266,7 @@ async def tool_recall(
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["experience", "world"], # Exclude opinions and observations
fact_type=["experience", "world"], # Exclude opinions and mental_models
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
@@ -3,69 +3,61 @@ Tool schema definitions for the reflect agent.
These are OpenAI-format tool definitions used with native tool calling.
The reflect agent uses a hierarchical retrieval strategy:
1. search_mental_models - User-curated stored reflect responses (highest quality, if applicable)
2. search_observations - Consolidated knowledge with freshness awareness
1. search_reflections - User-curated summaries (highest quality, if applicable)
2. search_mental_models - Consolidated knowledge with freshness awareness
3. recall - Raw facts (world/experience) as ground truth fallback
"""
# Tool definitions in OpenAI format
TOOL_SEARCH_REFLECTIONS = {
"type": "function",
"function": {
"name": "search_reflections",
"description": (
"Search user-curated reflections (summaries). These are high-quality, manually created "
"summaries about specific topics. Use FIRST when the question might be covered by an "
"existing reflection. Returns reflections with their content and last refresh time."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to find relevant reflections",
},
"max_results": {
"type": "integer",
"description": "Maximum number of reflections to return (default 5)",
},
},
"required": ["query"],
},
},
}
TOOL_SEARCH_MENTAL_MODELS = {
"type": "function",
"function": {
"name": "search_mental_models",
"description": (
"Search user-curated mental models (stored reflect responses). These are high-quality, manually created "
"summaries about specific topics. Use FIRST when the question might be covered by an "
"existing mental model. Returns mental models with their content and last refresh time."
"Search consolidated mental models (auto-generated knowledge). These are automatically "
"synthesized from memories. Returns models with freshness info (updated_at, is_stale). "
"If a model is STALE, you should ALSO use recall() to verify with current facts."
),
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you're making this search (for debugging)",
},
"query": {
"type": "string",
"description": "Search query to find relevant mental models",
},
"max_results": {
"type": "integer",
"description": "Maximum number of mental models to return (default 5)",
},
},
"required": ["reason", "query"],
},
},
}
TOOL_SEARCH_OBSERVATIONS = {
"type": "function",
"function": {
"name": "search_observations",
"description": (
"Search consolidated observations (auto-generated knowledge). These are automatically "
"synthesized from memories. Returns observations with freshness info (updated_at, is_stale). "
"If an observation is STALE, you should ALSO use recall() to verify with current facts."
),
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you're making this search (for debugging)",
},
"query": {
"type": "string",
"description": "Search query to find relevant observations",
},
"max_tokens": {
"type": "integer",
"description": "Maximum tokens for results (default 5000). Use higher values for broader searches.",
},
},
"required": ["reason", "query"],
"required": ["query"],
},
},
}
@@ -83,10 +75,6 @@ TOOL_RECALL = {
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you're making this search (for debugging)",
},
"query": {
"type": "string",
"description": "Search query string",
@@ -96,7 +84,7 @@ TOOL_RECALL = {
"description": "Optional limit on result size (default 2048). Use higher values for broader searches.",
},
},
"required": ["reason", "query"],
"required": ["query"],
},
},
}
@@ -109,10 +97,6 @@ TOOL_EXPAND = {
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Brief explanation of why you need more context (for debugging)",
},
"memory_ids": {
"type": "array",
"items": {"type": "string"},
@@ -124,7 +108,7 @@ TOOL_EXPAND = {
"description": "chunk: surrounding text chunk, document: full source document",
},
},
"required": ["reason", "memory_ids", "depth"],
"required": ["memory_ids", "depth"],
},
},
}
@@ -146,16 +130,16 @@ TOOL_DONE_ANSWER = {
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
"reflection_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of reflection IDs that support your answer",
},
"mental_model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model IDs that support your answer",
},
"observation_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of observation IDs that support your answer",
},
},
"required": ["answer"],
},
@@ -197,16 +181,16 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
"reflection_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of reflection IDs that support your answer",
},
"mental_model_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of mental model IDs that support your answer",
},
"observation_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Array of observation IDs that support your answer",
},
"directive_compliance": {
"type": "string",
"description": f"REQUIRED: Confirm your answer complies with ALL directives. List each directive and how your answer follows it:\n{rules_list}\n\nFormat: 'Directive 1: [how answer complies]. Directive 2: [how answer complies]...'",
@@ -223,8 +207,8 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
Get the list of tools for the reflect agent.
The tools support a hierarchical retrieval strategy:
1. search_mental_models - User-curated stored reflect responses (try first)
2. search_observations - Consolidated knowledge with freshness
1. search_reflections - User-curated summaries (try first)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
Args:
@@ -235,8 +219,8 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
List of tool definitions in OpenAI format
"""
tools = [
TOOL_SEARCH_REFLECTIONS,
TOOL_SEARCH_MENTAL_MODELS,
TOOL_SEARCH_OBSERVATIONS,
TOOL_RECALL,
TOOL_EXPAND,
]
@@ -10,8 +10,8 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "observation"])
# Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "mental_model"])
class LLMToolCall(BaseModel):
@@ -28,15 +28,12 @@ class LLMToolCallResult(BaseModel):
content: str | None = Field(default=None, description="Text content if any")
tool_calls: list[LLMToolCall] = Field(default_factory=list, description="Tool calls requested by the LLM")
finish_reason: str | None = Field(default=None, description="Reason the LLM stopped: 'stop', 'tool_calls', etc.")
input_tokens: int = Field(default=0, description="Input tokens used in this call")
output_tokens: int = Field(default=0, description="Output tokens used in this call")
class ToolCallTrace(BaseModel):
"""A single tool call made during reflect."""
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
reason: str | None = Field(default=None, description="Agent's reasoning for making this tool call")
input: dict = Field(description="Tool input parameters")
output: dict = Field(description="Tool output/result")
duration_ms: int = Field(description="Execution time in milliseconds")
@@ -50,13 +47,13 @@ class LLMCallTrace(BaseModel):
duration_ms: int = Field(description="Execution time in milliseconds")
class ObservationRef(BaseModel):
"""Reference to an observation accessed during reflect."""
class MentalModelRef(BaseModel):
"""Reference to a mental model accessed during reflect."""
id: str = Field(description="Observation ID")
name: str = Field(description="Observation name")
type: str = Field(description="Observation type: entity, concept, event")
subtype: str = Field(description="Observation subtype: structural, emergent, learned")
id: str = Field(description="Mental model ID")
name: str = Field(description="Mental model name")
type: str = Field(description="Mental model type: entity, concept, event")
subtype: str = Field(description="Mental model subtype: structural, emergent, learned")
description: str = Field(description="Brief description")
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
@@ -66,7 +63,7 @@ class DirectiveRef(BaseModel):
id: str = Field(description="Directive mental model ID")
name: str = Field(description="Directive name")
content: str = Field(description="Directive content")
rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied")
class TokenUsage(BaseModel):
@@ -169,23 +166,23 @@ class ChunkInfo(BaseModel):
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
class ObservationResult(BaseModel):
"""An observation result from recall (consolidated knowledge synthesized from facts)."""
class MentalModelResult(BaseModel):
"""A mental model result from recall."""
id: str = Field(description="Unique observation ID")
text: str = Field(description="The observation text")
proof_count: int = Field(description="Number of facts supporting this observation")
id: str = Field(description="Unique mental model ID")
text: str = Field(description="The mental model text")
proof_count: int = Field(description="Number of facts supporting this mental model")
relevance: float = Field(default=0.0, description="Relevance score to the query")
tags: list[str] | None = Field(default=None, description="Tags for visibility scoping")
source_memory_ids: list[str] = Field(
default_factory=list, description="IDs of facts that contribute to this observation"
default_factory=list, description="IDs of facts that contribute to this mental model"
)
class MentalModelResult(BaseModel):
"""A mental model result from recall (stored reflect response)."""
class ReflectionResult(BaseModel):
"""A reflection result from recall."""
id: str = Field(description="Unique mental model ID")
id: str = Field(description="Unique reflection ID")
name: str = Field(description="Human-readable name")
content: str = Field(description="The synthesized content")
relevance: float = Field(default=0.0, description="Relevance score to the query")
@@ -254,14 +251,7 @@ class ReflectResult(BaseModel):
],
"experience": [],
"opinion": [],
"mental_models": [],
"directives": [
{
"id": "directive-123",
"name": "Response Style",
"rules": ["Always be concise"],
}
],
"mental-models": [],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
@@ -271,8 +261,8 @@ class ReflectResult(BaseModel):
)
text: str = Field(description="The formulated answer text")
based_on: dict[str, Any] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion, mental_models, directives)"
based_on: dict[str, list[MemoryFact]] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion, mental-models)"
)
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
structured_output: dict[str, Any] | None = Field(
@@ -432,15 +432,34 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
# FACT EXTRACTION PROMPTS
# =============================================================================
# Base prompt template (shared by concise and custom modes)
# Uses {extraction_guidelines} placeholder for mode-specific instructions
_BASE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
# 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}
{extraction_guidelines}
══════════════════════════════════════════════════════════════════════════
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
══════════════════════════════════════════════════════════════════════════
ONLY extract facts that are:
✅ Personal info: names, relationships, roles, background
✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
✅ Significant events: milestones, decisions, achievements, changes
✅ Plans/goals: future intentions, deadlines, commitments
✅ Expertise: skills, knowledge, certifications, experience
✅ Important context: projects, problems, constraints
✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
✅ Observations: descriptions of people, places, things with specific details
DO NOT extract:
❌ Generic greetings: "how are you", "hello", pleasantries without substance
❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
❌ Process chatter: "let me check", "one moment", "I'll look into it"
❌ Repeated info: if already stated, don't extract again
CONSOLIDATE related statements into ONE fact when possible.
══════════════════════════════════════════════════════════════════════════
FACT FORMAT - BE CONCISE
@@ -488,33 +507,7 @@ ENTITIES
══════════════════════════════════════════════════════════════════════════
Include: people names, organizations, places, key objects, abstract concepts (career, friendship, etc.)
Always include "user" when fact is about the user.{examples}"""
# Concise mode guidelines
_CONCISE_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
══════════════════════════════════════════════════════════════════════════
ONLY extract facts that are:
✅ Personal info: names, relationships, roles, background
✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
✅ Significant events: milestones, decisions, achievements, changes
✅ Plans/goals: future intentions, deadlines, commitments
✅ Expertise: skills, knowledge, certifications, experience
✅ Important context: projects, problems, constraints
✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
✅ Observations: descriptions of people, places, things with specific details
DO NOT extract:
❌ Generic greetings: "how are you", "hello", pleasantries without substance
❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
❌ Process chatter: "let me check", "one moment", "I'll look into it"
❌ Repeated info: if already stated, don't extract again
CONSOLIDATE related statements into ONE fact when possible."""
# Concise mode examples
_CONCISE_EXAMPLES = """
Always include "user" when fact is about the user.
══════════════════════════════════════════════════════════════════════════
EXAMPLES
@@ -540,20 +533,6 @@ QUALITY OVER QUANTITY
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
# Assembled concise prompt (backward compatible - exact same output as before)
CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
fact_types_instruction="{fact_types_instruction}",
extraction_guidelines=_CONCISE_GUIDELINES,
examples=_CONCISE_EXAMPLES,
)
# Custom prompt uses same base but without examples
CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
fact_types_instruction="{fact_types_instruction}",
extraction_guidelines="{custom_instructions}",
examples="", # No examples for custom mode
)
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
@@ -701,12 +680,6 @@ async def _extract_facts_from_chunk(
Note: event_date parameter is kept for backward compatibility but not used in prompt.
The LLM extracts temporal information from the context string instead.
"""
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
# Determine which fact types to extract based on the flag
@@ -725,27 +698,13 @@ async def _extract_facts_from_chunk(
extract_causal_links = config.retain_extract_causal_links
# Select base prompt based on extraction mode
if extraction_mode == "custom":
# Custom mode: inject user-provided guidelines
if not config.retain_custom_instructions:
logger.warning(
"extraction_mode='custom' but HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS not set. "
"Falling back to 'concise' mode."
)
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
else:
base_prompt = CUSTOM_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(
fact_types_instruction=fact_types_instruction,
custom_instructions=config.retain_custom_instructions,
)
elif extraction_mode == "verbose":
if extraction_mode == "verbose":
base_prompt = VERBOSE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
else:
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
# Format the prompt with fact types instruction
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
# Build the full prompt with or without causal relationships section
# Select appropriate response schema based on extraction mode and causal links
@@ -758,6 +717,12 @@ async def _extract_facts_from_chunk(
else:
response_schema = FactExtractionResponseNoCausal
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
# Retry logic for JSON validation errors
max_retries = 2
last_error = None
@@ -155,7 +155,7 @@ class LinkExpansionRetriever(GraphRetriever):
all_seeds.extend(temporal_seeds)
if not all_seeds:
logger.info("[LinkExpansion] No seeds found, returning empty results")
logger.debug("[LinkExpansion] No seeds found, returning empty results")
return [], timings
seed_ids = list({s.id for s in all_seeds})
@@ -164,102 +164,30 @@ class LinkExpansionRetriever(GraphRetriever):
# Run entity and causal expansion sequentially on same connection
query_start = time.time()
# For observations, traverse through source_memory_ids to find entity connections.
# Observations don't have direct unit_entities - they inherit entities via their
# source world/experience facts.
#
# Path: observation → source_memory_ids → world fact → entities →
# ALL world facts with those entities → their observations (excluding seeds)
if fact_type == "observation":
# Debug: Check what source_memory_ids exist on seed observations
debug_sources = await conn.fetch(
f"""
SELECT id, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
seed_ids,
)
source_ids_found = []
for row in debug_sources:
if row["source_memory_ids"]:
source_ids_found.extend(row["source_memory_ids"])
logger.debug(
f"[LinkExpansion] observation graph: {len(seed_ids)} seeds, "
f"{len(source_ids_found)} source_memory_ids found"
)
entity_rows = await conn.fetch(
f"""
WITH seed_sources AS (
-- Get source memory IDs from seed observations
SELECT DISTINCT unnest(source_memory_ids) AS source_id
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND source_memory_ids IS NOT NULL
),
source_entities AS (
-- Get entities from those source memories (filtered by frequency)
SELECT DISTINCT ue.entity_id
FROM seed_sources ss
JOIN {fq_table("unit_entities")} ue ON ss.source_id = ue.unit_id
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE e.mention_count < $2
),
all_connected_sources AS (
-- Find ALL world facts sharing those entities (don't exclude seed sources)
-- The exclusion happens at the observation level, not the source level
SELECT DISTINCT other_ue.unit_id AS source_id
FROM source_entities se
JOIN {fq_table("unit_entities")} other_ue ON se.entity_id = other_ue.entity_id
)
-- Find observations derived from connected source memories
-- Only exclude the actual seed observations
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,
COUNT(DISTINCT cs.source_id)::float AS score
FROM all_connected_sources cs
JOIN {fq_table("memory_units")} mu
ON mu.source_memory_ids @> ARRAY[cs.source_id]
WHERE mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
""",
seed_ids,
self.max_entity_frequency,
budget,
)
logger.debug(f"[LinkExpansion] observation graph: found {len(entity_rows)} connected observations")
else:
# For world/experience facts, use direct entity lookup
entity_rows = 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,
COUNT(*)::float AS score
FROM {fq_table("unit_entities")} seed_ue
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
WHERE seed_ue.unit_id = ANY($1::uuid[])
AND e.mention_count < $2
AND mu.id != ALL($1::uuid[])
AND mu.fact_type = $3
GROUP BY mu.id
ORDER BY score DESC
LIMIT $4
""",
seed_ids,
self.max_entity_frequency,
fact_type,
budget,
)
entity_rows = 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,
COUNT(*)::float AS score
FROM {fq_table("unit_entities")} seed_ue
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
WHERE seed_ue.unit_id = ANY($1::uuid[])
AND e.mention_count < $2
AND mu.id != ALL($1::uuid[])
AND mu.fact_type = $3
GROUP BY mu.id
ORDER BY score DESC
LIMIT $4
""",
seed_ids,
self.max_entity_frequency,
fact_type,
budget,
)
causal_rows = await conn.fetch(
f"""
@@ -283,69 +211,11 @@ class LinkExpansionRetriever(GraphRetriever):
budget,
)
# Fallback: semantic/temporal/entity links from memory_links table
# These are secondary to entity links (via unit_entities) and causal links
# Weight is halved (0.5x) to prioritize primary link types
# Check both directions: seeds -> others AND others -> seeds
fallback_rows = await conn.fetch(
f"""
WITH outgoing AS (
-- Links FROM seeds TO other facts
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,
ml.weight
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('semantic', 'temporal', 'entity')
AND ml.weight >= $2
AND mu.fact_type = $3
AND mu.id != ALL($1::uuid[])
),
incoming AS (
-- Links FROM other facts TO seeds (reverse direction)
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,
ml.weight
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('semantic', 'temporal', 'entity')
AND ml.weight >= $2
AND mu.fact_type = $3
AND mu.id != ALL($1::uuid[])
),
combined AS (
SELECT * FROM outgoing
UNION ALL
SELECT * FROM incoming
)
SELECT DISTINCT ON (id)
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at, embedding,
fact_type, document_id, chunk_id, tags,
(MAX(weight) * 0.5) AS score
FROM combined
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at, embedding,
fact_type, document_id, chunk_id, tags
ORDER BY id, score DESC
LIMIT $4
""",
seed_ids,
self.causal_weight_threshold,
fact_type,
budget,
)
timings.edge_load_time = time.time() - query_start
timings.db_queries = 3
timings.edge_count = len(entity_rows) + len(causal_rows) + len(fallback_rows)
timings.db_queries = 2
timings.edge_count = len(entity_rows) + len(causal_rows)
# Merge results, taking max score per fact
# Priority: entity links (unit_entities) > causal links > fallback links
score_map: dict[str, float] = {}
row_map: dict[str, dict] = {}
@@ -360,12 +230,6 @@ class LinkExpansionRetriever(GraphRetriever):
if fact_id not in row_map:
row_map[fact_id] = dict(row)
for row in fallback_rows:
fact_id = str(row["id"])
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
if fact_id not in row_map:
row_map[fact_id] = dict(row)
# Sort by score and limit
sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
rows = [row_map[fact_id] for fact_id in sorted_ids]
@@ -0,0 +1,134 @@
"""
Scoring functions for memory search and retrieval.
Includes recency weighting, frequency weighting, temporal proximity,
and similarity calculations used in memory activation and ranking.
"""
from datetime import datetime
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
Args:
vec1: First vector
vec2: Second vector
Returns:
Similarity score between 0 and 1
"""
if len(vec1) != len(vec2):
raise ValueError("Vectors must have same dimension")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
"""
Calculate recency weight using logarithmic decay.
This provides much better differentiation over long time periods compared to
exponential decay. Uses a log-based decay where the half-life parameter controls
when memories reach 50% weight.
Examples:
- Today (0 days): 1.0
- 1 year (365 days): ~0.5 (with default half_life=365)
- 2 years (730 days): ~0.33
- 5 years (1825 days): ~0.17
- 10 years (3650 days): ~0.09
This ensures that 2-year-old and 5-year-old memories have meaningfully
different weights, unlike exponential decay which makes them both ~0.
Args:
days_since: Number of days since the memory was created
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
Returns:
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
return 1.0 / (1.0 + math.log1p(normalized_age))
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
"""
Calculate a single temporal anchor point from a temporal range.
Used for spreading activation - we need a single representative date
to calculate temporal proximity between facts. This simplifies the
range-to-range distance problem.
Strategy: Use midpoint of the range for balanced representation.
Args:
occurred_start: Start of temporal range
occurred_end: End of temporal range
Returns:
Single datetime representing the temporal anchor (midpoint)
Examples:
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
"""
# Calculate midpoint
time_delta = occurred_end - occurred_start
midpoint = occurred_start + (time_delta / 2)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
Used for spreading activation to determine how "close" two facts are
in time. Uses logarithmic decay so that temporal similarity doesn't
drop off too quickly.
Args:
anchor_a: Temporal anchor of first fact
anchor_b: Temporal anchor of second fact
half_life_days: Number of days for proximity to reach 0.5
(default: 30 days = 1 month)
Returns:
Proximity score in [0, 1] where:
- 1.0 = same day
- 0.5 = ~half_life days apart
- 0.0 = very distant in time
Examples:
- Same day: 1.0
- 1 week apart (half_life=30): ~0.7
- 1 month apart (half_life=30): ~0.5
- 1 year apart (half_life=30): ~0.2
"""
import math
days_apart = abs((anchor_a - anchor_b).days)
if days_apart == 0:
return 1.0
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
# Similar to calculate_recency_weight but for proximity between events
normalized_distance = days_apart / half_life_days
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
return proximity
@@ -144,21 +144,17 @@ class BrokerTaskBackend(TaskBackend):
self,
pool_getter: Callable[[], "asyncpg.Pool"],
schema: str | None = None,
schema_getter: Callable[[], str | None] | None = None,
):
"""
Initialize the broker task backend.
Args:
pool_getter: Callable that returns the asyncpg connection pool
schema: Database schema for multi-tenant support (optional, static)
schema_getter: Callable that returns current schema dynamically (optional).
If set, takes precedence over static schema for submit_task.
schema: Database schema for multi-tenant support (optional)
"""
super().__init__()
self._pool_getter = pool_getter
self._schema = schema
self._schema_getter = schema_getter
async def initialize(self):
"""Initialize the backend."""
@@ -184,8 +180,7 @@ class BrokerTaskBackend(TaskBackend):
bank_id = task_dict.get("bank_id")
payload_json = json.dumps(task_dict)
schema = self._schema_getter() if self._schema_getter else self._schema
table = fq_table("async_operations", schema)
table = fq_table("async_operations", self._schema)
if operation_id:
# Update existing operation with task payload
@@ -236,8 +231,7 @@ class BrokerTaskBackend(TaskBackend):
import asyncio
pool = self._pool_getter()
schema = self._schema_getter() if self._schema_getter else self._schema
table = fq_table("async_operations", schema)
table = fq_table("async_operations", self._schema)
start_time = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start_time < timeout:
+126
View File
@@ -65,3 +65,129 @@ async def extract_facts(
return [], chunks
return facts, chunks
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
Args:
vec1: First vector
vec2: Second vector
Returns:
Similarity score between 0 and 1
"""
if len(vec1) != len(vec2):
raise ValueError("Vectors must have same dimension")
dot_product = sum(a * b for a, b in zip(vec1, vec2))
magnitude1 = sum(a * a for a in vec1) ** 0.5
magnitude2 = sum(b * b for b in vec2) ** 0.5
if magnitude1 == 0 or magnitude2 == 0:
return 0.0
return dot_product / (magnitude1 * magnitude2)
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
"""
Calculate recency weight using logarithmic decay.
This provides much better differentiation over long time periods compared to
exponential decay. Uses a log-based decay where the half-life parameter controls
when memories reach 50% weight.
Examples:
- Today (0 days): 1.0
- 1 year (365 days): ~0.5 (with default half_life=365)
- 2 years (730 days): ~0.33
- 5 years (1825 days): ~0.17
- 10 years (3650 days): ~0.09
This ensures that 2-year-old and 5-year-old memories have meaningfully
different weights, unlike exponential decay which makes them both ~0.
Args:
days_since: Number of days since the memory was created
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
Returns:
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
return 1.0 / (1.0 + math.log1p(normalized_age))
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
"""
Calculate a single temporal anchor point from a temporal range.
Used for spreading activation - we need a single representative date
to calculate temporal proximity between facts. This simplifies the
range-to-range distance problem.
Strategy: Use midpoint of the range for balanced representation.
Args:
occurred_start: Start of temporal range
occurred_end: End of temporal range
Returns:
Single datetime representing the temporal anchor (midpoint)
Examples:
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
"""
# Calculate midpoint
time_delta = occurred_end - occurred_start
midpoint = occurred_start + (time_delta / 2)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
Used for spreading activation to determine how "close" two facts are
in time. Uses logarithmic decay so that temporal similarity doesn't
drop off too quickly.
Args:
anchor_a: Temporal anchor of first fact
anchor_b: Temporal anchor of second fact
half_life_days: Number of days for proximity to reach 0.5
(default: 30 days = 1 month)
Returns:
Proximity score in [0, 1] where:
- 1.0 = same day
- 0.5 = ~half_life days apart
- 0.0 = very distant in time
Examples:
- Same day: 1.0
- 1 week apart (half_life=30): ~0.7
- 1 month apart (half_life=30): ~0.5
- 1 year apart (half_life=30): ~0.2
"""
import math
days_apart = abs((anchor_a - anchor_b).days)
if days_apart == 0:
return 1.0
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
# Similar to calculate_recency_weight but for proximity between events
normalized_distance = days_apart / half_life_days
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
return proximity
@@ -21,10 +21,6 @@ from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionC
from hindsight_api.extensions.http import HttpExtension
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
# Core operations
OperationValidationError,
OperationValidatorExtension,
RecallContext,
@@ -37,7 +33,6 @@ from hindsight_api.extensions.operation_validator import (
)
from hindsight_api.extensions.tenant import (
AuthenticationError,
Tenant,
TenantContext,
TenantExtension,
)
@@ -52,7 +47,7 @@ __all__ = [
"DefaultExtensionContext",
# HTTP Extension
"HttpExtension",
# Operation Validator - Core
# Operation Validator
"OperationValidationError",
"OperationValidatorExtension",
"RecallContext",
@@ -62,14 +57,10 @@ __all__ = [
"RetainContext",
"RetainResult",
"ValidationResult",
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"AuthenticationError",
"RequestContext",
"Tenant",
"TenantContext",
"TenantExtension",
]
@@ -1,6 +1,6 @@
"""Built-in tenant extension implementations."""
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
@@ -31,7 +31,3 @@ class ApiKeyTenantExtension(TenantExtension):
if context.api_key != self.expected_api_key:
raise AuthenticationError("Invalid API key")
return TenantContext(schema_name="public")
async def list_tenants(self) -> list[Tenant]:
"""Return public schema for single-tenant setup."""
return [Tenant(schema="public")]
@@ -1,4 +1,4 @@
"""Operation Validator Extension for validating retain/recall/reflect/consolidate operations."""
"""Operation Validator Extension for validating retain/recall/reflect operations."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@@ -97,19 +97,6 @@ class ReflectContext:
context: str | None = None
# =============================================================================
# Consolidation Pre-operation Context
# =============================================================================
@dataclass
class ConsolidateContext:
"""Context for a consolidation operation validation (pre-operation)."""
bank_id: str
request_context: "RequestContext"
# =============================================================================
# Post-operation Contexts (includes results)
# =============================================================================
@@ -177,28 +164,9 @@ class ReflectResultContext:
error: str | None = None
# =============================================================================
# Consolidation Post-operation Context
# =============================================================================
@dataclass
class ConsolidateResult:
"""Result context for post-consolidation hook."""
bank_id: str
request_context: "RequestContext"
# Result
processed: int = 0
created: int = 0
updated: int = 0
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect/consolidate operations.
Validates and hooks into retain/recall/reflect operations.
This extension allows implementing custom logic such as:
- Rate limiting (pre-operation)
@@ -217,13 +185,9 @@ class OperationValidatorExtension(Extension, ABC):
-> config = {"max_requests": "100"}
Hook execution order:
1. validate_* (pre-operation)
1. validate_retain/validate_recall/validate_reflect (pre-operation)
2. [operation executes]
3. on_*_complete (post-operation)
Supported operations:
- retain, recall, reflect (core memory operations)
- consolidate (mental models consolidation)
3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation)
"""
# =========================================================================
@@ -361,44 +325,3 @@ class OperationValidatorExtension(Extension, ABC):
- error: Error message (if failed)
"""
pass
# =========================================================================
# Consolidation - Pre-operation validation hook (optional - override to implement)
# =========================================================================
async def validate_consolidate(self, ctx: ConsolidateContext) -> ValidationResult:
"""
Validate a consolidation operation before execution.
Override to implement custom validation logic for consolidation.
Args:
ctx: Context containing:
- bank_id: Bank identifier
- request_context: Request context with auth info
Returns:
ValidationResult indicating whether the operation is allowed.
"""
return ValidationResult.accept()
# =========================================================================
# Consolidation - Post-operation hook (optional - override to implement)
# =========================================================================
async def on_consolidate_complete(self, result: ConsolidateResult) -> None:
"""
Called after a consolidation operation completes (success or failure).
Override to implement post-operation logic such as usage tracking or audit logging.
Args:
result: Result context containing:
- bank_id: Bank identifier
- processed: Number of memories processed
- created: Number of mental models created
- updated: Number of mental models updated
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
@@ -28,18 +28,6 @@ class TenantContext:
schema_name: str
@dataclass
class Tenant:
"""
Represents a tenant for worker discovery.
Used by list_tenants() to return tenant information including
the PostgreSQL schema name for database operations.
"""
schema: str
class TenantExtension(Extension, ABC):
"""
Extension for multi-tenancy and API key authentication.
@@ -73,17 +61,3 @@ class TenantExtension(Extension, ABC):
AuthenticationError: If authentication fails.
"""
...
@abstractmethod
async def list_tenants(self) -> list[Tenant]:
"""
List all tenants that should be processed by workers.
This method is used by the worker to discover all tenants that need
task polling. Workers will poll for pending tasks in each tenant's schema.
Returns:
List of Tenant objects containing schema information.
For single-tenant setups, return [Tenant(schema="public")].
"""
...
+4 -6
View File
@@ -184,10 +184,6 @@ def main():
reflect_llm_api_key=config.reflect_llm_api_key,
reflect_llm_model=config.reflect_llm_model,
reflect_llm_base_url=config.reflect_llm_base_url,
consolidation_llm_provider=config.consolidation_llm_provider,
consolidation_llm_api_key=config.consolidation_llm_api_key,
consolidation_llm_model=config.consolidation_llm_model,
consolidation_llm_base_url=config.consolidation_llm_base_url,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_tei_url=config.embeddings_tei_url,
@@ -209,13 +205,15 @@ def main():
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
recall_max_concurrent=config.recall_max_concurrent,
recall_connection_budget=config.recall_connection_budget,
observation_min_facts=config.observation_min_facts,
observation_top_entities=config.observation_top_entities,
retain_max_completion_tokens=config.retain_max_completion_tokens,
retain_chunk_size=config.retain_chunk_size,
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_custom_instructions=config.retain_custom_instructions,
retain_observations_async=config.retain_observations_async,
enable_observations=config.enable_observations,
enable_mental_models=config.enable_mental_models,
consolidation_similarity_threshold=config.consolidation_similarity_threshold,
consolidation_batch_size=config.consolidation_batch_size,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
+52 -11
View File
@@ -44,6 +44,7 @@ import os
import sys
from mcp.server.fastmcp import FastMCP
from mcp.types import Icon
from hindsight_api.config import (
DEFAULT_MCP_LOCAL_BANK_ID,
@@ -52,7 +53,6 @@ from hindsight_api.config import (
ENV_MCP_INSTRUCTIONS,
ENV_MCP_LOCAL_BANK_ID,
)
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
# Configure logging - default to warning to avoid polluting stderr during MCP init
# MCP clients interpret stderr output as errors, so we suppress INFO logs by default
@@ -85,6 +85,9 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
"""
# Import here to avoid slow startup if just checking --help
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Create memory engine with pg0 embedded database if not provided
if memory is None:
@@ -102,17 +105,55 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
mcp = FastMCP("hindsight")
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=lambda: bank_id,
include_bank_id_param=False, # Local MCP uses fixed bank_id
tools={"retain", "recall"}, # Local MCP only has retain and recall
retain_description=retain_description,
recall_description=recall_description,
retain_fire_and_forget=True, # Local MCP uses fire-and-forget pattern
)
@mcp.tool(description=retain_description)
async def retain(content: str, context: str = "general") -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
"""
import asyncio
register_mcp_tools(mcp, memory, config)
async def _retain():
try:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": content, "context": context}],
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
# Fire and forget - don't block on memory storage
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
@mcp.tool(description=recall_description)
async def recall(query: str, max_tokens: int = 4096, budget: str = "low") -> dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget level - "low", "mid", or "high" (default: "low")
"""
try:
# Map string budget to enum
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
search_result = await memory.recall_async(
bank_id=bank_id,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=budget_enum,
max_tokens=max_tokens,
request_context=RequestContext(),
)
return search_result.model_dump()
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return {"error": str(e), "results": []}
return mcp
-494
View File
@@ -1,494 +0,0 @@
"""Shared MCP tool implementations for Hindsight.
This module provides the core tool logic used by both:
- mcp_local.py (stdio transport for Claude Code)
- api/mcp.py (HTTP transport for API server)
"""
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Callable
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.config import (
DEFAULT_MCP_RECALL_DESCRIPTION,
DEFAULT_MCP_RETAIN_DESCRIPTION,
)
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
@dataclass
class MCPToolsConfig:
"""Configuration for MCP tools registration."""
# How to resolve bank_id for operations
bank_id_resolver: Callable[[], str | None]
# Whether to include bank_id as a parameter on tools (for multi-bank support)
include_bank_id_param: bool = False
# Which tools to register
tools: set[str] | None = None # None means all tools
# Custom descriptions (if None, uses defaults)
retain_description: str | None = None
recall_description: str | None = None
# Retain behavior
retain_fire_and_forget: bool = False # If True, use asyncio.create_task pattern
def parse_timestamp(timestamp: str) -> datetime | None:
"""Parse an ISO format timestamp string.
Args:
timestamp: ISO format timestamp (e.g., '2024-01-15T10:30:00Z')
Returns:
Parsed datetime or None if invalid
Raises:
ValueError: If timestamp format is invalid
"""
try:
return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
except ValueError as e:
raise ValueError(
f"Invalid timestamp format '{timestamp}'. "
"Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z'"
) from e
def build_content_dict(
content: str,
context: str,
timestamp: str | None = None,
) -> tuple[dict[str, Any], str | None]:
"""Build a content dict for retain operations.
Args:
content: The memory content
context: Category for the memory
timestamp: Optional ISO timestamp
Returns:
Tuple of (content_dict, error_message). error_message is None if successful.
"""
content_dict: dict[str, Any] = {"content": content, "context": context}
if timestamp:
try:
parsed_timestamp = parse_timestamp(timestamp)
content_dict["event_date"] = parsed_timestamp
except ValueError as e:
return {}, str(e)
return content_dict, None
def register_mcp_tools(
mcp: FastMCP,
memory: MemoryEngine,
config: MCPToolsConfig,
) -> None:
"""Register MCP tools on a FastMCP server.
Args:
mcp: FastMCP server instance
memory: MemoryEngine instance
config: Tool configuration
"""
tools_to_register = config.tools or {"retain", "recall", "reflect", "list_banks", "create_bank"}
if "retain" in tools_to_register:
_register_retain(mcp, memory, config)
if "recall" in tools_to_register:
_register_recall(mcp, memory, config)
if "reflect" in tools_to_register:
_register_reflect(mcp, memory, config)
if "list_banks" in tools_to_register:
_register_list_banks(mcp, memory, config)
if "create_bank" in tools_to_register:
_register_create_bank(mcp, memory, config)
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the retain tool."""
description = config.retain_description or DEFAULT_MCP_RETAIN_DESCRIPTION
if config.include_bank_id_param:
if config.retain_fire_and_forget:
@mcp.tool(description=description)
async def retain(
content: str,
context: str = "general",
timestamp: str | None = None,
bank_id: str | None = None,
) -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
"""
import asyncio
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp)
if error:
return {"status": "error", "message": error}
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
else:
@mcp.tool(description=description)
async def retain(
content: str,
context: str = "general",
timestamp: str | None = None,
async_processing: bool = True,
bank_id: str | None = None,
) -> str:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return "Error: No bank_id configured"
content_dict, error = build_content_dict(content, context, timestamp)
if error:
return f"Error: {error}"
contents = [content_dict]
if async_processing:
result = await memory.submit_async_retain(
bank_id=target_bank, contents=contents, request_context=RequestContext()
)
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
else:
await memory.retain_batch_async(
bank_id=target_bank,
contents=contents,
request_context=RequestContext(),
)
return f"Memory stored successfully in bank '{target_bank}'"
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
return f"Error: {str(e)}"
else:
# No bank_id param - use fixed bank from resolver
@mcp.tool(description=description)
async def retain(
content: str,
context: str = "general",
timestamp: str | None = None,
) -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
"""
import asyncio
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp)
if error:
return {"status": "error", "message": error}
async def _retain():
try:
await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the recall tool."""
description = config.recall_description or DEFAULT_MCP_RECALL_DESCRIPTION
if config.include_bank_id_param:
@mcp.tool(description=description)
async def recall(
query: str,
max_tokens: int = 4096,
bank_id: str | None = None,
) -> str | dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return "Error: No bank_id configured"
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=RequestContext(),
)
return recall_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return f'{{"error": "{e}", "results": []}}'
else:
@mcp.tool(description=description)
async def recall(
query: str,
max_tokens: int = 4096,
) -> dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured", "results": []}
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=RequestContext(),
)
return recall_result.model_dump()
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return {"error": str(e), "results": []}
def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the reflect tool."""
if config.include_bank_id_param:
@mcp.tool()
async def reflect(
query: str,
context: str | None = None,
budget: str = "low",
bank_id: str | None = None,
) -> str:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
WHEN TO USE THIS TOOL:
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
thinks through the question using everything the bank knows and its personality traits.
EXAMPLES OF GOOD QUERIES:
- "What patterns have emerged in how I approach debugging?"
- "Based on my past decisions, what architectural style do I prefer?"
- "What might be the best approach for this problem given what you know about me?"
- "How should I prioritize these tasks based on my goals?"
HOW IT DIFFERS FROM RECALL:
- recall: Returns raw facts matching your search (fast lookup)
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
Args:
query: The question or topic to reflect on
context: Optional context about why this reflection is needed
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return "Error: No bank_id configured"
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
reflect_result = await memory.reflect_async(
bank_id=target_bank,
query=query,
budget=budget_enum,
context=context,
request_context=RequestContext(),
)
return reflect_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error reflecting: {e}", exc_info=True)
return f'{{"error": "{e}", "text": ""}}'
else:
@mcp.tool()
async def reflect(
query: str,
context: str | None = None,
budget: str = "low",
) -> dict:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
WHEN TO USE THIS TOOL:
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
thinks through the question using everything the bank knows and its personality traits.
EXAMPLES OF GOOD QUERIES:
- "What patterns have emerged in how I approach debugging?"
- "Based on my past decisions, what architectural style do I prefer?"
- "What might be the best approach for this problem given what you know about me?"
- "How should I prioritize these tasks based on my goals?"
HOW IT DIFFERS FROM RECALL:
- recall: Returns raw facts matching your search (fast lookup)
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
Args:
query: The question or topic to reflect on
context: Optional context about why this reflection is needed
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured", "text": ""}
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
reflect_result = await memory.reflect_async(
bank_id=target_bank,
query=query,
budget=budget_enum,
context=context,
request_context=RequestContext(),
)
return reflect_result.model_dump()
except Exception as e:
logger.error(f"Error reflecting: {e}", exc_info=True)
return {"error": str(e), "text": ""}
def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the list_banks tool."""
@mcp.tool()
async def list_banks() -> str:
"""
List all available memory banks.
Use this tool to discover what memory banks exist in the system.
Each bank is an isolated memory store (like a separate "brain").
Returns:
JSON list of banks with their IDs, names, dispositions, and missions.
"""
try:
banks = await memory.list_banks(request_context=RequestContext())
return json.dumps({"banks": banks}, indent=2)
except Exception as e:
logger.error(f"Error listing banks: {e}", exc_info=True)
return f'{{"error": "{e}", "banks": []}}'
def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_bank tool."""
@mcp.tool()
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
Banks are auto-created with default settings if they don't exist.
Args:
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
name: Optional human-friendly name for the bank
mission: Optional mission describing who the agent is and what they're trying to accomplish
"""
try:
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name/mission if provided
if name is not None or mission is not None:
await memory.update_bank(
bank_id,
name=name,
mission=mission,
request_context=RequestContext(),
)
# Fetch updated profile
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Serialize disposition if it's a Pydantic model
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
profile["disposition"] = profile["disposition"].model_dump()
return json.dumps(profile, indent=2)
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
+1 -12
View File
@@ -181,8 +181,6 @@ def main():
nonlocal memory, poller
import uvicorn
from ..extensions import TenantExtension, load_extension
# Initialize MemoryEngine
# Workers use SyncTaskBackend because they execute tasks directly,
# they don't need to store tasks (they poll from DB)
@@ -195,15 +193,7 @@ def main():
print(f"Database connected: {config.database_url}")
# Load tenant extension for dynamic schema discovery
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension:
print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
else:
print("No tenant extension configured, using public schema only")
# Create a single poller that handles all schemas dynamically
# Create and start the poller
poller = WorkerPoller(
pool=memory._pool,
worker_id=args.worker_id,
@@ -211,7 +201,6 @@ def main():
poll_interval_ms=args.poll_interval,
batch_size=args.batch_size,
max_retries=args.max_retries,
tenant_extension=tenant_extension,
)
# Create the HTTP app for metrics/health
+88 -170
View File
@@ -11,14 +11,11 @@ import logging
import time
import traceback
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import asyncpg
from hindsight_api.extensions.tenant import TenantExtension
logger = logging.getLogger(__name__)
# Progress logging interval in seconds
@@ -32,23 +29,12 @@ def fq_table(table: str, schema: str | None = None) -> str:
return table
@dataclass
class ClaimedTask:
"""A task claimed from the database with its schema context."""
operation_id: str
task_dict: dict[str, Any]
schema: str | None
class WorkerPoller:
"""
Polls PostgreSQL for pending tasks and executes them.
Uses FOR UPDATE SKIP LOCKED for safe distributed claiming,
allowing multiple workers to process tasks without conflicts.
Supports dynamic multi-tenant discovery via tenant_extension.
"""
def __init__(
@@ -60,7 +46,6 @@ class WorkerPoller:
batch_size: int = 10,
max_retries: int = 3,
schema: str | None = None,
tenant_extension: "TenantExtension | None" = None,
):
"""
Initialize the worker poller.
@@ -72,9 +57,7 @@ class WorkerPoller:
poll_interval_ms: Interval between polls when no tasks found (milliseconds)
batch_size: Maximum number of tasks to claim per poll cycle
max_retries: Maximum retry attempts before marking task as failed
schema: Database schema for single-tenant support (ignored if tenant_extension is set)
tenant_extension: Extension for dynamic multi-tenant discovery. If set, list_tenants()
is called on each poll cycle to discover schemas dynamically.
schema: Database schema for multi-tenant support (optional)
"""
self._pool = pool
self._worker_id = worker_id
@@ -83,56 +66,27 @@ class WorkerPoller:
self._batch_size = batch_size
self._max_retries = max_retries
self._schema = schema
self._tenant_extension = tenant_extension
self._shutdown = asyncio.Event()
self._current_tasks: set[asyncio.Task] = set()
self._in_flight_count = 0
self._in_flight_lock = asyncio.Lock()
self._last_progress_log = 0.0
self._tasks_completed_since_log = 0
# Track active tasks locally: operation_id -> (op_type, bank_id, schema)
self._active_tasks: dict[str, tuple[str, str, str | None]] = {}
self._active_banks: set[str] = set()
async def _get_schemas(self) -> list[str | None]:
"""Get list of schemas to poll. Returns [None] for public schema."""
if self._tenant_extension is not None:
tenants = await self._tenant_extension.list_tenants()
# Convert "public" to None for SQL compatibility, keep others as-is
return [t.schema if t.schema != "public" else None for t in tenants]
# Single schema mode
return [self._schema]
async def claim_batch(self) -> list[ClaimedTask]:
async def claim_batch(self) -> list[tuple[str, dict[str, Any]]]:
"""
Claim up to batch_size pending tasks atomically across all tenant schemas.
Claim up to batch_size pending tasks atomically.
Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers.
For consolidation tasks specifically, skips pending tasks if there's already
a processing consolidation for the same bank (to avoid duplicate work).
If tenant_extension is configured, dynamically discovers schemas on each call.
Returns:
List of ClaimedTask objects containing operation_id, task_dict, and schema
List of tuples (operation_id, task_dict)
"""
schemas = await self._get_schemas()
all_tasks: list[ClaimedTask] = []
remaining_batch = self._batch_size
for schema in schemas:
if remaining_batch <= 0:
break
tasks = await self._claim_batch_for_schema(schema, remaining_batch)
all_tasks.extend(tasks)
remaining_batch -= len(tasks)
return all_tasks
async def _claim_batch_for_schema(self, schema: str | None, limit: int) -> list[ClaimedTask]:
"""Claim tasks from a specific schema."""
table = fq_table("async_operations", schema)
table = fq_table("async_operations", self._schema)
async with self._pool.acquire() as conn:
async with conn.transaction():
@@ -159,7 +113,7 @@ class WorkerPoller:
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
limit,
self._batch_size,
)
if not rows:
@@ -177,19 +131,12 @@ class WorkerPoller:
operation_ids,
)
# Parse and return task payloads with schema context
return [
ClaimedTask(
operation_id=str(row["operation_id"]),
task_dict=json.loads(row["task_payload"]),
schema=schema,
)
for row in rows
]
# Parse and return task payloads
return [(str(row["operation_id"]), json.loads(row["task_payload"])) for row in rows]
async def _mark_completed(self, operation_id: str, schema: str | None):
async def _mark_completed(self, operation_id: str):
"""Mark a task as completed."""
table = fq_table("async_operations", schema)
table = fq_table("async_operations", self._schema)
await self._pool.execute(
f"""
UPDATE {table}
@@ -199,9 +146,9 @@ class WorkerPoller:
operation_id,
)
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
async def _mark_failed(self, operation_id: str, error_message: str):
"""Mark a task as failed with error message."""
table = fq_table("async_operations", schema)
table = fq_table("async_operations", self._schema)
# Truncate error message if too long (max 5000 chars in schema)
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
await self._pool.execute(
@@ -214,9 +161,9 @@ class WorkerPoller:
error_message,
)
async def _retry_or_fail(self, operation_id: str, error_message: str, schema: str | None):
async def _retry_or_fail(self, operation_id: str, error_message: str):
"""Increment retry count or mark as failed if max retries exceeded."""
table = fq_table("async_operations", schema)
table = fq_table("async_operations", self._schema)
# Get current retry count
row = await self._pool.fetchrow(
@@ -233,7 +180,7 @@ class WorkerPoller:
if retry_count >= self._max_retries:
# Max retries exceeded, mark as failed
await self._mark_failed(
operation_id, f"Max retries ({self._max_retries}) exceeded. Last error: {error_message}", schema
operation_id, f"Max retries ({self._max_retries}) exceeded. Last error: {error_message}"
)
logger.error(f"Task {operation_id} failed after {retry_count} retries")
else:
@@ -249,32 +196,20 @@ class WorkerPoller:
)
logger.warning(f"Task {operation_id} failed, will retry (attempt {retry_count + 1}/{self._max_retries})")
async def execute_task(self, task: ClaimedTask):
async def execute_task(self, operation_id: str, task_dict: dict[str, Any]):
"""Execute a single task and update its status."""
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
# Track this task as active
async with self._in_flight_lock:
self._active_tasks[task.operation_id] = (task_type, bank_id, task.schema)
task_type = task_dict.get("type", "unknown")
bank_id = task_dict.get("bank_id", "unknown")
try:
schema_info = f", schema={task.schema}" if task.schema else ""
logger.debug(f"Executing task {task.operation_id} (type={task_type}, bank={bank_id}{schema_info})")
# Pass schema to executor so it can set the correct context
if task.schema:
task.task_dict["_schema"] = task.schema
await self._executor(task.task_dict)
await self._mark_completed(task.operation_id, task.schema)
logger.debug(f"Task {task.operation_id} completed successfully")
logger.debug(f"Executing task {operation_id} (type={task_type}, bank={bank_id})")
await self._executor(task_dict)
await self._mark_completed(operation_id)
logger.debug(f"Task {operation_id} completed successfully")
except Exception as e:
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
logger.error(f"Task {task.operation_id} failed: {e}")
await self._retry_or_fail(task.operation_id, error_msg, task.schema)
finally:
# Remove from active tasks
async with self._in_flight_lock:
self._active_tasks.pop(task.operation_id, None)
logger.error(f"Task {operation_id} failed: {e}")
await self._retry_or_fail(operation_id, error_msg)
async def recover_own_tasks(self) -> int:
"""
@@ -284,33 +219,25 @@ class WorkerPoller:
On startup, we reset any tasks stuck in 'processing' for this worker_id
back to 'pending' so they can be picked up again.
If tenant_extension is configured, recovers across all tenant schemas.
Returns:
Number of tasks recovered
"""
schemas = await self._get_schemas()
total_count = 0
table = fq_table("async_operations", self._schema)
for schema in schemas:
table = fq_table("async_operations", schema)
result = await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE status = 'processing' AND worker_id = $1
""",
self._worker_id,
)
result = await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE status = 'processing' AND worker_id = $1
""",
self._worker_id,
)
# Parse "UPDATE N" to get count
count = int(result.split()[-1]) if result else 0
total_count += count
if total_count > 0:
logger.info(f"Worker {self._worker_id} recovered {total_count} stale tasks from previous run")
return total_count
# Parse "UPDATE N" to get count
count = int(result.split()[-1]) if result else 0
if count > 0:
logger.info(f"Worker {self._worker_id} recovered {count} stale tasks from previous run")
return count
async def run(self):
"""
@@ -318,8 +245,6 @@ class WorkerPoller:
Continuously polls for pending tasks, claims them, and executes them
until shutdown is signaled.
If tenant_extension is configured, dynamically discovers schemas on each poll.
"""
# Recover any tasks from a previous crash before starting
await self.recover_own_tasks()
@@ -328,22 +253,17 @@ class WorkerPoller:
while not self._shutdown.is_set():
try:
# Claim a batch of tasks (across all tenant schemas if configured)
# Claim a batch of tasks
tasks = await self.claim_batch()
if tasks:
# Log batch info
task_types: dict[str, int] = {}
schemas_seen: set[str | None] = set()
for task in tasks:
t = task.task_dict.get("type", "unknown")
task_types = {}
for _, task_dict in tasks:
t = task_dict.get("type", "unknown")
task_types[t] = task_types.get(t, 0) + 1
schemas_seen.add(task.schema)
types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
schemas_str = ", ".join(s or "public" for s in schemas_seen)
logger.info(
f"Worker {self._worker_id} claimed {len(tasks)} tasks: {types_str} (schemas: {schemas_str})"
)
logger.info(f"Worker {self._worker_id} claimed {len(tasks)} tasks: {types_str}")
# Track in-flight tasks
async with self._in_flight_lock:
@@ -352,7 +272,7 @@ class WorkerPoller:
# Execute tasks concurrently
try:
await asyncio.gather(
*[self.execute_task(task) for task in tasks],
*[self.execute_task(op_id, task_dict) for op_id, task_dict in tasks],
return_exceptions=True,
)
finally:
@@ -416,60 +336,58 @@ class WorkerPoller:
self._last_progress_log = now
try:
# Get local active tasks (this worker only)
table = fq_table("async_operations", self._schema)
async with self._pool.acquire() as conn:
# Get global stats by status
stats = await conn.fetch(
f"""
SELECT status, COUNT(*) as count
FROM {table}
WHERE created_at > now() - interval '24 hours'
GROUP BY status
"""
)
# Get currently processing tasks grouped by type and bank
processing = await conn.fetch(
f"""
SELECT operation_type, bank_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY operation_type, bank_id
"""
)
# Build stats dict
status_counts = {row["status"]: row["count"] for row in stats}
pending = status_counts.get("pending", 0)
processing_count = status_counts.get("processing", 0)
completed = status_counts.get("completed", 0)
failed = status_counts.get("failed", 0)
# Build processing breakdown
processing_info = []
banks_working = set()
for row in processing:
op_type = row["operation_type"]
bank_id = row["bank_id"]
count = row["count"]
banks_working.add(bank_id)
processing_info.append(f"{op_type}:{bank_id}({count})")
# Format log
async with self._in_flight_lock:
in_flight = self._in_flight_count
active_tasks = dict(self._active_tasks) # Copy to avoid holding lock
# Build local processing breakdown grouped by (op_type, bank_id)
task_groups: dict[tuple[str, str], int] = {}
for op_type, bank_id, _ in active_tasks.values():
key = (op_type, bank_id)
task_groups[key] = task_groups.get(key, 0) + 1
processing_info = [f"{op}:{bank}({cnt})" for (op, bank), cnt in task_groups.items()]
processing_str = ", ".join(processing_info[:10]) if processing_info else "none"
if len(processing_info) > 10:
processing_str += f" +{len(processing_info) - 10} more"
# Get global stats from DB across all schemas
schemas = await self._get_schemas()
global_pending = 0
all_worker_counts: dict[str, int] = {}
async with self._pool.acquire() as conn:
for schema in schemas:
table = fq_table("async_operations", schema)
row = await conn.fetchrow(f"SELECT COUNT(*) as count FROM {table} WHERE status = 'pending'")
global_pending += row["count"] if row else 0
# Get processing breakdown by worker
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
for wr in worker_rows:
wid = wr["worker_id"] or "unknown"
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
# Format other workers' processing counts
other_workers = []
for wid, cnt in all_worker_counts.items():
if wid != self._worker_id:
other_workers.append(f"{wid}:{cnt}")
others_str = ", ".join(other_workers) if other_workers else "none"
schemas_str = ", ".join(s or "public" for s in schemas)
logger.info(
f"[WORKER_STATS] worker={self._worker_id} in_flight={in_flight} | "
f"global: pending={global_pending} (schemas: {schemas_str}) | "
f"others: {others_str} | "
f"my_active: {processing_str}"
f"global: pending={pending} processing={processing_count} "
f"completed_24h={completed} failed_24h={failed} | "
f"active: {processing_str}"
)
except Exception as e:
+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():
File diff suppressed because it is too large Load Diff
@@ -1,148 +0,0 @@
"""
Tests for XPC error recovery in LocalSTCrossEncoder.
This tests the automatic reinitialization of the cross-encoder model when
XPC connection errors occur on macOS (common in long-running daemon processes).
"""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
class TestCrossEncoderXPCErrorRecovery:
"""Tests for XPC error detection and recovery in LocalSTCrossEncoder."""
@pytest.fixture
def cross_encoder(self):
"""Create a LocalSTCrossEncoder instance."""
return LocalSTCrossEncoder(model_name="cross-encoder/ms-marco-TinyBERT-L-2-v2")
def test_is_xpc_error_detection(self, cross_encoder):
"""Test that XPC errors are correctly detected."""
# Test various XPC error message formats
xpc_error = Exception("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
assert cross_encoder._is_xpc_error(xpc_error)
xpc_error2 = Exception("XPC error occurred")
assert cross_encoder._is_xpc_error(xpc_error2)
# Test that non-XPC errors are not detected
normal_error = Exception("Some other error")
assert not cross_encoder._is_xpc_error(normal_error)
@pytest.mark.asyncio
async def test_predict_with_xpc_recovery(self, cross_encoder):
"""Test that predict() recovers from XPC errors by reinitializing."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Track calls to reinitialize
reinit_called = False
original_reinit = cross_encoder._reinitialize_model_sync
def track_reinit():
nonlocal reinit_called
reinit_called = True
original_reinit()
# Track predict attempts
predict_attempts = []
original_predict = cross_encoder._model.predict
def mock_predict(*args, **kwargs):
predict_attempts.append(1)
# Only fail on first attempt
if len(predict_attempts) == 1:
raise RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
else:
# After reinit: succeed
return original_predict(*args, **kwargs)
# Mock the initial predict to fail, reinit happens, then new model succeeds
with patch.object(cross_encoder, "_reinitialize_model_sync", side_effect=track_reinit):
with patch.object(cross_encoder._model, "predict", side_effect=mock_predict):
# This should trigger XPC error on first attempt, then recover and succeed
result = await cross_encoder.predict([("query", "document")])
# Verify we got a result
assert result is not None
assert len(result) == 1
assert isinstance(result[0], float)
assert reinit_called # Should have reinitialized
assert len(predict_attempts) >= 1 # At least one attempt was made
@pytest.mark.asyncio
async def test_predict_fails_on_non_xpc_error(self, cross_encoder):
"""Test that predict() does not retry for non-XPC errors."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Create a mock that raises a non-XPC error
def mock_predict(*args, **kwargs):
raise RuntimeError("Some other error")
# Patch the model's predict method
with patch.object(cross_encoder._model, "predict", side_effect=mock_predict):
# This should fail without retry
with pytest.raises(RuntimeError) as exc_info:
await cross_encoder.predict([("query", "document")])
assert "Some other error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_reinitialize_clears_model(self, cross_encoder):
"""Test that _reinitialize_model_sync properly clears and reinits the model."""
# Initialize the cross-encoder
await cross_encoder.initialize()
original_model = cross_encoder._model
assert original_model is not None
# Reinitialize
cross_encoder._reinitialize_model_sync()
# Model should be reinitialized (new instance)
assert cross_encoder._model is not None
assert cross_encoder._model is not original_model
# Should still work
result = await cross_encoder.predict([("test query", "test document")])
assert len(result) == 1
assert isinstance(result[0], float)
@pytest.mark.asyncio
async def test_xpc_recovery_exhausts_retries(self, cross_encoder):
"""Test that XPC recovery gives up after max retries."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Track reinit calls
reinit_count = 0
original_reinit = cross_encoder._reinitialize_model_sync
def track_and_fail_reinit():
nonlocal reinit_count
reinit_count += 1
# Call original reinit, but the new model will also be mocked to fail
original_reinit()
# After reinit, patch the new model too
cross_encoder._model.predict = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
# Mock that always raises XPC error
cross_encoder._model.predict = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
with patch.object(cross_encoder, "_reinitialize_model_sync", side_effect=track_and_fail_reinit):
# Should try once, reinitialize, try again, and fail
with pytest.raises(Exception) as exc_info:
await cross_encoder.predict([("query", "document")])
assert "XPC_ERROR_CONNECTION_INVALID" in str(exc_info.value) or "Failed to recover" in str(exc_info.value)
assert reinit_count == 1 # Should have tried to reinitialize once
@@ -9,18 +9,18 @@ Includes tests for:
import asyncio
import os
from datetime import datetime
import pytest
from datetime import datetime
from sqlalchemy import create_engine, text
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api.engine.cross_encoder import CohereCrossEncoder, LocalSTCrossEncoder
from hindsight_api.engine.embeddings import CohereEmbeddings, LocalSTEmbeddings, OpenAIEmbeddings
from hindsight_api.engine.embeddings import LocalSTEmbeddings, OpenAIEmbeddings, CohereEmbeddings
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder, CohereCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.engine.task_backend import SyncTaskBackend
from hindsight_api.extensions import TenantContext, TenantExtension
from hindsight_api.migrations import ensure_embedding_dimension, run_migrations
from hindsight_api.extensions import TenantExtension, TenantContext
from hindsight_api.migrations import run_migrations, ensure_embedding_dimension
# =============================================================================
# Shared Utilities
@@ -36,11 +36,6 @@ class SchemaTenantExtension(TenantExtension):
async def authenticate(self, request_context: RequestContext) -> TenantContext:
return TenantContext(schema_name=self.schema_name)
async def list_tenants(self) -> list:
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema=self.schema_name)]
def get_test_schema(prefix: str, worker_id: str) -> str:
"""Get unique schema name per xdist worker."""
@@ -1,148 +0,0 @@
"""
Tests for XPC error recovery in LocalSTEmbeddings.
This tests the automatic reinitialization of the embedding model when
XPC connection errors occur on macOS (common in long-running daemon processes).
"""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.engine.embeddings import LocalSTEmbeddings
class TestXPCErrorRecovery:
"""Tests for XPC error detection and recovery in LocalSTEmbeddings."""
@pytest.fixture
def embeddings(self):
"""Create a LocalSTEmbeddings instance."""
return LocalSTEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
def test_is_xpc_error_detection(self, embeddings):
"""Test that XPC errors are correctly detected."""
# Test various XPC error message formats
xpc_error = Exception("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
assert embeddings._is_xpc_error(xpc_error)
xpc_error2 = Exception("XPC error occurred")
assert embeddings._is_xpc_error(xpc_error2)
# Test that non-XPC errors are not detected
normal_error = Exception("Some other error")
assert not embeddings._is_xpc_error(normal_error)
@pytest.mark.asyncio
async def test_encode_with_xpc_recovery(self, embeddings):
"""Test that encode() recovers from XPC errors by reinitializing."""
# Initialize the embeddings
await embeddings.initialize()
# Track calls to reinitialize
reinit_called = False
original_reinit = embeddings._reinitialize_model_sync
def track_reinit():
nonlocal reinit_called
reinit_called = True
original_reinit()
# Track encode attempts
encode_attempts = []
original_encode = embeddings._model.encode
def mock_encode(*args, **kwargs):
encode_attempts.append(1)
# Only fail on first attempt
if len(encode_attempts) == 1:
raise RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
else:
# After reinit: succeed
return original_encode(*args, **kwargs)
# Mock the initial encode to fail, reinit happens, then new model succeeds
with patch.object(embeddings, "_reinitialize_model_sync", side_effect=track_reinit):
with patch.object(embeddings._model, "encode", side_effect=mock_encode):
# This should trigger XPC error on first attempt, then recover and succeed
result = embeddings.encode(["test text"])
# Verify we got a result
assert result is not None
assert len(result) == 1
assert len(result[0]) > 0 # Should have embedding vector
assert reinit_called # Should have reinitialized
assert len(encode_attempts) >= 1 # At least one attempt was made
@pytest.mark.asyncio
async def test_encode_fails_on_non_xpc_error(self, embeddings):
"""Test that encode() does not retry for non-XPC errors."""
# Initialize the embeddings
await embeddings.initialize()
# Create a mock that raises a non-XPC error
def mock_encode(*args, **kwargs):
raise RuntimeError("Some other error")
# Patch the model's encode method
with patch.object(embeddings._model, "encode", side_effect=mock_encode):
# This should fail without retry
with pytest.raises(RuntimeError) as exc_info:
embeddings.encode(["test text"])
assert "Some other error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_reinitialize_clears_model(self, embeddings):
"""Test that _reinitialize_model_sync properly clears and reinits the model."""
# Initialize the embeddings
await embeddings.initialize()
original_model = embeddings._model
assert original_model is not None
# Reinitialize
embeddings._reinitialize_model_sync()
# Model should be reinitialized (new instance)
assert embeddings._model is not None
assert embeddings._model is not original_model
# Should still work
result = embeddings.encode(["test"])
assert len(result) == 1
assert len(result[0]) > 0
@pytest.mark.asyncio
async def test_xpc_recovery_exhausts_retries(self, embeddings):
"""Test that XPC recovery gives up after max retries."""
# Initialize the embeddings
await embeddings.initialize()
# Track reinit calls
reinit_count = 0
original_reinit = embeddings._reinitialize_model_sync
def track_and_fail_reinit():
nonlocal reinit_count
reinit_count += 1
# Call original reinit, but the new model will also be mocked to fail
original_reinit()
# After reinit, patch the new model too
embeddings._model.encode = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
# Mock that always raises XPC error
embeddings._model.encode = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
with patch.object(embeddings, "_reinitialize_model_sync", side_effect=track_and_fail_reinit):
# Should try once, reinitialize, try again, and fail
with pytest.raises(RuntimeError) as exc_info:
embeddings.encode(["test"])
assert "XPC_ERROR_CONNECTION_INVALID" in str(exc_info.value)
assert reinit_count == 1 # Should have tried to reinitialize once
+2 -17
View File
@@ -24,9 +24,6 @@ from hindsight_api.extensions import (
TenantExtension,
ValidationResult,
load_extension,
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
)
@@ -131,18 +128,14 @@ class TrackingValidator(OperationValidatorExtension):
def __init__(self, config: dict):
super().__init__(config)
# Pre-hook tracking - Core operations
# Pre-hook tracking
self.pre_retain_calls: list[RetainContext] = []
self.pre_recall_calls: list[RecallContext] = []
self.pre_reflect_calls: list[ReflectContext] = []
# Post-hook tracking - Core operations
# Post-hook tracking
self.post_retain_calls: list[RetainResult] = []
self.post_recall_calls: list[RecallResult] = []
self.post_reflect_calls: list[ReflectResultContext] = []
# Pre-hook tracking - Consolidation
self.pre_consolidate_calls: list[ConsolidateContext] = []
# Post-hook tracking - Consolidation
self.post_consolidate_calls: list[ConsolidateResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.pre_retain_calls.append(ctx)
@@ -165,14 +158,6 @@ class TrackingValidator(OperationValidatorExtension):
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
self.post_reflect_calls.append(result)
# Consolidation hooks
async def validate_consolidate(self, ctx: ConsolidateContext) -> ValidationResult:
self.pre_consolidate_calls.append(ctx)
return ValidationResult.accept()
async def on_consolidate_complete(self, result: ConsolidateResult) -> None:
self.post_consolidate_calls.append(result)
class TestMemoryEngineValidation:
"""Tests for validation integration with MemoryEngine.
@@ -969,22 +969,24 @@ async def test_reflect_returns_token_usage(api_client):
assert "text" in result
assert len(result["text"]) > 0
# Verify usage field exists and is populated (agentic reflect aggregates all LLM calls)
# Verify usage field exists (may be None for agentic reflect which makes multiple LLM calls)
assert "usage" in result, "Response should include 'usage' field"
usage = result["usage"]
# Usage must be present - agentic reflect now aggregates token usage from all LLM calls
assert usage is not None, "Usage should not be None - reflect aggregates all LLM call usages"
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
# Usage is optional - agentic reflect doesn't aggregate multiple LLM call usages
if usage is not None:
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
# Verify token counts are valid
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
# Verify token counts are valid
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
print(f"Reflect token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
print(f"Reflect token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
else:
print("Reflect usage is None (expected for agentic reflect)")
@pytest.mark.asyncio
@@ -1,278 +0,0 @@
"""
Tests for LinkExpansion graph retrieval.
Tests cover the entity-based graph traversal for observations.
"""
from datetime import datetime, timezone
import pytest
@pytest.fixture(autouse=True)
def enable_observations():
"""Enable observations for all tests in this module."""
from hindsight_api.config import get_config
config = get_config()
original_value = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original_value
@pytest.mark.asyncio
async def test_link_expansion_observation_graph_retrieval(memory, request_context):
"""
Test that observations can find other observations via shared entities.
This tests the scenario where:
1. World fact A has entity "Python"
2. World fact B has entity "Python"
3. Observation OA is derived from world fact A
4. Observation OB is derived from world fact B
When searching for observations related to OA, graph retrieval should find OB
because they share the "Python" entity through their source world facts.
Current issue: Graph retrieval returns 0 for observations because:
- Entity links are copied from world facts to observations during consolidation
- But the entity expansion query filters by fact_type
- Observations only share entities with world facts (cross-type), not with other observations
- So filtering to fact_type='observation' returns 0 results
"""
bank_id = f"test_link_expansion_obs_{datetime.now(timezone.utc).timestamp()}"
try:
# Store world facts with shared entities using retain_batch_async
# We need enough facts that semantic search won't return all of them as seeds
# Key: "Alice" query should find Alice's observation but NOT Bob's via semantic search
# Then graph retrieval should find Bob via shared "Python" entity
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
# Python developers - should be connected via "Python" entity
{
"content": "Alice works with Python at TechCorp building REST APIs",
"context": "employee info",
"entities": [{"text": "Python"}, {"text": "Alice"}, {"text": "TechCorp"}],
},
{
"content": "Bob uses Python at DataSoft for machine learning models",
"context": "employee info",
"entities": [{"text": "Python"}, {"text": "Bob"}, {"text": "DataSoft"}],
},
# Many unrelated facts to dilute semantic search and ensure
# "Alice" query only finds Alice-related content as seeds
{
"content": "The weather in San Francisco is often foggy and cool",
"context": "weather info",
"entities": [{"text": "San Francisco"}],
},
{
"content": "Tokyo is the capital city of Japan with many trains",
"context": "geography info",
"entities": [{"text": "Tokyo"}, {"text": "Japan"}],
},
{
"content": "The Great Wall of China is a historic fortification",
"context": "history info",
"entities": [{"text": "Great Wall"}, {"text": "China"}],
},
{
"content": "Coffee beans are grown in tropical regions worldwide",
"context": "food info",
"entities": [{"text": "Coffee"}],
},
{
"content": "Electric vehicles are becoming more popular globally",
"context": "technology info",
"entities": [{"text": "Electric vehicles"}],
},
{
"content": "The Amazon rainforest contains diverse wildlife species",
"context": "nature info",
"entities": [{"text": "Amazon"}, {"text": "Rainforest"}],
},
{
"content": "Basketball is a popular sport in the United States",
"context": "sports info",
"entities": [{"text": "Basketball"}, {"text": "United States"}],
},
{
"content": "Mozart composed many famous classical music pieces",
"context": "music info",
"entities": [{"text": "Mozart"}, {"text": "Classical music"}],
},
],
request_context=request_context,
)
# Consolidation runs automatically after retain - wait for it to complete
# by querying for observations (consolidation creates them)
import asyncio
from hindsight_api.engine.memory_engine import Budget
# Wait for consolidation to complete with retry logic
# Consolidation runs as a background task and may take longer in CI
obs_result = None
for _ in range(30): # Try up to 30 times (30 seconds max)
await asyncio.sleep(1) # Wait 1 second between attempts
obs_result = await memory.recall_async(
bank_id=bank_id,
query="Python developer",
fact_type=["observation"],
budget=Budget.MID,
max_tokens=2048,
request_context=request_context,
)
if obs_result.results and len(obs_result.results) >= 1:
break
assert obs_result is not None and obs_result.results is not None, "Should have observations after consolidation"
# We should have observations from consolidation
assert len(obs_result.results) >= 1, f"Should have at least 1 observation about Python, got {len(obs_result.results)}"
# Now test graph retrieval specifically
# Query for Alice - should find Bob via shared "Python" entity
result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
fact_type=["observation"],
budget=Budget.MID,
max_tokens=2048,
enable_trace=True,
request_context=request_context,
)
# Verify graph retrieval is working by checking the internal debug logs
# The graph retrieval finds observations via entity links, but may not return
# NEW results if semantic search already found all connected observations.
# This is correct behavior - we verify the entity traversal path works.
# Check the trace for graph results
assert result.trace is not None, "Should have trace data"
# The key verification: the entity expansion path works (sources -> entities -> observations)
# We validated this in the debug logs above:
# - Observations have source_memory_ids pointing to world facts ✓
# - World facts have entity links ✓
# - Graph retrieval can traverse this path (seen in logs: potential_obs > 0)
# For a more rigorous test, we need data where semantic search misses something.
# Let's verify the world fact graph retrieval works (it uses direct entity links).
world_result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
fact_type=["world"],
budget=Budget.MID,
max_tokens=2048,
enable_trace=True,
request_context=request_context,
)
assert world_result.trace is not None, "Should have trace data for world facts"
world_retrieval_results = world_result.trace.get("retrieval_results", [])
world_graph_results = [
r for r in world_retrieval_results if r.get("method_name") == "graph"
]
if world_graph_results:
world_graph_result = [r for r in world_graph_results if r.get("fact_type") == "world"][0]
world_graph_results_list = world_graph_result.get("results", [])
# World facts use direct entity links, so graph may find results
if world_graph_results_list:
print(f"\n✓ Graph retrieval found {len(world_graph_results_list)} connected world facts")
graph_texts = [r.get("text", "") for r in world_graph_results_list]
bob_found = any("Bob" in t or "DataSoft" in t for t in graph_texts)
if bob_found:
print(" Found Bob's world fact via shared 'Python' entity!")
print("\n✓ Link expansion observation test passed!")
print(" Entity traversal path verified (observations -> sources -> entities -> connected sources -> observations)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_link_expansion_world_fact_graph_retrieval(memory, request_context):
"""
Test that world facts can find other world facts via shared entities.
This verifies the direct entity link traversal for world facts works correctly.
Note: When semantic search finds all world facts as seeds, graph retrieval
won't return NEW results (this is correct - it shouldn't duplicate results).
"""
bank_id = f"test_link_expansion_world_{datetime.now(timezone.utc).timestamp()}"
try:
# Store world facts with shared entities
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
# Python developers - should be connected via "Python" entity
{
"content": "Alice works with Python at TechCorp building REST APIs",
"context": "employee info",
"entities": [{"text": "Python"}, {"text": "Alice"}, {"text": "TechCorp"}],
},
{
"content": "Bob uses Python at DataSoft for machine learning models",
"context": "employee info",
"entities": [{"text": "Python"}, {"text": "Bob"}, {"text": "DataSoft"}],
},
# Unrelated facts
{
"content": "The weather in San Francisco is often foggy",
"context": "weather info",
"entities": [{"text": "San Francisco"}],
},
{
"content": "Coffee beans are grown in tropical regions",
"context": "food info",
"entities": [{"text": "Coffee"}],
},
],
request_context=request_context,
)
from hindsight_api.engine.memory_engine import Budget
# Query for Alice
result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
fact_type=["world"],
budget=Budget.MID,
max_tokens=2048,
enable_trace=True,
request_context=request_context,
)
assert result.trace is not None, "Should have trace data"
# Verify graph retrieval ran (it may or may not find new results depending
# on whether semantic search already found everything)
retrieval_results = result.trace.get("retrieval_results", [])
graph_results = [
r for r in retrieval_results if r.get("method_name") == "graph"
]
assert len(graph_results) > 0, "Should have graph retrieval results in trace"
# The important thing is that recall works and returns relevant results
assert result.results is not None and len(result.results) > 0, (
"Should return results for 'Alice' query"
)
# Alice's result should be at or near the top
result_texts = [r.text for r in result.results]
alice_found = any("Alice" in t for t in result_texts)
assert alice_found, f"Should find Alice in results: {result_texts[:3]}"
print("\n✓ Link expansion world fact test passed!")
print(f" Recall returned {len(result.results)} results for 'Alice' query")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+2 -2
View File
@@ -241,8 +241,8 @@ class TestReflectToolSchemas:
tools = get_reflect_tools()
tool_names = [t["function"]["name"] for t in tools]
assert "search_reflections" in tool_names
assert "search_mental_models" in tool_names
assert "search_observations" in tool_names
assert "recall" in tool_names
assert "expand" in tool_names
assert "done" in tool_names
@@ -273,8 +273,8 @@ class TestReflectToolSchemas:
assert "answer" in params
assert "memory_ids" in params
assert "observation_ids" in params
assert "mental_model_ids" in params
assert "reflection_ids" in params
class TestLLMToolCallResult:
+5 -10
View File
@@ -355,14 +355,14 @@ class TestMainModuleExtensionLoading:
# Mock extensions for testing
from hindsight_api.extensions import (
TenantExtension,
TenantContext,
RequestContext,
OperationValidatorExtension,
ValidationResult,
RetainContext,
RecallContext,
ReflectContext,
RequestContext,
RetainContext,
TenantContext,
TenantExtension,
ValidationResult,
)
@@ -376,11 +376,6 @@ class MockTenantExtension(TenantExtension):
async def authenticate(self, request_context: RequestContext) -> TenantContext:
return TenantContext(schema_name="public")
async def list_tenants(self) -> list:
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema="public")]
def set_context(self, context) -> None:
self._context_set = True
+5 -55
View File
@@ -62,9 +62,9 @@ async def test_local_mcp_server_recall(mock_memory):
tools = mcp_server._tool_manager._tools
assert "recall" in tools
# Call recall
# Call recall with new params
recall_tool = tools["recall"]
result = await recall_tool.fn(query="test query", max_tokens=2048)
result = await recall_tool.fn(query="test query", max_tokens=2048, budget="mid")
# Result is a dict
assert isinstance(result, dict)
@@ -75,7 +75,7 @@ async def test_local_mcp_server_recall(mock_memory):
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["query"] == "test query"
assert call_kwargs["max_tokens"] == 2048
assert call_kwargs["budget"] == Budget.HIGH
assert call_kwargs["budget"] == Budget.MID
@pytest.mark.asyncio
@@ -141,7 +141,7 @@ async def test_local_mcp_server_recall_error_handling(mock_memory):
@pytest.mark.asyncio
async def test_local_mcp_server_recall_with_defaults(mock_memory):
"""Test that recall uses default max_tokens and HIGH budget."""
"""Test that recall uses default max_tokens and budget."""
from hindsight_api.mcp_local import create_local_mcp_server
from hindsight_api.engine.memory_engine import Budget
@@ -159,54 +159,4 @@ async def test_local_mcp_server_recall_with_defaults(mock_memory):
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["max_tokens"] == 4096
assert call_kwargs["budget"] == Budget.HIGH
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_timestamp(mock_memory):
"""Test that retain passes timestamp as event_date."""
from datetime import datetime, timezone
from hindsight_api.mcp_local import create_local_mcp_server
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain with timestamp
result = await retain_tool.fn(
content="test content", context="test_context", timestamp="2024-01-15T10:30:00Z"
)
assert result["status"] == "accepted"
# Wait for background task
await asyncio.sleep(0.1)
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
contents = call_kwargs["contents"]
assert len(contents) == 1
assert contents[0]["content"] == "test content"
assert contents[0]["context"] == "test_context"
assert "event_date" in contents[0]
assert contents[0]["event_date"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_invalid_timestamp(mock_memory):
"""Test that retain rejects invalid timestamp format."""
from hindsight_api.mcp_local import create_local_mcp_server
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain with invalid timestamp
result = await retain_tool.fn(content="test content", timestamp="not-a-date")
assert result["status"] == "error"
assert "Invalid timestamp format" in result["message"]
# Verify retain_batch_async was NOT called
mock_memory.retain_batch_async.assert_not_called()
assert call_kwargs["budget"] == Budget.LOW
-63
View File
@@ -1,63 +0,0 @@
"""Tests for the shared MCP tools module."""
from datetime import datetime, timezone
import pytest
from hindsight_api.mcp_tools import build_content_dict, parse_timestamp
class TestParseTimestamp:
"""Tests for parse_timestamp function."""
def test_parse_iso_format_with_z(self):
"""Test parsing ISO format with Z suffix."""
result = parse_timestamp("2024-01-15T10:30:00Z")
assert result == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
def test_parse_iso_format_with_offset(self):
"""Test parsing ISO format with timezone offset."""
result = parse_timestamp("2024-01-15T10:30:00+00:00")
assert result == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
def test_parse_iso_format_without_tz(self):
"""Test parsing ISO format without timezone."""
result = parse_timestamp("2024-01-15T10:30:00")
assert result == datetime(2024, 1, 15, 10, 30, 0)
def test_parse_invalid_format_raises(self):
"""Test that invalid format raises ValueError."""
with pytest.raises(ValueError) as exc_info:
parse_timestamp("not-a-date")
assert "Invalid timestamp format" in str(exc_info.value)
class TestBuildContentDict:
"""Tests for build_content_dict function."""
def test_basic_content(self):
"""Test building content dict with just content and context."""
result, error = build_content_dict("test content", "test_context")
assert error is None
assert result == {"content": "test content", "context": "test_context"}
def test_with_valid_timestamp(self):
"""Test building content dict with valid timestamp."""
result, error = build_content_dict("test content", "test_context", "2024-01-15T10:30:00Z")
assert error is None
assert result["content"] == "test content"
assert result["context"] == "test_context"
assert result["event_date"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
def test_with_invalid_timestamp(self):
"""Test building content dict with invalid timestamp."""
result, error = build_content_dict("test content", "test_context", "invalid")
assert error is not None
assert "Invalid timestamp format" in error
assert result == {}
def test_with_none_timestamp(self):
"""Test building content dict with None timestamp."""
result, error = build_content_dict("test content", "test_context", None)
assert error is None
assert "event_date" not in result
-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 -15
View File
@@ -8,20 +8,9 @@ populated from the summary for backwards compatibility.
import pytest
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
from hindsight_api.config import get_config
from datetime import datetime, timezone
@pytest.fixture
def disable_observations():
"""Disable observations for a specific test."""
config = get_config()
original_value = config.enable_observations
config.enable_observations = False
yield
config.enable_observations = original_value
@pytest.mark.asyncio
async def test_entity_extraction_on_retain(memory, request_context):
"""
@@ -381,12 +370,12 @@ async def test_get_entity_state(memory, request_context):
@pytest.mark.asyncio
async def test_observation_fact_type_in_database(memory, request_context, disable_observations):
async def test_observation_fact_type_in_database(memory, request_context):
"""
Test that when observations are disabled, no observation records are created.
Test that observations are NOT stored as memory_units with fact_type='observation'.
When enable_observations=False, consolidation does not run and no
memory_units with fact_type='observation' should exist.
NOTE: Observations are now handled via mental models, not as memory_units
or entity summaries.
"""
bank_id = f"test_obs_db_{datetime.now(timezone.utc).timestamp()}"
+7 -126
View File
@@ -13,126 +13,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
from hindsight_api.engine.reflect.agent import (
_normalize_tool_name,
_is_done_tool,
_clean_answer_text,
_clean_done_answer,
run_reflect_agent,
)
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
class TestCleanAnswerText:
"""Test cleanup of answer text that includes done() tool call syntax."""
def test_clean_text_with_done_call(self):
"""Text ending with done() call should have it stripped."""
text = '''The team's OKRs focus on performance.done({"answer":"The team's OKRs","memory_ids":[]})'''
cleaned = _clean_answer_text(text)
assert cleaned == "The team's OKRs focus on performance."
assert "done(" not in cleaned
def test_clean_text_with_done_call_and_whitespace(self):
"""done() call with whitespace should be stripped."""
text = '''Answer text here. done( {"answer": "short", "memory_ids": []} )'''
cleaned = _clean_answer_text(text)
assert cleaned == "Answer text here."
def test_clean_text_without_done_call(self):
"""Text without done() call should be unchanged."""
text = "This is a normal answer without any tool calls."
cleaned = _clean_answer_text(text)
assert cleaned == text
def test_clean_text_with_done_word_in_content(self):
"""The word 'done' in regular text should not be stripped."""
text = "The task is done and completed successfully."
cleaned = _clean_answer_text(text)
assert cleaned == text
def test_clean_empty_text(self):
"""Empty text should return empty."""
assert _clean_answer_text("") == ""
def test_clean_text_multiline_done(self):
"""done() call spanning multiple lines should be stripped."""
text = '''Summary of findings.done({
"answer": "Summary",
"memory_ids": ["id1", "id2"]
})'''
cleaned = _clean_answer_text(text)
assert cleaned == "Summary of findings."
class TestCleanDoneAnswer:
"""Test cleanup of answer field from done() tool call that leaks structured output."""
def test_clean_answer_with_leaked_json_code_block(self):
"""Answer with leaked JSON code block at the end should be cleaned."""
text = '''The user's favorite color is blue.
```json
{"observation_ids": ["obs-1", "obs-2"]}
```'''
cleaned = _clean_done_answer(text)
assert cleaned == "The user's favorite color is blue."
assert "observation_ids" not in cleaned
def test_clean_answer_with_memory_ids_code_block(self):
"""Answer with leaked memory_ids JSON code block should be cleaned."""
text = '''Here is the answer.
```json
{"memory_ids": ["mem-1"]}
```'''
cleaned = _clean_done_answer(text)
assert cleaned == "Here is the answer."
def test_clean_answer_with_raw_json_object(self):
"""Answer with raw JSON object containing IDs at the end should be cleaned."""
text = 'The answer is 42. {"observation_ids": ["obs-1"]}'
cleaned = _clean_done_answer(text)
assert cleaned == "The answer is 42."
def test_clean_answer_with_trailing_ids_pattern(self):
"""Answer with 'observation_ids: [...]' pattern at the end should be cleaned."""
text = "This is the answer.\n\nobservation_ids: [\"obs-1\", \"obs-2\"]"
cleaned = _clean_done_answer(text)
assert cleaned == "This is the answer."
def test_clean_answer_with_memory_ids_equals(self):
"""Answer with 'memory_ids = [...]' pattern at the end should be cleaned."""
text = "Answer text here.\nmemory_ids = [\"mem-1\"]"
cleaned = _clean_done_answer(text)
assert cleaned == "Answer text here."
def test_clean_normal_answer_unchanged(self):
"""Normal answer without leaked output should be unchanged."""
text = "This is a normal answer about observation strategies."
cleaned = _clean_done_answer(text)
assert cleaned == text
def test_clean_empty_answer(self):
"""Empty answer should return empty."""
assert _clean_done_answer("") == ""
def test_clean_answer_with_observation_word_in_content(self):
"""The word 'observation' in regular text should not be stripped."""
text = "Based on my observation, the user prefers dark mode."
cleaned = _clean_done_answer(text)
assert cleaned == text
def test_clean_answer_multiline_with_markdown(self):
"""Answer with markdown and leaked JSON at end should clean only the leak."""
text = '''Summary:
- Point 1
- Point 2
```json
{"mental_model_ids": ["mm-1"]}
```'''
cleaned = _clean_done_answer(text)
assert "Point 1" in cleaned
assert "Point 2" in cleaned
assert "mental_model_ids" not in cleaned
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult
class TestToolNameNormalization:
@@ -142,15 +25,15 @@ class TestToolNameNormalization:
"""Standard tool names should pass through unchanged."""
assert _normalize_tool_name("done") == "done"
assert _normalize_tool_name("recall") == "recall"
assert _normalize_tool_name("search_reflections") == "search_reflections"
assert _normalize_tool_name("search_mental_models") == "search_mental_models"
assert _normalize_tool_name("search_observations") == "search_observations"
assert _normalize_tool_name("expand") == "expand"
def test_normalize_functions_prefix(self):
"""Tool names with 'functions.' prefix should be normalized."""
assert _normalize_tool_name("functions.done") == "done"
assert _normalize_tool_name("functions.recall") == "recall"
assert _normalize_tool_name("functions.search_mental_models") == "search_mental_models"
assert _normalize_tool_name("functions.search_reflections") == "search_reflections"
def test_normalize_call_equals_prefix(self):
"""Tool names with 'call=' prefix should be normalized."""
@@ -161,7 +44,7 @@ class TestToolNameNormalization:
"""Tool names with 'call=functions.' prefix should be normalized."""
assert _normalize_tool_name("call=functions.done") == "done"
assert _normalize_tool_name("call=functions.recall") == "recall"
assert _normalize_tool_name("call=functions.search_observations") == "search_observations"
assert _normalize_tool_name("call=functions.search_mental_models") == "search_mental_models"
def test_is_done_tool(self):
"""Test _is_done_tool helper."""
@@ -187,18 +70,16 @@ class TestReflectAgentMocked:
"""Create a mock LLM provider."""
llm = MagicMock()
llm.call_with_tools = AsyncMock()
# Also mock call() for final iteration fallback - returns (response, usage) tuple
llm.call = AsyncMock(
return_value=("Fallback answer from final iteration", TokenUsage(input_tokens=100, output_tokens=50, total_tokens=150))
)
# Also mock call() for final iteration fallback
llm.call = AsyncMock(return_value="Fallback answer from final iteration")
return llm
@pytest.fixture
def mock_functions(self):
"""Create mock search/recall functions."""
return {
"search_reflections_fn": AsyncMock(return_value={"reflections": []}),
"search_mental_models_fn": AsyncMock(return_value={"mental_models": []}),
"search_observations_fn": AsyncMock(return_value={"observations": []}),
"recall_fn": AsyncMock(return_value={"memories": [{"id": "mem-1", "content": "test memory"}]}),
"expand_fn": AsyncMock(return_value={"memories": []}),
}
+124 -213
View File
@@ -1,4 +1,4 @@
"""Tests for mental models (formerly reflections), observations, and learnings functionality."""
"""Tests for reflections, mental models, and learnings functionality."""
import uuid
@@ -21,22 +21,22 @@ async def api_client(memory):
@pytest.fixture
def test_bank_id():
"""Provide a unique bank ID for this test run."""
return f"test_mental_models_{uuid.uuid4().hex[:8]}"
return f"test_reflections_{uuid.uuid4().hex[:8]}"
class TestMentalModelsCRUD:
"""Test mental models CRUD operations via memory engine."""
class TestReflectionsCRUD:
"""Test reflections CRUD operations via memory engine."""
@pytest.mark.asyncio
async def test_create_and_get_mental_model(self, memory: MemoryEngine, request_context):
"""Test creating and retrieving a mental model."""
bank_id = f"test-mental-model-{uuid.uuid4().hex[:8]}"
async def test_create_and_get_reflection(self, memory: MemoryEngine, request_context):
"""Test creating and retrieving a reflection."""
bank_id = f"test-reflection-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a mental model
mental_model = await memory.create_mental_model(
# Create a reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="Team Preferences",
source_query="What are the team's communication preferences?",
@@ -45,45 +45,45 @@ class TestMentalModelsCRUD:
request_context=request_context,
)
assert mental_model["name"] == "Team Preferences"
assert mental_model["source_query"] == "What are the team's communication preferences?"
assert mental_model["content"] == "The team prefers async communication via Slack"
assert mental_model["tags"] == ["team"]
assert "id" in mental_model
assert reflection["name"] == "Team Preferences"
assert reflection["source_query"] == "What are the team's communication preferences?"
assert reflection["content"] == "The team prefers async communication via Slack"
assert reflection["tags"] == ["team"]
assert "id" in reflection
# Get the mental model
fetched = await memory.get_mental_model(
# Get the reflection
fetched = await memory.get_reflection(
bank_id=bank_id,
mental_model_id=mental_model["id"],
reflection_id=reflection["id"],
request_context=request_context,
)
assert fetched["id"] == mental_model["id"]
assert fetched["id"] == reflection["id"]
assert fetched["name"] == "Team Preferences"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_mental_models(self, memory: MemoryEngine, request_context):
"""Test listing mental models with filters."""
bank_id = f"test-mental-model-list-{uuid.uuid4().hex[:8]}"
async def test_list_reflections(self, memory: MemoryEngine, request_context):
"""Test listing reflections with filters."""
bank_id = f"test-reflection-list-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create multiple mental models
await memory.create_mental_model(
# Create multiple reflections
await memory.create_reflection(
bank_id=bank_id,
name="Mental Model 1",
name="Reflection 1",
source_query="Query 1",
content="Content 1",
tags=["tag1"],
request_context=request_context,
)
await memory.create_mental_model(
await memory.create_reflection(
bank_id=bank_id,
name="Mental Model 2",
name="Reflection 2",
source_query="Query 2",
content="Content 2",
tags=["tag2"],
@@ -91,33 +91,33 @@ class TestMentalModelsCRUD:
)
# List all
all_mental_models = await memory.list_mental_models(
all_reflections = await memory.list_reflections(
bank_id=bank_id,
request_context=request_context,
)
assert len(all_mental_models) == 2
assert len(all_reflections) == 2
# List with tag filter
tag1_mental_models = await memory.list_mental_models(
tag1_reflections = await memory.list_reflections(
bank_id=bank_id,
tags=["tag1"],
request_context=request_context,
)
assert len(tag1_mental_models) == 1
assert len(tag1_reflections) == 1
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_mental_model(self, memory: MemoryEngine, request_context):
"""Test updating a mental model."""
bank_id = f"test-mental-model-update-{uuid.uuid4().hex[:8]}"
async def test_update_reflection(self, memory: MemoryEngine, request_context):
"""Test updating a reflection."""
bank_id = f"test-reflection-update-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a mental model
mental_model = await memory.create_mental_model(
# Create a reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="Original Name",
source_query="Original Query",
@@ -125,10 +125,10 @@ class TestMentalModelsCRUD:
request_context=request_context,
)
# Update the mental model
updated = await memory.update_mental_model(
# Update the reflection
updated = await memory.update_reflection(
bank_id=bank_id,
mental_model_id=mental_model["id"],
reflection_id=reflection["id"],
name="Updated Name",
content="Updated Content",
request_context=request_context,
@@ -141,15 +141,15 @@ class TestMentalModelsCRUD:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delete_mental_model(self, memory: MemoryEngine, request_context):
"""Test deleting a mental model."""
bank_id = f"test-mental-model-delete-{uuid.uuid4().hex[:8]}"
async def test_delete_reflection(self, memory: MemoryEngine, request_context):
"""Test deleting a reflection."""
bank_id = f"test-reflection-delete-{uuid.uuid4().hex[:8]}"
# Create the bank first
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a mental model
mental_model = await memory.create_mental_model(
# Create a reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="To Delete",
source_query="Query",
@@ -157,17 +157,17 @@ class TestMentalModelsCRUD:
request_context=request_context,
)
# Delete the mental model
await memory.delete_mental_model(
# Delete the reflection
await memory.delete_reflection(
bank_id=bank_id,
mental_model_id=mental_model["id"],
reflection_id=reflection["id"],
request_context=request_context,
)
# Verify deletion - should return None
fetched = await memory.get_mental_model(
fetched = await memory.get_reflection(
bank_id=bank_id,
mental_model_id=mental_model["id"],
reflection_id=reflection["id"],
request_context=request_context,
)
assert fetched is None
@@ -176,45 +176,45 @@ class TestMentalModelsCRUD:
await memory.delete_bank(bank_id, request_context=request_context)
class TestObservationsAPI:
"""Test observations API endpoints.
class TestMentalModelsAPI:
"""Test mental models API endpoints.
NOTE: Observations are now stored in memory_units with fact_type='observation'
and accessed via recall with fact_type=["observation"]. The old /observations
NOTE: Mental models are now stored in memory_units with fact_type='mental_model'
and accessed via recall with fact_type=["mental_model"]. The old /mental-models
endpoint was removed. These tests are skipped.
"""
@pytest.mark.skip(reason="Observations endpoint removed - use recall with fact_type=['observation']")
@pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']")
@pytest.mark.asyncio
async def test_list_observations_empty(self, api_client, test_bank_id):
"""Test listing observations when none exist."""
async def test_list_mental_models_empty(self, api_client, test_bank_id):
"""Test listing mental models when none exist."""
pass
@pytest.mark.skip(reason="Observations endpoint removed - use recall with fact_type=['observation']")
@pytest.mark.skip(reason="Mental models endpoint removed - use recall with fact_type=['mental_model']")
@pytest.mark.asyncio
async def test_get_observation_not_found(self, api_client, test_bank_id):
"""Test getting a non-existent observation."""
async def test_get_mental_model_not_found(self, api_client, test_bank_id):
"""Test getting a non-existent mental model."""
pass
class TestMentalModelsAPI:
"""Test mental models API endpoints."""
class TestReflectionsAPI:
"""Test reflections API endpoints."""
@pytest.mark.asyncio
async def test_mental_models_api_crud(self, api_client, test_bank_id):
async def test_reflections_api_crud(self, api_client, test_bank_id):
"""Test full CRUD cycle through API."""
import asyncio
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Create a mental model (async operation)
# Create a reflection (async operation)
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/mental-models",
f"/v1/default/banks/{test_bank_id}/reflections",
json={
"name": "API Test Mental Model",
"name": "API Test Reflection",
"source_query": "What is the API test about?",
"content": "This is an API test mental model",
"content": "This is an API test reflection",
"tags": ["api-test"],
},
)
@@ -232,72 +232,44 @@ class TestMentalModelsAPI:
break
await asyncio.sleep(1)
# List mental models to get the created mental model
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models")
# List reflections to get the created reflection
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections")
assert response.status_code == 200
mental_models = response.json()["items"]
assert len(mental_models) >= 1
reflections = response.json()["items"]
assert len(reflections) >= 1
# Find our mental model
mental_model = next((m for m in mental_models if m["name"] == "API Test Mental Model"), None)
assert mental_model is not None, f"Mental model not found. Items: {mental_models}"
mental_model_id = mental_model["id"]
# Find our reflection
reflection = next((r for r in reflections if r["name"] == "API Test Reflection"), None)
assert reflection is not None, f"Reflection not found. Items: {reflections}"
reflection_id = reflection["id"]
# Get the mental model
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}")
# Get the reflection
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
assert response.status_code == 200
assert response.json()["name"] == "API Test Mental Model"
assert response.json()["name"] == "API Test Reflection"
# Update the mental model
# Update the reflection
response = await api_client.patch(
f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}",
json={"name": "Updated API Test Mental Model"},
f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}",
json={"name": "Updated API Test Reflection"},
)
assert response.status_code == 200
assert response.json()["name"] == "Updated API Test Mental Model"
assert response.json()["name"] == "Updated API Test Reflection"
# Delete the mental model
response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}")
# Delete the reflection
response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
assert response.status_code == 200
# Verify deletion
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}")
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/reflections/{reflection_id}")
assert response.status_code == 404
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
class TestRecallWithObservationsAndMentalModels:
"""Test recall integration with observations and mental models."""
@pytest.mark.asyncio
async def test_recall_includes_observations(self, api_client, test_bank_id):
"""Test that recall can include observations in the response."""
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Note: Observations are auto-created via consolidation, not manually
# This test just verifies the include parameter works
# Recall with observations included
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "What is machine learning?",
"include": {
"observations": {"max_results": 5},
},
},
)
assert response.status_code == 200
result = response.json()
# Should have observations field in response (may be empty)
assert "observations" in result or result.get("observations") is None
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
class TestRecallWithMentalModelsAndReflections:
"""Test recall integration with mental models and reflections."""
@pytest.mark.asyncio
async def test_recall_includes_mental_models(self, api_client, test_bank_id):
@@ -305,9 +277,37 @@ class TestRecallWithObservationsAndMentalModels:
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Create a mental model first
# Note: Mental models are auto-created via consolidation, not manually
# This test just verifies the include parameter works
# Recall with mental models included
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/mental-models",
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "What is machine learning?",
"include": {
"mental_models": {"max_results": 5},
},
},
)
assert response.status_code == 200
result = response.json()
# Should have mental_models field in response (may be empty)
assert "mental_models" in result or result.get("mental_models") is None
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_recall_includes_reflections(self, api_client, test_bank_id):
"""Test that recall can include reflections in the response."""
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Create a reflection first
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/reflections",
json={
"name": "AI Overview",
"source_query": "What is AI?",
@@ -317,32 +317,32 @@ class TestRecallWithObservationsAndMentalModels:
)
assert response.status_code == 200
# Recall with mental models included
# Recall with reflections included
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "What is artificial intelligence?",
"include": {
"mental_models": {"max_results": 5},
"reflections": {"max_results": 5},
},
},
)
assert response.status_code == 200
result = response.json()
# Should have mental_models in response (may be empty if embedding not generated yet)
assert "mental_models" in result or result.get("mental_models") is None
# Should have reflections in response (may be empty if embedding not generated yet)
assert "reflections" in result or result.get("reflections") is None
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_recall_without_observations_by_default(self, api_client, test_bank_id):
"""Test that recall does not include observations by default."""
async def test_recall_without_mental_models_by_default(self, api_client, test_bank_id):
"""Test that recall does not include mental models by default."""
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# Recall without specifying observations
# Recall without specifying mental models
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
@@ -352,97 +352,8 @@ class TestRecallWithObservationsAndMentalModels:
assert response.status_code == 200
result = response.json()
# Observations should not be in response
assert result.get("observations") is None
# Mental models should not be in response
assert result.get("mental_models") is None
# Cleanup
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
class TestReflectUsesMentalModels:
"""Test that reflect searches and uses mental models when available."""
@pytest.mark.asyncio
async def test_reflect_searches_mental_models_when_available(self, memory: MemoryEngine, request_context):
"""Test that reflect uses search_mental_models when the bank has mental models.
Given:
- A bank with a mental model about "team collaboration"
Expected:
- Reflect should call search_mental_models tool
- The mental model content should influence the response
"""
bank_id = f"test-reflect-mm-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a mental model about team collaboration
mental_model = await memory.create_mental_model(
bank_id=bank_id,
mental_model_id=str(uuid.uuid4()),
name="Team Collaboration Practices",
source_query="How does the team collaborate?",
content="The team uses async communication via Slack and holds daily standups at 9am. "
"Code reviews are required before merging. The team values documentation and "
"prefers written communication for complex decisions.",
tags=["team"],
request_context=request_context,
)
# Run reflect with a query about team collaboration
result = await memory.reflect_async(
bank_id=bank_id,
query="How does the team work together?",
request_context=request_context,
)
# Check that mental models were searched
tool_calls = result.tool_trace
search_mm_calls = [tc for tc in tool_calls if tc.tool == "search_mental_models"]
assert len(search_mm_calls) > 0, (
f"Expected search_mental_models to be called when bank has mental models. "
f"Tool calls: {[tc.tool for tc in tool_calls]}"
)
# Check that the reason field is populated for debugging
for tc in search_mm_calls:
assert tc.reason is not None, "Tool call should have a reason for debugging"
# The response should mention concepts from the mental model
response_text = result.text.lower()
has_relevant_content = any(
keyword in response_text
for keyword in ["slack", "async", "standup", "code review", "documentation", "communication"]
)
assert has_relevant_content, (
f"Expected response to reference mental model content. Got: {result.text[:500]}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_reflect_tool_trace_includes_reason(self, memory: MemoryEngine, request_context):
"""Test that tool traces include the reason field for debugging."""
bank_id = f"test-reflect-reason-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Run reflect - it should use observations or recall
result = await memory.reflect_async(
bank_id=bank_id,
query="What is the weather like?",
request_context=request_context,
)
# All tool calls should have a reason
for tc in result.tool_trace:
if tc.tool != "done": # done doesn't need a reason
assert tc.reason is not None, f"Tool {tc.tool} should have a reason for debugging"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
-115
View File
@@ -279,7 +279,6 @@ async def test_event_date_storage(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.xfail(reason="LLM date extraction from content is non-deterministic", strict=False)
async def test_temporal_ordering(memory, request_context):
"""
Test that facts can be stored and retrieved with correct temporal ordering.
@@ -2082,117 +2081,3 @@ def test_recall_result_model_empty_construction():
assert result.chunks == {}, "Should have empty chunks"
logger.info("✓ RecallResult empty construction works correctly")
@pytest.mark.asyncio
async def test_custom_extraction_mode():
"""
Test that custom extraction mode uses custom guidelines from env variable.
This test verifies that when HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom and
HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS is set, the fact extraction uses the
custom guidelines while keeping structural parts intact.
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
from hindsight_api.config import clear_config_cache
# Save original env vars
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
original_instructions = os.getenv("HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS")
try:
# Set custom extraction mode with challenging language-specific guidelines
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "custom"
os.environ["HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"] = """ONLY extract facts that are in ITALIAN language.
DO NOT extract:
Facts in English
Facts in any other language besides Italian
If the text contains both Italian and English content, extract ONLY the Italian facts."""
# Clear config cache to pick up new env vars
clear_config_cache()
# Test content with BOTH Italian (should extract) and English (should NOT extract) facts
# This is a much harder test than filtering greetings
text = """
The team discussed the new architecture. We will use microservices.
Il database PostgreSQL ha ridotto la latenza delle query del 60%.
Alice ha suggerito di usare il connection pooling per migliorare le prestazioni.
Bob mentioned that the API endpoint is ready for testing.
The deployment pipeline has been updated to use Kubernetes.
Marco ha completato la revisione del codice e ha approvato le modifiche.
Il sistema di autenticazione è stato migrato a OAuth 2.0.
"""
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
context="team meeting notes",
llm_config=llm_config,
agent_name="TestUser"
)
logger.info(f"\nExtracted {len(facts)} facts with custom mode (Italian only):")
for i, fact in enumerate(facts):
logger.info(f" {i+1}. {fact.fact}")
assert len(facts) > 0, "Should extract at least one Italian fact"
# All facts text
all_facts_text = " ".join([f.fact for f in facts])
# Should HAVE Italian content
italian_keywords = ["postgresql", "latenza", "query", "alice", "connection pooling", "prestazioni",
"marco", "revisione", "codice", "autenticazione", "oauth"]
has_italian = any(keyword in all_facts_text.lower() for keyword in italian_keywords)
assert has_italian, f"Should extract Italian facts. Got: {all_facts_text}"
# Should NOT have English-only content
# These are facts that appear ONLY in English sections
english_only_keywords = ["microservices", "bob", "api endpoint", "testing", "deployment pipeline", "kubernetes"]
# Check if facts contain English-only content (this would be wrong)
facts_lower = all_facts_text.lower()
found_english_only = [kw for kw in english_only_keywords if kw in facts_lower]
if found_english_only:
logger.warning(f"⚠ Found English-only keywords in facts: {found_english_only}")
logger.warning(f" Facts: {all_facts_text}")
logger.warning(f" This may indicate the LLM is not strictly following language-specific custom guidelines")
# Log but don't fail - LLM behavior can vary
else:
logger.info("✓ Successfully extracted only Italian facts, ignored English facts")
# At least verify we have some Italian indicators
italian_indicators = ["latenza", "prestazioni", "revisione", "codice", "autenticazione"]
italian_count = sum(1 for ind in italian_indicators if ind in facts_lower)
assert italian_count >= 1, \
f"Should extract facts with Italian words. Found {italian_count} Italian indicators in: {all_facts_text}"
logger.info("✓ Custom extraction mode works with language-specific guidelines")
logger.info(f"✓ Extracted {len(facts)} Italian facts, found {italian_count} Italian indicators")
finally:
# Restore original env vars
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
if original_instructions is not None:
os.environ["HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"] = original_instructions
else:
os.environ.pop("HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS", None)
# Clear cache again to restore original config
clear_config_cache()
+1 -6
View File
@@ -11,8 +11,8 @@ import uuid
import pytest
import pytest_asyncio
from hindsight_api.engine.memory_engine import _current_schema, fq_table
from hindsight_api.extensions import RequestContext, TenantContext, TenantExtension
from hindsight_api.engine.memory_engine import _current_schema, fq_table
from hindsight_api.migrations import run_migrations
@@ -52,11 +52,6 @@ class MultiSchemaTestTenantExtension(TenantExtension):
raise AuthenticationError(f"Unknown API key: {context.api_key}")
async def list_tenants(self) -> list:
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema=schema) for schema in self.valid_schemas]
async def drop_schema(conn, schema_name: str) -> None:
"""Drop a schema and all its contents."""
+5 -10
View File
@@ -249,14 +249,14 @@ class TestServerModuleExtensionLoading:
# Mock extensions for testing
from hindsight_api.extensions import (
TenantExtension,
TenantContext,
RequestContext,
OperationValidatorExtension,
ValidationResult,
RetainContext,
RecallContext,
ReflectContext,
RequestContext,
RetainContext,
TenantContext,
TenantExtension,
ValidationResult,
)
@@ -270,11 +270,6 @@ class MockTenantExtension(TenantExtension):
async def authenticate(self, request_context: RequestContext) -> TenantContext:
return TenantContext(schema_name="public")
async def list_tenants(self) -> list:
from hindsight_api.extensions.tenant import Tenant
return [Tenant(schema="public")]
def set_context(self, context) -> None:
self._context_set = True
@@ -22,7 +22,7 @@ TABLES = [
"chunks",
"async_operations",
"directives",
"mental_models",
"reflections",
]
# Files to scan for SQL queries
+7 -13
View File
@@ -633,12 +633,7 @@ async def test_student_tracking_visibility(api_client):
@pytest.mark.asyncio
async def test_list_tags_returns_all_tags(api_client):
"""Test that list_tags returns all unique tags with counts.
Note: list_tags counts all memory units including observations.
Observations inherit tags from their source facts (for visibility security),
so counts may be higher than the number of stored memories.
"""
"""Test that list_tags returns all unique tags with counts."""
bank_id = f"list_tags_test_{datetime.now().timestamp()}"
# Store memories with various tags
@@ -667,19 +662,18 @@ async def test_list_tags_returns_all_tags(api_client):
assert "limit" in result
assert "offset" in result
# Verify tags exist with at least the expected counts
# Note: Counts may be higher due to observations inheriting source fact tags
# Verify tags and counts
tags_map = {item["tag"]: item["count"] for item in result["items"]}
assert "user:alice" in tags_map
assert tags_map["user:alice"] >= 3 # At least 3 memories have this tag
assert tags_map["user:alice"] == 3 # 3 memories have this tag
assert "user:bob" in tags_map
assert tags_map["user:bob"] >= 1
assert tags_map["user:bob"] == 1
assert "session:123" in tags_map
assert tags_map["session:123"] >= 1
assert tags_map["session:123"] == 1
assert "session:456" in tags_map
assert tags_map["session:456"] >= 1
assert tags_map["session:456"] == 1
assert result["total"] >= 4 # At least 4 unique tags
assert result["total"] == 4 # 4 unique tags
@pytest.mark.asyncio
@@ -7,7 +7,6 @@ from hindsight_api import RequestContext
@pytest.mark.asyncio
@pytest.mark.xfail(reason="LLM date extraction from content is non-deterministic", strict=False)
async def test_temporal_ranges_are_written(memory, request_context):
"""Test that occurred_start, occurred_end, and mentioned_at are actually written to database."""
bank_id = "test_temporal_ranges"
+9 -201
View File
@@ -162,11 +162,6 @@ class TestWorkerPoller:
claimed = await poller.claim_batch()
assert len(claimed) == 3
# ClaimedTask objects have operation_id, task_dict, schema attributes
for task in claimed:
assert task.operation_id is not None
assert task.task_dict is not None
# Verify tasks are marked as processing with worker_id
rows = await pool.fetch(
"SELECT status, worker_id FROM async_operations WHERE bank_id = $1",
@@ -211,7 +206,6 @@ class TestWorkerPoller:
async def test_execute_task_marks_completed(self, pool, clean_operations):
"""Test that successful task execution marks task as completed."""
from hindsight_api.worker import WorkerPoller
from hindsight_api.worker.poller import ClaimedTask
# Create a pending task
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
@@ -240,8 +234,7 @@ class TestWorkerPoller:
# Execute the task
task_dict = json.loads(payload)
claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None)
await poller.execute_task(claimed_task)
await poller.execute_task(str(op_id), task_dict)
assert len(executed) == 1
@@ -257,7 +250,6 @@ class TestWorkerPoller:
async def test_execute_task_retries_on_failure(self, pool, clean_operations):
"""Test that failed task execution triggers retry mechanism."""
from hindsight_api.worker import WorkerPoller
from hindsight_api.worker.poller import ClaimedTask
# Create a pending task with retry_count=0
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
@@ -285,8 +277,7 @@ class TestWorkerPoller:
# Execute (should fail and retry)
task_dict = json.loads(payload)
claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None)
await poller.execute_task(claimed_task)
await poller.execute_task(str(op_id), task_dict)
# Verify task is back to pending with incremented retry_count
row = await pool.fetchrow(
@@ -301,7 +292,6 @@ class TestWorkerPoller:
async def test_execute_task_fails_after_max_retries(self, pool, clean_operations):
"""Test that task is marked failed after exceeding max retries."""
from hindsight_api.worker import WorkerPoller
from hindsight_api.worker.poller import ClaimedTask
# Create a task that has already used all retries
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
@@ -329,8 +319,7 @@ class TestWorkerPoller:
# Execute (should fail permanently)
task_dict = json.loads(payload)
claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None)
await poller.execute_task(claimed_task)
await poller.execute_task(str(op_id), task_dict)
# Verify task is marked as failed
row = await pool.fetchrow(
@@ -395,8 +384,9 @@ class TestWorkerPoller:
# Should only claim the consolidation for the other bank
assert len(claimed) == 1
assert claimed[0].operation_id == str(other_op_id)
assert claimed[0].task_dict["bank_id"] == other_bank_id
claimed_op_id, claimed_payload = claimed[0]
assert claimed_op_id == str(other_op_id)
assert claimed_payload["bank_id"] == other_bank_id
# Verify the pending consolidation for first bank is still pending
row = await pool.fetchrow(
@@ -447,7 +437,8 @@ class TestWorkerPoller:
# Should claim the retain task (non-consolidation tasks are unaffected)
assert len(claimed) == 1
assert claimed[0].operation_id == str(retain_op_id)
claimed_op_id, _ = claimed[0]
assert claimed_op_id == str(retain_op_id)
class TestWorkerRecovery:
@@ -610,7 +601,7 @@ class TestConcurrentWorkers:
batch_size=5, # Each worker tries to claim 5
)
claimed = await poller.claim_batch()
workers_claimed[worker_id] = [task.operation_id for task in claimed]
workers_claimed[worker_id] = [op_id for op_id, _ in claimed]
# Run all workers concurrently
await asyncio.gather(
@@ -834,186 +825,3 @@ class TestSyncTaskBackend:
# Should not raise, error is logged
await backend.submit_task({"type": "test"})
class TestDynamicTenantDiscovery:
"""Tests for dynamic tenant discovery via TenantExtension."""
@pytest.mark.asyncio
async def test_poller_discovers_tenants_dynamically(self, pool, clean_operations):
"""Test that poller calls list_tenants() on each poll cycle."""
from hindsight_api.extensions.tenant import Tenant, TenantExtension
from hindsight_api.worker import WorkerPoller
# Create a mock tenant extension that tracks calls
class MockTenantExtension(TenantExtension):
def __init__(self):
self.list_tenants_calls = 0
self.tenants_to_return: list[Tenant] = [Tenant(schema="public")]
async def authenticate(self, context):
raise NotImplementedError("Not used in this test")
async def list_tenants(self) -> list[Tenant]:
self.list_tenants_calls += 1
return self.tenants_to_return
mock_extension = MockTenantExtension()
# Create pending tasks in public schema
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
for i in range(2):
op_id = uuid.uuid4()
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
""",
op_id,
bank_id,
payload,
)
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-1",
executor=lambda x: None,
batch_size=10,
tenant_extension=mock_extension,
)
# First claim_batch should call list_tenants
claimed1 = await poller.claim_batch()
assert mock_extension.list_tenants_calls == 1
assert len(claimed1) == 2
# Add more tasks
for i in range(2):
op_id = uuid.uuid4()
payload = json.dumps({"type": "test_task", "index": i + 10, "bank_id": bank_id})
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
""",
op_id,
bank_id,
payload,
)
# Second claim_batch should call list_tenants again
claimed2 = await poller.claim_batch()
assert mock_extension.list_tenants_calls == 2
assert len(claimed2) == 2
@pytest.mark.asyncio
async def test_poller_picks_up_new_tenants_without_restart(self, pool, clean_operations):
"""Test that new tenants are discovered on subsequent poll cycles."""
from hindsight_api.extensions.tenant import Tenant, TenantExtension
from hindsight_api.worker import WorkerPoller
class DynamicTenantExtension(TenantExtension):
def __init__(self):
# Start with just public
self.tenants: list[Tenant] = [Tenant(schema="public")]
self.list_tenants_calls = 0
async def authenticate(self, context):
raise NotImplementedError("Not used in this test")
async def list_tenants(self) -> list[Tenant]:
self.list_tenants_calls += 1
return self.tenants
dynamic_extension = DynamicTenantExtension()
# Create a task in public schema
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
op_id = uuid.uuid4()
payload = json.dumps({"type": "test_task", "bank_id": bank_id})
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
""",
op_id,
bank_id,
payload,
)
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-1",
executor=lambda x: None,
batch_size=10,
tenant_extension=dynamic_extension,
)
# First poll - only public schema
claimed1 = await poller.claim_batch()
assert len(claimed1) == 1
assert claimed1[0].schema is None # public is represented as None
assert dynamic_extension.list_tenants_calls == 1
# Simulate tenant list changing (but we won't add a non-existent schema)
# In real world, the schema would be created before list_tenants returns it
# Here we just verify that list_tenants is called again
# Add another task to public
op_id2 = uuid.uuid4()
payload2 = json.dumps({"type": "test_task", "bank_id": bank_id})
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
""",
op_id2,
bank_id,
payload2,
)
# Second poll - list_tenants should be called again
claimed2 = await poller.claim_batch()
assert len(claimed2) == 1
assert dynamic_extension.list_tenants_calls == 2 # Called again on second poll
# Third poll with no tasks - still calls list_tenants
claimed3 = await poller.claim_batch()
assert len(claimed3) == 0
assert dynamic_extension.list_tenants_calls == 3 # Called again even with no tasks
@pytest.mark.asyncio
async def test_poller_without_tenant_extension_uses_public(self, pool, clean_operations):
"""Test that poller uses public schema when no tenant extension is configured."""
from hindsight_api.worker import WorkerPoller
# Create pending tasks
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
for i in range(3):
op_id = uuid.uuid4()
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
""",
op_id,
bank_id,
payload,
)
# No tenant_extension provided
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-1",
executor=lambda x: None,
batch_size=10,
)
claimed = await poller.claim_batch()
assert len(claimed) == 3
# All tasks should have schema=None (public)
for task in claimed:
assert task.schema is None
+18 -43
View File
@@ -437,57 +437,57 @@ impl ApiClient {
})
}
// --- Mental Model Methods ---
// --- Reflection Methods ---
pub fn list_mental_models(&self, bank_id: &str, _verbose: bool) -> Result<types::MentalModelListResponse> {
pub fn list_reflections(&self, bank_id: &str, _verbose: bool) -> Result<types::ReflectionListResponse> {
self.runtime.block_on(async {
let response = self.client.list_mental_models(bank_id, None, None, None, None, None).await?;
let response = self.client.list_reflections(bank_id, None, None, None, None, None).await?;
Ok(response.into_inner())
})
}
pub fn get_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::MentalModelResponse> {
pub fn get_reflection(&self, bank_id: &str, reflection_id: &str, _verbose: bool) -> Result<types::ReflectionResponse> {
self.runtime.block_on(async {
let response = self.client.get_mental_model(bank_id, mental_model_id, None).await?;
let response = self.client.get_reflection(bank_id, reflection_id, None).await?;
Ok(response.into_inner())
})
}
pub fn create_mental_model(
pub fn create_reflection(
&self,
bank_id: &str,
request: &types::CreateMentalModelRequest,
request: &types::CreateReflectionRequest,
_verbose: bool,
) -> Result<types::CreateMentalModelResponse> {
) -> Result<types::CreateReflectionResponse> {
self.runtime.block_on(async {
let response = self.client.create_mental_model(bank_id, None, request).await?;
let response = self.client.create_reflection(bank_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn update_mental_model(
pub fn update_reflection(
&self,
bank_id: &str,
mental_model_id: &str,
request: &types::UpdateMentalModelRequest,
reflection_id: &str,
request: &types::UpdateReflectionRequest,
_verbose: bool,
) -> Result<types::MentalModelResponse> {
) -> Result<types::ReflectionResponse> {
self.runtime.block_on(async {
let response = self.client.update_mental_model(bank_id, mental_model_id, None, request).await?;
let response = self.client.update_reflection(bank_id, reflection_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn delete_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
pub fn delete_reflection(&self, bank_id: &str, reflection_id: &str, _verbose: bool) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.delete_mental_model(bank_id, mental_model_id, None).await?;
let response = self.client.delete_reflection(bank_id, reflection_id, None).await?;
Ok(response.into_inner())
})
}
pub fn refresh_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::AsyncOperationSubmitResponse> {
pub fn refresh_reflection(&self, bank_id: &str, reflection_id: &str, _verbose: bool) -> Result<types::ReflectionResponse> {
self.runtime.block_on(async {
let response = self.client.refresh_mental_model(bank_id, mental_model_id, None).await?;
let response = self.client.refresh_reflection(bank_id, reflection_id, None).await?;
Ok(response.into_inner())
})
}
@@ -539,31 +539,6 @@ impl ApiClient {
Ok(response.into_inner())
})
}
// --- Consolidation Methods ---
pub fn trigger_consolidation(&self, bank_id: &str, _verbose: bool) -> Result<types::ConsolidationResponse> {
self.runtime.block_on(async {
let response = self.client.trigger_consolidation(bank_id, None).await?;
Ok(response.into_inner())
})
}
pub fn clear_observations(&self, bank_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.clear_observations(bank_id, None).await?;
Ok(response.into_inner())
})
}
// --- Version Methods ---
pub fn get_version(&self, _verbose: bool) -> Result<types::VersionResponse> {
self.runtime.block_on(async {
let response = self.client.get_version().await?;
Ok(response.into_inner())
})
}
}
// Re-export types from the generated client for use in commands
-93
View File
@@ -495,96 +495,3 @@ pub fn delete(
Err(e) => Err(e)
}
}
/// Trigger consolidation to create/update observations
pub fn consolidate(
client: &ApiClient,
bank_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Triggering consolidation..."))
} else {
None
};
let response = client.trigger_consolidation(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success("Consolidation triggered");
println!(" {} {}", ui::dim("Operation ID:"), result.operation_id);
if result.deduplicated {
println!(" {} {}", ui::dim("Note:"), "Reusing existing pending consolidation task");
}
println!();
println!("{}", ui::dim("Use 'hindsight operation get' to check the operation status."));
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Clear all observations for a bank
pub fn clear_observations(
client: &ApiClient,
bank_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 clear all observations for bank '{}'? This cannot be undone.",
bank_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("Clearing observations..."))
} else {
None
};
let response = client.clear_observations(bank_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!("Observations cleared for bank '{}'", bank_id));
if let Some(count) = result.deleted_count {
println!(" Observations deleted: {}", count);
}
} else {
ui::print_error("Failed to clear observations");
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
-39
View File
@@ -75,45 +75,6 @@ pub fn health(
}
}
/// Get API version information
pub fn version(
client: &ApiClient,
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_version(verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_section_header("API Version");
println!(" {} {}", ui::dim("Version:"), result.api_version);
println!();
println!(" {}", ui::dim("Features:"));
println!(" {} MCP Server: {}", ui::gradient_start(""), if result.features.mcp { "enabled" } else { "disabled" });
println!(" {} Observations: {}", ui::gradient_start(""), if result.features.observations { "enabled" } else { "disabled" });
println!(" {} Background Worker: {}", ui::gradient_start(""), if result.features.worker { "enabled" } else { "disabled" });
println!();
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Get Prometheus metrics
pub fn metrics(
client: &ApiClient,
+1 -1
View File
@@ -7,5 +7,5 @@ pub mod explore;
pub mod health;
pub mod memory;
pub mod operation;
pub mod mental_model;
pub mod reflection;
pub mod tag;
@@ -1,4 +1,4 @@
//! Mental model commands for managing user-curated summaries.
//! Reflection commands for managing user-curated summaries.
use anyhow::Result;
@@ -8,7 +8,7 @@ use crate::ui;
use hindsight_client::types;
/// List mental models for a bank
/// List reflections for a bank
pub fn list(
client: &ApiClient,
bank_id: &str,
@@ -16,12 +16,12 @@ pub fn list(
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching mental models..."))
Some(ui::create_spinner("Fetching reflections..."))
} else {
None
};
let response = client.list_mental_models(bank_id, verbose);
let response = client.list_reflections(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
@@ -30,21 +30,21 @@ pub fn list(
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Mental Models: {}", bank_id));
ui::print_section_header(&format!("Reflections: {}", bank_id));
if result.items.is_empty() {
println!(" {}", ui::dim("No mental models found."));
println!(" {}", ui::dim("No reflections found."));
} else {
for mental_model in &result.items {
for reflection in &result.items {
println!(
" {} {}",
ui::gradient_start(&mental_model.id),
mental_model.name
ui::gradient_start(&reflection.id),
reflection.name
);
// Show content preview
let preview: String = mental_model.content.chars().take(80).collect();
let ellipsis = if mental_model.content.len() > 80 { "..." } else { "" };
let preview: String = reflection.content.chars().take(80).collect();
let ellipsis = if reflection.content.len() > 80 { "..." } else { "" };
println!(" {}{}", ui::dim(&preview), ellipsis);
println!();
@@ -59,32 +59,32 @@ pub fn list(
}
}
/// Get a specific mental model
/// Get a specific reflection
pub fn get(
client: &ApiClient,
bank_id: &str,
mental_model_id: &str,
reflection_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching mental model..."))
Some(ui::create_spinner("Fetching reflection..."))
} else {
None
};
let response = client.get_mental_model(bank_id, mental_model_id, verbose);
let response = client.get_reflection(bank_id, reflection_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(mental_model) => {
Ok(reflection) => {
if output_format == OutputFormat::Pretty {
print_mental_model_detail(&mental_model);
print_reflection_detail(&reflection);
} else {
output::print_output(&mental_model, output_format)?;
output::print_output(&reflection, output_format)?;
}
Ok(())
}
@@ -92,7 +92,7 @@ pub fn get(
}
}
/// Create a new mental model
/// Create a new reflection
pub fn create(
client: &ApiClient,
bank_id: &str,
@@ -102,20 +102,19 @@ pub fn create(
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Creating mental model..."))
Some(ui::create_spinner("Creating reflection..."))
} else {
None
};
let request = types::CreateMentalModelRequest {
let request = types::CreateReflectionRequest {
name: name.to_string(),
source_query: source_query.to_string(),
max_tokens: 2048,
tags: vec![],
trigger: None,
};
let response = client.create_mental_model(bank_id, &request, verbose);
let response = client.create_reflection(bank_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
@@ -124,7 +123,7 @@ pub fn create(
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Mental model created, operation_id: {}", result.operation_id));
ui::print_success(&format!("Reflection created, operation_id: {}", result.operation_id));
} else {
output::print_output(&result, output_format)?;
}
@@ -134,11 +133,11 @@ pub fn create(
}
}
/// Update a mental model
/// Update a reflection
pub fn update(
client: &ApiClient,
bank_id: &str,
mental_model_id: &str,
reflection_id: &str,
name: Option<String>,
verbose: bool,
output_format: OutputFormat,
@@ -148,33 +147,27 @@ pub fn update(
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating mental model..."))
Some(ui::create_spinner("Updating reflection..."))
} else {
None
};
let request = types::UpdateMentalModelRequest {
name,
source_query: None,
max_tokens: None,
tags: None,
trigger: None,
};
let request = types::UpdateReflectionRequest { name };
let response = client.update_mental_model(bank_id, mental_model_id, &request, verbose);
let response = client.update_reflection(bank_id, reflection_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(mental_model) => {
Ok(reflection) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Mental model '{}' updated successfully", mental_model_id));
ui::print_success(&format!("Reflection '{}' updated successfully", reflection_id));
println!();
print_mental_model_detail(&mental_model);
print_reflection_detail(&reflection);
} else {
output::print_output(&mental_model, output_format)?;
output::print_output(&reflection, output_format)?;
}
Ok(())
}
@@ -182,11 +175,11 @@ pub fn update(
}
}
/// Delete a mental model
/// Delete a reflection
pub fn delete(
client: &ApiClient,
bank_id: &str,
mental_model_id: &str,
reflection_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat,
@@ -194,8 +187,8 @@ pub fn delete(
// 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.",
mental_model_id
"Are you sure you want to delete reflection '{}'? This cannot be undone.",
reflection_id
);
let confirmed = ui::prompt_confirmation(&message)?;
@@ -207,12 +200,12 @@ pub fn delete(
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Deleting mental model..."))
Some(ui::create_spinner("Deleting reflection..."))
} else {
None
};
let response = client.delete_mental_model(bank_id, mental_model_id, verbose);
let response = client.delete_reflection(bank_id, reflection_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
@@ -221,7 +214,7 @@ pub fn delete(
match response {
Ok(_) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Mental model '{}' deleted successfully", mental_model_id));
ui::print_success(&format!("Reflection '{}' deleted successfully", reflection_id));
} else {
println!("{{\"success\": true}}");
}
@@ -231,38 +224,34 @@ pub fn delete(
}
}
/// Refresh a mental model
/// Refresh a reflection
pub fn refresh(
client: &ApiClient,
bank_id: &str,
mental_model_id: &str,
reflection_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Submitting mental model refresh..."))
Some(ui::create_spinner("Refreshing reflection..."))
} else {
None
};
let response = client.refresh_mental_model(bank_id, mental_model_id, verbose);
let response = client.refresh_reflection(bank_id, reflection_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(operation) => {
Ok(reflection) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!(
"Mental model refresh submitted. Operation ID: {}",
operation.operation_id
));
println!(" {} {}", ui::dim("Status:"), operation.status);
ui::print_success(&format!("Reflection '{}' refreshed successfully", reflection_id));
println!();
println!("{}", ui::dim("Use 'hindsight operations get' to check the operation status."));
print_reflection_detail(&reflection);
} else {
output::print_output(&operation, output_format)?;
output::print_output(&reflection, output_format)?;
}
Ok(())
}
@@ -270,16 +259,16 @@ pub fn refresh(
}
}
// Helper function to print mental model details
fn print_mental_model_detail(mental_model: &types::MentalModelResponse) {
ui::print_section_header(&mental_model.name);
// Helper function to print reflection details
fn print_reflection_detail(reflection: &types::ReflectionResponse) {
ui::print_section_header(&reflection.name);
println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&mental_model.id));
println!(" {} {}", ui::dim("Source Query:"), &mental_model.source_query);
println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&reflection.id));
println!(" {} {}", ui::dim("Source Query:"), &reflection.source_query);
println!();
println!("{}", ui::gradient_text("─── Content ───"));
println!();
println!("{}", &mental_model.content);
println!("{}", &reflection.content);
println!();
}
+34 -60
View File
@@ -95,9 +95,9 @@ enum Commands {
#[command(subcommand)]
Operation(OperationCommands),
/// Manage mental models (user-curated summaries)
/// Manage reflections (user-curated summaries)
#[command(subcommand)]
MentalModel(MentalModelCommands),
Reflection(ReflectionCommands),
/// Manage directives (behavioral rules)
#[command(subcommand)]
@@ -109,9 +109,6 @@ enum Commands {
/// Get Prometheus metrics
Metrics,
/// Get API version information
Version,
/// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect
#[command(alias = "tui")]
Explore,
@@ -255,22 +252,6 @@ enum BankCommands {
#[arg(short = 'y', long)]
yes: bool,
},
/// Trigger consolidation to create/update observations
Consolidate {
/// Bank ID
bank_id: String,
},
/// Clear all observations for a bank
ClearObservations {
/// Bank ID
bank_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
@@ -558,67 +539,67 @@ enum ChunkCommands {
}
#[derive(Subcommand)]
enum MentalModelCommands {
/// List mental models for a bank
enum ReflectionCommands {
/// List reflections for a bank
List {
/// Bank ID
bank_id: String,
},
/// Get a specific mental model
/// Get a specific reflection
Get {
/// Bank ID
bank_id: String,
/// Mental model ID
mental_model_id: String,
/// Reflection ID
reflection_id: String,
},
/// Create a new mental model
/// Create a new reflection
Create {
/// Bank ID
bank_id: String,
/// Mental model name
/// Reflection name
name: String,
/// Source query to generate the mental model from
/// Source query to generate the reflection from
source_query: String,
},
/// Update a mental model
/// Update a reflection
Update {
/// Bank ID
bank_id: String,
/// Mental model ID
mental_model_id: String,
/// Reflection ID
reflection_id: String,
/// New name
#[arg(long)]
name: Option<String>,
},
/// Delete a mental model
/// Delete a reflection
Delete {
/// Bank ID
bank_id: String,
/// Mental model ID
mental_model_id: String,
/// Reflection ID
reflection_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
/// Refresh a mental model (re-run the source query)
/// Refresh a reflection (re-run the source query)
Refresh {
/// Bank ID
bank_id: String,
/// Mental model ID
mental_model_id: String,
/// Reflection ID
reflection_id: String,
},
}
@@ -725,10 +706,9 @@ fn run() -> Result<()> {
Commands::Ui => unreachable!(), // Handled above
Commands::Explore => commands::explore::run(&client),
// Health, Metrics, and Version
// Health and Metrics
Commands::Health => commands::health::health(&client, verbose, output_format),
Commands::Metrics => commands::health::metrics(&client, verbose, output_format),
Commands::Version => commands::health::version(&client, verbose, output_format),
// Bank commands
Commands::Bank(bank_cmd) => match bank_cmd {
@@ -754,12 +734,6 @@ fn run() -> Result<()> {
BankCommands::Delete { bank_id, yes } => {
commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
}
BankCommands::Consolidate { bank_id } => {
commands::bank::consolidate(&client, &bank_id, verbose, output_format)
}
BankCommands::ClearObservations { bank_id, yes } => {
commands::bank::clear_observations(&client, &bank_id, yes, verbose, output_format)
}
},
// Memory commands
@@ -843,25 +817,25 @@ fn run() -> Result<()> {
}
},
// Mental model commands
Commands::MentalModel(mm_cmd) => match mm_cmd {
MentalModelCommands::List { bank_id } => {
commands::mental_model::list(&client, &bank_id, verbose, output_format)
// Reflection commands
Commands::Reflection(ref_cmd) => match ref_cmd {
ReflectionCommands::List { bank_id } => {
commands::reflection::list(&client, &bank_id, verbose, output_format)
}
MentalModelCommands::Get { bank_id, mental_model_id } => {
commands::mental_model::get(&client, &bank_id, &mental_model_id, verbose, output_format)
ReflectionCommands::Get { bank_id, reflection_id } => {
commands::reflection::get(&client, &bank_id, &reflection_id, verbose, output_format)
}
MentalModelCommands::Create { bank_id, name, source_query } => {
commands::mental_model::create(&client, &bank_id, &name, &source_query, verbose, output_format)
ReflectionCommands::Create { bank_id, name, source_query } => {
commands::reflection::create(&client, &bank_id, &name, &source_query, verbose, output_format)
}
MentalModelCommands::Update { bank_id, mental_model_id, name } => {
commands::mental_model::update(&client, &bank_id, &mental_model_id, name, verbose, output_format)
ReflectionCommands::Update { bank_id, reflection_id, name } => {
commands::reflection::update(&client, &bank_id, &reflection_id, name, verbose, output_format)
}
MentalModelCommands::Delete { bank_id, mental_model_id, yes } => {
commands::mental_model::delete(&client, &bank_id, &mental_model_id, yes, verbose, output_format)
ReflectionCommands::Delete { bank_id, reflection_id, yes } => {
commands::reflection::delete(&client, &bank_id, &reflection_id, yes, verbose, output_format)
}
MentalModelCommands::Refresh { bank_id, mental_model_id } => {
commands::mental_model::refresh(&client, &bank_id, &mental_model_id, verbose, output_format)
ReflectionCommands::Refresh { bank_id, reflection_id } => {
commands::reflection::refresh(&client, &bank_id, &reflection_id, verbose, output_format)
}
},
-406
View File
@@ -481,409 +481,3 @@ fn test_json_yaml_output_formats() {
.expect("Expected valid YAML for bank list");
}
}
// ============================================================================
// Directive Tests
// ============================================================================
#[test]
fn test_directive_list() {
skip_if_no_server!();
let bank_id = test_bank_id("dir-list");
// Create the bank first
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
// List directives
let output = run_hindsight(&["directive", "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(),
"Directive list command failed: {} / {}",
stdout,
stderr
);
// Clean up
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
}
#[test]
fn test_directive_create_get_update_delete() {
skip_if_no_server!();
let bank_id = test_bank_id("dir-crud");
// Create the bank first
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
// Create a directive
let output = run_hindsight(&[
"directive", "create",
&bank_id,
"Test Directive",
"Always respond politely",
]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Directive create failed: stdout={}, stderr={}",
stdout,
stderr
);
// List directives and get the ID
let output = run_hindsight(&["directive", "list", &bank_id, "-o", "json"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"Directive list failed: {}",
stdout
);
// Parse JSON and get directive ID
let directive_id: Option<String> = if let Ok(result) = serde_json::from_str::<serde_json::Value>(&stdout) {
result.get("items")
.and_then(|v| v.as_array())
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
};
if let Some(id) = directive_id {
// Get the directive
let output = run_hindsight(&["directive", "get", &bank_id, &id]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Directive get failed: stdout={}, stderr={}",
stdout,
stderr
);
// Update the directive
let output = run_hindsight(&[
"directive", "update",
&bank_id,
&id,
"--name", "Updated Directive",
"--content", "Always respond very politely",
]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Directive update failed: stdout={}, stderr={}",
stdout,
stderr
);
// Verify update in JSON
let output = run_hindsight(&["directive", "get", &bank_id, &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 Directive")
);
}
// Delete the directive
let output = run_hindsight(&["directive", "delete", &bank_id, &id, "-y"]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Directive delete failed: stdout={}, stderr={}",
stdout,
stderr
);
}
// Clean up
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
}
// ============================================================================
// Mental Model Extended Tests
// ============================================================================
#[test]
fn test_mental_model_get() {
skip_if_no_server!();
let bank_id = test_bank_id("mm-get");
// 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 Get Model",
"What are the key facts?",
]);
if output.status.success() {
// List to get the ID
let output = run_hindsight(&["mental-model", "list", &bank_id, "-o", "json"]);
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&stdout) {
if let Some(id) = result.get("items")
.and_then(|v| v.as_array())
.and_then(|items| items.iter().find(|item| {
item.get("name").and_then(|v| v.as_str()) == Some("Test Get Model")
}))
.and_then(|item| item.get("id"))
.and_then(|v| v.as_str())
{
// Get the mental model
let output = run_hindsight(&["mental-model", "get", &bank_id, id]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Mental model get failed: stdout={}, stderr={}",
stdout,
stderr
);
}
}
}
// Clean up
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
}
#[test]
fn test_mental_model_update() {
skip_if_no_server!();
let bank_id = test_bank_id("mm-update");
// 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 Update Model",
"What are the key facts?",
]);
if output.status.success() {
// List to get the ID
let output = run_hindsight(&["mental-model", "list", &bank_id, "-o", "json"]);
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&stdout) {
if let Some(id) = result.get("items")
.and_then(|v| v.as_array())
.and_then(|items| items.iter().find(|item| {
item.get("name").and_then(|v| v.as_str()) == Some("Test Update Model")
}))
.and_then(|item| item.get("id"))
.and_then(|v| v.as_str())
{
// Update the mental model
let output = run_hindsight(&[
"mental-model", "update",
&bank_id,
id,
"--name", "Updated Model Name",
]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Mental model update failed: stdout={}, stderr={}",
stdout,
stderr
);
// Verify update
let output = run_hindsight(&["mental-model", "get", &bank_id, 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 Model Name")
);
}
}
}
}
// Clean up
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
}
#[test]
fn test_mental_model_refresh() {
skip_if_no_server!();
let bank_id = test_bank_id("mm-refresh");
// 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 Refresh Model",
"What are the key facts?",
]);
if output.status.success() {
// List to get the ID
let output = run_hindsight(&["mental-model", "list", &bank_id, "-o", "json"]);
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&stdout) {
if let Some(id) = result.get("items")
.and_then(|v| v.as_array())
.and_then(|items| items.iter().find(|item| {
item.get("name").and_then(|v| v.as_str()) == Some("Test Refresh Model")
}))
.and_then(|item| item.get("id"))
.and_then(|v| v.as_str())
{
// Refresh the mental model
let output = run_hindsight(&["mental-model", "refresh", &bank_id, id]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Mental model refresh failed: stdout={}, stderr={}",
stdout,
stderr
);
}
}
}
// Clean up
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
}
// ============================================================================
// Bank Consolidation Tests
// ============================================================================
#[test]
fn test_bank_consolidate() {
skip_if_no_server!();
let bank_id = test_bank_id("bank-consolidate");
// Create the bank first
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
// Trigger consolidation
let output = run_hindsight(&["bank", "consolidate", &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 consolidate command failed: {} / {}",
stdout,
stderr
);
// Clean up
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
}
#[test]
fn test_bank_clear_observations() {
skip_if_no_server!();
let bank_id = test_bank_id("bank-clear-obs");
// Create the bank first
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
// Clear observations
let output = run_hindsight(&["bank", "clear-observations", &bank_id, "-y"]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Should succeed
assert!(
output.status.success(),
"Bank clear-observations command failed: {} / {}",
stdout,
stderr
);
// Clean up
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
}
// ============================================================================
// Version Test
// ============================================================================
#[test]
fn test_version() {
skip_if_no_server!();
let output = run_hindsight(&["version"]);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Should succeed
assert!(
output.status.success(),
"Version command failed: {} / {}",
stdout,
stderr
);
}
#[test]
fn test_version_json() {
skip_if_no_server!();
let output = run_hindsight(&["version", "-o", "json"]);
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let result: serde_json::Value = serde_json::from_str(&stdout)
.expect(&format!("Expected valid JSON output, got: {}", stdout));
// Should have api_version and features
assert!(result.get("api_version").is_some(), "Expected api_version field");
assert!(result.get("features").is_some(), "Expected features field");
}
}
@@ -5,16 +5,15 @@ hindsight_client_api/api/directives_api.py
hindsight_client_api/api/documents_api.py
hindsight_client_api/api/entities_api.py
hindsight_client_api/api/memory_api.py
hindsight_client_api/api/mental_models_api.py
hindsight_client_api/api/monitoring_api.py
hindsight_client_api/api/operations_api.py
hindsight_client_api/api/reflections_api.py
hindsight_client_api/api_client.py
hindsight_client_api/api_response.py
hindsight_client_api/configuration.py
hindsight_client_api/exceptions.py
hindsight_client_api/models/__init__.py
hindsight_client_api/models/add_background_request.py
hindsight_client_api/models/async_operation_submit_response.py
hindsight_client_api/models/background_response.py
hindsight_client_api/models/bank_list_item.py
hindsight_client_api/models/bank_list_response.py
@@ -28,8 +27,8 @@ hindsight_client_api/models/chunk_response.py
hindsight_client_api/models/consolidation_response.py
hindsight_client_api/models/create_bank_request.py
hindsight_client_api/models/create_directive_request.py
hindsight_client_api/models/create_mental_model_request.py
hindsight_client_api/models/create_mental_model_response.py
hindsight_client_api/models/create_reflection_request.py
hindsight_client_api/models/create_reflection_response.py
hindsight_client_api/models/delete_document_response.py
hindsight_client_api/models/delete_response.py
hindsight_client_api/models/directive_list_response.py
@@ -51,9 +50,6 @@ hindsight_client_api/models/list_documents_response.py
hindsight_client_api/models/list_memory_units_response.py
hindsight_client_api/models/list_tags_response.py
hindsight_client_api/models/memory_item.py
hindsight_client_api/models/mental_model_list_response.py
hindsight_client_api/models/mental_model_response.py
hindsight_client_api/models/mental_model_trigger.py
hindsight_client_api/models/operation_response.py
hindsight_client_api/models/operation_status_response.py
hindsight_client_api/models/operations_list_response.py
@@ -61,7 +57,6 @@ hindsight_client_api/models/recall_request.py
hindsight_client_api/models/recall_response.py
hindsight_client_api/models/recall_result.py
hindsight_client_api/models/reflect_based_on.py
hindsight_client_api/models/reflect_directive.py
hindsight_client_api/models/reflect_fact.py
hindsight_client_api/models/reflect_include_options.py
hindsight_client_api/models/reflect_llm_call.py
@@ -70,6 +65,8 @@ hindsight_client_api/models/reflect_request.py
hindsight_client_api/models/reflect_response.py
hindsight_client_api/models/reflect_tool_call.py
hindsight_client_api/models/reflect_trace.py
hindsight_client_api/models/reflection_list_response.py
hindsight_client_api/models/reflection_response.py
hindsight_client_api/models/retain_request.py
hindsight_client_api/models/retain_response.py
hindsight_client_api/models/tag_item.py
@@ -77,7 +74,7 @@ hindsight_client_api/models/token_usage.py
hindsight_client_api/models/tool_calls_include_options.py
hindsight_client_api/models/update_directive_request.py
hindsight_client_api/models/update_disposition_request.py
hindsight_client_api/models/update_mental_model_request.py
hindsight_client_api/models/update_reflection_request.py
hindsight_client_api/models/validation_error.py
hindsight_client_api/models/validation_error_loc_inner.py
hindsight_client_api/models/version_response.py
@@ -25,18 +25,17 @@ Example:
```
"""
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.recall_response import RecallResponse as _RecallResponse
from hindsight_client_api.models.recall_result import RecallResult as _RecallResult
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.reflect_response import ReflectResponse
from .hindsight_client import Hindsight
# Re-export response types for convenient access
from hindsight_client_api.models.retain_response import RetainResponse
from .hindsight_client import Hindsight
from hindsight_client_api.models.recall_response import RecallResponse as _RecallResponse
from hindsight_client_api.models.recall_result import RecallResult as _RecallResult
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_fact import ReflectFact
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.disposition_traits import DispositionTraits
# Add cleaner __repr__ and __iter__ for REPL usability
@@ -6,23 +6,23 @@ easy-to-use interface on top of the auto-generated OpenAPI client.
"""
import asyncio
from typing import Optional, List, Dict, Any, Literal
from datetime import datetime
from typing import Any, Literal
import hindsight_client_api
from hindsight_client_api.api import banks_api, directives_api, memory_api, mental_models_api
from hindsight_client_api.api import memory_api, banks_api
from hindsight_client_api.models import (
memory_item,
recall_request,
reflect_request,
retain_request,
memory_item,
reflect_request,
)
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
def _run_async(coro):
@@ -63,7 +63,7 @@ class Hindsight:
```
"""
def __init__(self, base_url: str, api_key: str | None = None, timeout: float = 30.0):
def __init__(self, base_url: str, api_key: Optional[str] = None, timeout: float = 30.0):
"""
Initialize the Hindsight client.
@@ -78,8 +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)
self._directives_api = directives_api.DirectivesApi(self._api_client)
def __enter__(self):
"""Context manager entry."""
@@ -112,12 +110,12 @@ class Hindsight:
self,
bank_id: str,
content: str,
timestamp: datetime | None = None,
context: str | None = None,
document_id: str | None = None,
metadata: dict[str, str] | None = None,
entities: list[dict[str, str]] | None = None,
tags: list[str] | None = None,
timestamp: Optional[datetime] = None,
context: Optional[str] = None,
document_id: Optional[str] = None,
metadata: Optional[Dict[str, str]] = None,
entities: Optional[List[Dict[str, str]]] = None,
tags: Optional[List[str]] = None,
) -> RetainResponse:
"""
Store a single memory (simplified interface).
@@ -130,33 +128,24 @@ class Hindsight:
document_id: Optional document ID for grouping
metadata: Optional user-defined metadata
entities: Optional list of entities [{"text": "...", "type": "..."}]
tags: Optional list of tags for filtering memories during recall/reflect
tags: Optional list of tags for this memory
Returns:
RetainResponse with success status
"""
return self.retain_batch(
bank_id=bank_id,
items=[
{
"content": content,
"timestamp": timestamp,
"context": context,
"metadata": metadata,
"entities": entities,
"tags": tags,
}
],
items=[{"content": content, "timestamp": timestamp, "context": context, "metadata": metadata, "entities": entities, "tags": tags}],
document_id=document_id,
)
def retain_batch(
self,
bank_id: str,
items: list[dict[str, Any]],
document_id: str | None = None,
document_tags: list[str] | None = None,
items: List[Dict[str, Any]],
document_id: Optional[str] = None,
retain_async: bool = False,
document_tags: Optional[List[str]] = None,
) -> RetainResponse:
"""
Store multiple memories in batch.
@@ -165,8 +154,8 @@ class Hindsight:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags'
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
document_tags: Optional list of tags applied to all items in this batch (merged with per-item tags)
retain_async: If True, process asynchronously in background (default: False)
document_tags: Optional list of tags to apply to all memories in this batch
Returns:
RetainResponse with success status and item count
@@ -177,7 +166,10 @@ class Hindsight:
for item in items:
entities = None
if item.get("entities"):
entities = [EntityInput(text=e["text"], type=e.get("type")) for e in item["entities"]]
entities = [
EntityInput(text=e["text"], type=e.get("type"))
for e in item["entities"]
]
memory_items.append(
memory_item.MemoryItem(
content=item["content"],
@@ -203,17 +195,17 @@ class Hindsight:
self,
bank_id: str,
query: str,
types: list[str] | None = None,
types: Optional[List[str]] = None,
max_tokens: int = 4096,
budget: str = "mid",
trace: bool = False,
query_timestamp: str | None = None,
query_timestamp: Optional[str] = None,
include_entities: bool = False,
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
tags: Optional[List[str]] = None,
tags_match: str = "any",
) -> RecallResponse:
"""
Recall memories using semantic similarity.
@@ -231,18 +223,16 @@ class Hindsight:
include_chunks: Include raw text chunks in results (default: False)
max_chunk_tokens: Maximum tokens for chunks (default: 8192)
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
tags_match: How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged),
'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any'
Returns:
RecallResponse with results, optional entities, optional chunks, and optional trace
"""
from hindsight_client_api.models import chunk_include_options, entity_include_options, include_options
from hindsight_client_api.models import include_options, entity_include_options, chunk_include_options
include_opts = include_options.IncludeOptions(
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens)
if include_entities
else None,
entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens) if include_entities else None,
chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None,
)
@@ -265,11 +255,11 @@ class Hindsight:
bank_id: str,
query: str,
budget: str = "low",
context: str | None = None,
max_tokens: int | None = None,
response_schema: dict[str, Any] | None = None,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
context: Optional[str] = None,
max_tokens: Optional[int] = None,
response_schema: Optional[Dict[str, Any]] = None,
tags: Optional[List[str]] = None,
tags_match: str = "any",
) -> ReflectResponse:
"""
Generate a contextual answer based on bank identity and memories.
@@ -284,8 +274,8 @@ class Hindsight:
the response will include a 'structured_output' field with the LLM
response parsed according to this schema.
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
tags_match: How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged),
'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any'
Returns:
ReflectResponse with answer text, optionally facts used, and optionally
@@ -306,37 +296,28 @@ class Hindsight:
def list_memories(
self,
bank_id: str,
type: str | None = None,
search_query: str | None = None,
type: Optional[str] = None,
search_query: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> ListMemoryUnitsResponse:
"""List memory units with pagination."""
return _run_async(
self._memory_api.list_memories(
bank_id=bank_id,
type=type,
q=search_query,
limit=limit,
offset=offset,
)
)
return _run_async(self._memory_api.list_memories(
bank_id=bank_id,
type=type,
q=search_query,
limit=limit,
offset=offset,
))
def create_bank(
self,
bank_id: str,
name: str | None = None,
mission: str | None = None,
disposition: dict[str, float] | None = None,
name: Optional[str] = None,
background: Optional[str] = None,
disposition: Optional[Dict[str, float]] = None,
) -> BankProfileResponse:
"""Create or update a memory bank.
Args:
bank_id: Unique identifier for the bank
name: Human-readable display name
mission: Instructions guiding what Hindsight should learn and remember (for mental models)
disposition: Optional disposition traits (skepticism, literalism, empathy)
"""
"""Create or update a memory bank."""
from hindsight_client_api.models import create_bank_request, disposition_traits
disposition_obj = None
@@ -345,7 +326,7 @@ class Hindsight:
request_obj = create_bank_request.CreateBankRequest(
name=name,
mission=mission,
background=background,
disposition=disposition_obj,
)
@@ -376,9 +357,8 @@ class Hindsight:
async def aretain_batch(
self,
bank_id: str,
items: list[dict[str, Any]],
document_id: str | None = None,
document_tags: list[str] | None = None,
items: List[Dict[str, Any]],
document_id: Optional[str] = None,
retain_async: bool = False,
) -> RetainResponse:
"""
@@ -386,9 +366,8 @@ class Hindsight:
Args:
bank_id: The memory bank ID
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags'
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities'
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
document_tags: Optional list of tags applied to all items in this batch (merged with per-item tags)
retain_async: If True, process asynchronously in background (default: False)
Returns:
@@ -400,7 +379,10 @@ class Hindsight:
for item in items:
entities = None
if item.get("entities"):
entities = [EntityInput(text=e["text"], type=e.get("type")) for e in item["entities"]]
entities = [
EntityInput(text=e["text"], type=e.get("type"))
for e in item["entities"]
]
memory_items.append(
memory_item.MemoryItem(
content=item["content"],
@@ -410,14 +392,12 @@ class Hindsight:
# Use item's document_id if provided, otherwise fall back to batch-level document_id
document_id=item.get("document_id") or document_id,
entities=entities,
tags=item.get("tags"),
)
)
request_obj = retain_request.RetainRequest(
items=memory_items,
async_=retain_async,
document_tags=document_tags,
)
return await self._memory_api.retain_memories(bank_id, request_obj)
@@ -426,12 +406,11 @@ class Hindsight:
self,
bank_id: str,
content: str,
timestamp: datetime | None = None,
context: str | None = None,
document_id: str | None = None,
metadata: dict[str, str] | None = None,
entities: list[dict[str, str]] | None = None,
tags: list[str] | None = None,
timestamp: Optional[datetime] = None,
context: Optional[str] = None,
document_id: Optional[str] = None,
metadata: Optional[Dict[str, str]] = None,
entities: Optional[List[Dict[str, str]]] = None,
) -> RetainResponse:
"""
Store a single memory (async).
@@ -444,23 +423,13 @@ class Hindsight:
document_id: Optional document ID for grouping
metadata: Optional user-defined metadata
entities: Optional list of entities [{"text": "...", "type": "..."}]
tags: Optional list of tags for filtering memories during recall/reflect
Returns:
RetainResponse with success status
"""
return await self.aretain_batch(
bank_id=bank_id,
items=[
{
"content": content,
"timestamp": timestamp,
"context": context,
"metadata": metadata,
"entities": entities,
"tags": tags,
}
],
items=[{"content": content, "timestamp": timestamp, "context": context, "metadata": metadata, "entities": entities}],
document_id=document_id,
)
@@ -468,12 +437,10 @@ class Hindsight:
self,
bank_id: str,
query: str,
types: list[str] | None = None,
types: Optional[List[str]] = None,
max_tokens: int = 4096,
budget: str = "mid",
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
) -> list[RecallResult]:
) -> List[RecallResult]:
"""
Recall memories using semantic similarity (async).
@@ -483,9 +450,6 @@ class Hindsight:
types: Optional list of fact types to filter (world, experience, opinion, observation)
max_tokens: Maximum tokens in results (default: 4096)
budget: Budget level for recall - "low", "mid", or "high" (default: "mid")
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
List of RecallResult objects
@@ -496,21 +460,17 @@ class Hindsight:
budget=budget,
max_tokens=max_tokens,
trace=False,
tags=tags,
tags_match=tags_match,
)
response = await self._memory_api.recall_memories(bank_id, request_obj)
return response.results if hasattr(response, "results") else []
return response.results if hasattr(response, 'results') else []
async def areflect(
self,
bank_id: str,
query: str,
budget: str = "low",
context: str | None = None,
tags: list[str] | None = None,
tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any",
context: Optional[str] = None,
) -> ReflectResponse:
"""
Generate a contextual answer based on bank identity and memories (async).
@@ -520,9 +480,6 @@ class Hindsight:
query: The question or prompt
budget: Budget level for reflection - "low", "mid", or "high" (default: "low")
context: Optional additional context
tags: Optional list of tags to filter memories by
tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged),
"any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any"
Returns:
ReflectResponse with answer text and optionally facts used
@@ -531,258 +488,6 @@ class Hindsight:
query=query,
budget=budget,
context=context,
tags=tags,
tags_match=tags_match,
)
return await self._memory_api.reflect(bank_id, request_obj)
# Mental Models methods
def create_mental_model(
self,
bank_id: str,
name: str,
source_query: str,
tags: list[str] | None = None,
max_tokens: int | None = None,
trigger: dict[str, Any] | None = None,
):
"""
Create a mental model (runs reflect in background).
Args:
bank_id: The memory bank ID
name: Human-readable name for the mental model
source_query: The query to run to generate content
tags: Optional tags for filtering during retrieval
max_tokens: Optional maximum tokens for the mental model content
trigger: Optional trigger settings (e.g., {"refresh_after_consolidation": True})
Returns:
CreateMentalModelResponse with operation_id
"""
from hindsight_client_api.models import create_mental_model_request, mental_model_trigger
trigger_obj = None
if trigger:
trigger_obj = mental_model_trigger.MentalModelTrigger(**trigger)
request_obj = create_mental_model_request.CreateMentalModelRequest(
name=name,
source_query=source_query,
tags=tags,
max_tokens=max_tokens,
trigger=trigger_obj,
)
return _run_async(self._mental_models_api.create_mental_model(bank_id, request_obj))
def list_mental_models(self, bank_id: str, tags: list[str] | None = None):
"""
List all mental models in a bank.
Args:
bank_id: The memory bank ID
tags: Optional tags to filter by
Returns:
ListMentalModelsResponse with items
"""
return _run_async(self._mental_models_api.list_mental_models(bank_id, tags=tags))
def get_mental_model(self, bank_id: str, mental_model_id: str):
"""
Get a specific mental model.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
Returns:
MentalModelResponse
"""
return _run_async(self._mental_models_api.get_mental_model(bank_id, mental_model_id))
def refresh_mental_model(self, bank_id: str, mental_model_id: str):
"""
Refresh a mental model to update with current knowledge.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
Returns:
RefreshMentalModelResponse with operation_id
"""
return _run_async(self._mental_models_api.refresh_mental_model(bank_id, mental_model_id))
def update_mental_model(
self,
bank_id: str,
mental_model_id: str,
name: str | None = None,
source_query: str | None = None,
tags: list[str] | None = None,
max_tokens: int | None = None,
trigger: dict[str, Any] | None = None,
):
"""
Update a mental model's metadata.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
name: Optional new name
source_query: Optional new source query
tags: Optional new tags
max_tokens: Optional new max tokens
trigger: Optional trigger settings (e.g., {"refresh_after_consolidation": True})
Returns:
MentalModelResponse
"""
from hindsight_client_api.models import mental_model_trigger, update_mental_model_request
trigger_obj = None
if trigger:
trigger_obj = mental_model_trigger.MentalModelTrigger(**trigger)
request_obj = update_mental_model_request.UpdateMentalModelRequest(
name=name,
source_query=source_query,
tags=tags,
max_tokens=max_tokens,
trigger=trigger_obj,
)
return _run_async(self._mental_models_api.update_mental_model(bank_id, mental_model_id, request_obj))
def delete_mental_model(self, bank_id: str, mental_model_id: str):
"""
Delete a mental model.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
"""
return _run_async(self._mental_models_api.delete_mental_model(bank_id, mental_model_id))
# Directives methods
def create_directive(
self,
bank_id: str,
name: str,
content: str,
priority: int = 0,
is_active: bool = True,
tags: list[str] | None = None,
):
"""
Create a directive (hard rule for reflect).
Args:
bank_id: The memory bank ID
name: Human-readable name for the directive
content: The directive content/rules
priority: Priority level (higher = injected first)
is_active: Whether the directive is active
tags: Optional tags for filtering
Returns:
DirectiveResponse
"""
from hindsight_client_api.models import create_directive_request
request_obj = create_directive_request.CreateDirectiveRequest(
name=name,
content=content,
priority=priority,
is_active=is_active,
tags=tags,
)
return _run_async(self._directives_api.create_directive(bank_id, request_obj))
def list_directives(self, bank_id: str, tags: list[str] | None = None):
"""
List all directives in a bank.
Args:
bank_id: The memory bank ID
tags: Optional tags to filter by
Returns:
ListDirectivesResponse with items
"""
return _run_async(self._directives_api.list_directives(bank_id, tags=tags))
def get_directive(self, bank_id: str, directive_id: str):
"""
Get a specific directive.
Args:
bank_id: The memory bank ID
directive_id: The directive ID
Returns:
DirectiveResponse
"""
return _run_async(self._directives_api.get_directive(bank_id, directive_id))
def update_directive(
self,
bank_id: str,
directive_id: str,
name: str | None = None,
content: str | None = None,
priority: int | None = None,
is_active: bool | None = None,
tags: list[str] | None = None,
):
"""
Update a directive.
Args:
bank_id: The memory bank ID
directive_id: The directive ID
name: Optional new name
content: Optional new content
priority: Optional new priority
is_active: Optional new active status
tags: Optional new tags
Returns:
DirectiveResponse
"""
from hindsight_client_api.models import update_directive_request
request_obj = update_directive_request.UpdateDirectiveRequest(
name=name,
content=content,
priority=priority,
is_active=is_active,
tags=tags,
)
return _run_async(self._directives_api.update_directive(bank_id, directive_id, request_obj))
def delete_directive(self, bank_id: str, directive_id: str):
"""
Delete a directive.
Args:
bank_id: The memory bank ID
directive_id: The directive ID
"""
return _run_async(self._directives_api.delete_directive(bank_id, directive_id))
def delete_bank(self, bank_id: str):
"""
Delete a memory bank.
Args:
bank_id: The memory bank ID
"""
return _run_async(self._banks_api.delete_bank(bank_id))
@@ -22,9 +22,9 @@ from hindsight_client_api.api.directives_api import DirectivesApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
from hindsight_client_api.api.monitoring_api import MonitoringApi
from hindsight_client_api.api.operations_api import OperationsApi
from hindsight_client_api.api.reflections_api import ReflectionsApi
# import ApiClient
from hindsight_client_api.api_response import ApiResponse
@@ -39,7 +39,6 @@ from hindsight_client_api.exceptions import ApiException
# import models into sdk package
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_list_item import BankListItem
from hindsight_client_api.models.bank_list_response import BankListResponse
@@ -53,8 +52,8 @@ from hindsight_client_api.models.chunk_response import ChunkResponse
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
from hindsight_client_api.models.create_reflection_request import CreateReflectionRequest
from hindsight_client_api.models.create_reflection_response import CreateReflectionResponse
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
from hindsight_client_api.models.directive_list_response import DirectiveListResponse
@@ -76,9 +75,6 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.list_tags_response import ListTagsResponse
from hindsight_client_api.models.memory_item import MemoryItem
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.mental_model_trigger import MentalModelTrigger
from hindsight_client_api.models.operation_response import OperationResponse
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
from hindsight_client_api.models.operations_list_response import OperationsListResponse
@@ -86,7 +82,6 @@ from hindsight_client_api.models.recall_request import RecallRequest
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.reflect_based_on import ReflectBasedOn
from hindsight_client_api.models.reflect_directive import ReflectDirective
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
@@ -95,6 +90,8 @@ from hindsight_client_api.models.reflect_request import ReflectRequest
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.reflection_list_response import ReflectionListResponse
from hindsight_client_api.models.reflection_response import ReflectionResponse
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.tag_item import TagItem
@@ -102,7 +99,7 @@ from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
from hindsight_client_api.models.update_reflection_request import UpdateReflectionRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
from hindsight_client_api.models.version_response import VersionResponse
@@ -6,7 +6,7 @@ from hindsight_client_api.api.directives_api import DirectivesApi
from hindsight_client_api.api.documents_api import DocumentsApi
from hindsight_client_api.api.entities_api import EntitiesApi
from hindsight_client_api.api.memory_api import MemoryApi
from hindsight_client_api.api.mental_models_api import MentalModelsApi
from hindsight_client_api.api.monitoring_api import MonitoringApi
from hindsight_client_api.api.operations_api import OperationsApi
from hindsight_client_api.api.reflections_api import ReflectionsApi
@@ -356,7 +356,7 @@ class BanksApi:
@validate_call
async def clear_observations(
async def clear_mental_models(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
@@ -373,9 +373,9 @@ class BanksApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> DeleteResponse:
"""Clear all observations
"""Clear all mental models
Delete all observations for a memory bank. This is useful for resetting the consolidated knowledge.
Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
:param bank_id: (required)
:type bank_id: str
@@ -403,7 +403,7 @@ class BanksApi:
:return: Returns the result object.
""" # noqa: E501
_param = self._clear_observations_serialize(
_param = self._clear_mental_models_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
@@ -428,7 +428,7 @@ class BanksApi:
@validate_call
async def clear_observations_with_http_info(
async def clear_mental_models_with_http_info(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
@@ -445,9 +445,9 @@ class BanksApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[DeleteResponse]:
"""Clear all observations
"""Clear all mental models
Delete all observations for a memory bank. This is useful for resetting the consolidated knowledge.
Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
:param bank_id: (required)
:type bank_id: str
@@ -475,7 +475,7 @@ class BanksApi:
:return: Returns the result object.
""" # noqa: E501
_param = self._clear_observations_serialize(
_param = self._clear_mental_models_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
@@ -500,7 +500,7 @@ class BanksApi:
@validate_call
async def clear_observations_without_preload_content(
async def clear_mental_models_without_preload_content(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
@@ -517,9 +517,9 @@ class BanksApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Clear all observations
"""Clear all mental models
Delete all observations for a memory bank. This is useful for resetting the consolidated knowledge.
Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
:param bank_id: (required)
:type bank_id: str
@@ -547,7 +547,7 @@ class BanksApi:
:return: Returns the result object.
""" # noqa: E501
_param = self._clear_observations_serialize(
_param = self._clear_mental_models_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
@@ -567,7 +567,7 @@ class BanksApi:
return response_data.response
def _clear_observations_serialize(
def _clear_mental_models_serialize(
self,
bank_id,
authorization,
@@ -617,7 +617,7 @@ class BanksApi:
return self.api_client.param_serialize(
method='DELETE',
resource_path='/v1/default/banks/{bank_id}/observations',
resource_path='/v1/default/banks/{bank_id}/mental-models',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
@@ -2056,7 +2056,7 @@ class BanksApi:
) -> ConsolidationResponse:
"""Trigger consolidation
Run memory consolidation to create/update observations from recent memories.
Run memory consolidation to create/update mental models from recent memories.
:param bank_id: (required)
:type bank_id: str
@@ -2128,7 +2128,7 @@ class BanksApi:
) -> ApiResponse[ConsolidationResponse]:
"""Trigger consolidation
Run memory consolidation to create/update observations from recent memories.
Run memory consolidation to create/update mental models from recent memories.
:param bank_id: (required)
:type bank_id: str
@@ -2200,7 +2200,7 @@ class BanksApi:
) -> RESTResponseType:
"""Trigger consolidation
Run memory consolidation to create/update observations from recent memories.
Run memory consolidation to create/update mental models from recent memories.
:param bank_id: (required)
:type bank_id: str
@@ -15,7 +15,6 @@
# import models into model package
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_list_item import BankListItem
from hindsight_client_api.models.bank_list_response import BankListResponse
@@ -29,8 +28,8 @@ from hindsight_client_api.models.chunk_response import ChunkResponse
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.create_mental_model_response import CreateMentalModelResponse
from hindsight_client_api.models.create_reflection_request import CreateReflectionRequest
from hindsight_client_api.models.create_reflection_response import CreateReflectionResponse
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.delete_response import DeleteResponse
from hindsight_client_api.models.directive_list_response import DirectiveListResponse
@@ -52,9 +51,6 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.list_tags_response import ListTagsResponse
from hindsight_client_api.models.memory_item import MemoryItem
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.mental_model_trigger import MentalModelTrigger
from hindsight_client_api.models.operation_response import OperationResponse
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
from hindsight_client_api.models.operations_list_response import OperationsListResponse
@@ -62,7 +58,6 @@ from hindsight_client_api.models.recall_request import RecallRequest
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.reflect_based_on import ReflectBasedOn
from hindsight_client_api.models.reflect_directive import ReflectDirective
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
@@ -71,6 +66,8 @@ from hindsight_client_api.models.reflect_request import ReflectRequest
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.reflection_list_response import ReflectionListResponse
from hindsight_client_api.models.reflection_response import ReflectionResponse
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.tag_item import TagItem
@@ -78,7 +75,7 @@ from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
from hindsight_client_api.models.update_reflection_request import UpdateReflectionRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
from hindsight_client_api.models.version_response import VersionResponse
@@ -37,9 +37,9 @@ class BankStatsResponse(BaseModel):
pending_operations: StrictInt
failed_operations: StrictInt
last_consolidated_at: Optional[StrictStr] = None
pending_consolidation: Optional[StrictInt] = Field(default=0, description="Number of memories not yet processed into observations")
total_observations: Optional[StrictInt] = Field(default=0, description="Total number of observations")
__properties: ClassVar[List[str]] = ["bank_id", "total_nodes", "total_links", "total_documents", "nodes_by_fact_type", "links_by_link_type", "links_by_fact_type", "links_breakdown", "pending_operations", "failed_operations", "last_consolidated_at", "pending_consolidation", "total_observations"]
pending_consolidation: Optional[StrictInt] = Field(default=0, description="Number of memories not yet processed into mental models")
total_mental_models: Optional[StrictInt] = Field(default=0, description="Total number of mental models")
__properties: ClassVar[List[str]] = ["bank_id", "total_nodes", "total_links", "total_documents", "nodes_by_fact_type", "links_by_link_type", "links_by_fact_type", "links_breakdown", "pending_operations", "failed_operations", "last_consolidated_at", "pending_consolidation", "total_mental_models"]
model_config = ConfigDict(
populate_by_name=True,
@@ -109,7 +109,7 @@ class BankStatsResponse(BaseModel):
"failed_operations": obj.get("failed_operations"),
"last_consolidated_at": obj.get("last_consolidated_at"),
"pending_consolidation": obj.get("pending_consolidation") if obj.get("pending_consolidation") is not None else 0,
"total_observations": obj.get("total_observations") if obj.get("total_observations") is not None else 0
"total_mental_models": obj.get("total_mental_models") if obj.get("total_mental_models") is not None else 0
})
return _obj
@@ -17,8 +17,8 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
@@ -26,9 +26,12 @@ class ConsolidationResponse(BaseModel):
"""
Response model for consolidation trigger endpoint.
""" # noqa: E501
operation_id: StrictStr = Field(description="ID of the async consolidation operation")
deduplicated: Optional[StrictBool] = Field(default=False, description="True if an existing pending task was reused")
__properties: ClassVar[List[str]] = ["operation_id", "deduplicated"]
status: StrictStr = Field(description="Status of the consolidation (completed or queued)")
processed: StrictInt = Field(description="Number of memories processed")
created: StrictInt = Field(description="Number of mental models created")
updated: StrictInt = Field(description="Number of mental models updated")
message: StrictStr = Field(description="Human-readable summary")
__properties: ClassVar[List[str]] = ["status", "processed", "created", "updated", "message"]
model_config = ConfigDict(
populate_by_name=True,
@@ -81,8 +84,11 @@ class ConsolidationResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"operation_id": obj.get("operation_id"),
"deduplicated": obj.get("deduplicated") if obj.get("deduplicated") is not None else False
"status": obj.get("status"),
"processed": obj.get("processed"),
"created": obj.get("created"),
"updated": obj.get("updated"),
"message": obj.get("message")
})
return _obj
@@ -20,20 +20,18 @@ import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing_extensions import Annotated
from hindsight_client_api.models.mental_model_trigger import MentalModelTrigger
from typing import Optional, Set
from typing_extensions import Self
class CreateMentalModelRequest(BaseModel):
class CreateReflectionRequest(BaseModel):
"""
Request model for creating a mental model.
Request model for creating a reflection.
""" # noqa: E501
name: StrictStr = Field(description="Human-readable name for the mental model")
name: StrictStr = Field(description="Human-readable name for the reflection")
source_query: StrictStr = Field(description="The query to run to generate content")
tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility")
max_tokens: Optional[Annotated[int, Field(le=8192, strict=True, ge=256)]] = Field(default=2048, description="Maximum tokens for generated content")
trigger: Optional[MentalModelTrigger] = Field(default=None, description="Trigger settings")
__properties: ClassVar[List[str]] = ["name", "source_query", "tags", "max_tokens", "trigger"]
__properties: ClassVar[List[str]] = ["name", "source_query", "tags", "max_tokens"]
model_config = ConfigDict(
populate_by_name=True,
@@ -53,7 +51,7 @@ class CreateMentalModelRequest(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreateMentalModelRequest from a JSON string"""
"""Create an instance of CreateReflectionRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -74,14 +72,11 @@ class CreateMentalModelRequest(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of trigger
if self.trigger:
_dict['trigger'] = self.trigger.to_dict()
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreateMentalModelRequest from a dict"""
"""Create an instance of CreateReflectionRequest from a dict"""
if obj is None:
return None
@@ -92,8 +87,7 @@ class CreateMentalModelRequest(BaseModel):
"name": obj.get("name"),
"source_query": obj.get("source_query"),
"tags": obj.get("tags"),
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 2048,
"trigger": MentalModelTrigger.from_dict(obj["trigger"]) if obj.get("trigger") is not None else None
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 2048
})
return _obj
@@ -22,9 +22,9 @@ from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class CreateMentalModelResponse(BaseModel):
class CreateReflectionResponse(BaseModel):
"""
Response model for mental model creation.
Response model for reflection creation.
""" # noqa: E501
operation_id: StrictStr = Field(description="Operation ID to track progress")
__properties: ClassVar[List[str]] = ["operation_id"]
@@ -47,7 +47,7 @@ class CreateMentalModelResponse(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreateMentalModelResponse from a JSON string"""
"""Create an instance of CreateReflectionResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -72,7 +72,7 @@ class CreateMentalModelResponse(BaseModel):
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreateMentalModelResponse from a dict"""
"""Create an instance of CreateReflectionResponse from a dict"""
if obj is None:
return None
@@ -26,10 +26,10 @@ class FeaturesInfo(BaseModel):
"""
Feature flags indicating which capabilities are enabled.
""" # noqa: E501
observations: StrictBool = Field(description="Whether observations (auto-consolidation) are enabled")
mental_models: StrictBool = Field(description="Whether mental models (auto-consolidation) are enabled")
mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: StrictBool = Field(description="Whether the background worker is enabled")
__properties: ClassVar[List[str]] = ["observations", "mcp", "worker"]
__properties: ClassVar[List[str]] = ["mental_models", "mcp", "worker"]
model_config = ConfigDict(
populate_by_name=True,
@@ -82,7 +82,7 @@ class FeaturesInfo(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"observations": obj.get("observations"),
"mental_models": obj.get("mental_models"),
"mcp": obj.get("mcp"),
"worker": obj.get("worker")
})
@@ -1,87 +0,0 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class MentalModelTrigger(BaseModel):
"""
Trigger settings for a mental model.
""" # noqa: E501
refresh_after_consolidation: Optional[StrictBool] = Field(default=False, description="If true, refresh this mental model after observations consolidation (real-time mode)")
__properties: ClassVar[List[str]] = ["refresh_after_consolidation"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of MentalModelTrigger from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelTrigger from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"refresh_after_consolidation": obj.get("refresh_after_consolidation") if obj.get("refresh_after_consolidation") is not None else False
})
return _obj
@@ -19,20 +19,16 @@ import json
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.reflect_directive import ReflectDirective
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel
from typing import Optional, Set
from typing_extensions import Self
class ReflectBasedOn(BaseModel):
"""
Evidence the response is based on: memories, mental models, and directives.
Evidence the response is based on: memories and mental models.
""" # noqa: E501
memories: Optional[List[ReflectFact]] = Field(default=None, description="Memory facts used to generate the response")
mental_models: Optional[List[ReflectMentalModel]] = Field(default=None, description="Mental models used during reflection")
directives: Optional[List[ReflectDirective]] = Field(default=None, description="Directives applied during reflection")
__properties: ClassVar[List[str]] = ["memories", "mental_models", "directives"]
__properties: ClassVar[List[str]] = ["memories"]
model_config = ConfigDict(
populate_by_name=True,
@@ -80,20 +76,6 @@ class ReflectBasedOn(BaseModel):
if _item_memories:
_items.append(_item_memories.to_dict())
_dict['memories'] = _items
# override the default output from pydantic by calling `to_dict()` of each item in mental_models (list)
_items = []
if self.mental_models:
for _item_mental_models in self.mental_models:
if _item_mental_models:
_items.append(_item_mental_models.to_dict())
_dict['mental_models'] = _items
# override the default output from pydantic by calling `to_dict()` of each item in directives (list)
_items = []
if self.directives:
for _item_directives in self.directives:
if _item_directives:
_items.append(_item_directives.to_dict())
_dict['directives'] = _items
return _dict
@classmethod
@@ -106,9 +88,7 @@ class ReflectBasedOn(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"memories": [ReflectFact.from_dict(_item) for _item in obj["memories"]] if obj.get("memories") is not None else None,
"mental_models": [ReflectMentalModel.from_dict(_item) for _item in obj["mental_models"]] if obj.get("mental_models") is not None else None,
"directives": [ReflectDirective.from_dict(_item) for _item in obj["directives"]] if obj.get("directives") is not None else None
"memories": [ReflectFact.from_dict(_item) for _item in obj["memories"]] if obj.get("memories") is not None else None
})
return _obj
@@ -1,91 +0,0 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class ReflectDirective(BaseModel):
"""
A directive applied during reflect.
""" # noqa: E501
id: StrictStr = Field(description="Directive ID")
name: StrictStr = Field(description="Directive name")
content: StrictStr = Field(description="Directive content")
__properties: ClassVar[List[str]] = ["id", "name", "content"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ReflectDirective from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ReflectDirective from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"name": obj.get("name"),
"content": obj.get("content")
})
return _obj
@@ -24,12 +24,14 @@ from typing_extensions import Self
class ReflectMentalModel(BaseModel):
"""
A mental model used during reflect.
A mental model accessed during reflect.
""" # noqa: E501
id: StrictStr = Field(description="Mental model ID")
text: StrictStr = Field(description="Mental model content")
context: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["id", "text", "context"]
name: StrictStr = Field(description="Mental model name")
type: StrictStr = Field(description="Mental model type: entity, concept, event")
subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned, directive")
observations: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "observations"]
model_config = ConfigDict(
populate_by_name=True,
@@ -70,10 +72,10 @@ class ReflectMentalModel(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# set to None if context (nullable) is None
# set to None if observations (nullable) is None
# and model_fields_set contains the field
if self.context is None and "context" in self.model_fields_set:
_dict['context'] = None
if self.observations is None and "observations" in self.model_fields_set:
_dict['observations'] = None
return _dict
@@ -88,8 +90,10 @@ class ReflectMentalModel(BaseModel):
_obj = cls.model_validate({
"id": obj.get("id"),
"text": obj.get("text"),
"context": obj.get("context")
"name": obj.get("name"),
"type": obj.get("type"),
"subtype": obj.get("subtype"),
"observations": obj.get("observations")
})
return _obj
@@ -20,6 +20,7 @@ import json
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel
from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from typing import Optional, Set
from typing_extensions import Self
@@ -30,7 +31,8 @@ class ReflectTrace(BaseModel):
""" # noqa: E501
tool_calls: Optional[List[ReflectToolCall]] = Field(default=None, description="Tool calls made during reflection")
llm_calls: Optional[List[ReflectLLMCall]] = Field(default=None, description="LLM calls made during reflection")
__properties: ClassVar[List[str]] = ["tool_calls", "llm_calls"]
mental_models: Optional[List[ReflectMentalModel]] = Field(default=None, description="Mental models used during reflection (includes directives with subtype='directive')")
__properties: ClassVar[List[str]] = ["tool_calls", "llm_calls", "mental_models"]
model_config = ConfigDict(
populate_by_name=True,
@@ -85,6 +87,13 @@ class ReflectTrace(BaseModel):
if _item_llm_calls:
_items.append(_item_llm_calls.to_dict())
_dict['llm_calls'] = _items
# override the default output from pydantic by calling `to_dict()` of each item in mental_models (list)
_items = []
if self.mental_models:
for _item_mental_models in self.mental_models:
if _item_mental_models:
_items.append(_item_mental_models.to_dict())
_dict['mental_models'] = _items
return _dict
@classmethod
@@ -98,7 +107,8 @@ class ReflectTrace(BaseModel):
_obj = cls.model_validate({
"tool_calls": [ReflectToolCall.from_dict(_item) for _item in obj["tool_calls"]] if obj.get("tool_calls") is not None else None,
"llm_calls": [ReflectLLMCall.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") is not None else None
"llm_calls": [ReflectLLMCall.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") is not None else None,
"mental_models": [ReflectMentalModel.from_dict(_item) for _item in obj["mental_models"]] if obj.get("mental_models") is not None else None
})
return _obj
@@ -19,15 +19,15 @@ import json
from pydantic import BaseModel, ConfigDict
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.reflection_response import ReflectionResponse
from typing import Optional, Set
from typing_extensions import Self
class MentalModelListResponse(BaseModel):
class ReflectionListResponse(BaseModel):
"""
Response model for listing mental models.
Response model for listing reflections.
""" # noqa: E501
items: List[MentalModelResponse]
items: List[ReflectionResponse]
__properties: ClassVar[List[str]] = ["items"]
model_config = ConfigDict(
@@ -48,7 +48,7 @@ class MentalModelListResponse(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of MentalModelListResponse from a JSON string"""
"""Create an instance of ReflectionListResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -80,7 +80,7 @@ class MentalModelListResponse(BaseModel):
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelListResponse from a dict"""
"""Create an instance of ReflectionListResponse from a dict"""
if obj is None:
return None
@@ -88,7 +88,7 @@ class MentalModelListResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"items": [MentalModelResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None
"items": [ReflectionResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None
})
return _obj
@@ -17,15 +17,14 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.mental_model_trigger import MentalModelTrigger
from typing import Optional, Set
from typing_extensions import Self
class MentalModelResponse(BaseModel):
class ReflectionResponse(BaseModel):
"""
Response model for a mental model (stored reflect response).
Response model for a reflection.
""" # noqa: E501
id: StrictStr
bank_id: StrictStr
@@ -33,12 +32,10 @@ class MentalModelResponse(BaseModel):
source_query: StrictStr
content: StrictStr
tags: Optional[List[StrictStr]] = None
max_tokens: Optional[StrictInt] = 2048
trigger: Optional[MentalModelTrigger] = None
last_refreshed_at: Optional[StrictStr] = None
created_at: Optional[StrictStr] = None
reflect_response: Optional[Dict[str, Any]] = None
__properties: ClassVar[List[str]] = ["id", "bank_id", "name", "source_query", "content", "tags", "max_tokens", "trigger", "last_refreshed_at", "created_at", "reflect_response"]
__properties: ClassVar[List[str]] = ["id", "bank_id", "name", "source_query", "content", "tags", "last_refreshed_at", "created_at", "reflect_response"]
model_config = ConfigDict(
populate_by_name=True,
@@ -58,7 +55,7 @@ class MentalModelResponse(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of MentalModelResponse from a JSON string"""
"""Create an instance of ReflectionResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -79,9 +76,6 @@ class MentalModelResponse(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of trigger
if self.trigger:
_dict['trigger'] = self.trigger.to_dict()
# set to None if last_refreshed_at (nullable) is None
# and model_fields_set contains the field
if self.last_refreshed_at is None and "last_refreshed_at" in self.model_fields_set:
@@ -101,7 +95,7 @@ class MentalModelResponse(BaseModel):
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelResponse from a dict"""
"""Create an instance of ReflectionResponse from a dict"""
if obj is None:
return None
@@ -115,8 +109,6 @@ class MentalModelResponse(BaseModel):
"source_query": obj.get("source_query"),
"content": obj.get("content"),
"tags": obj.get("tags"),
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 2048,
"trigger": MentalModelTrigger.from_dict(obj["trigger"]) if obj.get("trigger") is not None else None,
"last_refreshed_at": obj.get("last_refreshed_at"),
"created_at": obj.get("created_at"),
"reflect_response": obj.get("reflect_response")
@@ -1,125 +0,0 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.1.0
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing_extensions import Annotated
from hindsight_client_api.models.mental_model_trigger import MentalModelTrigger
from typing import Optional, Set
from typing_extensions import Self
class UpdateMentalModelRequest(BaseModel):
"""
Request model for updating a mental model.
""" # noqa: E501
name: Optional[StrictStr] = None
source_query: Optional[StrictStr] = None
max_tokens: Optional[Annotated[int, Field(le=8192, strict=True, ge=256)]] = None
tags: Optional[List[StrictStr]] = None
trigger: Optional[MentalModelTrigger] = None
__properties: ClassVar[List[str]] = ["name", "source_query", "max_tokens", "tags", "trigger"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of UpdateMentalModelRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of trigger
if self.trigger:
_dict['trigger'] = self.trigger.to_dict()
# set to None if name (nullable) is None
# and model_fields_set contains the field
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
# set to None if source_query (nullable) is None
# and model_fields_set contains the field
if self.source_query is None and "source_query" in self.model_fields_set:
_dict['source_query'] = None
# set to None if max_tokens (nullable) is None
# and model_fields_set contains the field
if self.max_tokens is None and "max_tokens" in self.model_fields_set:
_dict['max_tokens'] = None
# set to None if tags (nullable) is None
# and model_fields_set contains the field
if self.tags is None and "tags" in self.model_fields_set:
_dict['tags'] = None
# set to None if trigger (nullable) is None
# and model_fields_set contains the field
if self.trigger is None and "trigger" in self.model_fields_set:
_dict['trigger'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of UpdateMentalModelRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"source_query": obj.get("source_query"),
"max_tokens": obj.get("max_tokens"),
"tags": obj.get("tags"),
"trigger": MentalModelTrigger.from_dict(obj["trigger"]) if obj.get("trigger") is not None else None
})
return _obj
@@ -18,17 +18,16 @@ import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class AsyncOperationSubmitResponse(BaseModel):
class UpdateReflectionRequest(BaseModel):
"""
Response model for submitting an async operation.
Request model for updating a reflection.
""" # noqa: E501
operation_id: StrictStr
status: StrictStr
__properties: ClassVar[List[str]] = ["operation_id", "status"]
name: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name"]
model_config = ConfigDict(
populate_by_name=True,
@@ -48,7 +47,7 @@ class AsyncOperationSubmitResponse(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of AsyncOperationSubmitResponse from a JSON string"""
"""Create an instance of UpdateReflectionRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -69,11 +68,16 @@ class AsyncOperationSubmitResponse(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# set to None if name (nullable) is None
# and model_fields_set contains the field
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of AsyncOperationSubmitResponse from a dict"""
"""Create an instance of UpdateReflectionRequest from a dict"""
if obj is None:
return None
@@ -81,8 +85,7 @@ class AsyncOperationSubmitResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"operation_id": obj.get("operation_id"),
"status": obj.get("status")
"name": obj.get("name")
})
return _obj
@@ -6,12 +6,11 @@ These tests require a running Hindsight API server.
import os
import uuid
from datetime import datetime
import pytest
from datetime import datetime
from hindsight_client import Hindsight
# Test configuration
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
@@ -139,7 +138,7 @@ class TestReflect:
"""Setup: Store some test memories and bank background."""
client.create_bank(
bank_id=bank_id,
mission="I am a helpful AI assistant interested in technology and science.",
background="I am a helpful AI assistant interested in technology and science.",
)
client.retain_batch(
@@ -192,14 +191,14 @@ class TestReflect:
When response_schema is provided, the response returns structured_output
field parsed according to the provided JSON schema.
"""
from typing import Optional
from pydantic import BaseModel
# Define schema using Pydantic model
class RecommendationResponse(BaseModel):
recommendation: str
reasons: list[str]
confidence: str | None = None # Optional for LLM flexibility
confidence: Optional[str] = None # Optional for LLM flexibility
response = client.reflect(
bank_id=bank_id,
@@ -225,7 +224,9 @@ class TestListMemories:
"""Setup: Store some test memories synchronously."""
client.retain_batch(
bank_id=bank_id,
items=[{"content": f"Alice likes topic number {i}"} for i in range(5)],
items=[
{"content": f"Alice likes topic number {i}"} for i in range(5)
],
retain_async=False, # Wait for fact extraction to complete
)
@@ -261,7 +262,7 @@ class TestEndToEndWorkflow:
# 1. Create bank
client.create_bank(
bank_id=workflow_bank_id,
mission="I am a software engineer who loves Python programming.",
background="I am a software engineer who loves Python programming.",
)
# 2. Store memories
@@ -358,7 +359,6 @@ class TestDocuments:
def test_delete_document(self, client, bank_id):
"""Test deleting a document."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
@@ -390,7 +390,6 @@ class TestDocuments:
def test_get_document(self, client, bank_id):
"""Test getting a document."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
@@ -433,7 +432,6 @@ class TestEntities:
def test_list_entities(self, client, bank_id):
"""Test listing entities."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import EntitiesApi
@@ -458,7 +456,6 @@ class TestEntities:
def test_list_entities_with_pagination(self, client, bank_id):
"""Test listing entities with pagination parameters."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import EntitiesApi
@@ -485,7 +482,6 @@ class TestEntities:
def test_get_entity(self, client, bank_id):
"""Test getting a specific entity."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import EntitiesApi
@@ -512,151 +508,12 @@ class TestEntities:
assert entity.id == entity_id
class TestTags:
"""Tests for tags filtering functionality."""
@pytest.fixture(autouse=True)
def setup_memories(self, client, bank_id):
"""Setup: Store memories with different tags."""
client.retain_batch(
bank_id=bank_id,
items=[
{"content": "Project X meeting notes from Monday", "tags": ["project_x", "meetings"]},
{"content": "Project X design document", "tags": ["project_x", "docs"]},
{"content": "Project Y sprint planning", "tags": ["project_y", "meetings"]},
{"content": "General company announcement", "tags": ["company"]},
{"content": "Untagged memory about random things"}, # no tags
],
retain_async=False,
)
def test_recall_with_tags_any(self, client, bank_id):
"""Test recall with tags using 'any' match (includes untagged)."""
response = client.recall(
bank_id=bank_id,
query="What are the documents?",
tags=["project_x"],
tags_match="any",
)
assert response is not None
assert response.results is not None
# Should include project_x tagged items and potentially untagged items
result_texts = [r.text.lower() for r in response.results]
assert any("project x" in text for text in result_texts)
def test_recall_with_tags_any_strict(self, client, bank_id):
"""Test recall with tags using 'any_strict' match (excludes untagged)."""
response = client.recall(
bank_id=bank_id,
query="meetings",
tags=["project_x"],
tags_match="any_strict",
)
assert response is not None
assert response.results is not None
# All results should have project_x tag - no untagged items
result_texts = [r.text.lower() for r in response.results]
# Should find project_x items only
for text in result_texts:
assert "project x" in text or "untagged" not in text
def test_recall_with_tags_all_strict(self, client, bank_id):
"""Test recall with tags using 'all_strict' match (AND matching)."""
response = client.recall(
bank_id=bank_id,
query="meeting notes",
tags=["project_x", "meetings"],
tags_match="all_strict",
)
assert response is not None
assert response.results is not None
# Should only return items tagged with BOTH project_x AND meetings
if len(response.results) > 0:
result_texts = [r.text.lower() for r in response.results]
# The "Project X meeting notes" should be found
assert any("project x" in text and "meeting" in text for text in result_texts)
def test_recall_with_multiple_tags_any(self, client, bank_id):
"""Test recall with multiple tags using 'any' match (OR)."""
response = client.recall(
bank_id=bank_id,
query="What's happening?",
tags=["project_x", "project_y"],
tags_match="any_strict",
)
assert response is not None
assert response.results is not None
# Should include items from both project_x and project_y
result_texts = [r.text.lower() for r in response.results]
has_project_x = any("project x" in text for text in result_texts)
has_project_y = any("project y" in text for text in result_texts)
# At least one of them should be present
assert has_project_x or has_project_y
def test_reflect_with_tags(self, client, bank_id):
"""Test reflect with tags filtering."""
response = client.reflect(
bank_id=bank_id,
query="Summarize project X activities",
tags=["project_x"],
tags_match="any_strict",
)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
def test_retain_with_tags(self, client, bank_id):
"""Test storing a memory with tags."""
response = client.retain(
bank_id=bank_id,
content="New feature implementation for project Z",
tags=["project_z", "features"],
)
assert response is not None
assert response.success is True
# Verify we can recall it with the tag
recall_response = client.recall(
bank_id=bank_id,
query="project Z features",
tags=["project_z"],
tags_match="any_strict",
)
assert recall_response is not None
result_texts = [r.text.lower() for r in recall_response.results]
assert any("project z" in text for text in result_texts)
def test_retain_batch_with_document_tags(self, client, bank_id):
"""Test batch retain with document-level tags."""
response = client.retain_batch(
bank_id=bank_id,
items=[
{"content": "First item in batch"},
{"content": "Second item in batch"},
],
document_tags=["batch_import", "test_data"],
retain_async=False,
)
assert response is not None
assert response.success is True
assert response.items_count == 2
class TestDeleteBank:
"""Tests for bank deletion."""
def test_delete_bank(self, client):
"""Test deleting a bank."""
import asyncio
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import BanksApi
@@ -666,7 +523,7 @@ class TestDeleteBank:
# Create bank with some data
client.create_bank(
bank_id=bank_id,
mission="This bank will be deleted",
background="This bank will be deleted",
)
client.retain(
bank_id=bank_id,
+19 -7
View File
@@ -37,13 +37,7 @@ mod tests {
async fn test_memory_lifecycle() {
let api_url = std::env::var("HINDSIGHT_API_URL")
.unwrap_or_else(|_| "http://localhost:8888".to_string());
// Use a custom reqwest client with longer timeout for LLM operations
let http_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.expect("Failed to build HTTP client");
let client = Client::new_with_client(&api_url, http_client);
let client = Client::new(&api_url);
// Generate unique bank ID for this test
let bank_id = format!("rust-test-{}", uuid::Uuid::new_v4());
@@ -109,6 +103,24 @@ mod tests {
let recall_result = recall_response.into_inner();
assert!(!recall_result.results.is_empty(), "Should recall at least one memory");
// 4. Reflect on a question
let reflect_request = types::ReflectRequest {
query: "What do you know about Alice?".to_string(),
budget: None,
context: None,
max_tokens: 4096,
include: None,
response_schema: None,
tags: None,
tags_match: types::TagsMatch::Any,
};
let reflect_response = client
.reflect(&bank_id, None, &reflect_request)
.await
.expect("Failed to reflect");
let reflect_result = reflect_response.into_inner();
assert!(!reflect_result.text.is_empty(), "Reflect should return some text");
// Cleanup: delete the test bank's memories
let _ = client.clear_bank_memories(&bank_id, None, None).await;
}
@@ -12,18 +12,18 @@ import type {
ClearBankMemoriesData,
ClearBankMemoriesErrors,
ClearBankMemoriesResponses,
ClearObservationsData,
ClearObservationsErrors,
ClearObservationsResponses,
ClearMentalModelsData,
ClearMentalModelsErrors,
ClearMentalModelsResponses,
CreateDirectiveData,
CreateDirectiveErrors,
CreateDirectiveResponses,
CreateMentalModelData,
CreateMentalModelErrors,
CreateMentalModelResponses,
CreateOrUpdateBankData,
CreateOrUpdateBankErrors,
CreateOrUpdateBankResponses,
CreateReflectionData,
CreateReflectionErrors,
CreateReflectionResponses,
DeleteBankData,
DeleteBankErrors,
DeleteBankResponses,
@@ -33,9 +33,9 @@ import type {
DeleteDocumentData,
DeleteDocumentErrors,
DeleteDocumentResponses,
DeleteMentalModelData,
DeleteMentalModelErrors,
DeleteMentalModelResponses,
DeleteReflectionData,
DeleteReflectionErrors,
DeleteReflectionResponses,
GetAgentStatsData,
GetAgentStatsErrors,
GetAgentStatsResponses,
@@ -60,12 +60,12 @@ import type {
GetMemoryData,
GetMemoryErrors,
GetMemoryResponses,
GetMentalModelData,
GetMentalModelErrors,
GetMentalModelResponses,
GetOperationStatusData,
GetOperationStatusErrors,
GetOperationStatusResponses,
GetReflectionData,
GetReflectionErrors,
GetReflectionResponses,
GetVersionData,
GetVersionResponses,
HealthEndpointHealthGetData,
@@ -85,12 +85,12 @@ import type {
ListMemoriesData,
ListMemoriesErrors,
ListMemoriesResponses,
ListMentalModelsData,
ListMentalModelsErrors,
ListMentalModelsResponses,
ListOperationsData,
ListOperationsErrors,
ListOperationsResponses,
ListReflectionsData,
ListReflectionsErrors,
ListReflectionsResponses,
ListTagsData,
ListTagsErrors,
ListTagsResponses,
@@ -102,9 +102,9 @@ import type {
ReflectData,
ReflectErrors,
ReflectResponses,
RefreshMentalModelData,
RefreshMentalModelErrors,
RefreshMentalModelResponses,
RefreshReflectionData,
RefreshReflectionErrors,
RefreshReflectionResponses,
RegenerateEntityObservationsData,
RegenerateEntityObservationsErrors,
RegenerateEntityObservationsResponses,
@@ -123,9 +123,9 @@ import type {
UpdateDirectiveData,
UpdateDirectiveErrors,
UpdateDirectiveResponses,
UpdateMentalModelData,
UpdateMentalModelErrors,
UpdateMentalModelResponses,
UpdateReflectionData,
UpdateReflectionErrors,
UpdateReflectionResponses,
} from "./types.gen";
export type Options<
@@ -363,33 +363,33 @@ export const regenerateEntityObservations = <
});
/**
* List mental models
* List reflections
*
* List user-curated living documents that stay current.
*/
export const listMentalModels = <ThrowOnError extends boolean = false>(
options: Options<ListMentalModelsData, ThrowOnError>,
export const listReflections = <ThrowOnError extends boolean = false>(
options: Options<ListReflectionsData, ThrowOnError>,
) =>
(options.client ?? client).get<
ListMentalModelsResponses,
ListMentalModelsErrors,
ListReflectionsResponses,
ListReflectionsErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/mental-models", ...options });
>({ url: "/v1/default/banks/{bank_id}/reflections", ...options });
/**
* Create mental model
* Create reflection
*
* Create a mental model by running reflect with the source query in the background. Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. Use the operations endpoint to check completion status.
* Create a reflection by running reflect with the source query in the background. Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. Use the operations endpoint to check completion status.
*/
export const createMentalModel = <ThrowOnError extends boolean = false>(
options: Options<CreateMentalModelData, ThrowOnError>,
export const createReflection = <ThrowOnError extends boolean = false>(
options: Options<CreateReflectionData, ThrowOnError>,
) =>
(options.client ?? client).post<
CreateMentalModelResponses,
CreateMentalModelErrors,
CreateReflectionResponses,
CreateReflectionErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models",
url: "/v1/default/banks/{bank_id}/reflections",
...options,
headers: {
"Content-Type": "application/json",
@@ -398,53 +398,53 @@ export const createMentalModel = <ThrowOnError extends boolean = false>(
});
/**
* Delete mental model
* Delete reflection
*
* Delete a mental model.
* Delete a reflection.
*/
export const deleteMentalModel = <ThrowOnError extends boolean = false>(
options: Options<DeleteMentalModelData, ThrowOnError>,
export const deleteReflection = <ThrowOnError extends boolean = false>(
options: Options<DeleteReflectionData, ThrowOnError>,
) =>
(options.client ?? client).delete<
DeleteMentalModelResponses,
DeleteMentalModelErrors,
DeleteReflectionResponses,
DeleteReflectionErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
...options,
});
/**
* Get mental model
* Get reflection
*
* Get a specific mental model by ID.
* Get a specific reflection by ID.
*/
export const getMentalModel = <ThrowOnError extends boolean = false>(
options: Options<GetMentalModelData, ThrowOnError>,
export const getReflection = <ThrowOnError extends boolean = false>(
options: Options<GetReflectionData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetMentalModelResponses,
GetMentalModelErrors,
GetReflectionResponses,
GetReflectionErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
...options,
});
/**
* Update mental model
* Update reflection
*
* Update a mental model's name and/or source query.
* Update a reflection's name.
*/
export const updateMentalModel = <ThrowOnError extends boolean = false>(
options: Options<UpdateMentalModelData, ThrowOnError>,
export const updateReflection = <ThrowOnError extends boolean = false>(
options: Options<UpdateReflectionData, ThrowOnError>,
) =>
(options.client ?? client).patch<
UpdateMentalModelResponses,
UpdateMentalModelErrors,
UpdateReflectionResponses,
UpdateReflectionErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}",
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
...options,
headers: {
"Content-Type": "application/json",
@@ -453,19 +453,19 @@ export const updateMentalModel = <ThrowOnError extends boolean = false>(
});
/**
* Refresh mental model
* Refresh reflection
*
* Submit an async task to re-run the source query through reflect and update the content.
* Re-run the source query through reflect and update the content.
*/
export const refreshMentalModel = <ThrowOnError extends boolean = false>(
options: Options<RefreshMentalModelData, ThrowOnError>,
export const refreshReflection = <ThrowOnError extends boolean = false>(
options: Options<RefreshReflectionData, ThrowOnError>,
) =>
(options.client ?? client).post<
RefreshMentalModelResponses,
RefreshMentalModelErrors,
RefreshReflectionResponses,
RefreshReflectionErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh",
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh",
...options,
});
@@ -799,23 +799,23 @@ export const createOrUpdateBank = <ThrowOnError extends boolean = false>(
});
/**
* Clear all observations
* Clear all mental models
*
* Delete all observations for a memory bank. This is useful for resetting the consolidated knowledge.
* Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
*/
export const clearObservations = <ThrowOnError extends boolean = false>(
options: Options<ClearObservationsData, ThrowOnError>,
export const clearMentalModels = <ThrowOnError extends boolean = false>(
options: Options<ClearMentalModelsData, ThrowOnError>,
) =>
(options.client ?? client).delete<
ClearObservationsResponses,
ClearObservationsErrors,
ClearMentalModelsResponses,
ClearMentalModelsErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/observations", ...options });
>({ url: "/v1/default/banks/{bank_id}/mental-models", ...options });
/**
* Trigger consolidation
*
* Run memory consolidation to create/update observations from recent memories.
* Run memory consolidation to create/update mental models from recent memories.
*/
export const triggerConsolidation = <ThrowOnError extends boolean = false>(
options: Options<TriggerConsolidationData, ThrowOnError>,
@@ -24,22 +24,6 @@ export type AddBackgroundRequest = {
update_disposition?: boolean;
};
/**
* AsyncOperationSubmitResponse
*
* Response model for submitting an async operation.
*/
export type AsyncOperationSubmitResponse = {
/**
* Operation Id
*/
operation_id: string;
/**
* Status
*/
status: string;
};
/**
* BackgroundResponse
*
@@ -194,15 +178,15 @@ export type BankStatsResponse = {
/**
* Pending Consolidation
*
* Number of memories not yet processed into observations
* Number of memories not yet processed into mental models
*/
pending_consolidation?: number;
/**
* Total Observations
* Total Mental Models
*
* Total number of observations
* Total number of mental models
*/
total_observations?: number;
total_mental_models?: number;
};
/**
@@ -311,17 +295,35 @@ export type ChunkResponse = {
*/
export type ConsolidationResponse = {
/**
* Operation Id
* Status
*
* ID of the async consolidation operation
* Status of the consolidation (completed or queued)
*/
operation_id: string;
status: string;
/**
* Deduplicated
* Processed
*
* True if an existing pending task was reused
* Number of memories processed
*/
deduplicated?: boolean;
processed: number;
/**
* Created
*
* Number of mental models created
*/
created: number;
/**
* Updated
*
* Number of mental models updated
*/
updated: number;
/**
* Message
*
* Human-readable summary
*/
message: string;
};
/**
@@ -388,15 +390,15 @@ export type CreateDirectiveRequest = {
};
/**
* CreateMentalModelRequest
* CreateReflectionRequest
*
* Request model for creating a mental model.
* Request model for creating a reflection.
*/
export type CreateMentalModelRequest = {
export type CreateReflectionRequest = {
/**
* Name
*
* Human-readable name for the mental model
* Human-readable name for the reflection
*/
name: string;
/**
@@ -417,18 +419,14 @@ export type CreateMentalModelRequest = {
* Maximum tokens for generated content
*/
max_tokens?: number;
/**
* Trigger settings
*/
trigger?: MentalModelTrigger;
};
/**
* CreateMentalModelResponse
* CreateReflectionResponse
*
* Response model for mental model creation.
* Response model for reflection creation.
*/
export type CreateMentalModelResponse = {
export type CreateReflectionResponse = {
/**
* Operation Id
*
@@ -787,11 +785,11 @@ export type FactsIncludeOptions = {
*/
export type FeaturesInfo = {
/**
* Observations
* Mental Models
*
* Whether observations (auto-consolidation) are enabled
* Whether mental models (auto-consolidation) are enabled
*/
observations: boolean;
mental_models: boolean;
/**
* Mcp
*
@@ -986,85 +984,6 @@ export type MemoryItem = {
tags?: Array<string> | null;
};
/**
* MentalModelListResponse
*
* Response model for listing mental models.
*/
export type MentalModelListResponse = {
/**
* Items
*/
items: Array<MentalModelResponse>;
};
/**
* MentalModelResponse
*
* Response model for a mental model (stored reflect response).
*/
export type MentalModelResponse = {
/**
* Id
*/
id: string;
/**
* Bank Id
*/
bank_id: string;
/**
* Name
*/
name: string;
/**
* Source Query
*/
source_query: string;
/**
* Content
*/
content: string;
/**
* Tags
*/
tags?: Array<string>;
/**
* Max Tokens
*/
max_tokens?: number;
trigger?: MentalModelTrigger;
/**
* Last Refreshed At
*/
last_refreshed_at?: string | null;
/**
* Created At
*/
created_at?: string | null;
/**
* Reflect Response
*
* Full reflect API response payload including based_on facts and observations
*/
reflect_response?: {
[key: string]: unknown;
} | null;
};
/**
* MentalModelTrigger
*
* Trigger settings for a mental model.
*/
export type MentalModelTrigger = {
/**
* Refresh After Consolidation
*
* If true, refresh this mental model after observations consolidation (real-time mode)
*/
refresh_after_consolidation?: boolean;
};
/**
* OperationResponse
*
@@ -1178,7 +1097,7 @@ export type RecallRequest = {
/**
* Types
*
* List of fact types to recall: 'world', 'experience', 'observation'. Defaults to world and experience if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall).
* List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall).
*/
types?: Array<string> | null;
budget?: Budget;
@@ -1309,7 +1228,7 @@ export type RecallResult = {
/**
* ReflectBasedOn
*
* Evidence the response is based on: memories, mental models, and directives.
* Evidence the response is based on: memories and mental models.
*/
export type ReflectBasedOn = {
/**
@@ -1318,44 +1237,6 @@ export type ReflectBasedOn = {
* Memory facts used to generate the response
*/
memories?: Array<ReflectFact>;
/**
* Mental Models
*
* Mental models used during reflection
*/
mental_models?: Array<ReflectMentalModel>;
/**
* Directives
*
* Directives applied during reflection
*/
directives?: Array<ReflectDirective>;
};
/**
* ReflectDirective
*
* A directive applied during reflect.
*/
export type ReflectDirective = {
/**
* Id
*
* Directive ID
*/
id: string;
/**
* Name
*
* Directive name
*/
name: string;
/**
* Content
*
* Directive content
*/
content: string;
};
/**
@@ -1429,7 +1310,7 @@ export type ReflectLlmCall = {
/**
* ReflectMentalModel
*
* A mental model used during reflect.
* A mental model accessed during reflect.
*/
export type ReflectMentalModel = {
/**
@@ -1439,17 +1320,29 @@ export type ReflectMentalModel = {
*/
id: string;
/**
* Text
* Name
*
* Mental model content
* Mental model name
*/
text: string;
name: string;
/**
* Context
* Type
*
* Additional context
* Mental model type: entity, concept, event
*/
context?: string | null;
type: string;
/**
* Subtype
*
* Mental model subtype: structural, emergent, learned, directive
*/
subtype: string;
/**
* Observations
*
* Observations for directive mental models (subtype='directive')
*/
observations?: Array<string> | null;
};
/**
@@ -1595,6 +1488,72 @@ export type ReflectTrace = {
* LLM calls made during reflection
*/
llm_calls?: Array<ReflectLlmCall>;
/**
* Mental Models
*
* Mental models used during reflection (includes directives with subtype='directive')
*/
mental_models?: Array<ReflectMentalModel>;
};
/**
* ReflectionListResponse
*
* Response model for listing reflections.
*/
export type ReflectionListResponse = {
/**
* Items
*/
items: Array<ReflectionResponse>;
};
/**
* ReflectionResponse
*
* Response model for a reflection.
*/
export type ReflectionResponse = {
/**
* Id
*/
id: string;
/**
* Bank Id
*/
bank_id: string;
/**
* Name
*/
name: string;
/**
* Source Query
*/
source_query: string;
/**
* Content
*/
content: string;
/**
* Tags
*/
tags?: Array<string>;
/**
* Last Refreshed At
*/
last_refreshed_at?: string | null;
/**
* Created At
*/
created_at?: string | null;
/**
* Reflect Response
*
* Full reflect API response payload including based_on facts and mental_models
*/
reflect_response?: {
[key: string]: unknown;
} | null;
};
/**
@@ -1768,39 +1727,17 @@ export type UpdateDispositionRequest = {
};
/**
* UpdateMentalModelRequest
* UpdateReflectionRequest
*
* Request model for updating a mental model.
* Request model for updating a reflection.
*/
export type UpdateMentalModelRequest = {
export type UpdateReflectionRequest = {
/**
* Name
*
* New name for the mental model
* New name for the reflection
*/
name?: string | null;
/**
* Source Query
*
* New source query for the mental model
*/
source_query?: string | null;
/**
* Max Tokens
*
* Maximum tokens for generated content
*/
max_tokens?: number | null;
/**
* Tags
*
* Tags for scoped visibility
*/
tags?: Array<string> | null;
/**
* Trigger settings
*/
trigger?: MentalModelTrigger | null;
};
/**
@@ -2294,7 +2231,7 @@ export type RegenerateEntityObservationsResponses = {
export type RegenerateEntityObservationsResponse =
RegenerateEntityObservationsResponses[keyof RegenerateEntityObservationsResponses];
export type ListMentalModelsData = {
export type ListReflectionsData = {
body?: never;
headers?: {
/**
@@ -2330,31 +2267,31 @@ export type ListMentalModelsData = {
*/
offset?: number;
};
url: "/v1/default/banks/{bank_id}/mental-models";
url: "/v1/default/banks/{bank_id}/reflections";
};
export type ListMentalModelsErrors = {
export type ListReflectionsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListMentalModelsError =
ListMentalModelsErrors[keyof ListMentalModelsErrors];
export type ListReflectionsError =
ListReflectionsErrors[keyof ListReflectionsErrors];
export type ListMentalModelsResponses = {
export type ListReflectionsResponses = {
/**
* Successful Response
*/
200: MentalModelListResponse;
200: ReflectionListResponse;
};
export type ListMentalModelsResponse =
ListMentalModelsResponses[keyof ListMentalModelsResponses];
export type ListReflectionsResponse =
ListReflectionsResponses[keyof ListReflectionsResponses];
export type CreateMentalModelData = {
body: CreateMentalModelRequest;
export type CreateReflectionData = {
body: CreateReflectionRequest;
headers?: {
/**
* Authorization
@@ -2368,30 +2305,30 @@ export type CreateMentalModelData = {
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models";
url: "/v1/default/banks/{bank_id}/reflections";
};
export type CreateMentalModelErrors = {
export type CreateReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateMentalModelError =
CreateMentalModelErrors[keyof CreateMentalModelErrors];
export type CreateReflectionError =
CreateReflectionErrors[keyof CreateReflectionErrors];
export type CreateMentalModelResponses = {
export type CreateReflectionResponses = {
/**
* Successful Response
*/
200: CreateMentalModelResponse;
200: CreateReflectionResponse;
};
export type CreateMentalModelResponse2 =
CreateMentalModelResponses[keyof CreateMentalModelResponses];
export type CreateReflectionResponse2 =
CreateReflectionResponses[keyof CreateReflectionResponses];
export type DeleteMentalModelData = {
export type DeleteReflectionData = {
body?: never;
headers?: {
/**
@@ -2405,32 +2342,32 @@ export type DeleteMentalModelData = {
*/
bank_id: string;
/**
* Mental Model Id
* Reflection Id
*/
mental_model_id: string;
reflection_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}";
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}";
};
export type DeleteMentalModelErrors = {
export type DeleteReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteMentalModelError =
DeleteMentalModelErrors[keyof DeleteMentalModelErrors];
export type DeleteReflectionError =
DeleteReflectionErrors[keyof DeleteReflectionErrors];
export type DeleteMentalModelResponses = {
export type DeleteReflectionResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetMentalModelData = {
export type GetReflectionData = {
body?: never;
headers?: {
/**
@@ -2444,36 +2381,35 @@ export type GetMentalModelData = {
*/
bank_id: string;
/**
* Mental Model Id
* Reflection Id
*/
mental_model_id: string;
reflection_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}";
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}";
};
export type GetMentalModelErrors = {
export type GetReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetMentalModelError =
GetMentalModelErrors[keyof GetMentalModelErrors];
export type GetReflectionError = GetReflectionErrors[keyof GetReflectionErrors];
export type GetMentalModelResponses = {
export type GetReflectionResponses = {
/**
* Successful Response
*/
200: MentalModelResponse;
200: ReflectionResponse;
};
export type GetMentalModelResponse =
GetMentalModelResponses[keyof GetMentalModelResponses];
export type GetReflectionResponse =
GetReflectionResponses[keyof GetReflectionResponses];
export type UpdateMentalModelData = {
body: UpdateMentalModelRequest;
export type UpdateReflectionData = {
body: UpdateReflectionRequest;
headers?: {
/**
* Authorization
@@ -2486,35 +2422,35 @@ export type UpdateMentalModelData = {
*/
bank_id: string;
/**
* Mental Model Id
* Reflection Id
*/
mental_model_id: string;
reflection_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}";
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}";
};
export type UpdateMentalModelErrors = {
export type UpdateReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateMentalModelError =
UpdateMentalModelErrors[keyof UpdateMentalModelErrors];
export type UpdateReflectionError =
UpdateReflectionErrors[keyof UpdateReflectionErrors];
export type UpdateMentalModelResponses = {
export type UpdateReflectionResponses = {
/**
* Successful Response
*/
200: MentalModelResponse;
200: ReflectionResponse;
};
export type UpdateMentalModelResponse =
UpdateMentalModelResponses[keyof UpdateMentalModelResponses];
export type UpdateReflectionResponse =
UpdateReflectionResponses[keyof UpdateReflectionResponses];
export type RefreshMentalModelData = {
export type RefreshReflectionData = {
body?: never;
headers?: {
/**
@@ -2528,33 +2464,33 @@ export type RefreshMentalModelData = {
*/
bank_id: string;
/**
* Mental Model Id
* Reflection Id
*/
mental_model_id: string;
reflection_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh";
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh";
};
export type RefreshMentalModelErrors = {
export type RefreshReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RefreshMentalModelError =
RefreshMentalModelErrors[keyof RefreshMentalModelErrors];
export type RefreshReflectionError =
RefreshReflectionErrors[keyof RefreshReflectionErrors];
export type RefreshMentalModelResponses = {
export type RefreshReflectionResponses = {
/**
* Successful Response
*/
200: AsyncOperationSubmitResponse;
200: ReflectionResponse;
};
export type RefreshMentalModelResponse =
RefreshMentalModelResponses[keyof RefreshMentalModelResponses];
export type RefreshReflectionResponse =
RefreshReflectionResponses[keyof RefreshReflectionResponses];
export type ListDirectivesData = {
body?: never;
@@ -3370,7 +3306,7 @@ export type CreateOrUpdateBankResponses = {
export type CreateOrUpdateBankResponse =
CreateOrUpdateBankResponses[keyof CreateOrUpdateBankResponses];
export type ClearObservationsData = {
export type ClearMentalModelsData = {
body?: never;
headers?: {
/**
@@ -3385,28 +3321,28 @@ export type ClearObservationsData = {
bank_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/observations";
url: "/v1/default/banks/{bank_id}/mental-models";
};
export type ClearObservationsErrors = {
export type ClearMentalModelsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ClearObservationsError =
ClearObservationsErrors[keyof ClearObservationsErrors];
export type ClearMentalModelsError =
ClearMentalModelsErrors[keyof ClearMentalModelsErrors];
export type ClearObservationsResponses = {
export type ClearMentalModelsResponses = {
/**
* Successful Response
*/
200: DeleteResponse;
};
export type ClearObservationsResponse =
ClearObservationsResponses[keyof ClearObservationsResponses];
export type ClearMentalModelsResponse =
ClearMentalModelsResponses[keyof ClearMentalModelsResponses];
export type TriggerConsolidationData = {
body?: never;
-219
View File
@@ -321,225 +321,6 @@ export class HindsightClient {
return this.validateResponse(response, 'setMission');
}
/**
* Delete a bank.
*/
async deleteBank(bankId: string): Promise<void> {
const response = await sdk.deleteBank({
client: this.client,
path: { bank_id: bankId },
});
if (response.error) {
throw new Error(`deleteBank failed: ${JSON.stringify(response.error)}`);
}
}
// Directive methods
/**
* Create a directive (hard rule for reflect).
*/
async createDirective(
bankId: string,
name: string,
content: string,
options?: {
priority?: number;
isActive?: boolean;
tags?: string[];
}
): Promise<any> {
const response = await sdk.createDirective({
client: this.client,
path: { bank_id: bankId },
body: {
name,
content,
priority: options?.priority ?? 0,
is_active: options?.isActive ?? true,
tags: options?.tags,
},
});
return this.validateResponse(response, 'createDirective');
}
/**
* List all directives in a bank.
*/
async listDirectives(bankId: string, options?: { tags?: string[] }): Promise<any> {
const response = await sdk.listDirectives({
client: this.client,
path: { bank_id: bankId },
query: { tags: options?.tags },
});
return this.validateResponse(response, 'listDirectives');
}
/**
* Get a specific directive.
*/
async getDirective(bankId: string, directiveId: string): Promise<any> {
const response = await sdk.getDirective({
client: this.client,
path: { bank_id: bankId, directive_id: directiveId },
});
return this.validateResponse(response, 'getDirective');
}
/**
* Update a directive.
*/
async updateDirective(
bankId: string,
directiveId: string,
options: {
name?: string;
content?: string;
priority?: number;
isActive?: boolean;
tags?: string[];
}
): Promise<any> {
const response = await sdk.updateDirective({
client: this.client,
path: { bank_id: bankId, directive_id: directiveId },
body: {
name: options.name,
content: options.content,
priority: options.priority,
is_active: options.isActive,
tags: options.tags,
},
});
return this.validateResponse(response, 'updateDirective');
}
/**
* Delete a directive.
*/
async deleteDirective(bankId: string, directiveId: string): Promise<void> {
const response = await sdk.deleteDirective({
client: this.client,
path: { bank_id: bankId, directive_id: directiveId },
});
if (response.error) {
throw new Error(`deleteDirective failed: ${JSON.stringify(response.error)}`);
}
}
// Mental Model methods
/**
* Create a mental model (runs reflect in background).
*/
async createMentalModel(
bankId: string,
name: string,
sourceQuery: string,
options?: {
tags?: string[];
maxTokens?: number;
trigger?: { refreshAfterConsolidation?: boolean };
}
): Promise<any> {
const response = await sdk.createMentalModel({
client: this.client,
path: { bank_id: bankId },
body: {
name,
source_query: sourceQuery,
tags: options?.tags,
max_tokens: options?.maxTokens,
trigger: options?.trigger ? { refresh_after_consolidation: options.trigger.refreshAfterConsolidation } : undefined,
},
});
return this.validateResponse(response, 'createMentalModel');
}
/**
* List all mental models in a bank.
*/
async listMentalModels(bankId: string, options?: { tags?: string[] }): Promise<any> {
const response = await sdk.listMentalModels({
client: this.client,
path: { bank_id: bankId },
query: { tags: options?.tags },
});
return this.validateResponse(response, 'listMentalModels');
}
/**
* Get a specific mental model.
*/
async getMentalModel(bankId: string, mentalModelId: string): Promise<any> {
const response = await sdk.getMentalModel({
client: this.client,
path: { bank_id: bankId, mental_model_id: mentalModelId },
});
return this.validateResponse(response, 'getMentalModel');
}
/**
* Refresh a mental model to update with current knowledge.
*/
async refreshMentalModel(bankId: string, mentalModelId: string): Promise<any> {
const response = await sdk.refreshMentalModel({
client: this.client,
path: { bank_id: bankId, mental_model_id: mentalModelId },
});
return this.validateResponse(response, 'refreshMentalModel');
}
/**
* Update a mental model's metadata.
*/
async updateMentalModel(
bankId: string,
mentalModelId: string,
options: {
name?: string;
sourceQuery?: string;
tags?: string[];
maxTokens?: number;
trigger?: { refreshAfterConsolidation?: boolean };
}
): Promise<any> {
const response = await sdk.updateMentalModel({
client: this.client,
path: { bank_id: bankId, mental_model_id: mentalModelId },
body: {
name: options.name,
source_query: options.sourceQuery,
tags: options.tags,
max_tokens: options.maxTokens,
trigger: options.trigger ? { refresh_after_consolidation: options.trigger.refreshAfterConsolidation } : undefined,
},
});
return this.validateResponse(response, 'updateMentalModel');
}
/**
* Delete a mental model.
*/
async deleteMentalModel(bankId: string, mentalModelId: string): Promise<void> {
const response = await sdk.deleteMentalModel({
client: this.client,
path: { bank_id: bankId, mental_model_id: mentalModelId },
});
if (response.error) {
throw new Error(`deleteMentalModel failed: ${JSON.stringify(response.error)}`);
}
}
}
// Re-export types for convenience
@@ -1,116 +0,0 @@
import { NextResponse } from "next/server";
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
export async function GET(
request: Request,
{ params }: { params: Promise<{ bankId: string; mentalModelId: string }> }
) {
try {
const { bankId, mentalModelId } = await params;
if (!bankId || !mentalModelId) {
return NextResponse.json(
{ error: "bank_id and mental_model_id are required" },
{ status: 400 }
);
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${mentalModelId}`,
{ method: "GET" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error getting mental model:", errorText);
return NextResponse.json(
{ error: "Failed to get mental model" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error getting mental model:", error);
return NextResponse.json({ error: "Failed to get mental model" }, { status: 500 });
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ bankId: string; mentalModelId: string }> }
) {
try {
const { bankId, mentalModelId } = await params;
if (!bankId || !mentalModelId) {
return NextResponse.json(
{ error: "bank_id and mental_model_id are required" },
{ status: 400 }
);
}
const body = await request.json();
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${mentalModelId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error updating mental model:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to update mental model" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error updating mental model:", error);
return NextResponse.json({ error: "Failed to update mental model" }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ bankId: string; mentalModelId: string }> }
) {
try {
const { bankId, mentalModelId } = await params;
if (!bankId || !mentalModelId) {
return NextResponse.json(
{ error: "bank_id and mental_model_id are required" },
{ status: 400 }
);
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${mentalModelId}`,
{ method: "DELETE" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error deleting mental model:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to delete mental model" },
{ status: response.status }
);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error deleting mental model:", error);
return NextResponse.json({ error: "Failed to delete mental model" }, { status: 500 });
}
}
@@ -1,47 +1,54 @@
import { NextResponse } from "next/server";
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
const { searchParams } = new URL(request.url);
const tags = searchParams.getAll("tags");
const tagsMatch = searchParams.get("tags_match");
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const queryParams = new URLSearchParams();
if (tags.length > 0) {
tags.forEach((t) => queryParams.append("tags", t));
}
if (tagsMatch) {
queryParams.append("tags_match", tagsMatch);
// Note: tags filtering is not supported by the list_memories API endpoint
const response = await sdk.listMemories({
client: lowLevelClient,
path: { bank_id: bankId },
query: {
type: "mental_model",
limit: 1000,
},
});
if (response.error) {
console.error("API error listing mental models:", response.error);
return NextResponse.json({ error: "Failed to list mental models" }, { status: 500 });
}
const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models${queryParams.toString() ? `?${queryParams}` : ""}`;
const response = await fetch(url, { method: "GET" });
// Transform list memories response to mental models format
const items = (response.data?.items || []).map((item) => ({
id: item.id,
bank_id: bankId,
text: item.text,
proof_count: 1,
history: [],
tags: item.tags || [],
source_memory_ids: [],
source_memories: [],
created_at: item.date,
updated_at: item.date,
}));
if (!response.ok) {
const errorText = await response.text();
console.error("API error listing mental models:", errorText);
return NextResponse.json(
{ error: "Failed to list mental models" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
return NextResponse.json({ items }, { status: 200 });
} catch (error) {
console.error("Error listing mental models:", error);
return NextResponse.json({ error: "Failed to list mental models" }, { status: 500 });
}
}
export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ bankId: string }> }
) {
try {
const { bankId } = await params;
@@ -49,28 +56,19 @@ export async function POST(request: Request, { params }: { params: Promise<{ ban
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const body = await request.json();
const response = await fetch(`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
const response = await sdk.clearMentalModels({
client: lowLevelClient,
path: { bank_id: bankId },
});
if (!response.ok) {
const errorText = await response.text();
console.error("API error creating mental model:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to create mental model" },
{ status: response.status }
);
if (response.error) {
console.error("API error clearing mental models:", response.error);
return NextResponse.json({ error: "Failed to clear mental models" }, { status: 500 });
}
const data = await response.json();
// Returns operation_id - content is generated in background
return NextResponse.json(data, { status: 202 });
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error creating mental model:", error);
return NextResponse.json({ error: "Failed to create mental model" }, { status: 500 });
console.error("Error clearing mental models:", error);
return NextResponse.json({ error: "Failed to clear mental models" }, { status: 500 });
}
}
@@ -1,74 +0,0 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
// Note: tags filtering is not supported by the list_memories API endpoint
const response = await sdk.listMemories({
client: lowLevelClient,
path: { bank_id: bankId },
query: {
type: "observation",
limit: 1000,
},
});
if (response.error) {
console.error("API error listing observations:", response.error);
return NextResponse.json({ error: "Failed to list observations" }, { status: 500 });
}
// Transform list memories response to observations format
const items = (response.data?.items || []).map((item) => ({
id: item.id,
bank_id: bankId,
text: item.text,
proof_count: 1,
history: [],
tags: item.tags || [],
source_memory_ids: [],
source_memories: [],
created_at: item.date,
updated_at: item.date,
}));
return NextResponse.json({ items }, { status: 200 });
} catch (error) {
console.error("Error listing observations:", error);
return NextResponse.json({ error: "Failed to list observations" }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ bankId: string }> }
) {
try {
const { bankId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const response = await sdk.clearObservations({
client: lowLevelClient,
path: { bank_id: bankId },
});
if (response.error) {
console.error("API error clearing observations:", response.error);
return NextResponse.json({ error: "Failed to clear observations" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error clearing observations:", error);
return NextResponse.json({ error: "Failed to clear observations" }, { status: 500 });
}
}
@@ -4,28 +4,28 @@ const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://loca
export async function POST(
request: Request,
{ params }: { params: Promise<{ bankId: string; mentalModelId: string }> }
{ params }: { params: Promise<{ bankId: string; reflectionId: string }> }
) {
try {
const { bankId, mentalModelId } = await params;
const { bankId, reflectionId } = await params;
if (!bankId || !mentalModelId) {
if (!bankId || !reflectionId) {
return NextResponse.json(
{ error: "bank_id and mental_model_id are required" },
{ error: "bank_id and reflection_id are required" },
{ status: 400 }
);
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${mentalModelId}/refresh`,
`${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}/refresh`,
{ method: "POST" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error refreshing mental model:", errorText);
console.error("API error refreshing reflection:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to refresh mental model" },
{ error: errorText || "Failed to refresh reflection" },
{ status: response.status }
);
}
@@ -33,7 +33,7 @@ export async function POST(
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error refreshing mental model:", error);
return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 });
console.error("Error refreshing reflection:", error);
return NextResponse.json({ error: "Failed to refresh reflection" }, { status: 500 });
}
}
@@ -0,0 +1,113 @@
import { NextResponse } from "next/server";
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
export async function GET(
request: Request,
{ params }: { params: Promise<{ bankId: string; reflectionId: string }> }
) {
try {
const { bankId, reflectionId } = await params;
if (!bankId || !reflectionId) {
return NextResponse.json(
{ error: "bank_id and reflection_id are required" },
{ status: 400 }
);
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}`,
{ method: "GET" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error getting reflection:", errorText);
return NextResponse.json({ error: "Failed to get reflection" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error getting reflection:", error);
return NextResponse.json({ error: "Failed to get reflection" }, { status: 500 });
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ bankId: string; reflectionId: string }> }
) {
try {
const { bankId, reflectionId } = await params;
if (!bankId || !reflectionId) {
return NextResponse.json(
{ error: "bank_id and reflection_id are required" },
{ status: 400 }
);
}
const body = await request.json();
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error updating reflection:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to update reflection" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error updating reflection:", error);
return NextResponse.json({ error: "Failed to update reflection" }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ bankId: string; reflectionId: string }> }
) {
try {
const { bankId, reflectionId } = await params;
if (!bankId || !reflectionId) {
return NextResponse.json(
{ error: "bank_id and reflection_id are required" },
{ status: 400 }
);
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections/${reflectionId}`,
{ method: "DELETE" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error deleting reflection:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to delete reflection" },
{ status: response.status }
);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error deleting reflection:", error);
return NextResponse.json({ error: "Failed to delete reflection" }, { status: 500 });
}
}
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
const { searchParams } = new URL(request.url);
const tags = searchParams.getAll("tags");
const tagsMatch = searchParams.get("tags_match");
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const queryParams = new URLSearchParams();
if (tags.length > 0) {
tags.forEach((t) => queryParams.append("tags", t));
}
if (tagsMatch) {
queryParams.append("tags_match", tagsMatch);
}
const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections${queryParams.toString() ? `?${queryParams}` : ""}`;
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
const errorText = await response.text();
console.error("API error listing reflections:", errorText);
return NextResponse.json(
{ error: "Failed to list reflections" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error listing reflections:", error);
return NextResponse.json({ error: "Failed to list reflections" }, { status: 500 });
}
}
export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
try {
const { bankId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const body = await request.json();
const response = await fetch(`${DATAPLANE_URL}/v1/default/banks/${bankId}/reflections`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error("API error creating reflection:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to create reflection" },
{ status: response.status }
);
}
const data = await response.json();
// Returns operation_id - content is generated in background
return NextResponse.json(data, { status: 202 });
} catch (error) {
console.error("Error creating reflection:", error);
return NextResponse.json({ error: "Failed to create reflection" }, { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show More