Compare commits

...
10 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
115 changed files with 16609 additions and 15764 deletions
@@ -0,0 +1,41 @@
"""mental_model_id_to_text
Revision ID: m8h9i0j1k2l3
Revises: l7g8h9i0j1k2
Create Date: 2026-01-19 00:00:00.000000
This migration changes the mental_models.id column from VARCHAR(64) to TEXT
to support longer model IDs (e.g., entity names that exceed 64 characters).
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "m8h9i0j1k2l3"
down_revision: str | Sequence[str] | None = "l7g8h9i0j1k2"
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 VARCHAR(64) to TEXT."""
schema = _get_schema_prefix()
# Alter the id column type from VARCHAR(64) to TEXT
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT")
def downgrade() -> None:
"""Revert mental_models.id from TEXT to VARCHAR(64)."""
schema = _get_schema_prefix()
# Note: This may fail if any id values exceed 64 characters
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE VARCHAR(64)")
@@ -0,0 +1,134 @@
"""learnings_and_pinned_reflections
Revision ID: n9i0j1k2l3m4
Revises: m8h9i0j1k2l3
Create Date: 2026-01-21 00:00:00.000000
This migration:
1. Creates the 'learnings' table for automatic bottom-up consolidation
2. Creates the 'pinned_reflections' table for user-curated living documents
3. Adds consolidation tracking columns to the 'banks' table
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "n9i0j1k2l3m4"
down_revision: str | Sequence[str] | None = "m8h9i0j1k2l3"
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:
"""Create learnings and pinned_reflections tables."""
schema = _get_schema_prefix()
# 1. Create learnings table
op.execute(f"""
CREATE TABLE {schema}learnings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
text TEXT NOT NULL,
proof_count INT NOT NULL DEFAULT 1,
history JSONB DEFAULT '[]'::jsonb,
mission_context VARCHAR(64),
pre_mission_change BOOLEAN DEFAULT FALSE,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key constraint
op.execute(f"""
ALTER TABLE {schema}learnings
ADD CONSTRAINT fk_learnings_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Indexes for learnings
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
# Full-text search for learnings
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', text)) STORED
""")
op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)")
# 2. Create pinned_reflections table
op.execute(f"""
CREATE TABLE {schema}pinned_reflections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
source_query TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
last_refreshed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key constraint
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
ADD CONSTRAINT fk_pinned_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Indexes for pinned_reflections
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
# Full-text search for pinned_reflections
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING gin(search_vector)
""")
# 3. Add consolidation tracking columns to banks table
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN IF NOT EXISTS last_consolidated_at TIMESTAMP WITH TIME ZONE
""")
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN IF NOT EXISTS mission_changed_at TIMESTAMP WITH TIME ZONE
""")
def downgrade() -> None:
"""Drop learnings and pinned_reflections tables."""
schema = _get_schema_prefix()
# Drop tables
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
op.execute(f"DROP TABLE IF EXISTS {schema}pinned_reflections CASCADE")
# Remove columns from banks
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS last_consolidated_at")
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission_changed_at")
@@ -0,0 +1,113 @@
"""migrate_mental_models_data
Revision ID: o0j1k2l3m4n5
Revises: n9i0j1k2l3m4
Create Date: 2026-01-21 00:00:00.000000
This migration:
1. Migrates existing 'pinned' mental models to the new 'pinned_reflections' table
2. Migrates existing 'learned' mental models to the new 'learnings' table
3. Deletes non-directive mental models (structural, emergent, pinned, learned)
4. Drops the mental_model_versions table (no longer used)
5. Adds a CHECK constraint that only 'directive' subtype is allowed
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "o0j1k2l3m4n5"
down_revision: str | Sequence[str] | None = "n9i0j1k2l3m4"
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:
"""Migrate data and clean up old mental models."""
schema = _get_schema_prefix()
# 1. Migrate 'pinned' mental models to pinned_reflections
# For pinned models, the first observation's content becomes the pinned reflection content
op.execute(f"""
INSERT INTO {schema}pinned_reflections (bank_id, name, source_query, content, tags, created_at)
SELECT
bank_id,
name,
description AS source_query,
COALESCE(
observations->'observations'->0->>'content',
description,
''
) AS content,
tags,
created_at
FROM {schema}mental_models
WHERE subtype = 'pinned'
ON CONFLICT DO NOTHING
""")
# 2. Migrate 'learned' mental models to learnings
# Each observation in a learned model becomes a separate learning
op.execute(f"""
INSERT INTO {schema}learnings (bank_id, text, proof_count, tags, created_at)
SELECT
mm.bank_id,
obs->>'content' AS text,
GREATEST(1, COALESCE(jsonb_array_length(obs->'evidence'), 1)) AS proof_count,
mm.tags,
mm.created_at
FROM {schema}mental_models mm,
LATERAL jsonb_array_elements(mm.observations->'observations') AS obs
WHERE mm.subtype = 'learned'
AND obs->>'content' IS NOT NULL
AND obs->>'content' != ''
ON CONFLICT DO NOTHING
""")
# 3. Delete all non-directive mental models (they've been migrated or are obsolete)
op.execute(f"""
DELETE FROM {schema}mental_models
WHERE subtype != 'directive'
""")
# 4. Drop the mental_model_versions table (no longer used)
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions CASCADE")
# 5. Drop old constraints and add new one that only allows 'directive'
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype CHECK (subtype = 'directive')
""")
def downgrade() -> None:
"""Reverse the migration (data migration is one-way, so this just removes constraints)."""
schema = _get_schema_prefix()
# Remove the directive-only constraint
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
# Re-create mental_model_versions table
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}mental_model_versions (
id SERIAL PRIMARY KEY,
bank_id VARCHAR(64) NOT NULL,
model_id VARCHAR(128) NOT NULL,
version INT NOT NULL,
observations JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_mm_versions_lookup ON {schema}mental_model_versions(bank_id, model_id, version DESC)"
)
# Note: Data migration cannot be reversed - pinned_reflections and learnings data remains
@@ -0,0 +1,194 @@
"""new_knowledge_architecture
Revision ID: p1k2l3m4n5o6
Revises: o0j1k2l3m4n5
Create Date: 2026-01-21 00:00:00.000000
This migration implements the new knowledge architecture:
1. Drops the 'learnings' table (mental models are now in memory_units)
2. Renames 'pinned_reflections' to 'reflections'
3. Drops the 'mental_models' table completely
4. Creates 'directives' table for hard rules
5. Adds mental model support columns to 'memory_units' (proof_count, source_memory_ids, history)
The new architecture:
- Directives: Hard rules in their own table
- Mental Models: Stored in memory_units with fact_type='mental_model'
- Reflections: User-curated documents (renamed from pinned_reflections)
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "p1k2l3m4n5o6"
down_revision: str | Sequence[str] | None = "o0j1k2l3m4n5"
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:
"""Implement new knowledge architecture."""
schema = _get_schema_prefix()
# 1. Drop the learnings table (mental models will be in memory_units)
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
# 2. Rename pinned_reflections to reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}pinned_reflections RENAME TO reflections")
# Rename indexes for reflections
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_bank_id RENAME TO idx_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_embedding RENAME TO idx_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_tags RENAME TO idx_reflections_tags")
op.execute(
f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_text_search RENAME TO idx_reflections_text_search"
)
# Rename foreign key constraint
op.execute(f"""
ALTER TABLE {schema}reflections
DROP CONSTRAINT IF EXISTS fk_pinned_reflections_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
""")
# 3. Drop the mental_models table completely
op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE")
# 4. Create directives table
op.execute(f"""
CREATE TABLE {schema}directives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
content TEXT NOT NULL,
priority INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key and indexes for directives
op.execute(f"""
ALTER TABLE {schema}directives
ADD CONSTRAINT fk_directives_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
op.execute(f"CREATE INDEX idx_directives_bank_id ON {schema}directives(bank_id)")
op.execute(f"CREATE INDEX idx_directives_bank_active ON {schema}directives(bank_id, is_active)")
op.execute(f"CREATE INDEX idx_directives_tags ON {schema}directives USING GIN(tags)")
# 5. Add mental model support columns to memory_units
# proof_count: Number of memories that support this mental model
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS proof_count INT DEFAULT 1
""")
# source_memory_ids: Array of memory IDs that consolidated into this mental model
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] DEFAULT ARRAY[]::UUID[]
""")
# history: JSONB array tracking changes to mental models
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb
""")
# Add index for finding mental models
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'
""")
# 6. Update fact_type check constraint to include 'mental_model'
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'))
""")
def downgrade() -> None:
"""Reverse the migration."""
schema = _get_schema_prefix()
# Restore original fact_type check constraint (without 'mental_model')
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'))
""")
# Drop mental model columns from memory_units
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS proof_count")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS source_memory_ids")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
# Drop directives table
op.execute(f"DROP TABLE IF EXISTS {schema}directives CASCADE")
# Rename reflections back to pinned_reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO pinned_reflections")
# Restore indexes
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_pinned_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_pinned_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_pinned_reflections_tags")
op.execute(
f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_pinned_reflections_text_search"
)
# Restore foreign key
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
ADD CONSTRAINT fk_pinned_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Re-create learnings table
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}learnings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
text TEXT NOT NULL,
proof_count INT NOT NULL DEFAULT 1,
history JSONB DEFAULT '[]'::jsonb,
mission_context VARCHAR(64),
pre_mission_change BOOLEAN DEFAULT FALSE,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
op.execute(f"""
ALTER TABLE {schema}learnings
ADD CONSTRAINT fk_learnings_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Note: mental_models table recreation is complex and would need separate handling
@@ -0,0 +1,50 @@
"""fix_mental_model_fact_type
Revision ID: q2l3m4n5o6p7
Revises: p1k2l3m4n5o6
Create Date: 2026-01-21 13:30:00.000000
Fix the fact_type check constraint to include 'mental_model'.
This is a fix for p1k2l3m4n5o6 which should have included this change.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "q2l3m4n5o6p7"
down_revision: str | Sequence[str] | None = "p1k2l3m4n5o6"
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 'mental_model' to the fact_type check constraint."""
schema = _get_schema_prefix()
# Drop the old constraint and add the new one with mental_model included
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'))
""")
def downgrade() -> None:
"""Remove 'mental_model' from the fact_type check constraint."""
schema = _get_schema_prefix()
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'))
""")
@@ -0,0 +1,47 @@
"""Add reflect_response JSONB column to reflections
Revision ID: r3m4n5o6p7q8
Revises: q2l3m4n5o6p7
Create Date: 2026-01-21
This migration adds a reflect_response JSONB column to store the full
reflect API response payload, including based_on facts and trace data.
Note: Table was renamed from pinned_reflections to reflections in p1k2l3m4n5o6.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "r3m4n5o6p7q8"
down_revision: str | Sequence[str] | None = "q2l3m4n5o6p7"
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 reflect_response JSONB column to reflections."""
schema = _get_schema_prefix()
# Add reflect_response column to store the full reflect API response
op.execute(f"""
ALTER TABLE {schema}reflections
ADD COLUMN IF NOT EXISTS reflect_response JSONB
""")
def downgrade() -> None:
"""Remove reflect_response column from reflections."""
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}reflections
DROP COLUMN IF EXISTS reflect_response
""")
File diff suppressed because it is too large Load Diff
+24
View File
@@ -93,6 +93,11 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
# 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
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
@@ -171,6 +176,11 @@ DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "ver
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
# 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
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
@@ -324,6 +334,11 @@ class HindsightConfig:
retain_extraction_mode: str
retain_observations_async: bool
# Mental models settings
enable_mental_models: bool
consolidation_similarity_threshold: float
consolidation_batch_size: int
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
@@ -426,6 +441,15 @@ class HindsightConfig:
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
).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))
),
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
# Database connection pool
@@ -0,0 +1,5 @@
"""Consolidation engine for automatic learning creation from memories."""
from .consolidator import run_consolidation_job
__all__ = ["run_consolidation_job"]
@@ -0,0 +1,842 @@
"""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 mental models from novel facts
- Updates existing mental models when new evidence supports/contradicts/refines them
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 mental model
- history: JSONB tracking changes over time
"""
import json
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from ..memory_engine import fq_table
from ..retain import embedding_utils
from .prompts import (
CONSOLIDATION_SYSTEM_PROMPT,
CONSOLIDATION_USER_PROMPT,
)
if TYPE_CHECKING:
from asyncpg import Connection
from ...api.http import RequestContext
from ..memory_engine import MemoryEngine
logger = logging.getLogger(__name__)
class ConsolidationPerfLog:
"""Performance logging for consolidation operations."""
def __init__(self, bank_id: str):
self.bank_id = bank_id
self.start_time = time.time()
self.lines: list[str] = []
self.timings: dict[str, float] = {}
def log(self, message: str) -> None:
"""Add a log line."""
self.lines.append(message)
def record_timing(self, key: str, duration: float) -> None:
"""Record a timing measurement."""
if key in self.timings:
self.timings[key] += duration
else:
self.timings[key] = duration
def flush(self) -> None:
"""Flush all log lines to the logger."""
total_time = time.time() - self.start_time
header = f"\n{'=' * 60}\nCONSOLIDATION for bank {self.bank_id}"
footer = f"{'=' * 60}\nCONSOLIDATION COMPLETE: {total_time:.3f}s total\n{'=' * 60}"
log_output = header + "\n" + "\n".join(self.lines) + "\n" + footer
logger.info(log_output)
async def run_consolidation_job(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Run consolidation job for a bank.
This is called after retain operations to consolidate new memories into mental models.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
Returns:
Dict with consolidation results
"""
from ...config import get_config
config = get_config()
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
# Check if consolidation is enabled
if not config.enable_mental_models:
logger.debug(f"Consolidation disabled for bank {bank_id}")
return {"status": "disabled", "bank_id": bank_id}
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, last_consolidated_at
FROM {fq_table("banks")}
WHERE bank_id = $1
""",
bank_id,
)
if not bank_row:
logger.warning(f"Bank {bank_id} not found for consolidation")
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)
# 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, 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 fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $2
""",
bank_id,
max_memories_per_batch,
)
perf.record_timing("fetch_memories", time.time() - t0)
if not 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}
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 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,
bank_id=bank_id,
memory=dict(memory),
mission=mission,
request_context=request_context,
perf=perf,
)
mem_time = time.time() - mem_start
perf.record_timing("process_memory_total", mem_time)
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"""
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
""",
bank_id,
last_consolidated_at,
max_memories_per_batch,
list(processed_ids),
)
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)
# 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"[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)"
)
# 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,
)
async def _process_memory(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
memory: dict[str, Any],
mission: str,
request_context: "RequestContext",
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Process a single memory for consolidation using a SINGLE LLM call.
This function:
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 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:
Dict with action summary: created/updated/merged counts
"""
fact_text = memory["text"]
memory_id = memory["id"]
fact_tags = memory.get("tags") or []
# Find related mental models using the full recall system (NO tag filtering)
t0 = time.time()
related_mental_models = await _find_related_mental_models(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
)
if perf:
perf.record_timing("recall", time.time() - t0)
# 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,
fact_tags=fact_tags,
mental_models=related_mental_models, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
mental_models=related_mental_models,
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
perf=perf,
)
results.append(result)
if not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
# Summarize results
created = sum(1 for r in results if r.get("action") == "created")
updated = sum(1 for r in results if r.get("action") == "updated")
merged = sum(1 for r in results if r.get("action") == "merged")
if len(results) == 1:
return results[0]
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
async def _execute_update_action(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
mental_models: list[dict[str, Any]],
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute an update action on an existing mental model.
Updates the mental model text, adds to history, and increments proof_count.
"""
learning_id = action.get("learning_id")
new_text = action.get("text")
reason = action.get("reason", "Updated with new fact")
if not learning_id or not new_text:
return {"action": "skipped", "reason": "missing_learning_id_or_text"}
# 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"}
# Build history entry
history = list(model.get("history", []))
history.append(
{
"previous_text": model["text"],
"changed_at": datetime.now(timezone.utc).isoformat(),
"reason": reason,
"source_memory_id": str(memory_id),
}
)
# Update source_memory_ids
source_ids = list(model.get("source_memory_ids", []))
source_ids.append(memory_id)
# Generate new embedding for updated text
t0 = time.time()
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
embedding_str = str(embeddings[0]) if embeddings else None
if perf:
perf.record_timing("embedding", time.time() - t0)
# Update the mental model
t0 = time.time()
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
embedding = $2::vector,
history = $3,
source_memory_ids = $4,
proof_count = $5,
updated_at = now()
WHERE id = $6
""",
new_text,
embedding_str,
json.dumps(history),
source_ids,
len(source_ids),
uuid.UUID(learning_id),
)
# 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 mental model {learning_id} with memory {memory_id}")
return {"action": "updated", "mental_model_id": learning_id}
async def _execute_create_action(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
event_date: datetime | None = None,
occurred_start: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
Execute a create action for a new mental model.
Creates a new mental model with the specified text and tags.
The text comes directly from the classify LLM - no second LLM call needed.
"""
text = action.get("text")
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_mental_model_directly(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_id=memory_id,
mental_model_text=text, # Text already processed by classify LLM
tags=tags,
event_date=event_date,
occurred_start=occurred_start,
perf=perf,
)
logger.debug(f"Created mental model {result.get('mental_model_id')} from memory {memory_id} (tags: {tags})")
return result
async def _create_memory_links(
conn: "Connection",
memory_id: uuid.UUID,
mental_model_id: uuid.UUID,
) -> None:
"""
Create links between a source memory and its mental model.
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 enables graph traversal to find related memories via their mental models.
Note: Uses EXISTS checks to handle the case where source memory was deleted
by a concurrent operation between fetching and link creation.
"""
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_mental_models(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
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 mental models regardless of scope, so the LLM can
decide on tag routing (same scope update vs cross-scope create).
This leverages:
- Semantic search (embedding similarity)
- BM25 text search (keyword matching)
- Entity-based retrieval (shared entities)
- Graph traversal (connected via entity links)
Returns:
List of related mental models with their tags for LLM tag routing
"""
# 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 mental models
fact_type=["mental_model"], # Only retrieve mental models
request_context=request_context,
_quiet=True, # Suppress logging
# NO tags parameter - intentionally get ALL mental models
)
# 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 mental model
results = []
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 = 'mental_model'
""",
uuid.UUID(mm.id),
bank_id,
)
if row:
history = row["history"]
if isinstance(history, str):
history = json.loads(history)
elif history is None:
history = []
results.append(
{
"id": row["id"],
"text": row["text"],
"proof_count": row["proof_count"] or 1,
"history": history,
"tags": row["tags"] or [], # Include tags for LLM tag routing
"source_memory_ids": row["source_memory_ids"] or [],
"similarity": 1.0, # Retrieved via recall so assumed relevant
}
)
return results
async def _consolidate_with_llm(
memory_engine: "MemoryEngine",
fact_text: str,
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 mental models: extracts durable knowledge, returns create action
- Related models exist: compares and returns update/create actions
- Purely ephemeral fact: returns empty array
Returns:
List of actions, each being:
- {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
- {"action": "create", "tags": [...], "text": "...", "reason": "..."}
- [] if fact is purely ephemeral (no durable knowledge)
"""
# 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:
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 = ""
if mission and mission != "General memory consolidation":
mission_section = f"""
MISSION CONTEXT: {mission}
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,
fact_tags=json.dumps(fact_tags),
mental_models_text=mental_models_text,
)
messages = [
{"role": "system", "content": CONSOLIDATION_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
try:
result = await memory_engine._llm_config.call(
messages=messages,
skip_validation=True, # Raw JSON response
scope="consolidation",
)
# Parse JSON response - should be an array
if isinstance(result, str):
result = json.loads(result)
# Ensure result is a list
if isinstance(result, list):
return result
# Handle legacy single-action format for backward compatibility
if isinstance(result, dict):
if result.get("related_ids") and result.get("consolidated_text"):
# Convert old format to new format
return [
{
"action": "update",
"learning_id": result["related_ids"][0],
"text": result["consolidated_text"],
"reason": result.get("reason", ""),
}
]
return []
return []
except Exception as e:
logger.warning(f"Error in consolidation LLM call: {e}")
return []
async def _create_mental_model_directly(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
source_memory_id: uuid.UUID,
mental_model_text: str,
tags: list[str] | None = None,
event_date: datetime | None = None,
occurred_start: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> dict[str, Any]:
"""
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 mental model (convert to string for pgvector)
t0 = time.time()
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 mental model as a memory_unit
now = datetime.now(timezone.utc)
mm_event_date = event_date or now
mm_occurred_start = occurred_start or now
mm_tags = tags or []
t0 = time.time()
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
)
VALUES ($1, $2, $3, 'mental_model', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8)
RETURNING id
""",
mental_model_id,
bank_id,
mental_model_text,
embedding_str,
[source_memory_id],
mm_tags,
mm_event_date,
mm_occurred_start,
)
# 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 mental model {mental_model_id} from memory {source_memory_id} (tags: {mm_tags})")
return {"action": "created", "mental_model_id": str(row["id"]), "tags": mm_tags}
@@ -0,0 +1,91 @@
"""Prompts for the consolidation engine."""
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.
## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
Examples of extracting durable knowledge:
- "User moved to Room 203" -> "Room 203 exists" (location exists, not where user is now)
- "User visited Acme Corp at Room 105" -> "Acme Corp is located in Room 105"
- "User took the elevator to floor 3" -> "Floor 3 is accessible by elevator"
- "User met Sarah at the lobby" -> "Sarah can be found at the lobby"
DO NOT track current user position/state as knowledge - that changes constantly.
DO track permanent facts learned from the user's actions.
## PRESERVE SPECIFIC DETAILS
Keep names, locations, numbers, and other specifics. Do NOT:
- Abstract into general principles
- Generate business insights
- Make knowledge generic
GOOD examples:
- Fact: "John likes pizza" -> "John likes pizza"
- Fact: "Alice works at Google" -> "Alice works at Google"
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 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 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 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 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", "tags": ["tag"], "text": "new durable knowledge", "reason": "..."}}
]
If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
[]
If no models exist and fact contains durable knowledge:
[{{"action": "create", "tags": {fact_tags}, "text": "durable knowledge text", "reason": "new topic"}}]"""
@@ -0,0 +1,5 @@
"""Directives module for hard rules injected into prompts."""
from .models import Directive
__all__ = ["Directive"]
@@ -0,0 +1,37 @@
"""Pydantic models for directives."""
from datetime import datetime, timezone
from uuid import UUID
from pydantic import BaseModel, Field
class Directive(BaseModel):
"""A directive is a hard rule injected into prompts.
Directives are user-defined rules that guide agent behavior. Unlike mental models
which are automatically consolidated from memories, directives are explicit
instructions that are always included in relevant prompts.
Examples:
- "Always respond in formal English"
- "Never share personal data with third parties"
- "Prefer conservative investment recommendations"
"""
id: UUID = Field(description="Unique identifier")
bank_id: str = Field(description="Bank this directive belongs to")
name: str = Field(description="Human-readable name")
content: str = Field(description="The directive text to inject into prompts")
priority: int = Field(default=0, description="Higher priority directives are injected first")
is_active: bool = Field(default=True, description="Whether this directive is currently active")
tags: list[str] = Field(default_factory=list, description="Tags for filtering")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was created"
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was last updated"
)
class Config:
from_attributes = True
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,12 @@
"""
Mental models module for Hindsight.
Mental models are synthesized summaries that represent understanding. They come
in different subtypes based on how they were created:
Mental models contain directives - hard rules that are injected into reflect prompts.
Directives are user-defined and their observations are user-provided (not LLM-generated).
- Structural: Derived from the bank's mission (e.g., "Be a PM for engineering team")
These are created upfront based on what any agent with this role would need.
- Emergent: Discovered from data patterns (named entities, temporal clusters, etc.)
These surface organically as facts are retained.
- Pinned: User-defined models that persist across refreshes.
Other types of consolidated knowledge are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
from .models import MentalModel, MentalModelSubtype
@@ -1,311 +0,0 @@
"""
Emergent mental model detection and promotion.
Emergent models are discovered from data patterns:
- Named entity extraction (people, projects, systems)
- Temporal clustering (events with multiple references)
- Causal patterns ("Because X, we do Y")
- Behavioral anchors ("After X, we started Y")
- Reference frequency (anything mentioned repeatedly)
When a pattern is detected, it goes through a mission filter to check relevance,
and if relevant, is promoted to a mental model.
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import EmergentCandidate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class MissionFilterCandidate(BaseModel):
"""Result of mission filtering for a single candidate."""
name: str
promote: bool = Field(description="True if this is a specific named entity worth tracking")
reason: str = Field(description="Brief explanation for the decision")
class MissionFilterResponse(BaseModel):
"""Response from LLM for mission filtering."""
candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate")
def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str:
"""Build the prompt for filtering candidates by mission relevance."""
candidate_list = "\n".join(
[f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates]
)
return f"""Filter these detected entities. For each one, decide: promote=true or promote=false.
MISSION: {mission}
DETECTED ENTITIES:
{candidate_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
- "Maria" → YES (promote=true)
- "park" → NO (promote=false)
When in doubt, set promote=false."""
def get_mission_filter_system_message() -> str:
"""System message for mission filtering."""
return """You filter entities for promotion. Output JSON with 'candidates' array.
Rules:
- promote=true ONLY for specific names (people, organizations, named places/projects)
- promote=false for common words, generic categories, abstract concepts
Examples:
- "John" → promote=true (person name)
- "kids" → promote=false (generic category)
- "community" → promote=false (abstract concept)
- "Google" → promote=true (organization name)
- "motivation" → promote=false (abstract concept)
When in doubt, promote=false. Most entities should be rejected."""
async def filter_candidates_by_mission(
llm_config: "LLMConfig",
mission: str,
candidates: list[EmergentCandidate],
) -> list[EmergentCandidate]:
"""
Filter emergent candidates to keep only specific, named entities.
Args:
llm_config: LLM configuration
mission: The bank's mission (used for context)
candidates: List of detected candidates
Returns:
Filtered list of candidates that are specific named entities
"""
if not candidates:
return []
if not mission:
# No mission = no filtering, keep all candidates
logger.debug("[EMERGENT] No mission set, skipping filter")
return candidates
prompt = build_mission_filter_prompt(mission, candidates)
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_mission_filter",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Filter candidates
filtered = []
for candidate in candidates:
if candidate.name in promote_map:
if promote_map[candidate.name]:
filtered.append(candidate)
logger.debug(f"[EMERGENT] Promoting '{candidate.name}'")
else:
logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'")
else:
# Candidate not in response - reject by default
logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting")
logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted")
return filtered
except Exception as e:
logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}")
return []
async def evaluate_emergent_models(
llm_config: "LLMConfig",
models: list[dict],
) -> list[str]:
"""
Evaluate existing emergent models to check if they should be kept.
This re-evaluates emergent models using the same filtering criteria
as new candidates. Models that are generic/abstract will be removed.
Args:
llm_config: LLM configuration
models: List of existing emergent model dicts with 'name', 'id'
Returns:
List of model IDs that should be REMOVED (no longer valid)
"""
if not models:
return []
# Convert existing models to candidates for evaluation
candidates = [
EmergentCandidate(
name=m["name"],
detection_method="existing_emergent_model",
mention_count=0,
)
for m in models
]
# Build a simple prompt for re-evaluation
names_list = "\n".join([f"- {m['name']}" for m in models])
prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove).
EXISTING MODELS:
{names_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
When in doubt, set promote=false."""
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_emergent_evaluation",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Find models to remove
models_to_remove = []
for model in models:
name = model["name"]
if name in promote_map:
if not promote_map[name]:
models_to_remove.append(model["id"])
else:
logger.debug(f"[EMERGENT] Keeping '{name}'")
else:
# Model not in response - remove to be safe
logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal")
models_to_remove.append(model["id"])
logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal")
return models_to_remove
except Exception as e:
logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}")
return []
async def detect_entity_candidates(
pool,
bank_id: str,
min_mentions: int = 5,
top_percent: int = 20,
) -> list[EmergentCandidate]:
"""
Detect entities that are candidates for promotion to mental models.
Args:
pool: Database connection pool
bank_id: Bank identifier
min_mentions: Minimum mention count to consider
top_percent: Only consider top X% by mention count
Returns:
List of entity candidates
"""
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
candidates = []
async with acquire_with_retry(pool) as conn:
# Get entities that meet criteria and don't already have mental models
rows = await conn.fetch(
f"""
WITH ranked AS (
SELECT
e.id,
e.canonical_name,
e.mention_count,
PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct
FROM {fq_table("entities")} e
LEFT JOIN {fq_table("mental_models")} mm
ON mm.entity_id = e.id AND mm.bank_id = e.bank_id
WHERE e.bank_id = $1
AND e.mention_count >= $2
AND mm.id IS NULL -- Not already a mental model
)
SELECT id, canonical_name, mention_count
FROM ranked
WHERE rank_pct <= $3
ORDER BY mention_count DESC
LIMIT 50
""",
bank_id,
min_mentions,
top_percent / 100.0,
)
for row in rows:
candidates.append(
EmergentCandidate(
name=row["canonical_name"],
detection_method="named_entity_extraction",
mention_count=row["mention_count"],
entity_id=str(row["id"]),
relevance_score=0.0,
)
)
logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates")
return candidates
@@ -9,12 +9,14 @@ from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
"""Subtype of mental model - how it was created."""
"""Subtype of mental model.
Currently only DIRECTIVE is supported. Other types of consolidated knowledge
are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
STRUCTURAL = "structural" # Derived from mission, created upfront
EMERGENT = "emergent" # Discovered from data patterns
LEARNED = "learned" # Formed through reflection
PINNED = "pinned" # User-defined topic, observations LLM-generated
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
@@ -49,50 +51,3 @@ class MentalModel(BaseModel):
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
class StructuralModelTemplate(BaseModel):
"""
A template for a structural mental model.
Generated by LLM based on the bank's mission. Represents what any agent
with this role would need to track.
"""
id: str = Field(default="", description="Existing model ID to keep, or empty for new models")
name: str = Field(description="Human-readable name")
description: str = Field(description="What this model should track")
initial_probes: list[str] = Field(default_factory=list, description="Initial search queries to populate this model")
class StructuralModelDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class EmergentCandidate(BaseModel):
"""
A candidate for promotion to emergent mental model.
Detected through pattern analysis of facts.
"""
name: str = Field(description="Name of the detected pattern/entity")
detection_method: str = Field(description="How this candidate was detected")
mention_count: int = Field(default=0, description="How many times referenced")
entity_id: str | None = Field(default=None, description="Entity ID if detected as entity")
relevance_score: float = Field(default=0.0, description="Score from mission filter (0-1)")
class ResearchResult(BaseModel):
"""
Result from the research endpoint.
Contains the answer along with the mental models and facts used.
"""
answer: str = Field(description="The synthesized answer")
mental_models_used: list[str] = Field(default_factory=list, description="IDs of mental models that contributed")
facts_used: list[str] = Field(default_factory=list, description="Fact IDs that contributed")
question_type: str | None = Field(default=None, description="Detected question type (WHO, WHAT, HOW, etc.)")
@@ -1,228 +0,0 @@
"""
Structural mental model derivation from bank mission.
Structural models are derived from the bank's mission - they represent what
any agent with this role would need to track. For example:
Mission: "Be a PM for engineering team"
Structural models:
- Team Structure (who's on the team, roles)
- Project Overview (current projects, status)
- Processes (how releases work, how decisions are made)
- Key Systems (what we own, dependencies)
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import StructuralModelTemplate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class StructuralDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class StructuralRelevanceResult(BaseModel):
"""Result of evaluating a structural model's relevance to the mission."""
name: str
relevant: bool
reason: str
class StructuralRelevanceResponse(BaseModel):
"""Response from LLM for structural model relevance evaluation."""
models: list[StructuralRelevanceResult] = Field(description="Relevance evaluation for each model")
def build_structural_derivation_prompt(mission: str, existing_models: list[dict] | None = None) -> str:
"""Build the prompt for deriving structural models from a mission."""
existing_section = ""
if existing_models:
model_list = "\n".join([f"- id='{m['id']}' name='{m['name']}': {m['description']}" for m in existing_models])
existing_section = f"""
EXISTING STRUCTURAL MODELS:
{model_list}
IMPORTANT: If keeping an existing model, you MUST return its EXACT 'id' value.
Models not included in your output will be REMOVED.
"""
return f"""Given this agent mission, identify the KEY THINGS to track to achieve it.
MISSION: {mission}
{existing_section}
IMPORTANT CONSTRAINTS:
- Return 0-3 structural models MAXIMUM (less is better!)
- Only include models for SPECIFIC, CONCRETE things the agent needs to track
- Each model must be DIRECTLY tied to achieving the mission
- If the mission is simple, return 0 models (empty array is fine)
- If existing models are provided and you want to keep one, use its EXACT id
- Do NOT create near-duplicates (e.g., don't create "topic-map" if "topic-connections" exists)
GOOD examples (specific, actionable):
- Mission: "Be a PM for engineering team""Team Members" (track who's on the team)
- Mission: "Track customer feedback""Customer Issues" (track specific complaints/requests)
- Mission: "Manage project X""Project X Milestones" (track progress)
BAD examples (too generic, don't create these):
- "Processes", "Workflows", "Key Systems", "Important Events"
- "Communication", "Collaboration", "Progress", "Status"
- Generic role-based models not tied to the specific mission
For each model:
1. id: Use EXACT existing id if keeping a model, or leave empty for new models
2. name: Short, specific name (e.g., "Team Members", "Sprint Goals")
3. description: One line describing what to track
4. initial_probes: 2-3 search queries to find relevant information
Return ONLY the models that should exist. Existing models not in your output will be deleted."""
def get_structural_derivation_system_message() -> str:
"""System message for structural model derivation."""
return """You identify the key things to track for a mission. Be VERY selective.
Rules:
- Maximum 3 models (prefer fewer)
- Only SPECIFIC, CONCRETE things - not generic categories
- Each must DIRECTLY help achieve the mission
- Empty array is valid if no models are truly needed
- If existing models are shown and you want to keep one, return its EXACT id
- Never create duplicates - if a similar model exists, keep the existing one
Output JSON with 'templates' array (can be empty)."""
def _normalize_id(text: str) -> str:
"""Normalize a string to a canonical form for comparison.
Removes common suffixes, pluralization, and normalizes separators.
"""
# Lowercase and normalize separators
normalized = text.lower().replace(" ", "-").replace("_", "-")
# Remove common suffixes that indicate the same concept
suffixes_to_remove = ["-map", "-list", "-overview", "-tracker", "-s"]
for suffix in suffixes_to_remove:
if normalized.endswith(suffix) and len(normalized) > len(suffix):
normalized = normalized[: -len(suffix)]
return normalized
def _find_similar_existing_id(new_id: str, existing_models: list[dict]) -> str | None:
"""Find an existing model ID that is similar to the new ID.
Returns the existing ID if a similar one is found, None otherwise.
"""
if not existing_models:
return None
new_normalized = _normalize_id(new_id)
for model in existing_models:
existing_id = model.get("id", "")
existing_normalized = _normalize_id(existing_id)
# Check if one is a prefix of the other (normalized)
if new_normalized.startswith(existing_normalized) or existing_normalized.startswith(new_normalized):
return existing_id
# Check if they're the same when normalized
if new_normalized == existing_normalized:
return existing_id
return None
async def derive_structural_models(
llm_config: "LLMConfig",
mission: str,
existing_models: list[dict] | None = None,
) -> tuple[list[StructuralModelTemplate], list[str]]:
"""
Derive structural model templates from a bank's mission.
This combines derivation and evaluation in one call. The LLM sees existing
models and decides which to keep. Any existing model not in the output
will be marked for removal.
Args:
llm_config: LLM configuration for calling the model
mission: The bank's mission (e.g., "Be a PM for engineering team")
existing_models: Optional list of existing model dicts with 'name', 'description', 'id'
Returns:
Tuple of (templates to create/keep, IDs of existing models to remove)
Raises:
Exception: If LLM call fails
"""
prompt = build_structural_derivation_prompt(mission, existing_models)
result = await llm_config.call(
messages=[
{"role": "system", "content": get_structural_derivation_system_message()},
{"role": "user", "content": prompt},
],
response_format=StructuralDerivationResponse,
scope="mental_model_structural_derivation",
)
templates = result.templates
logger.info(f"[STRUCTURAL] LLM returned {len(templates)} structural models")
# Build set of existing IDs for quick lookup
existing_ids = {m["id"] for m in existing_models} if existing_models else set()
# Process templates: validate IDs, deduplicate, assign stable IDs
processed_templates: list[StructuralModelTemplate] = []
kept_existing_ids: set[str] = set()
for template in templates:
# If LLM returned an ID, check if it's a valid existing ID
if template.id and template.id in existing_ids:
# LLM is keeping an existing model
kept_existing_ids.add(template.id)
processed_templates.append(template)
logger.info(f"[STRUCTURAL] Keeping existing model: {template.id}")
else:
# New model or LLM didn't return a valid ID
# Generate ID from name
generated_id = template.name.lower().replace(" ", "-").replace("_", "-")
# Check for similar existing models to prevent near-duplicates
similar_id = _find_similar_existing_id(generated_id, existing_models)
if similar_id and similar_id not in kept_existing_ids:
# Use the existing similar model instead of creating a new one
logger.info(f"[STRUCTURAL] Detected near-duplicate: '{generated_id}' matches existing '{similar_id}'")
template.id = similar_id
kept_existing_ids.add(similar_id)
else:
template.id = generated_id
processed_templates.append(template)
# Find existing models to remove (not kept in LLM output)
models_to_remove = []
if existing_models:
for model in existing_models:
if model["id"] not in kept_existing_ids:
logger.info(f"[STRUCTURAL] Marking '{model['name']}' (id={model['id']}) for removal")
models_to_remove.append(model["id"])
if models_to_remove:
logger.info(f"[STRUCTURAL] {len(models_to_remove)} existing models will be removed")
return processed_templates, models_to_remove
@@ -1,5 +1,10 @@
"""
Reflect agent - agentic loop for reflection with native tool calling.
Uses hierarchical retrieval:
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
@@ -8,7 +13,7 @@ import logging
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .models import DirectiveInfo, LLMCall, MentalModelInput, ReflectAgentResult, 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
@@ -46,6 +51,32 @@ logger = logging.getLogger(__name__)
DEFAULT_MAX_ITERATIONS = 10
def _normalize_tool_name(name: str) -> str:
"""Normalize tool name from various LLM output formats.
Some LLMs output tool names in non-standard formats:
- 'functions.done' (OpenAI-style prefix)
- 'call=functions.done' (some models)
- 'call=done' (some models)
Returns the normalized tool name (e.g., 'done', 'recall', etc.)
"""
# Handle 'call=functions.name' or 'call=name' format
if name.startswith("call="):
name = name[len("call=") :]
# Handle 'functions.name' format
if name.startswith("functions."):
name = name[len("functions.") :]
return name
def _is_done_tool(name: str) -> bool:
"""Check if the tool name represents the 'done' tool."""
return _normalize_tool_name(name) == "done"
async def _generate_structured_output(
answer: str,
response_schema: dict,
@@ -153,10 +184,10 @@ async def run_reflect_agent(
bank_id: str,
query: str,
bank_profile: dict[str, Any],
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_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]]],
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
context: str | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
max_tokens: int | None = None,
@@ -166,19 +197,20 @@ async def run_reflect_agent(
"""
Execute the reflect agent loop using native tool calling.
The agent iteratively calls tools to gather information and learn,
then provides a final answer via the done() tool.
The agent uses hierarchical retrieval:
1. search_reflections - User-curated summaries (try first)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
Args:
llm_config: LLM provider for agent calls
bank_id: Bank identifier
query: Question to answer
bank_profile: Bank profile with name and mission
lookup_fn: Tool callback for lookup (model_id) -> result
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
recall_fn: Tool callback for recall (query, max_tokens) -> result
expand_fn: Tool callback for expand (memory_id, depth) -> result
learn_fn: Optional tool callback for learn (MentalModelInput) -> result.
If None, learn tool is disabled.
expand_fn: Tool callback for expand (memory_ids, depth) -> result
context: Optional additional context
max_iterations: Maximum number of iterations before forcing response
max_tokens: Maximum tokens for the final response
@@ -188,7 +220,6 @@ async def run_reflect_agent(
Returns:
ReflectAgentResult with final answer and metadata
"""
enable_learn = learn_fn is not None
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
start_time = time.time()
@@ -199,7 +230,7 @@ async def run_reflect_agent(
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist)
tools = get_reflect_tools(enable_learn=enable_learn, directive_rules=directive_rules)
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)
@@ -209,7 +240,6 @@ async def run_reflect_agent(
]
# Tracking
mental_models_created: list[str] = []
total_tools_called = 0
tool_trace: list[ToolCall] = []
tool_trace_summary: list[dict[str, Any]] = []
@@ -218,45 +248,8 @@ async def run_reflect_agent(
# Track available IDs for validation (prevents hallucinated citations)
available_memory_ids: set[str] = set()
available_model_ids: set[str] = set()
# Pre-fetch mental models so the agent always starts with this knowledge
prefetch_start = time.time()
models_result = await lookup_fn(None) # List all mental models
prefetch_duration = int((time.time() - prefetch_start) * 1000)
# Track available model IDs
if isinstance(models_result, dict) and "models" in models_result:
for model in models_result["models"]:
if "id" in model:
available_model_ids.add(model["id"])
# Add to context history for the agent
context_history.append({"tool": "list_mental_models", "output": models_result})
# Add to tool trace
tool_trace.append(
ToolCall(
tool="list_mental_models",
input={"tool": "list_mental_models"},
output=models_result,
duration_ms=prefetch_duration,
iteration=0,
)
)
tool_trace_summary.append(
{
"tool": "list_mental_models",
"input_summary": "(prefetch)",
"duration_ms": prefetch_duration,
"output_chars": len(json.dumps(models_result, default=str)),
}
)
total_tools_called += 1
# Include in the user message so the agent sees it
models_info = json.dumps(models_result, indent=2, default=str)
messages[1]["content"] = f"{query}\n\n## Available Mental Models (pre-fetched)\n```json\n{models_info}\n```"
available_reflection_ids: set[str] = set()
available_mental_model_ids: set[str] = set()
def _get_llm_trace() -> list[LLMCall]:
return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace]
@@ -315,7 +308,6 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -334,12 +326,14 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
llm_trace.append({"scope": f"agent_{iteration + 1}", "duration_ms": llm_duration})
except Exception:
llm_trace.append(
{"scope": f"agent_{iteration + 1}_err", "duration_ms": int((time.time() - llm_start) * 1000)}
)
except Exception as e:
err_duration = int((time.time() - llm_start) * 1000)
logger.warning(f"[REFLECT {reflect_id}] LLM error on iteration {iteration + 1}: {e} ({err_duration}ms)")
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_model_ids)
has_gathered_evidence = (
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)
@@ -366,7 +360,6 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -390,7 +383,6 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -420,17 +412,18 @@ async def run_reflect_agent(
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
)
# Check for done tool call (handle both 'done' and 'functions.done')
done_call = next((tc for tc in result.tool_calls if tc.name == "done" or tc.name == "functions.done"), None)
# Check for done tool call (handle various LLM output formats)
done_call = next((tc for tc in result.tool_calls if _is_done_tool(tc.name)), None)
if done_call:
# Guardrail: Require evidence before done
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
has_gathered_evidence = (
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
messages.append(
@@ -445,7 +438,7 @@ async def run_reflect_agent(
"tool_call_id": done_call.id,
"content": json.dumps(
{
"error": "You must call recall() or list_mental_models() to gather evidence 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."
}
),
}
@@ -456,10 +449,10 @@ async def run_reflect_agent(
return await _process_done_tool(
done_call,
available_memory_ids,
available_model_ids,
available_reflection_ids,
available_mental_model_ids,
iteration + 1,
total_tools_called,
mental_models_created,
tool_trace,
_get_llm_trace(),
_log_completion,
@@ -469,8 +462,8 @@ async def run_reflect_agent(
response_schema=response_schema,
)
# Execute other tools in parallel (exclude done and functions.done)
other_tools = [tc for tc in result.tool_calls if tc.name not in ("done", "functions.done")]
# Execute other tools in parallel (exclude done tool in all its format variants)
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
if other_tools:
# Add assistant message with tool calls
messages.append(
@@ -482,7 +475,14 @@ async def run_reflect_agent(
# Execute tools in parallel
tool_tasks = [
_execute_tool_with_timing(tc, lookup_fn, recall_fn, expand_fn, learn_fn) for tc in other_tools
_execute_tool_with_timing(
tc,
search_reflections_fn,
search_mental_models_fn,
recall_fn,
expand_fn,
)
for tc in other_tools
]
tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True)
total_tools_called += len(other_tools)
@@ -490,38 +490,46 @@ async def run_reflect_agent(
# Process results and add to messages
for tc, result_data in zip(other_tools, tool_results):
if isinstance(result_data, Exception):
# Tool execution failed - log and raise to fail the request
logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
raise RuntimeError(f"Reflect tool '{tc.name}' failed: {result_data}")
# Tool execution failed - send error back to LLM so it can try again
logger.warning(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
output = {"error": f"Tool execution failed: {result_data}"}
duration_ms = 0
else:
output, duration_ms = result_data
output, duration_ms = result_data
# Normalize tool name for consistent tracking
normalized_tool_name = _normalize_tool_name(tc.name)
# Check if tool returned an error response
# Check if tool returned an error response - log but continue (LLM will see the error)
if isinstance(output, dict) and "error" in output:
logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} returned error: {output['error']}")
raise RuntimeError(f"Reflect tool '{tc.name}' error: {output['error']}")
logger.warning(
f"[REFLECT {reflect_id}] Tool {normalized_tool_name} returned error: {output['error']}"
)
# Track created mental models
if tc.name == "learn" and isinstance(output, dict) and "model_id" in output:
mental_models_created.append(output["model_id"])
# 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"])
# Track available memory IDs from recall
if tc.name == "recall" and isinstance(output, dict) and "memories" in output:
if (
normalized_tool_name == "search_mental_models"
and isinstance(output, dict)
and "mental_models" in output
):
for mm in output["mental_models"]:
if "id" in mm:
available_mental_model_ids.add(mm["id"])
if normalized_tool_name == "recall" and isinstance(output, dict) and "memories" in output:
for memory in output["memories"]:
if "id" in memory:
available_memory_ids.add(memory["id"])
# Track available model IDs
if tc.name in ("list_mental_models", "get_mental_model") and isinstance(output, dict):
if output.get("found") and "model" in output:
model_id = output["model"].get("id")
if model_id:
available_model_ids.add(model_id)
elif "models" in output:
for model in output["models"]:
if "id" in model:
available_model_ids.add(model["id"])
# Add tool result message
messages.append(
{
@@ -565,7 +573,6 @@ async def run_reflect_agent(
text=answer,
iterations=max_iterations,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
directives_applied=directives_applied,
@@ -587,10 +594,10 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
async def _process_done_tool(
done_call: "LLMToolCall",
available_memory_ids: set[str],
available_model_ids: set[str],
available_reflection_ids: set[str],
available_mental_model_ids: set[str],
iterations: int,
total_tools_called: int,
mental_models_created: list[str],
tool_trace: list[ToolCall],
llm_trace: list[LLMCall],
log_completion: Callable,
@@ -606,9 +613,10 @@ async def _process_done_tool(
if not answer:
answer = "No answer provided."
# Validate IDs
# 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_model_ids = [mid for mid in args.get("model_ids", []) if mid in available_model_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]
# Generate structured output if schema provided
structured_output = None
@@ -621,25 +629,32 @@ async def _process_done_tool(
structured_output=structured_output,
iterations=iterations,
tools_called=total_tools_called,
mental_models_created=mental_models_created,
tool_trace=tool_trace,
llm_trace=llm_trace,
used_memory_ids=used_memory_ids,
used_model_ids=used_model_ids,
used_reflection_ids=used_reflection_ids,
used_mental_model_ids=used_mental_model_ids,
directives_applied=directives_applied,
)
async def _execute_tool_with_timing(
tc: "LLMToolCall",
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_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]]],
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
start = time.time()
result = await _execute_tool(tc.name, tc.arguments, lookup_fn, recall_fn, expand_fn, learn_fn)
result = await _execute_tool(
tc.name,
tc.arguments,
search_reflections_fn,
search_mental_models_fn,
recall_fn,
expand_fn,
)
duration_ms = int((time.time() - start) * 1000)
return result, duration_ms
@@ -647,24 +662,28 @@ async def _execute_tool_with_timing(
async def _execute_tool(
tool_name: str,
args: dict[str, Any],
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
search_reflections_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_mental_models_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]]],
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
) -> dict[str, Any]:
"""Execute a single tool by name."""
# Normalize tool name - some LLMs return 'functions.done' instead of 'done'
if tool_name.startswith("functions."):
tool_name = tool_name[len("functions.") :]
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
if tool_name == "list_mental_models":
return await lookup_fn(None)
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 == "get_mental_model":
model_id = args.get("model_id")
if not model_id:
return {"error": "get_mental_model requires model_id"}
return await lookup_fn(model_id)
elif tool_name == "search_mental_models":
query = args.get("query")
if not query:
return {"error": "search_mental_models requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
return await search_mental_models_fn(query, max_tokens)
elif tool_name == "recall":
query = args.get("query")
@@ -673,15 +692,6 @@ async def _execute_tool(
max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000
return await recall_fn(query, max_tokens)
elif tool_name == "learn":
if learn_fn is None:
return {"error": "learn tool is not available"}
name = args.get("name")
description = args.get("description")
if not name or not description:
return {"error": "learn requires name and description"}
return await learn_fn(MentalModelInput(name=name, description=description))
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
if not memory_ids:
@@ -695,21 +705,22 @@ 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 == "list_mental_models":
return "()"
elif tool_name == "get_mental_model":
return f"(model_id={args.get('model_id', '?')})"
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_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)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "recall":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
# Show actual value used (default 2048, min 1000)
max_tokens = max(args.get("max_tokens") or 2048, 1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "learn":
name = args.get("name", "?")
desc = args.get("description", "")
desc_preview = f"'{desc[:20]}...'" if len(desc) > 20 else f"'{desc}'"
return f"(name='{name}', description={desc_preview})"
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
depth = args.get("depth", "chunk")
@@ -718,6 +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", [])
model_ids = args.get("model_ids", [])
return f"(answer={answer_preview}, memory_ids={len(memory_ids)}, model_ids={len(model_ids)})"
reflection_ids = args.get("reflection_ids", [])
mental_model_ids = args.get("mental_model_ids", [])
return (
f"(answer={answer_preview}, mem={len(memory_ids)}, ref={len(reflection_ids)}, mm={len(mental_model_ids)})"
)
return str(args)
File diff suppressed because it is too large Load Diff
@@ -104,11 +104,15 @@ class ReflectAgentResult(BaseModel):
)
iterations: int = Field(default=0, description="Number of iterations taken")
tools_called: int = Field(default=0, description="Total number of tool calls made")
mental_models_created: list[str] = Field(default_factory=list, description="IDs of mental models created/updated")
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")
used_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer")
used_model_ids: list[str] = Field(default_factory=list, description="Validated model 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"
)
directives_applied: list[DirectiveInfo] = Field(
default_factory=list, description="Directive mental models that affected this reflection"
)
@@ -184,65 +184,3 @@ def compute_trend(
return Trend.WEAKENING
else:
return Trend.STABLE
class CandidateObservation(BaseModel):
"""A candidate observation generated during the seed phase.
Candidates are preliminary observations that need evidence validation
before becoming full observations.
"""
content: str = Field(description="The proposed observation content")
seed_memory_ids: list[str] = Field(default_factory=list, description="Memory IDs that inspired this candidate")
class CandidateWithEvidence(BaseModel):
"""A candidate observation with gathered supporting and contradicting evidence."""
candidate: CandidateObservation
supporting_memories: list[dict] = Field(default_factory=list, description="Memories that support this observation")
contradicting_memories: list[dict] = Field(
default_factory=list, description="Memories that contradict this observation"
)
class MentalModelSnapshot(BaseModel):
"""A versioned snapshot of a mental model's observations.
Used for tracking changes over time and enabling diff views.
"""
version: int = Field(description="Version number (1-indexed)")
observations: list[Observation] = Field(default_factory=list, description="Observations at this version")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this version was created"
)
reflect_summary: str | None = Field(default=None, description="Summary of changes in this version")
def verify_evidence_quotes(
observation: Observation,
memories: dict[str, str],
) -> tuple[bool, list[str]]:
"""Verify that all evidence quotes exist in the referenced memories.
Args:
observation: The observation to verify
memories: Dict mapping memory_id to memory content
Returns:
Tuple of (is_valid, list of error messages)
"""
errors = []
for evidence in observation.evidence:
memory_content = memories.get(evidence.memory_id)
if memory_content is None:
errors.append(f"Memory {evidence.memory_id} not found")
continue
if evidence.quote not in memory_content:
errors.append(f"Quote not found in memory {evidence.memory_id}: '{evidence.quote[:50]}...'")
return len(errors) == 0, errors
@@ -1,5 +1,10 @@
"""
System prompts for the reflect agent.
The reflect agent uses hierarchical retrieval:
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
"""
import json
@@ -11,7 +16,7 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
Extract directive rules as a list of strings.
Args:
directives: List of directive mental models with observations
directives: List of directives with name and content
Returns:
List of directive rule strings
@@ -19,25 +24,34 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
rules = []
for directive in directives:
directive_name = directive.get("name", "")
observations = directive.get("observations", [])
if observations:
for obs in observations:
# Support both Pydantic Observation objects and dicts
if hasattr(obs, "title"):
title = obs.title
content = obs.content
else:
title = obs.get("title", "")
content = obs.get("content", "")
if title and content:
rules.append(f"**{title}**: {content}")
elif content:
rules.append(content)
elif directive_name:
# Fallback to description if no observations
desc = directive.get("description", "")
if desc:
rules.append(f"**{directive_name}**: {desc}")
# New format: directives have direct content field
content = directive.get("content", "")
if content:
if directive_name:
rules.append(f"**{directive_name}**: {content}")
else:
rules.append(content)
else:
# Legacy format: check for observations
observations = directive.get("observations", [])
if observations:
for obs in observations:
# Support both Pydantic Observation objects and dicts
if hasattr(obs, "title"):
title = obs.title
obs_content = obs.content
else:
title = obs.get("title", "")
obs_content = obs.get("content", "")
if title and obs_content:
rules.append(f"**{title}**: {obs_content}")
elif obs_content:
rules.append(obs_content)
elif directive_name:
# Fallback to description
desc = directive.get("description", "")
if desc:
rules.append(f"**{directive_name}**: {desc}")
return rules
@@ -111,24 +125,25 @@ def build_system_prompt_for_tools(
bank_profile: dict[str, Any],
context: str | None = None,
directives: list[dict[str, Any]] | None = None,
has_reflections: bool = False,
) -> str:
"""
Build the system prompt for tool-calling reflect agent.
This is a simplified prompt since tools are defined separately via the tools parameter.
The agent uses hierarchical retrieval:
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_reflections: Whether the bank has any reflections (skip if not)
"""
name = bank_profile.get("name", "Assistant")
mission = bank_profile.get("mission", "")
no_info_rule = (
"- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results"
)
parts = []
# Inject directives at the VERY START for maximum prominence
@@ -147,8 +162,7 @@ def build_system_prompt_for_tools(
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
"- You MUST call recall() before saying you don't have information",
no_info_rule,
"- You MUST search before saying you don't have information",
"",
"## How to Reason",
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
@@ -156,7 +170,56 @@ def build_system_prompt_for_tools(
"- Be a thoughtful interpreter, not just a literal repeater",
"- When the exact answer isn't stated, use what IS stated to give the best answer",
"",
"## Query Strategy (IMPORTANT)",
"## HIERARCHICAL RETRIEVAL STRATEGY",
"",
]
)
# Build retrieval levels based on what's available
if has_reflections:
parts.extend(
[
"You have access to THREE levels of knowledge. Use them in this order:",
"",
"### 1. REFLECTIONS (search_reflections) - Try First",
"- User-curated summaries about specific topics",
"- HIGHEST quality - manually created and maintained",
"- 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. 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 reflections/models exist, they're stale, or you need specific details",
"- This is the source of truth that other levels are built from",
"",
]
)
else:
parts.extend(
[
"You have access to TWO levels of knowledge. Use them in this order:",
"",
"### 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 mental models exist, they're stale, or you need specific details",
"- This is the source of truth that mental models are built from",
"",
]
)
parts.extend(
[
"## Query Strategy",
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
"",
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
@@ -164,44 +227,41 @@ def build_system_prompt_for_tools(
" 1. recall('lessons') - find all lesson-related memories",
" 2. recall('teaching sessions') - alternative phrasing",
" 3. recall('student progress') - find student-related memories",
" 4. recall('topics taught') - find subject matter",
"",
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
"- Questions about patterns → search for the individual instances first",
"- Questions comparing things → search for each thing separately",
"- Questions about relationships → search for each party involved",
"",
"## Workflow",
]
)
# Answer mode: include mental model lookup in workflow
if has_reflections:
parts.extend(
[
"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",
]
)
else:
parts.extend(
[
"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",
]
)
parts.extend(
[
"1. Review the pre-fetched mental models for relevant synthesized knowledge",
"2. If relevant, call get_mental_model(model_id) for full observations",
"3. DECOMPOSE the question into component searches (see Query Strategy above)",
" - Identify entities and concepts in the question",
" - Search for each separately with targeted queries",
"4. Run multiple recall() calls - don't just echo the user's question",
"5. Use expand() if you need more context on specific memories",
"6. BEFORE answering: Check if any person/project/concept from the memories deserves a mental model - use learn() if so",
"7. When ready, call done() with your answer and supporting memory_ids",
"",
"## When to Use learn() - IMPORTANT",
"ACTIVELY look for opportunities to use learn() when you discover:",
"- A person mentioned in 2+ memories who has no mental model yet",
"- A project or concept the user asks about that has no mental model",
"- A pattern or topic worth tracking for future questions",
"",
"DO NOT wait to be asked - proactively create models when you see the need.",
"Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
"",
"## Output Format: Plain Text Answer",
"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 memory IDs ONLY in the memory_ids array parameter, not in the answer",
"- Put IDs ONLY in the memory_ids/reflection_ids/mental_model_ids arrays, not in the answer",
]
)
@@ -295,9 +355,10 @@ def build_agent_prompt(
else:
parts.append(
"\n## Instructions\n"
"Start by calling list_mental_models() to see available mental models - they contain pre-synthesized knowledge. "
"If a relevant model exists, use get_mental_model(model_id) to get its observations. "
"Then use recall(query) for specific details not covered by mental models."
"Start by searching for relevant information using the hierarchical retrieval strategy:\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"
)
return "\n".join(parts)
@@ -377,386 +438,3 @@ Your approach:
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
Do NOT fabricate information that has no basis in the retrieved data."""
# =============================================================================
# 4-Phase Mental Model Reflect Prompts
# =============================================================================
SEED_PHASE_SYSTEM_PROMPT = """You are analyzing memories to discover NEW patterns and generate candidate observations.
Your task is to identify potential observations (beliefs, preferences, patterns, behaviors) that could be part of a mental model about this person/topic.
## Important: Avoid Redundancy
If existing observations are provided, DO NOT generate candidates that are essentially the same.
Focus on discovering NEW patterns not already covered by existing observations.
## Rules
- Generate 5-15 candidate observations for NEW patterns only
- Each candidate should be specific and testable (can be supported or contradicted by evidence)
- Note which memory IDs inspired each candidate (these are seeds, not final evidence)
- Focus on patterns that appear MULTIPLE TIMES across many memories - the more the better
- The best candidates are ones you can find 10, 20, or even 50+ supporting memories for
- Skip patterns that are already covered by existing observations
## Output Format
Return a JSON array of candidate observations:
```json
{
"candidates": [
{
"content": "The specific observation/belief/pattern - be detailed and specific",
"seed_memory_ids": ["memory_id_1", "memory_id_2", "memory_id_3"]
}
]
}
```
Focus on patterns that appear multiple times or have strong signals. Don't generate obvious or trivial observations.
Prefer candidates with MORE seed memories - they're more likely to be real patterns.
Return an empty candidates array if no genuinely new patterns are found."""
def build_seed_phase_prompt(
memories: list[dict],
topic: str | None = None,
existing_observations: list[dict] | None = None,
) -> str:
"""Build the user prompt for the seed phase.
Args:
memories: List of memories to analyze
topic: Optional topic focus for the mental model
existing_observations: Optional list of existing observations to avoid rediscovering
"""
parts = []
if topic:
parts.append(f"## Topic Focus\n{topic}\n")
# Include existing observations so we don't rediscover them
if existing_observations:
parts.append("## Existing Observations (DO NOT regenerate these)")
parts.append("These patterns are already tracked. Focus on discovering NEW patterns:\n")
for i, obs in enumerate(existing_observations, 1):
title = obs.get("title", "")
content = obs.get("content", "")
parts.append(f"{i}. **{title}**: {content}\n")
parts.append("")
parts.append("## Memories to Analyze")
parts.append("Review these memories and identify patterns, preferences, beliefs, and behaviors:\n")
for mem in memories:
mem_id = mem.get("id", "unknown")
content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"[{mem_id}] ({timestamp}): {content}\n")
parts.append("\n## Instructions")
if existing_observations:
parts.append("Generate candidate observations for NEW patterns not already covered above.")
parts.append("If all patterns are already covered by existing observations, return an empty candidates array.")
else:
parts.append("Generate candidate observations based on patterns you see in these memories.")
parts.append("Look for: recurring themes, stated preferences, behavioral patterns, beliefs, values, goals.")
return "\n".join(parts)
VALIDATE_PHASE_SYSTEM_PROMPT = """You are validating candidate observations against evidence.
For each candidate, you have:
- Supporting memories (evidence FOR the observation)
- Contradicting memories (evidence AGAINST the observation)
## Your Task
1. Evaluate each candidate based on the evidence
2. For valid candidates, extract EXACT QUOTES from supporting memories
3. Discard candidates with insufficient or contradicting evidence
4. Merge similar candidates into single, refined observations
## Rules for Quotes
- Quotes must be EXACT text from the memory, not paraphrased
- Each quote should directly support the observation
- The MORE evidence quotes, the BETTER - don't limit yourself, include ALL relevant quotes (10, 20, 50+)
- Observations with only 1-2 quotes are weak and should be discarded unless the evidence is exceptionally strong
- Stronger observations have more supporting evidence - aim for comprehensive coverage
## Output Format
Return validated observations with evidence:
```json
{
"observations": [
{
"title": "Short descriptive title (3-8 words) - like a headline",
"content": "The full observation content - detailed explanation of the pattern/belief",
"evidence": [
{
"memory_id": "exact_memory_id",
"quote": "Exact quote from the memory text",
"relevance": "Brief explanation of how this supports the observation",
"timestamp": "2024-01-15T10:00:00Z"
}
]
}
],
"discarded": [
{
"content": "The discarded candidate",
"reason": "Why it was discarded (insufficient evidence, contradicted, etc.)"
}
],
"merged": [
{
"from": ["candidate 1 content", "candidate 2 content"],
"into": "The merged observation content"
}
]
}
```
## Title Guidelines
- Title should be a SHORT label (like "Prefers morning meetings" or "Coffee enthusiast")
- NOT a truncated version of the content
- Think of it as a category/tag for the observation
Be rigorous: only keep observations with clear, verifiable evidence from multiple memories."""
def build_validate_phase_prompt(candidates_with_evidence: list[dict]) -> str:
"""Build the user prompt for the validate phase."""
parts = ["## Candidates to Validate\n"]
for i, item in enumerate(candidates_with_evidence, 1):
candidate = item.get("candidate", {})
supporting = item.get("supporting_memories", [])
contradicting = item.get("contradicting_memories", [])
parts.append(f"### Candidate {i}: {candidate.get('content', '')}")
if supporting:
parts.append("\n**Supporting Evidence:**")
for mem in supporting:
mem_id = mem.get("id", "unknown")
content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {content}")
if contradicting:
parts.append("\n**Contradicting Evidence:**")
for mem in contradicting:
mem_id = mem.get("id", "unknown")
content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {content}")
if not supporting and not contradicting:
parts.append("\n*No additional evidence found*")
parts.append("")
parts.append("## Instructions")
parts.append("1. Evaluate each candidate based on its evidence")
parts.append("2. Keep candidates with strong supporting evidence")
parts.append("3. Discard candidates with no evidence or strong contradictions")
parts.append("4. Merge similar candidates")
parts.append("5. Extract EXACT quotes (copy-paste from memory text) for evidence")
return "\n".join(parts)
COMPARE_PHASE_SYSTEM_PROMPT = """You are merging new observations with an existing mental model.
You have:
- EXISTING observations (from the current mental model)
- NEW observations (from this reflect cycle)
## Your Task
Produce the final, complete mental model by:
1. Keeping existing observations that are still valid
2. Updating existing observations with new evidence (ADD new evidence to existing)
3. Adding new observations that don't overlap with existing
4. Removing existing observations that are contradicted by new evidence
5. Merging overlapping observations
## Rules
- The final model should have no contradictions
- Each observation must have evidence with exact quotes
- COMBINE evidence from both existing and new observations
- If an existing observation has new supporting evidence, ADD ALL the new evidence to it
- Include ALL relevant evidence - the more quotes the better (10, 20, 50+ is great)
- Observations with more evidence are more reliable - don't limit the number of quotes
## Output Format
Return the complete, final mental model:
```json
{
"observations": [
{
"title": "Short descriptive title (3-8 words)",
"content": "Full observation content - detailed explanation",
"evidence": [
{
"memory_id": "id",
"quote": "exact quote",
"relevance": "explanation",
"timestamp": "ISO timestamp"
}
],
"created_at": "ISO timestamp of when observation was first created"
}
],
"changes": {
"kept": ["Observation that was kept unchanged"],
"updated": [{"from": "old content", "to": "new content", "reason": "why"}],
"added": ["New observation that was added"],
"removed": [{"content": "removed observation", "reason": "why removed"}],
"merged": [{"from": ["obs1", "obs2"], "into": "merged observation"}]
}
}
```"""
def build_compare_phase_prompt(
existing_observations: list[dict],
new_observations: list[dict],
) -> str:
"""Build the user prompt for the compare phase."""
parts = []
parts.append("## Existing Mental Model Observations")
if existing_observations:
for i, obs in enumerate(existing_observations, 1):
title = obs.get("title", "")
content = obs.get("content", obs.get("text", ""))
evidence = obs.get("evidence", [])
parts.append(f"\n### Existing {i}: {title}")
parts.append(f"Content: {content}")
if evidence:
parts.append(f"Evidence ({len(evidence)} items):")
for ev in evidence[:5]: # Show max 5 evidence items
parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
if len(evidence) > 5:
parts.append(f" ... and {len(evidence) - 5} more")
else:
parts.append("*No existing observations*")
parts.append("\n## New Observations from This Reflect")
if new_observations:
for i, obs in enumerate(new_observations, 1):
title = obs.get("title", "")
content = obs.get("content", "")
evidence = obs.get("evidence", [])
parts.append(f"\n### New {i}: {title}")
parts.append(f"Content: {content}")
if evidence:
parts.append(f"Evidence ({len(evidence)} items):")
for ev in evidence:
parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
else:
parts.append("*No new observations*")
parts.append("\n## Instructions")
parts.append("Merge these into a coherent, non-contradictory mental model.")
parts.append("Preserve all valid evidence. Remove stale or contradicted observations.")
return "\n".join(parts)
# =============================================================================
# UPDATE EXISTING Phase Prompts (for diff-based refresh)
# =============================================================================
UPDATE_EXISTING_SYSTEM_PROMPT = """You are updating existing observations with newly found evidence.
For each existing observation, you have been given:
- The original observation (title, content, existing evidence)
- Newly found supporting memories
- Newly found contradicting memories
## Your Task
1. Extract EXACT QUOTES from new supporting memories to add to the observation
2. Flag observations with strong contradicting evidence for potential removal
3. Keep existing evidence intact - only ADD new evidence
## Rules for Quotes
- Quotes must be EXACT text from the memory, not paraphrased
- Each quote should directly support the observation
- Include ALL relevant quotes from the new memories
## Output Format
Return updated observations with new evidence:
```json
{
"updated_observations": [
{
"title": "Original title",
"content": "Original content",
"existing_evidence_count": 5,
"new_evidence": [
{
"memory_id": "exact_memory_id",
"quote": "Exact quote from the memory text",
"relevance": "Brief explanation of how this supports the observation",
"timestamp": "2024-01-15T10:00:00Z"
}
],
"has_contradiction": false,
"contradiction_note": null
}
]
}
```
If an observation has strong contradicting evidence, set has_contradiction=true and explain in contradiction_note."""
def build_update_existing_prompt(observations_with_evidence: list[dict]) -> str:
"""Build the user prompt for the update existing phase.
Args:
observations_with_evidence: List of existing observations with new evidence found
"""
parts = ["## Existing Observations to Update\n"]
for i, item in enumerate(observations_with_evidence, 1):
obs = item.get("observation", {})
supporting = item.get("supporting_memories", [])
contradicting = item.get("contradicting_memories", [])
title = obs.get("title", "")
content = obs.get("content", "")
existing_evidence = obs.get("evidence", [])
parts.append(f"### Observation {i}: {title}")
parts.append(f"Content: {content}")
parts.append(f"Existing evidence count: {len(existing_evidence)}")
if supporting:
parts.append("\n**New Supporting Memories:**")
for mem in supporting:
mem_id = mem.get("id", "unknown")
mem_content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
if contradicting:
parts.append("\n**New Contradicting Memories:**")
for mem in contradicting:
mem_id = mem.get("id", "unknown")
mem_content = mem.get("content", mem.get("text", ""))
timestamp = mem.get("timestamp", mem.get("created_at", ""))
parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
if not supporting and not contradicting:
parts.append("\n*No new evidence found*")
parts.append("")
parts.append("## Instructions")
parts.append("1. Extract EXACT quotes from new supporting memories")
parts.append("2. Flag observations with strong contradictions")
parts.append("3. Return the updated observations with new evidence added")
return "\n".join(parts)
@@ -1,16 +1,17 @@
"""
Tool implementations for the reflect agent.
Implements hierarchical retrieval:
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
"""
import logging
import re
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from .models import MentalModelInput
from .observations import Observation, ObservationEvidence, Trend
if TYPE_CHECKING:
from asyncpg import Connection
@@ -19,156 +20,216 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def generate_model_id(name: str) -> str:
"""Generate a stable ID from mental model name."""
# Normalize: lowercase, replace spaces/special chars with hyphens
normalized = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
# Truncate to reasonable length
return normalized[:50]
# Mental model is considered stale if not updated in this many days
STALE_THRESHOLD_DAYS = 7
def _parse_observations(observations_raw: list) -> list[Observation]:
"""Parse raw observation dicts into typed Observation models."""
observations: list[Observation] = []
for obs in observations_raw:
if not isinstance(obs, dict):
continue
try:
parsed = Observation(
title=obs.get("title", ""),
content=obs.get("content", ""),
evidence=[
ObservationEvidence(
memory_id=ev.get("memory_id", ""),
quote=ev.get("quote", ""),
relevance=ev.get("relevance", ""),
timestamp=ev.get("timestamp"),
)
for ev in obs.get("evidence", [])
if isinstance(ev, dict)
],
created_at=obs.get("created_at"),
)
observations.append(parsed)
except Exception as e:
logger.warning(f"Failed to parse observation: {e}")
continue
return observations
async def tool_lookup(
async def tool_search_reflections(
conn: "Connection",
bank_id: str,
model_id: str | None = None,
query: str,
query_embedding: list[float],
max_results: int = 5,
tags: list[str] | None = None,
tags_match: str = "any",
exclude_ids: list[str] | None = None,
) -> dict[str, Any]:
"""
List or get mental models.
Search user-curated reflections by semantic similarity.
Reflections are high-quality, manually created summaries about specific topics.
They should be searched FIRST as they represent the most reliable synthesized knowledge.
Args:
conn: Database connection
bank_id: Bank identifier
model_id: Optional specific model ID to get (if None, lists all)
tags: Optional tags to filter models (when listing)
query: Search query (for logging/tracing)
query_embedding: Pre-computed embedding for semantic search
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 reflection IDs to exclude (e.g., when refreshing a reflection)
Returns:
Dict with either a list of models or a single model's details
Dict with matching reflections including content and freshness info
"""
if model_id:
# Get specific mental model with full details including observations
row = await conn.fetchrow(
"""
SELECT id, subtype, name, description, observations, entity_id, last_updated
FROM mental_models
WHERE id = $1 AND bank_id = $2
""",
model_id,
bank_id,
)
if row:
# Parse observations JSON
obs_data = row["observations"] or {"observations": []}
if isinstance(obs_data, str):
import json
from ..memory_engine import fq_table
obs_data = json.loads(obs_data)
observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data
# Build filters dynamically
filters = ""
params: list[Any] = [bank_id, str(query_embedding), max_results]
next_param = 4
# Parse observations into typed models
observations = _parse_observations(observations_raw)
return {
"found": True,
"model": {
"id": row["id"],
"subtype": row["subtype"],
"name": row["name"],
"description": row["description"],
"observations": observations,
"entity_id": str(row["entity_id"]) if row["entity_id"] else None,
"last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
},
}
return {"found": False, "model_id": model_id}
else:
# List mental models (compact: id, name, description only)
# Full observations are retrieved via get_mental_model(model_id)
# NOTE: Directives (subtype='directive') are excluded from listing -
# they are injected into the system prompt, not discoverable via tools
# Filter by tags if provided
if tags:
if tags_match == "all":
# All tags must match
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1 AND tags @> $2::varchar[] AND subtype != 'directive'
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
tags,
)
else:
# Any tag matches (OR) - default
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1 AND tags && $2::varchar[] AND subtype != 'directive'
ORDER BY last_updated DESC NULLS LAST, created_at DESC
""",
bank_id,
tags,
)
if tags:
if tags_match == "all":
filters += f" AND tags @> ${next_param}::varchar[]"
else:
rows = await conn.fetch(
"""
SELECT id, subtype, name, description
FROM mental_models
WHERE bank_id = $1 AND subtype != 'directive'
ORDER BY last_updated DESC NULLS LAST, created_at DESC
filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
params.append(tags)
next_param += 1
if exclude_ids:
filters += f" AND id != ALL(${next_param}::uuid[])"
params.append(exclude_ids)
next_param += 1
# Search reflections by embedding similarity
rows = await conn.fetch(
f"""
SELECT
id, name, content, reflect_response,
tags, created_at, last_refreshed_at,
1 - (embedding <=> $2::vector) as relevance
FROM {fq_table("reflections")}
WHERE bank_id = $1 AND embedding IS NOT NULL {filters}
ORDER BY embedding <=> $2::vector
LIMIT $3
""",
*params,
)
now = datetime.now(timezone.utc)
reflections = []
for row in rows:
last_refreshed_at = row["last_refreshed_at"]
if last_refreshed_at and last_refreshed_at.tzinfo is None:
last_refreshed_at = last_refreshed_at.replace(tzinfo=timezone.utc)
# Calculate freshness
is_stale = False
if last_refreshed_at:
age = now - last_refreshed_at
is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS)
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,
"is_stale": is_stale,
}
)
return {
"query": query,
"count": len(reflections),
"reflections": reflections,
}
async def tool_search_mental_models(
memory_engine: "MemoryEngine",
bank_id: str,
query: str,
request_context: "RequestContext",
max_tokens: int = 5000,
tags: list[str] | None = None,
tags_match: str = "any",
last_consolidated_at: datetime | None = None,
pending_consolidation: int = 0,
) -> dict[str, Any]:
"""
Search consolidated mental models using recall with include_mental_models.
Mental models are auto-generated from memories. Returns freshness info
so the agent knows if it should also verify with recall().
Args:
memory_engine: Memory engine instance
bank_id: Bank identifier
query: Search query
request_context: Request context for authentication
max_tokens: Maximum tokens for results (default 5000)
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 mental models including freshness info
"""
from ..memory_engine import fq_table
# 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=["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,
tags_match=tags_match,
_connection_budget=1,
_quiet=True,
)
mental_models = []
# 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:
mm_ids = [m.id for m in result.results]
# Fetch proof_count and source_memory_ids for these mental models
pool = await memory_engine._get_pool()
async with pool.acquire() as conn:
mm_rows = await conn.fetch(
f"""
SELECT id, proof_count, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
bank_id,
mm_ids,
)
mm_data = {str(row["id"]): row for row in mm_rows}
for m in result.results:
# Get additional data from DB lookup
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
source_memory_ids = [str(sid) for sid in (source_ids or [])]
# Determine staleness
is_stale = False
staleness_reason = None
if pending_consolidation > 0:
is_stale = True
staleness_reason = f"{pending_consolidation} memories pending consolidation"
mental_models.append(
{
"id": str(m.id),
"text": m.text,
"proof_count": proof_count,
"source_memory_ids": source_memory_ids,
"tags": m.tags or [],
"is_stale": is_stale,
"staleness_reason": staleness_reason,
}
)
return {
"count": len(rows),
"models": [
{
"id": row["id"],
"subtype": row["subtype"],
"name": row["name"],
"description": row["description"],
}
for row in rows
],
}
# Return freshness info (more understandable than raw pending_consolidation count)
if pending_consolidation == 0:
freshness = "up_to_date"
elif pending_consolidation < 10:
freshness = "slightly_stale"
else:
freshness = "stale"
return {
"query": query,
"count": len(mental_models),
"mental_models": mental_models,
"freshness": freshness,
}
async def tool_recall(
@@ -185,6 +246,9 @@ async def tool_recall(
"""
Search memories using TEMPR retrieval.
This is the ground truth - raw facts and experiences.
Use when reflections/mental models don't exist, are stale, or need verification.
Args:
memory_engine: Memory engine instance
bank_id: Bank identifier
@@ -202,13 +266,14 @@ async def tool_recall(
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["experience", "world"], # Exclude opinions
fact_type=["experience", "world"], # Exclude opinions and mental_models
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
tags=tags,
tags_match=tags_match,
_connection_budget=connection_budget,
_quiet=True, # Suppress logging for internal operations
)
memories = []
@@ -230,85 +295,6 @@ async def tool_recall(
}
async def tool_learn(
conn: "Connection",
bank_id: str,
input: MentalModelInput,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Create a mental model placeholder with subtype='learned'.
The agent only specifies name and description - actual observations are generated
in the background via refresh, similar to pinned models.
Args:
conn: Database connection
bank_id: Bank identifier
input: Mental model input data (name, description, optional entity_id)
tags: Tags to apply to new mental models (from reflect context)
Returns:
Dict with created model info including model_id for background generation
"""
model_id = generate_model_id(input.name)
# Parse entity_id if provided
entity_uuid = None
if input.entity_id:
try:
entity_uuid = uuid.UUID(input.entity_id)
except ValueError:
logger.warning(f"Invalid entity_id format: {input.entity_id}")
# Check if model exists
existing = await conn.fetchrow(
"SELECT id FROM mental_models WHERE id = $1 AND bank_id = $2",
model_id,
bank_id,
)
if existing:
# Update description only - observations will be regenerated
await conn.execute(
"""
UPDATE mental_models SET
description = $3,
entity_id = $4
WHERE id = $1 AND bank_id = $2
""",
model_id,
bank_id,
input.description,
entity_uuid,
)
status = "updated"
else:
# Insert new model placeholder - observations will be generated in background
await conn.execute(
"""
INSERT INTO mental_models (id, bank_id, subtype, name, description, observations, entity_id, tags, created_at)
VALUES ($1, $2, 'learned', $3, $4, '{}'::jsonb, $5, $6, NOW())
""",
model_id,
bank_id,
input.name,
input.description,
entity_uuid,
tags or [],
)
status = "created"
logger.info(f"[REFLECT] Mental model '{model_id}' {status} in bank {bank_id} - pending background generation")
return {
"status": status,
"model_id": model_id,
"name": input.name,
"pending_generation": True,
}
async def tool_expand(
conn: "Connection",
bank_id: str,
@@ -327,6 +313,8 @@ async def tool_expand(
Returns:
Dict with results array, each containing memory, chunk, and optionally document data
"""
from ..memory_engine import fq_table
if not memory_ids:
return {"error": "memory_ids is required and must not be empty"}
@@ -344,9 +332,9 @@ async def tool_expand(
# Batch fetch all memory units
memories = await conn.fetch(
"""
f"""
SELECT id, text, chunk_id, document_id, fact_type, context
FROM memory_units
FROM {fq_table("memory_units")}
WHERE id = ANY($1) AND bank_id = $2
""",
valid_uuids,
@@ -363,9 +351,9 @@ async def tool_expand(
chunk_map: dict[str, Any] = {}
if chunk_ids:
chunks = await conn.fetch(
"""
f"""
SELECT chunk_id, chunk_text, chunk_index, document_id
FROM chunks
FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1)
""",
chunk_ids,
@@ -385,9 +373,9 @@ async def tool_expand(
all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct)
if all_doc_ids:
docs = await conn.fetch(
"""
f"""
SELECT id, original_text, metadata, retain_params
FROM documents
FROM {fq_table("documents")}
WHERE id = ANY($1) AND bank_id = $2
""",
all_doc_ids,
@@ -2,36 +2,62 @@
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_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_LIST_MENTAL_MODELS = {
TOOL_SEARCH_REFLECTIONS = {
"type": "function",
"function": {
"name": "list_mental_models",
"description": "List all available mental models - your synthesized knowledge about entities, concepts, and events. Returns an array of models with id, name, and description.",
"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": {},
"required": [],
"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_GET_MENTAL_MODEL = {
TOOL_SEARCH_MENTAL_MODELS = {
"type": "function",
"function": {
"name": "get_mental_model",
"description": "Get full details of a specific mental model including all observations and memory references.",
"name": "search_mental_models",
"description": (
"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": {
"model_id": {
"query": {
"type": "string",
"description": "ID of the mental model (from list_mental_models results)",
"description": "Search query to find relevant mental models",
},
"max_tokens": {
"type": "integer",
"description": "Maximum tokens for results (default 5000). Use higher values for broader searches.",
},
},
"required": ["model_id"],
"required": ["query"],
},
},
}
@@ -40,7 +66,12 @@ TOOL_RECALL = {
"type": "function",
"function": {
"name": "recall",
"description": "Search memories using semantic + temporal retrieval. Returns relevant memories from experience and world knowledge, each with an 'id' you can reference.",
"description": (
"Search raw memories (facts and experiences). This is the ground truth data. "
"Use when: (1) no reflections/mental models exist, (2) mental models are stale, "
"(3) you need specific details not in synthesized knowledge. "
"Returns individual memory facts with their timestamps."
),
"parameters": {
"type": "object",
"properties": {
@@ -58,28 +89,6 @@ TOOL_RECALL = {
},
}
TOOL_LEARN = {
"type": "function",
"function": {
"name": "learn",
"description": "Create a new mental model to track an important recurring topic. Use when you discover a person, project, concept, or pattern that appears frequently and would benefit from synthesized knowledge. The model content will be generated automatically.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable name (e.g., 'Project Alpha', 'John Smith', 'Product Strategy')",
},
"description": {
"type": "string",
"description": "What to track and synthesize (e.g., 'Track goals, milestones, blockers, and key decisions for Project Alpha')",
},
},
"required": ["name", "description"],
},
},
}
TOOL_EXPAND = {
"type": "function",
"function": {
@@ -121,7 +130,12 @@ TOOL_DONE_ANSWER = {
"items": {"type": "string"},
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
},
"model_ids": {
"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",
@@ -143,8 +157,6 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
Args:
directive_rules: List of directive rule strings
"""
from typing import Any, cast
# Build rules list for description
rules_list = "\n".join(f" {i + 1}. {rule}" for i, rule in enumerate(directive_rules))
@@ -169,7 +181,12 @@ 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)",
},
"model_ids": {
"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",
@@ -185,29 +202,28 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
}
def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | None = None) -> list[dict]:
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_reflections - User-curated summaries (try first)
2. search_mental_models - Consolidated knowledge with freshness
3. recall - Raw facts as ground truth
Args:
enable_learn: Whether to include the learn tool
directive_rules: Optional list of directive rule strings. If provided,
the done() tool will require directive compliance confirmation.
Returns:
List of tool definitions in OpenAI format
"""
tools = []
# Include mental model tools for lookup
tools.append(TOOL_LIST_MENTAL_MODELS)
tools.append(TOOL_GET_MENTAL_MODEL)
tools.append(TOOL_RECALL)
if enable_learn:
tools.append(TOOL_LEARN)
tools.append(TOOL_EXPAND)
tools = [
TOOL_SEARCH_REFLECTIONS,
TOOL_SEARCH_MENTAL_MODELS,
TOOL_RECALL,
TOOL_EXPAND,
]
# Use directive-aware done tool if directives are present
if directive_rules:
@@ -11,7 +11,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience"])
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "mental_model"])
class LLMToolCall(BaseModel):
@@ -166,6 +166,28 @@ class ChunkInfo(BaseModel):
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
class MentalModelResult(BaseModel):
"""A mental model result from recall."""
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 mental model"
)
class ReflectionResult(BaseModel):
"""A reflection result from recall."""
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")
class RecallResult(BaseModel):
"""
Result from a recall operation.
@@ -229,6 +251,7 @@ class ReflectResult(BaseModel):
],
"experience": [],
"opinion": [],
"mental-models": [],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
@@ -239,7 +262,7 @@ class ReflectResult(BaseModel):
text: str = Field(description="The formulated answer text")
based_on: dict[str, list[MemoryFact]] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
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(
@@ -258,10 +281,6 @@ class ReflectResult(BaseModel):
default_factory=list,
description="Trace of LLM calls made during reflection. Only present when include.tool_calls is enabled.",
)
mental_models: list[MentalModelRef] = Field(
default_factory=list,
description="Mental models accessed during reflection, including directives (subtype='directive').",
)
directives_applied: list[DirectiveRef] = Field(
default_factory=list,
description="Directive mental models that were applied during this reflection.",
@@ -114,11 +114,8 @@ class CausalRelation(BaseModel):
"""Causal relationship from this fact to a previous fact (stored format)."""
target_fact_index: int = Field(description="Index of the related fact in the facts array (0-based).")
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
description="How this fact relates to the target: "
"'caused_by' = this fact was caused by the target, "
"'enabled_by' = this fact was enabled by the target, "
"'prevented_by' = this fact was prevented by the target"
relation_type: Literal["caused_by"] = Field(
description="How this fact relates to the target: 'caused_by' = this fact was caused by the target"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0)",
@@ -141,11 +138,8 @@ class FactCausalRelation(BaseModel):
"MUST be less than this fact's position in the list. "
"Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4."
)
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
description="How this fact relates to the target fact: "
"'caused_by' = this fact was caused by the target fact, "
"'enabled_by' = this fact was enabled by the target fact, "
"'prevented_by' = this fact was blocked/prevented by the target fact"
relation_type: Literal["caused_by"] = Field(
description="How this fact relates to the target fact: 'caused_by' = this fact was caused by the target fact"
)
strength: float = Field(
description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
@@ -662,7 +656,7 @@ CAUSAL RELATIONSHIPS
══════════════════════════════════════════════════════════════════════════
Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index.
Types: "caused_by", "enabled_by", "prevented_by"
Type: "caused_by" (this fact was caused by the target fact)
Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 0: Lost job, causal_relations: null
@@ -823,7 +817,8 @@ Text:
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
fact_type = llm_fact.get("fact_type")
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience" for storage
if fact_type == "assistant":
@@ -840,7 +835,10 @@ Text:
else:
# Default to 'world' if we can't determine
fact_type = "world"
logger.warning(f"Fact {i}: defaulting to fact_type='world'")
logger.warning(
f"Fact {i}: defaulting to fact_type='world' "
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
)
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -754,17 +754,14 @@ async def create_causal_links_batch(
causal_relations_per_fact: List of causal relations for each fact.
Each element is a list of dicts with:
- target_fact_index: Index into unit_ids for the target fact
- relation_type: "causes", "caused_by", "enables", or "prevents"
- relation_type: "caused_by"
- strength: Float in [0.0, 1.0] representing relationship strength
Returns:
Number of causal links created
Causal link types:
- "causes": This fact directly causes the target fact (forward causation)
- "caused_by": This fact was caused by the target fact (backward causation)
- "enables": This fact enables/allows the target fact (enablement)
- "prevents": This fact prevents/blocks the target fact (prevention)
Causal link type:
- "caused_by": This fact was caused by the target fact
"""
if not unit_ids or not causal_relations_per_fact:
return 0
@@ -787,8 +784,8 @@ async def create_causal_links_batch(
relation_type = relation["relation_type"]
strength = relation.get("strength", 1.0)
# Validate relation_type - must match database constraint
valid_types = {"causes", "caused_by", "enables", "prevents"}
# Validate relation_type - only "caused_by" is supported (DB constraint)
valid_types = {"caused_by"}
if relation_type not in valid_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
@@ -86,10 +86,10 @@ class CausalRelation:
"""
Causal relationship between facts.
Represents how one fact causes, enables, or prevents another.
Represents how one fact was caused by another.
"""
relation_type: str # "causes", "enables", "prevents", "caused_by"
relation_type: str # "caused_by"
target_fact_index: int # Index of the target fact in the batch
strength: float = 1.0 # Strength of the causal relationship
@@ -330,8 +330,8 @@ class SearchTracer:
RetrievalResult(
rank=rank,
node_id=doc_id,
text=data.get("text", ""),
context=data.get("context", ""),
text=data.get("text") or "",
context=data.get("context") or "",
event_date=data.get("event_date"),
fact_type=data.get("fact_type") or fact_type,
score=score,
@@ -27,8 +27,6 @@ from hindsight_api.extensions.operation_validator import (
RecallResult,
ReflectContext,
ReflectResultContext,
RefreshMentalModelContext,
RefreshMentalModelResult,
RetainContext,
RetainResult,
ValidationResult,
@@ -56,8 +54,6 @@ __all__ = [
"RecallResult",
"ReflectContext",
"ReflectResultContext",
"RefreshMentalModelContext",
"RefreshMentalModelResult",
"RetainContext",
"RetainResult",
"ValidationResult",
@@ -97,18 +97,6 @@ class ReflectContext:
context: str | None = None
@dataclass
class RefreshMentalModelContext:
"""Context for a refresh mental model operation validation (pre-operation).
Contains ALL user-provided parameters for the refresh mental model operation.
"""
bank_id: str
model_id: str
request_context: "RequestContext"
# =============================================================================
# Post-operation Contexts (includes results)
# =============================================================================
@@ -176,27 +164,6 @@ class ReflectResultContext:
error: str | None = None
@dataclass
class RefreshMentalModelResult:
"""Result context for post-refresh-mental-model hook.
Contains the operation parameters and the result including token usage.
"""
bank_id: str
model_id: str
request_context: "RequestContext"
# Result
model_name: str | None = None
observations_count: int = 0
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
duration_ms: int = 0
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect operations.
@@ -298,25 +265,6 @@ class OperationValidatorExtension(Extension, ABC):
"""
...
@abstractmethod
async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
"""
Validate a refresh mental model operation before execution.
Called before the refresh mental model operation is processed.
Return ValidationResult.reject() to prevent the operation from executing.
Args:
ctx: Context containing all user-provided parameters:
- bank_id: Bank identifier
- model_id: Mental model ID to refresh
- request_context: Request context with auth info
Returns:
ValidationResult indicating whether the operation is allowed.
"""
...
# =========================================================================
# Post-operation hooks (optional - override to implement)
# =========================================================================
@@ -377,28 +325,3 @@ class OperationValidatorExtension(Extension, ABC):
- error: Error message (if failed)
"""
pass
async def on_refresh_mental_model_complete(self, result: RefreshMentalModelResult) -> None:
"""
Called after a refresh mental model operation completes (success or failure).
Override this method to implement post-operation logic such as:
- Token usage tracking and billing
- Audit logging
- Metrics collection
Args:
result: Result context containing:
- bank_id: Bank identifier
- model_id: Mental model ID
- request_context: Request context with auth info
- model_name: Name of the mental model (if success)
- observations_count: Number of observations generated
- input_tokens: Number of input tokens used
- output_tokens: Number of output tokens used
- total_tokens: Total tokens used (input + output)
- duration_ms: Total operation duration in milliseconds
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
+3
View File
@@ -212,6 +212,9 @@ def main():
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_observations_async=config.retain_observations_async,
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,
run_migrations_on_startup=config.run_migrations_on_startup,
+124 -1
View File
@@ -8,6 +8,7 @@ FOR UPDATE SKIP LOCKED for safe concurrent claiming.
import asyncio
import json
import logging
import time
import traceback
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
@@ -17,6 +18,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Progress logging interval in seconds
PROGRESS_LOG_INTERVAL = 30
def fq_table(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with optional schema prefix."""
@@ -66,6 +70,9 @@ class WorkerPoller:
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
self._active_banks: set[str] = set()
async def claim_batch(self) -> list[tuple[str, dict[str, Any]]]:
"""
@@ -73,6 +80,9 @@ class WorkerPoller:
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).
Returns:
List of tuples (operation_id, task_dict)
"""
@@ -81,11 +91,24 @@ class WorkerPoller:
async with self._pool.acquire() as conn:
async with conn.transaction():
# Select and lock pending tasks
# For consolidation: skip if same bank already has one processing
rows = await conn.fetch(
f"""
SELECT operation_id, task_payload
FROM {table}
FROM {table} AS pending
WHERE status = 'pending' AND task_payload IS NOT NULL
AND (
-- Non-consolidation tasks: always claimable
operation_type != 'consolidation'
OR
-- Consolidation: only if no other consolidation processing for same bank
NOT EXISTS (
SELECT 1 FROM {table} AS processing
WHERE processing.bank_id = pending.bank_id
AND processing.operation_type = 'consolidation'
AND processing.status = 'processing'
)
)
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
@@ -188,6 +211,34 @@ class WorkerPoller:
logger.error(f"Task {operation_id} failed: {e}")
await self._retry_or_fail(operation_id, error_msg)
async def recover_own_tasks(self) -> int:
"""
Recover tasks that were assigned to this worker but not completed.
This handles the case where a worker crashes while processing tasks.
On startup, we reset any tasks stuck in 'processing' for this worker_id
back to 'pending' so they can be picked up again.
Returns:
Number of tasks recovered
"""
table = fq_table("async_operations", self._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,
)
# 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):
"""
Main polling loop.
@@ -195,6 +246,9 @@ class WorkerPoller:
Continuously polls for pending tasks, claims them, and executes them
until shutdown is signaled.
"""
# Recover any tasks from a previous crash before starting
await self.recover_own_tasks()
logger.info(f"Worker {self._worker_id} starting polling loop")
while not self._shutdown.is_set():
@@ -234,6 +288,9 @@ class WorkerPoller:
except asyncio.TimeoutError:
pass # Normal timeout, continue polling
# Log progress stats periodically
await self._log_progress_if_due()
except asyncio.CancelledError:
logger.info(f"Worker {self._worker_id} polling loop cancelled")
break
@@ -270,6 +327,72 @@ class WorkerPoller:
logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s")
async def _log_progress_if_due(self):
"""Log progress stats every PROGRESS_LOG_INTERVAL seconds."""
now = time.time()
if now - self._last_progress_log < PROGRESS_LOG_INTERVAL:
return
self._last_progress_log = now
try:
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
processing_str = ", ".join(processing_info[:10]) if processing_info else "none"
if len(processing_info) > 10:
processing_str += f" +{len(processing_info) - 10} more"
logger.info(
f"[WORKER_STATS] worker={self._worker_id} in_flight={in_flight} | "
f"global: pending={pending} processing={processing_count} "
f"completed_24h={completed} failed_24h={failed} | "
f"active: {processing_str}"
)
except Exception as e:
logger.debug(f"Failed to log progress stats: {e}")
@property
def worker_id(self) -> str:
"""Get the worker ID."""
File diff suppressed because it is too large Load Diff
@@ -1,516 +0,0 @@
"""Tests for emergent entity filtering."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from hindsight_api.engine.mental_models.emergent import (
build_mission_filter_prompt,
evaluate_emergent_models,
filter_candidates_by_mission,
MissionFilterResponse,
MissionFilterCandidate,
)
from hindsight_api.engine.mental_models.models import EmergentCandidate
class TestBuildMissionFilterPrompt:
"""Test prompt building for mission filtering."""
def test_prompt_contains_mission(self):
"""Test that prompt includes the mission."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
)
]
prompt = build_mission_filter_prompt("Be a PM for engineering team", candidates)
assert "Be a PM for engineering team" in prompt
def test_prompt_contains_candidates(self):
"""Test that prompt includes all candidates."""
candidates = [
EmergentCandidate(
name="Alice Chen",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="Project Phoenix",
detection_method="named_entity_extraction",
mention_count=5,
),
]
prompt = build_mission_filter_prompt("Track projects", candidates)
assert "Alice Chen" in prompt
assert "Project Phoenix" in prompt
def test_prompt_contains_rejection_guidance(self):
"""Test that prompt contains guidance to reject generic entities."""
candidates = [
EmergentCandidate(
name="test",
detection_method="named_entity_extraction",
mention_count=1,
)
]
prompt = build_mission_filter_prompt("Test mission", candidates)
# Should contain rejection guidance for generic terms
assert "promote=false" in prompt
assert "kids" in prompt # Example of generic term to reject
assert "community" in prompt # Example of abstract concept to reject
assert "motivation" in prompt # Example of abstract concept to reject
class TestFilterCandidatesByMission:
"""Test the filter_candidates_by_mission function."""
@pytest.fixture
def mock_llm_config(self):
"""Create a mock LLM config."""
config = MagicMock()
config.call = AsyncMock()
return config
async def test_empty_candidates(self, mock_llm_config):
"""Test with empty candidate list."""
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Test mission",
candidates=[],
)
assert result == []
mock_llm_config.call.assert_not_called()
async def test_no_mission_keeps_all(self, mock_llm_config):
"""Test that no mission keeps all candidates (skips filtering)."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
)
]
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="", # Empty mission
candidates=candidates,
)
assert len(result) == 1
assert result[0].name == "Alice"
mock_llm_config.call.assert_not_called()
async def test_filters_by_promote_flag(self, mock_llm_config):
"""Test that candidates are filtered by promote flag."""
candidates = [
EmergentCandidate(
name="Alice Chen",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="community",
detection_method="named_entity_extraction",
mention_count=5,
),
]
# Mock LLM response - Alice is promoted, community is not
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="Alice Chen", promote=True, reason="Specific person"),
MissionFilterCandidate(name="community", promote=False, reason="Generic abstract concept"),
]
)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Be a PM for engineering team",
candidates=candidates,
)
assert len(result) == 1
assert result[0].name == "Alice Chen"
async def test_rejects_generic_entities(self, mock_llm_config):
"""Test that generic entities are rejected."""
# These are all generic/abstract terms that should be rejected
generic_names = [
"user", "support", "community", "family", "motivation",
"photo", "gratitude", "difference", "volunteering",
"kids", "veterans", "impact", "kindness", "encouragement",
"education", "nature", "joy", "positivity", "inspiration",
"help", "commitment", "passion", "energy", "connection",
]
candidates = [
EmergentCandidate(
name=name,
detection_method="named_entity_extraction",
mention_count=10,
)
for name in generic_names
]
# Add some valid candidates
valid_candidates = [
EmergentCandidate(
name="John",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="Maria",
detection_method="named_entity_extraction",
mention_count=8,
),
EmergentCandidate(
name="Max",
detection_method="named_entity_extraction",
mention_count=6,
),
]
candidates.extend(valid_candidates)
# Mock LLM response - reject all generic, promote only specific names
response_candidates = [
MissionFilterCandidate(name=name, promote=False, reason="Generic/abstract term")
for name in generic_names
]
response_candidates.extend([
MissionFilterCandidate(name=c.name, promote=True, reason="Specific person name")
for c in valid_candidates
])
mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Be a health coach",
candidates=candidates,
)
# Should only have John, Maria, and Max
result_names = {c.name for c in result}
assert result_names == {"John", "Maria", "Max"}
async def test_accepts_specific_named_entities(self, mock_llm_config):
"""Test that specific named entities are accepted."""
# These should all be accepted
valid_names = [
"Alice Chen", # Full name
"Dr. Smith", # Title + name
"John", # First name (when it's clearly a person)
"Google", # Organization
"Frontend Team", # Named team
"Project Phoenix", # Named project
"NYC Office", # Named place
"Q4 Planning", # Named event
"Sprint 23 Review", # Named meeting
]
candidates = [
EmergentCandidate(
name=name,
detection_method="named_entity_extraction",
mention_count=10,
)
for name in valid_names
]
# Mock LLM response - promote all
response_candidates = [
MissionFilterCandidate(name=name, promote=True, reason="Specific named entity")
for name in valid_names
]
mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Be a PM for engineering team",
candidates=candidates,
)
# Should have all valid names
result_names = {c.name for c in result}
assert result_names == set(valid_names)
async def test_llm_error_rejects_all_candidates(self, mock_llm_config):
"""Test that LLM errors result in rejecting all candidates (fail-safe)."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
)
]
mock_llm_config.call.side_effect = Exception("LLM error")
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Test mission",
candidates=candidates,
)
# Should reject all candidates on error (fail-safe)
assert len(result) == 0
async def test_missing_candidate_in_response_is_rejected(self, mock_llm_config):
"""Test that candidates not in LLM response are rejected by default."""
candidates = [
EmergentCandidate(
name="Alice",
detection_method="named_entity_extraction",
mention_count=10,
),
EmergentCandidate(
name="Bob",
detection_method="named_entity_extraction",
mention_count=5,
),
]
# Mock LLM response - only includes Alice, not Bob
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="Alice", promote=True, reason="Specific person"),
]
)
result = await filter_candidates_by_mission(
llm_config=mock_llm_config,
mission="Test mission",
candidates=candidates,
)
# Only Alice should be in result (Bob was missing from response, so rejected)
assert len(result) == 1
assert result[0].name == "Alice"
class TestEvaluateEmergentModels:
"""Test the evaluate_emergent_models function for cleanup of existing models."""
@pytest.fixture
def mock_llm_config(self):
"""Create a mock LLM config."""
config = MagicMock()
config.call = AsyncMock()
return config
async def test_empty_models(self, mock_llm_config):
"""Test with empty model list."""
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=[],
)
assert result == []
mock_llm_config.call.assert_not_called()
async def test_removes_generic_models(self, mock_llm_config):
"""Test that generic/abstract models are marked for removal."""
models = [
{"id": "id-kids", "name": "kids"},
{"id": "id-community", "name": "community"},
{"id": "id-motivation", "name": "motivation"},
{"id": "id-john", "name": "John"},
{"id": "id-maria", "name": "Maria"},
]
# Mock LLM response - reject generic, keep specific names
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="kids", promote=False, reason="Generic category"),
MissionFilterCandidate(name="community", promote=False, reason="Abstract concept"),
MissionFilterCandidate(name="motivation", promote=False, reason="Abstract concept"),
MissionFilterCandidate(name="John", promote=True, reason="Person name"),
MissionFilterCandidate(name="Maria", promote=True, reason="Person name"),
]
)
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# Should return IDs of generic models to remove
assert set(result) == {"id-kids", "id-community", "id-motivation"}
async def test_keeps_specific_named_models(self, mock_llm_config):
"""Test that specific named models are kept."""
models = [
{"id": "id-john", "name": "John"},
{"id": "id-google", "name": "Google"},
{"id": "id-project", "name": "Project Phoenix"},
]
# Mock LLM response - keep all
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="John", promote=True, reason="Person name"),
MissionFilterCandidate(name="Google", promote=True, reason="Organization"),
MissionFilterCandidate(name="Project Phoenix", promote=True, reason="Named project"),
]
)
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# No models should be removed
assert result == []
async def test_llm_error_keeps_all_models(self, mock_llm_config):
"""Test that LLM errors result in keeping all models (safe default)."""
models = [
{"id": "id-kids", "name": "kids"},
{"id": "id-john", "name": "John"},
]
mock_llm_config.call.side_effect = Exception("LLM error")
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# Should keep all models on error (return empty removal list)
assert result == []
async def test_missing_model_in_response_is_removed(self, mock_llm_config):
"""Test that models not in LLM response are marked for removal."""
models = [
{"id": "id-alice", "name": "Alice"},
{"id": "id-bob", "name": "Bob"},
]
# Mock LLM response - only includes Alice
mock_llm_config.call.return_value = MissionFilterResponse(
candidates=[
MissionFilterCandidate(name="Alice", promote=True, reason="Person name"),
]
)
result = await evaluate_emergent_models(
llm_config=mock_llm_config,
models=models,
)
# Bob should be marked for removal (missing from response)
assert result == ["id-bob"]
class TestRemovedEntitiesNotRepromoted:
"""Test that entities removed by evaluation are not re-promoted.
This tests the fix for a bug where:
1. evaluate_emergent_models returns model IDs to remove (e.g., 'entity-maya')
2. We delete those models
3. detect_entity_candidates finds the same entities (now eligible since model was deleted)
4. filter_candidates_by_goal approves them (different LLM call)
5. BUG: We were re-promoting the same entities we just removed
The fix tracks removed entity_ids and excludes them from promotion.
"""
async def test_removed_entity_ids_excluded_from_promotion(self):
"""Test that entities whose models were removed are not re-promoted."""
from hindsight_api.engine.mental_models.models import EmergentCandidate
# Simulate the scenario from the bug:
# - existing_emergent has model 'entity-maya' with entity_id='uuid-maya'
# - evaluate_emergent_models says to remove 'entity-maya'
# - detect_entity_candidates returns 'Maya' with entity_id='uuid-maya' (now eligible)
# - filter_candidates_by_goal says to promote 'Maya'
# - But we should NOT promote because we just removed it
existing_emergent = [
{"id": "entity-maya", "name": "Maya", "entity_id": "uuid-maya"},
{"id": "entity-alex", "name": "Alex", "entity_id": "uuid-alex"},
{"id": "entity-john", "name": "John", "entity_id": "uuid-john"}, # This one will be kept
]
# Models to remove (evaluate_emergent_models would return these)
models_to_remove = ["entity-maya", "entity-alex"]
# Build model_id -> entity_id mapping (this is what the fix does)
model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent}
# Track removed entity_ids
removed_entity_ids: set[str] = set()
for model_id in models_to_remove:
entity_id = model_to_entity.get(model_id)
if entity_id:
removed_entity_ids.add(str(entity_id))
# Verify we tracked the right entity_ids
assert removed_entity_ids == {"uuid-maya", "uuid-alex"}
# Now simulate candidates that were detected (includes removed entities)
candidates = [
EmergentCandidate(
name="Maya", entity_id="uuid-maya", detection_method="named_entity", mention_count=10
),
EmergentCandidate(
name="Alex", entity_id="uuid-alex", detection_method="named_entity", mention_count=8
),
EmergentCandidate(
name="NewPerson", entity_id="uuid-new", detection_method="named_entity", mention_count=5
),
]
# Filter out candidates whose entity was just removed (the fix)
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
# Only NewPerson should remain - Maya and Alex were removed and should not be re-promoted
assert len(filtered_candidates) == 1
assert filtered_candidates[0].name == "NewPerson"
assert filtered_candidates[0].entity_id == "uuid-new"
async def test_candidates_without_matching_removal_are_kept(self):
"""Test that candidates not in the removed set are still promoted."""
from hindsight_api.engine.mental_models.models import EmergentCandidate
# No models removed
removed_entity_ids: set[str] = set()
candidates = [
EmergentCandidate(
name="Alice", entity_id="uuid-alice", detection_method="named_entity", mention_count=10
),
EmergentCandidate(
name="Bob", entity_id="uuid-bob", detection_method="named_entity", mention_count=8
),
]
# Filter (should keep all since nothing was removed)
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
assert len(filtered_candidates) == 2
assert {c.name for c in filtered_candidates} == {"Alice", "Bob"}
async def test_partial_removal_keeps_other_candidates(self):
"""Test that only removed entities are excluded, others pass through."""
from hindsight_api.engine.mental_models.models import EmergentCandidate
# Only one entity removed
removed_entity_ids = {"uuid-removed"}
candidates = [
EmergentCandidate(
name="Removed", entity_id="uuid-removed", detection_method="named_entity", mention_count=10
),
EmergentCandidate(
name="Kept1", entity_id="uuid-kept1", detection_method="named_entity", mention_count=8
),
EmergentCandidate(
name="Kept2", entity_id="uuid-kept2", detection_method="named_entity", mention_count=5
),
]
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
assert len(filtered_candidates) == 2
assert {c.name for c in filtered_candidates} == {"Kept1", "Kept2"}
-125
View File
@@ -17,8 +17,6 @@ from hindsight_api.extensions import (
RecallResult,
ReflectContext,
ReflectResultContext,
RefreshMentalModelContext,
RefreshMentalModelResult,
RequestContext,
RetainContext,
RetainResult,
@@ -95,7 +93,6 @@ class RateLimitingValidator(OperationValidatorExtension):
self.retain_counts: dict[str, int] = defaultdict(int)
self.recall_counts: dict[str, int] = defaultdict(int)
self.reflect_counts: dict[str, int] = defaultdict(int)
self.refresh_mental_model_counts: dict[str, int] = defaultdict(int)
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.retain_counts[ctx.bank_id] += 1
@@ -121,16 +118,6 @@ class RateLimitingValidator(OperationValidatorExtension):
)
return ValidationResult.accept()
async def validate_refresh_mental_model(
self, ctx: RefreshMentalModelContext
) -> ValidationResult:
self.refresh_mental_model_counts[ctx.bank_id] += 1
if self.refresh_mental_model_counts[ctx.bank_id] > self.max_attempts:
return ValidationResult.reject(
f"Refresh mental model limit exceeded for bank {ctx.bank_id}"
)
return ValidationResult.accept()
class TrackingValidator(OperationValidatorExtension):
"""
@@ -145,12 +132,10 @@ class TrackingValidator(OperationValidatorExtension):
self.pre_retain_calls: list[RetainContext] = []
self.pre_recall_calls: list[RecallContext] = []
self.pre_reflect_calls: list[ReflectContext] = []
self.pre_refresh_mental_model_calls: list[RefreshMentalModelContext] = []
# Post-hook tracking
self.post_retain_calls: list[RetainResult] = []
self.post_recall_calls: list[RecallResult] = []
self.post_reflect_calls: list[ReflectResultContext] = []
self.post_refresh_mental_model_calls: list[RefreshMentalModelResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.pre_retain_calls.append(ctx)
@@ -164,12 +149,6 @@ class TrackingValidator(OperationValidatorExtension):
self.pre_reflect_calls.append(ctx)
return ValidationResult.accept()
async def validate_refresh_mental_model(
self, ctx: RefreshMentalModelContext
) -> ValidationResult:
self.pre_refresh_mental_model_calls.append(ctx)
return ValidationResult.accept()
async def on_retain_complete(self, result: RetainResult) -> None:
self.post_retain_calls.append(result)
@@ -179,11 +158,6 @@ class TrackingValidator(OperationValidatorExtension):
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
self.post_reflect_calls.append(result)
async def on_refresh_mental_model_complete(
self, result: RefreshMentalModelResult
) -> None:
self.post_refresh_mental_model_calls.append(result)
class TestMemoryEngineValidation:
"""Tests for validation integration with MemoryEngine.
@@ -541,105 +515,6 @@ class TestOperationHooksParameters:
assert len(validator.pre_recall_calls) == 1
assert len(validator.post_recall_calls) == 1
@pytest.mark.asyncio
async def test_refresh_mental_model_pre_hook_receives_all_parameters(
self, memory_with_tracking_validator
):
"""Pre-refresh-mental-model hook receives all user-provided parameters."""
import uuid
memory, validator = memory_with_tracking_validator
bank_id = f"test-refresh-mm-params-{uuid.uuid4().hex[:8]}"
ctx = RequestContext(api_key="test-key")
# Create bank first (get_bank_profile auto-creates if needed)
await memory.get_bank_profile(bank_id, request_context=ctx)
# Create a pinned mental model
model = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
description="Test description",
subtype="pinned",
request_context=ctx,
)
assert model is not None
model_id = model["id"]
# Attempt to refresh (may not actually refresh if no data, but hook should be called)
try:
await memory.refresh_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=ctx,
)
except Exception:
pass # May fail if no data
# Check pre-hook was called
assert len(validator.pre_refresh_mental_model_calls) == 1
pre_ctx = validator.pre_refresh_mental_model_calls[0]
assert pre_ctx.bank_id == bank_id
assert pre_ctx.model_id == model_id
assert pre_ctx.request_context == ctx
@pytest.mark.asyncio
async def test_refresh_mental_model_post_hook_receives_token_usage(
self, memory_with_tracking_validator
):
"""Post-refresh-mental-model hook receives token usage information."""
import uuid
memory, validator = memory_with_tracking_validator
bank_id = f"test-refresh-mm-tokens-{uuid.uuid4().hex[:8]}"
ctx = RequestContext(api_key="test-key")
# Store some content first
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice is a software engineer who works on machine learning."},
{"content": "Alice enjoys hiking and outdoor activities on weekends."},
{"content": "Alice has been working at the company for 5 years."},
],
request_context=ctx,
)
# Create a pinned mental model
model = await memory.create_mental_model(
bank_id=bank_id,
name="Alice Profile",
description="Profile of Alice including work and hobbies",
subtype="pinned",
request_context=ctx,
)
if model:
model_id = model["id"]
# Refresh the mental model
result = await memory.refresh_mental_model(
bank_id=bank_id,
model_id=model_id,
request_context=ctx,
)
# Check post-hook was called with token usage
if validator.post_refresh_mental_model_calls:
post_result = validator.post_refresh_mental_model_calls[0]
assert post_result.bank_id == bank_id
assert post_result.model_id == model_id
assert post_result.request_context == ctx
assert post_result.success is True
assert post_result.error is None
# Token usage should be populated (may be 0 if refresh was skipped)
assert post_result.total_tokens >= 0
assert post_result.input_tokens >= 0
assert post_result.output_tokens >= 0
assert post_result.duration_ms >= 0
class TestTenantExtension:
"""Tests for TenantExtension and ApiKeyTenantExtension."""
+12 -8
View File
@@ -241,24 +241,27 @@ class TestReflectToolSchemas:
tools = get_reflect_tools()
tool_names = [t["function"]["name"] for t in tools]
assert "list_mental_models" in tool_names
assert "get_mental_model" in tool_names
assert "search_reflections" in tool_names
assert "search_mental_models" in tool_names
assert "recall" in tool_names
assert "learn" in tool_names
assert "expand" in tool_names
assert "done" in tool_names
def test_get_reflect_tools_without_learn(self):
"""Test getting reflect tools without learn."""
def test_get_reflect_tools_with_directives(self):
"""Test getting reflect tools with directive rules."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
tools = get_reflect_tools(enable_learn=False)
tools = get_reflect_tools(directive_rules=["Always respond in French"])
tool_names = [t["function"]["name"] for t in tools]
assert "learn" not in tool_names
assert "recall" in tool_names
assert "done" in tool_names
# Done tool should have directive_compliance field when directives are present
done_tool = next(t for t in tools if t["function"]["name"] == "done")
params = done_tool["function"]["parameters"]["properties"]
assert "directive_compliance" in params
def test_get_reflect_tools_answer_mode(self):
"""Test getting reflect tools with answer output mode."""
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
@@ -270,7 +273,8 @@ class TestReflectToolSchemas:
assert "answer" in params
assert "memory_ids" in params
assert "model_ids" in params
assert "mental_model_ids" in params
assert "reflection_ids" in params
class TestLLMToolCallResult:
-4
View File
@@ -363,7 +363,6 @@ from hindsight_api.extensions import (
RetainContext,
RecallContext,
ReflectContext,
RefreshMentalModelContext,
)
@@ -395,6 +394,3 @@ class MockOperationValidator(OperationValidatorExtension):
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
return ValidationResult.accept()
File diff suppressed because it is too large Load Diff
@@ -1,405 +0,0 @@
"""Tests for observation trend computation and evidence-grounded models."""
from datetime import datetime, timedelta, timezone
import pytest
from hindsight_api.engine.reflect.observations import (
CandidateObservation,
Observation,
ObservationEvidence,
Trend,
compute_trend,
verify_evidence_quotes,
)
class TestComputeTrend:
"""Tests for the compute_trend function."""
def test_empty_evidence_returns_stale(self):
"""No evidence should return STALE trend."""
trend = compute_trend([])
assert trend == Trend.STALE
def test_all_recent_evidence_returns_new(self):
"""All evidence within recent window (30 days) should return NEW trend.
Scenario: User just started using the app and mentioned they like coffee twice.
Both mentions are within the last 2 weeks, so this is a NEW observation.
"""
now = datetime.now(timezone.utc)
evidence = [
ObservationEvidence(
memory_id="mem-coffee-morning",
quote="I always start my day with a large black coffee",
relevance="Shows preference for coffee and morning routine",
timestamp=now - timedelta(days=5),
),
ObservationEvidence(
memory_id="mem-coffee-meeting",
quote="grabbed coffee before the standup meeting",
relevance="Confirms regular coffee consumption",
timestamp=now - timedelta(days=10),
),
]
trend = compute_trend(evidence, now=now)
assert trend == Trend.NEW
def test_no_recent_evidence_returns_stale(self):
"""No evidence in recent window should return STALE trend.
Scenario: User mentioned running 3 months ago but hasn't mentioned it since.
The observation about running as a hobby may no longer be accurate.
"""
now = datetime.now(timezone.utc)
evidence = [
ObservationEvidence(
memory_id="mem-running-march",
quote="training for a half marathon in the spring",
relevance="Shows interest in running",
timestamp=now - timedelta(days=60),
),
ObservationEvidence(
memory_id="mem-running-feb",
quote="went for a 10k run this morning",
relevance="Active runner",
timestamp=now - timedelta(days=100),
),
]
trend = compute_trend(evidence, now=now)
assert trend == Trend.STALE
def test_stable_evidence_distribution(self):
"""Evidence spread evenly across time should return STABLE trend.
Scenario: User has consistently mentioned working remotely over 4 months.
Evidence is well-distributed, indicating a stable, ongoing preference.
"""
now = datetime.now(timezone.utc)
evidence = [
# Recent (within 30 days)
ObservationEvidence(
memory_id="mem-remote-jan",
quote="working from my home office today",
relevance="Current remote work",
timestamp=now - timedelta(days=5),
),
ObservationEvidence(
memory_id="mem-remote-dec",
quote="the flexibility of remote work is great",
relevance="Values remote work",
timestamp=now - timedelta(days=15),
),
# Middle period (30-90 days)
ObservationEvidence(
memory_id="mem-remote-nov",
quote="set up a standing desk at home",
relevance="Invested in home office",
timestamp=now - timedelta(days=45),
),
ObservationEvidence(
memory_id="mem-remote-oct",
quote="prefer async communication over meetings",
relevance="Remote work style preference",
timestamp=now - timedelta(days=60),
),
# Older (90+ days)
ObservationEvidence(
memory_id="mem-remote-sep",
quote="switched to fully remote last quarter",
relevance="Original transition to remote",
timestamp=now - timedelta(days=100),
),
ObservationEvidence(
memory_id="mem-remote-aug",
quote="negotiated remote work in my new contract",
relevance="Intentional choice for remote",
timestamp=now - timedelta(days=120),
),
]
trend = compute_trend(evidence, now=now)
assert trend == Trend.STABLE
def test_strengthening_trend(self):
"""Much more recent evidence than older should return STRENGTHENING trend.
Scenario: User has been increasingly talking about learning Python recently
after mentioning it once months ago. Interest appears to be growing.
"""
now = datetime.now(timezone.utc)
evidence = [
# Lots of recent evidence - actively learning
ObservationEvidence(
memory_id="mem-python-project",
quote="finished my first Python project - a web scraper",
relevance="Completed Python project",
timestamp=now - timedelta(days=2),
),
ObservationEvidence(
memory_id="mem-python-course",
quote="halfway through the Python bootcamp",
relevance="Active learning",
timestamp=now - timedelta(days=5),
),
ObservationEvidence(
memory_id="mem-python-book",
quote="reading Fluent Python, it's excellent",
relevance="Deepening knowledge",
timestamp=now - timedelta(days=10),
),
ObservationEvidence(
memory_id="mem-python-practice",
quote="solved 50 LeetCode problems in Python",
relevance="Practicing skills",
timestamp=now - timedelta(days=15),
),
ObservationEvidence(
memory_id="mem-python-ide",
quote="set up VS Code with all the Python extensions",
relevance="Setting up environment",
timestamp=now - timedelta(days=20),
),
# Only one old mention - initial interest
ObservationEvidence(
memory_id="mem-python-start",
quote="thinking about learning Python someday",
relevance="Initial interest",
timestamp=now - timedelta(days=100),
),
]
trend = compute_trend(evidence, now=now)
assert trend == Trend.STRENGTHENING
def test_weakening_trend(self):
"""Much less recent evidence than older should return WEAKENING trend.
Scenario: User was very active in a book club last year but mentions
have tapered off. The observation about being a book club member
may be becoming less relevant.
"""
now = datetime.now(timezone.utc)
evidence = [
# Only one recent mention
ObservationEvidence(
memory_id="mem-book-recent",
quote="haven't had time for book club lately",
relevance="Reduced participation",
timestamp=now - timedelta(days=10),
),
# Lots of older evidence - was very active
ObservationEvidence(
memory_id="mem-book-aug",
quote="hosting book club at my place next week",
relevance="Active organizer",
timestamp=now - timedelta(days=40),
),
ObservationEvidence(
memory_id="mem-book-july",
quote="leading the discussion on 1984",
relevance="Active participant",
timestamp=now - timedelta(days=50),
),
ObservationEvidence(
memory_id="mem-book-june",
quote="we picked The Midnight Library for June",
relevance="Regular member",
timestamp=now - timedelta(days=60),
),
ObservationEvidence(
memory_id="mem-book-may",
quote="book club was amazing tonight",
relevance="Enthusiastic member",
timestamp=now - timedelta(days=100),
),
ObservationEvidence(
memory_id="mem-book-april",
quote="joined a new book club in my neighborhood",
relevance="Started participation",
timestamp=now - timedelta(days=110),
),
ObservationEvidence(
memory_id="mem-book-march",
quote="excited to finally join a book club",
relevance="Initial enthusiasm",
timestamp=now - timedelta(days=120),
),
]
trend = compute_trend(evidence, now=now)
assert trend == Trend.WEAKENING
class TestObservationModel:
"""Tests for the Observation model."""
def test_observation_computed_trend(self):
"""Observation should have computed trend property based on evidence."""
now = datetime.now(timezone.utc)
obs = Observation(
title="Morning meeting preference",
content="Prefers morning meetings over afternoon ones",
evidence=[
ObservationEvidence(
memory_id="mem-morning-standup",
quote="I'm most productive in morning meetings",
relevance="Direct preference statement",
timestamp=now - timedelta(days=5),
),
],
created_at=now,
)
assert obs.trend == Trend.NEW
assert obs.evidence_count == 1
def test_observation_evidence_span(self):
"""Observation should compute evidence span correctly.
The span shows the date range of supporting evidence, helping
understand how long this pattern has been observed.
"""
now = datetime.now(timezone.utc)
old_time = now - timedelta(days=100)
recent_time = now - timedelta(days=5)
obs = Observation(
title="Values work-life balance",
content="Values work-life balance highly",
evidence=[
ObservationEvidence(
memory_id="mem-balance-old",
quote="turned down a promotion because of the hours",
relevance="Prioritized balance over advancement",
timestamp=old_time,
),
ObservationEvidence(
memory_id="mem-balance-recent",
quote="always log off by 6pm no matter what",
relevance="Maintains boundaries",
timestamp=recent_time,
),
],
created_at=now,
)
evidence_span = obs.evidence_span
assert evidence_span["from"] == old_time.isoformat()
assert evidence_span["to"] == recent_time.isoformat()
def test_observation_empty_evidence_span(self):
"""Observation with no evidence should have null span."""
obs = Observation(
title="Test observation",
content="Test observation without evidence",
evidence=[],
)
evidence_span = obs.evidence_span
assert evidence_span["from"] is None
assert evidence_span["to"] is None
class TestVerifyEvidenceQuotes:
"""Tests for evidence quote verification.
This ensures the LLM isn't hallucinating quotes - every quote
must actually appear in the source memory.
"""
def test_valid_quotes(self):
"""Should return True when quotes exist in their source memories."""
obs = Observation(
title="Enjoys hiking",
content="Enjoys hiking on weekends",
evidence=[
ObservationEvidence(
memory_id="mem-hiking-trip",
quote="went hiking at Mount Tam",
relevance="Shows hiking activity",
timestamp=datetime.now(timezone.utc),
),
],
)
memories = {
"mem-hiking-trip": "Had a great Saturday - went hiking at Mount Tam with friends and saw amazing views."
}
is_valid, errors = verify_evidence_quotes(obs, memories)
assert is_valid is True
assert len(errors) == 0
def test_invalid_quote(self):
"""Should return False when quote doesn't exist in memory.
This catches LLM hallucinations where it fabricates quotes.
"""
obs = Observation(
title="Loves spicy food",
content="Loves spicy food",
evidence=[
ObservationEvidence(
memory_id="mem-dinner",
quote="I love extra hot salsa",
relevance="Shows spicy food preference",
timestamp=datetime.now(timezone.utc),
),
],
)
memories = {"mem-dinner": "Had tacos for dinner. The guacamole was really fresh."}
is_valid, errors = verify_evidence_quotes(obs, memories)
assert is_valid is False
assert len(errors) == 1
assert "Quote not found" in errors[0]
def test_missing_memory(self):
"""Should return False when referenced memory doesn't exist.
This catches cases where the LLM references a memory ID that
was never actually retrieved.
"""
obs = Observation(
title="Has a dog named Max",
content="Has a dog named Max",
evidence=[
ObservationEvidence(
memory_id="mem-pet-story",
quote="took Max to the vet",
relevance="Shows pet ownership",
timestamp=datetime.now(timezone.utc),
),
],
)
memories = {"mem-different-id": "Some unrelated memory content"}
is_valid, errors = verify_evidence_quotes(obs, memories)
assert is_valid is False
assert len(errors) == 1
assert "not found" in errors[0]
class TestCandidateObservation:
"""Tests for candidate observation model.
Candidates are generated in the SEED phase and validated
before becoming full observations.
"""
def test_create_candidate(self):
"""Should create candidate with content and seed memories."""
candidate = CandidateObservation(
content="User prefers async communication over meetings",
seed_memory_ids=["mem-slack-pref", "mem-meeting-decline"],
)
assert candidate.content == "User prefers async communication over meetings"
assert len(candidate.seed_memory_ids) == 2
assert "mem-slack-pref" in candidate.seed_memory_ids
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
"""Tests for reflections, mental models, and learnings functionality."""
import uuid
import pytest
import pytest_asyncio
import httpx
from hindsight_api.api import create_app
from hindsight_api.engine.memory_engine import MemoryEngine
@pytest_asyncio.fixture
async def api_client(memory):
"""Create an async test client for the FastAPI app."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture
def test_bank_id():
"""Provide a unique bank ID for this test run."""
return f"test_reflections_{uuid.uuid4().hex[:8]}"
class TestReflectionsCRUD:
"""Test reflections CRUD operations via memory engine."""
@pytest.mark.asyncio
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 reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="Team Preferences",
source_query="What are the team's communication preferences?",
content="The team prefers async communication via Slack",
tags=["team"],
request_context=request_context,
)
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 reflection
fetched = await memory.get_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
request_context=request_context,
)
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_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 reflections
await memory.create_reflection(
bank_id=bank_id,
name="Reflection 1",
source_query="Query 1",
content="Content 1",
tags=["tag1"],
request_context=request_context,
)
await memory.create_reflection(
bank_id=bank_id,
name="Reflection 2",
source_query="Query 2",
content="Content 2",
tags=["tag2"],
request_context=request_context,
)
# List all
all_reflections = await memory.list_reflections(
bank_id=bank_id,
request_context=request_context,
)
assert len(all_reflections) == 2
# List with tag filter
tag1_reflections = await memory.list_reflections(
bank_id=bank_id,
tags=["tag1"],
request_context=request_context,
)
assert len(tag1_reflections) == 1
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
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 reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="Original Name",
source_query="Original Query",
content="Original Content",
request_context=request_context,
)
# Update the reflection
updated = await memory.update_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
name="Updated Name",
content="Updated Content",
request_context=request_context,
)
assert updated["name"] == "Updated Name"
assert updated["content"] == "Updated Content"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
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 reflection
reflection = await memory.create_reflection(
bank_id=bank_id,
name="To Delete",
source_query="Query",
content="Content",
request_context=request_context,
)
# Delete the reflection
await memory.delete_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
request_context=request_context,
)
# Verify deletion - should return None
fetched = await memory.get_reflection(
bank_id=bank_id,
reflection_id=reflection["id"],
request_context=request_context,
)
assert fetched is None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelsAPI:
"""Test mental models API endpoints.
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="Mental models endpoint removed - use recall with fact_type=['mental_model']")
@pytest.mark.asyncio
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="Mental models endpoint removed - use recall with fact_type=['mental_model']")
@pytest.mark.asyncio
async def test_get_mental_model_not_found(self, api_client, test_bank_id):
"""Test getting a non-existent mental model."""
pass
class TestReflectionsAPI:
"""Test reflections API endpoints."""
@pytest.mark.asyncio
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 reflection (async operation)
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/reflections",
json={
"name": "API Test Reflection",
"source_query": "What is the API test about?",
"content": "This is an API test reflection",
"tags": ["api-test"],
},
)
assert response.status_code == 200
create_result = response.json()
assert "operation_id" in create_result
operation_id = create_result["operation_id"]
# Wait for the async operation to complete
for _ in range(30): # Wait up to 30 seconds
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{operation_id}")
if response.status_code == 200:
op_status = response.json()
if op_status.get("status") == "completed":
break
await asyncio.sleep(1)
# 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
reflections = response.json()["items"]
assert len(reflections) >= 1
# 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 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 Reflection"
# Update the reflection
response = await api_client.patch(
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 Reflection"
# 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}/reflections/{reflection_id}")
assert response.status_code == 404
# 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):
"""Test that recall can include mental models in the response."""
# Create bank first via profile endpoint
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
# 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}/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?",
"content": "Artificial intelligence is the simulation of human intelligence",
"tags": [],
},
)
assert response.status_code == 200
# 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": {
"reflections": {"max_results": 5},
},
},
)
assert response.status_code == 200
result = response.json()
# 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_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 mental models
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "Test query",
},
)
assert response.status_code == 200
result = response.json()
# 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}")
@@ -257,7 +257,6 @@ from hindsight_api.extensions import (
RetainContext,
RecallContext,
ReflectContext,
RefreshMentalModelContext,
)
@@ -289,6 +288,3 @@ class MockOperationValidator(OperationValidatorExtension):
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
return ValidationResult.accept()
@@ -21,6 +21,8 @@ TABLES = [
"documents",
"chunks",
"async_operations",
"directives",
"reflections",
]
# Files to scan for SQL queries
+235
View File
@@ -329,6 +329,241 @@ class TestWorkerPoller:
assert row["status"] == "failed"
assert "Max retries" in row["error_message"]
@pytest.mark.asyncio
async def test_claim_batch_skips_consolidation_when_same_bank_processing(self, pool, clean_operations):
"""Test that pending consolidation is skipped if same bank has one processing."""
from hindsight_api.worker import WorkerPoller
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
# Create a processing consolidation for bank
processing_op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'other-worker')
""",
processing_op_id,
bank_id,
json.dumps({"type": "consolidation", "bank_id": bank_id}),
)
# Create a pending consolidation for same bank (should be skipped)
pending_op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb)
""",
pending_op_id,
bank_id,
json.dumps({"type": "consolidation", "bank_id": bank_id}),
)
# Create a pending consolidation for different bank (should be claimed)
other_bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
other_op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb)
""",
other_op_id,
other_bank_id,
json.dumps({"type": "consolidation", "bank_id": other_bank_id}),
)
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-1",
executor=lambda x: None,
batch_size=10,
)
claimed = await poller.claim_batch()
# Should only claim the consolidation for the other bank
assert len(claimed) == 1
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(
"SELECT status, worker_id FROM async_operations WHERE operation_id = $1",
pending_op_id,
)
assert row["status"] == "pending"
assert row["worker_id"] is None
@pytest.mark.asyncio
async def test_claim_batch_allows_non_consolidation_when_consolidation_processing(self, pool, clean_operations):
"""Test that non-consolidation tasks are still claimed even if consolidation is processing."""
from hindsight_api.worker import WorkerPoller
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
# Create a processing consolidation for bank
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'other-worker')
""",
uuid.uuid4(),
bank_id,
json.dumps({"type": "consolidation", "bank_id": bank_id}),
)
# Create a pending retain task for same bank (should be claimed)
retain_op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'retain', 'pending', $3::jsonb)
""",
retain_op_id,
bank_id,
json.dumps({"type": "batch_retain", "bank_id": bank_id}),
)
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-1",
executor=lambda x: None,
batch_size=10,
)
claimed = await poller.claim_batch()
# Should claim the retain task (non-consolidation tasks are unaffected)
assert len(claimed) == 1
claimed_op_id, _ = claimed[0]
assert claimed_op_id == str(retain_op_id)
class TestWorkerRecovery:
"""Tests for worker task recovery on startup."""
@pytest.mark.asyncio
async def test_recover_own_tasks_resets_processing_to_pending(self, pool, clean_operations):
"""Test that recover_own_tasks resets processing tasks back to pending."""
from hindsight_api.worker import WorkerPoller
# Create tasks that were being processed by this worker (simulating a crash)
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
worker_id = "crashed-worker"
task_ids = []
for i in range(3):
op_id = uuid.uuid4()
task_ids.append(op_id)
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, worker_id, claimed_at)
VALUES ($1, $2, 'test', 'processing', $3::jsonb, $4, now())
""",
op_id,
bank_id,
payload,
worker_id,
)
# Create poller with same worker_id and call recover
poller = WorkerPoller(
pool=pool,
worker_id=worker_id,
executor=lambda x: None,
)
recovered_count = await poller.recover_own_tasks()
assert recovered_count == 3
# Verify all tasks are back to pending with no worker assigned
rows = await pool.fetch(
"SELECT status, worker_id, claimed_at FROM async_operations WHERE bank_id = $1",
bank_id,
)
for row in rows:
assert row["status"] == "pending"
assert row["worker_id"] is None
assert row["claimed_at"] is None
@pytest.mark.asyncio
async def test_recover_own_tasks_does_not_affect_other_workers(self, pool, clean_operations):
"""Test that recover_own_tasks only affects tasks from the same worker_id."""
from hindsight_api.worker import WorkerPoller
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
# Create tasks for worker-1 (the one that will recover)
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, worker_id)
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-1')
""",
op_id,
bank_id,
payload,
)
# Create tasks for worker-2 (should not be affected)
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, worker_id)
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-2')
""",
op_id,
bank_id,
payload,
)
# Worker-1 recovers its tasks
poller = WorkerPoller(
pool=pool,
worker_id="worker-1",
executor=lambda x: None,
)
recovered_count = await poller.recover_own_tasks()
assert recovered_count == 2
# Verify worker-1 tasks are released
worker1_rows = await pool.fetch(
"SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id IS NULL",
bank_id,
)
assert len(worker1_rows) == 2
# Verify worker-2 tasks are unaffected
worker2_rows = await pool.fetch(
"SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id = 'worker-2'",
bank_id,
)
assert len(worker2_rows) == 2
for row in worker2_rows:
assert row["status"] == "processing"
@pytest.mark.asyncio
async def test_recover_own_tasks_returns_zero_when_no_stale_tasks(self, pool, clean_operations):
"""Test that recover_own_tasks returns 0 when there are no stale tasks."""
from hindsight_api.worker import WorkerPoller
poller = WorkerPoller(
pool=pool,
worker_id="fresh-worker",
executor=lambda x: None,
)
recovered_count = await poller.recover_own_tasks()
assert recovered_count == 0
class TestConcurrentWorkers:
"""Tests for concurrent worker task claiming (FOR UPDATE SKIP LOCKED)."""
+2 -23
View File
@@ -115,31 +115,10 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED=
# Test 14: Clear memories
run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
# Test 15: Health check
run_test_output "health check" "healthy" "$HINDSIGHT_CLI" health || FAILED=1
# Test 16: List memories (new command)
run_test "list memories" "$HINDSIGHT_CLI" memory list "$TEST_BANK" || FAILED=1
# Test 17: List tags
run_test "list tags" "$HINDSIGHT_CLI" tag list "$TEST_BANK" || FAILED=1
# Test 18: List mental models
run_test "list mental models" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
# Test 19: Create mental model
run_test "create mental model" "$HINDSIGHT_CLI" mental-model create "$TEST_BANK" "Test Model" "A test mental model" || FAILED=1
# Test 20: List mental models (should have one now)
run_test_output "list mental models with model" "Test Model" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
# Test 21: Bank graph
run_test "bank graph" "$HINDSIGHT_CLI" bank graph "$TEST_BANK" || FAILED=1
# Test 22: List operations
# Test 15: List operations
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
# Test 23: Delete bank
# Test 16: Delete bank
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
echo ""
+105 -140
View File
@@ -173,7 +173,7 @@ impl ApiClient {
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
self.runtime.block_on(async {
loop {
let response = self.client.list_operations(agent_id, None).await?;
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
let ops = response.into_inner();
// Find our operation
@@ -258,7 +258,7 @@ impl ApiClient {
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
self.runtime.block_on(async {
let response = self.client.list_operations(agent_id, None).await?;
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
let value = response.into_inner();
// Convert to JSON Value first, then parse into our type
let json_value = serde_json::to_value(&value)?;
@@ -321,144 +321,6 @@ impl ApiClient {
// ============================================================================
impl ApiClient {
// --- Mental Model Methods ---
pub fn list_mental_models(
&self,
bank_id: &str,
subtype: Option<&str>,
tags: Option<Vec<String>>,
tags_match: Option<&str>,
_verbose: bool,
) -> Result<types::MentalModelListResponse> {
self.runtime.block_on(async {
let tags_match_enum = match tags_match {
Some("all") => Some(types::TagsMatch::All),
Some("any_strict") => Some(types::TagsMatch::AnyStrict),
Some("all_strict") => Some(types::TagsMatch::AllStrict),
_ => Some(types::TagsMatch::Any),
};
let response = self.client.list_mental_models(
bank_id,
subtype,
tags.as_ref(),
tags_match_enum,
None,
).await?;
Ok(response.into_inner())
})
}
pub fn get_mental_model(
&self,
bank_id: &str,
model_id: &str,
_verbose: bool,
) -> Result<types::MentalModelResponse> {
self.runtime.block_on(async {
let response = self.client.get_mental_model(bank_id, model_id, None).await?;
Ok(response.into_inner())
})
}
pub fn create_mental_model(
&self,
bank_id: &str,
request: &types::CreateMentalModelRequest,
_verbose: bool,
) -> Result<types::MentalModelResponse> {
self.runtime.block_on(async {
let response = self.client.create_mental_model(bank_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn delete_mental_model(
&self,
bank_id: &str,
model_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.delete_mental_model(bank_id, model_id, None).await?;
Ok(response.into_inner())
})
}
pub fn update_mental_model(
&self,
bank_id: &str,
model_id: &str,
request: &types::UpdateMentalModelRequest,
_verbose: bool,
) -> Result<types::MentalModelResponse> {
self.runtime.block_on(async {
let response = self.client.update_mental_model(bank_id, model_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn refresh_mental_models(
&self,
bank_id: &str,
subtype: Option<&str>,
tags: Option<Vec<String>>,
_verbose: bool,
) -> Result<types::AsyncOperationSubmitResponse> {
self.runtime.block_on(async {
let subtype_enum = match subtype {
Some("structural") => Some(types::Subtype::Structural),
Some("emergent") => Some(types::Subtype::Emergent),
Some("pinned") => Some(types::Subtype::Pinned),
Some("learned") => Some(types::Subtype::Learned),
_ => None,
};
let request = types::RefreshMentalModelsRequest {
subtype: subtype_enum,
tags,
};
let response = self.client.refresh_mental_models(bank_id, None, &request).await?;
Ok(response.into_inner())
})
}
pub fn refresh_mental_model(
&self,
bank_id: &str,
model_id: &str,
_verbose: bool,
) -> Result<types::AsyncOperationSubmitResponse> {
self.runtime.block_on(async {
let response = self.client.refresh_mental_model(bank_id, model_id, None).await?;
Ok(response.into_inner())
})
}
pub fn list_mental_model_versions(
&self,
bank_id: &str,
model_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.list_mental_model_versions(bank_id, model_id, None).await?;
Ok(response.into_inner())
})
}
pub fn get_mental_model_version(
&self,
bank_id: &str,
model_id: &str,
version: i64,
_verbose: bool,
) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.get_mental_model_version(bank_id, model_id, version, None).await?;
Ok(response.into_inner())
})
}
// --- Memory Methods ---
pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result<serde_json::Value> {
@@ -574,6 +436,109 @@ impl ApiClient {
Ok(response.into_inner())
})
}
// --- Reflection Methods ---
pub fn list_reflections(&self, bank_id: &str, _verbose: bool) -> Result<types::ReflectionListResponse> {
self.runtime.block_on(async {
let response = self.client.list_reflections(bank_id, None, None, None, None, None).await?;
Ok(response.into_inner())
})
}
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_reflection(bank_id, reflection_id, None).await?;
Ok(response.into_inner())
})
}
pub fn create_reflection(
&self,
bank_id: &str,
request: &types::CreateReflectionRequest,
_verbose: bool,
) -> Result<types::CreateReflectionResponse> {
self.runtime.block_on(async {
let response = self.client.create_reflection(bank_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn update_reflection(
&self,
bank_id: &str,
reflection_id: &str,
request: &types::UpdateReflectionRequest,
_verbose: bool,
) -> Result<types::ReflectionResponse> {
self.runtime.block_on(async {
let response = self.client.update_reflection(bank_id, reflection_id, None, request).await?;
Ok(response.into_inner())
})
}
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_reflection(bank_id, reflection_id, None).await?;
Ok(response.into_inner())
})
}
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_reflection(bank_id, reflection_id, None).await?;
Ok(response.into_inner())
})
}
// --- Directive Methods ---
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
self.runtime.block_on(async {
let response = self.client.list_directives(bank_id, None, None, None, None, None, None).await?;
Ok(response.into_inner())
})
}
pub fn get_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<types::DirectiveResponse> {
self.runtime.block_on(async {
let response = self.client.get_directive(bank_id, directive_id, None).await?;
Ok(response.into_inner())
})
}
pub fn create_directive(
&self,
bank_id: &str,
request: &types::CreateDirectiveRequest,
_verbose: bool,
) -> Result<types::DirectiveResponse> {
self.runtime.block_on(async {
let response = self.client.create_directive(bank_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn update_directive(
&self,
bank_id: &str,
directive_id: &str,
request: &types::UpdateDirectiveRequest,
_verbose: bool,
) -> Result<types::DirectiveResponse> {
self.runtime.block_on(async {
let response = self.client.update_directive(bank_id, directive_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn delete_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.delete_directive(bank_id, directive_id, None).await?;
Ok(response.into_inner())
})
}
}
// Re-export types from the generated client for use in commands
+266
View File
@@ -0,0 +1,266 @@
//! Directive commands for managing behavioral rules.
use anyhow::Result;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
use hindsight_client::types;
/// List directives for a bank
pub fn list(
client: &ApiClient,
bank_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching directives..."))
} else {
None
};
let response = client.list_directives(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Directives: {}", bank_id));
if result.items.is_empty() {
println!(" {}", ui::dim("No directives found."));
} else {
for directive in &result.items {
let status = if directive.is_active {
ui::gradient_start("active")
} else {
ui::dim("inactive")
};
println!(
" {} {} [{}]",
ui::gradient_start(&directive.id),
directive.name,
status
);
// Show content preview
let preview: String = directive.content.chars().take(80).collect();
let ellipsis = if directive.content.len() > 80 { "..." } else { "" };
println!(" {}{}", ui::dim(&preview), ellipsis);
println!();
}
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Get a specific directive
pub fn get(
client: &ApiClient,
bank_id: &str,
directive_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching directive..."))
} else {
None
};
let response = client.get_directive(bank_id, directive_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(directive) => {
if output_format == OutputFormat::Pretty {
print_directive_detail(&directive);
} else {
output::print_output(&directive, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Create a new directive
pub fn create(
client: &ApiClient,
bank_id: &str,
name: &str,
content: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Creating directive..."))
} else {
None
};
let request = types::CreateDirectiveRequest {
name: name.to_string(),
content: content.to_string(),
is_active: true,
priority: 0,
tags: vec![],
};
let response = client.create_directive(bank_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(directive) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Directive '{}' created successfully", directive.id));
println!();
print_directive_detail(&directive);
} else {
output::print_output(&directive, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Update a directive
pub fn update(
client: &ApiClient,
bank_id: &str,
directive_id: &str,
name: Option<String>,
content: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() && content.is_none() {
anyhow::bail!("At least one of --name or --content must be provided");
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating directive..."))
} else {
None
};
let request = types::UpdateDirectiveRequest {
name,
content,
is_active: None,
priority: None,
tags: None,
};
let response = client.update_directive(bank_id, directive_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(directive) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Directive '{}' updated successfully", directive_id));
println!();
print_directive_detail(&directive);
} else {
output::print_output(&directive, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Delete a directive
pub fn delete(
client: &ApiClient,
bank_id: &str,
directive_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
// Confirmation prompt unless -y flag is used
if !yes && output_format == OutputFormat::Pretty {
let message = format!(
"Are you sure you want to delete directive '{}'? This cannot be undone.",
directive_id
);
let confirmed = ui::prompt_confirmation(&message)?;
if !confirmed {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Deleting directive..."))
} else {
None
};
let response = client.delete_directive(bank_id, directive_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(_) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Directive '{}' deleted successfully", directive_id));
} else {
println!("{{\"success\": true}}");
}
Ok(())
}
Err(e) => Err(e),
}
}
// Helper function to print directive details
fn print_directive_detail(directive: &types::DirectiveResponse) {
ui::print_section_header(&directive.name);
println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&directive.id));
let status = if directive.is_active {
ui::gradient_start("active")
} else {
ui::dim("inactive")
};
println!(" {} {}", ui::dim("Status:"), status);
println!(" {} {}", ui::dim("Priority:"), directive.priority);
if !directive.tags.is_empty() {
println!(" {} {}", ui::dim("Tags:"), directive.tags.join(", "));
}
println!();
println!("{}", ui::gradient_text("─── Content ───"));
println!();
println!("{}", &directive.content);
println!();
}
-721
View File
@@ -1,721 +0,0 @@
//! Mental model commands for managing structured knowledge containers.
use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
use hindsight_client::types;
use serde::Deserialize;
// Local types for serde_json::Value deserialization
#[derive(Debug, Deserialize)]
struct VersionListResponse {
versions: Vec<VersionItem>,
}
#[derive(Debug, Deserialize)]
struct VersionItem {
version: i64,
created_at: String,
observations_count: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct VersionDetailResponse {
version: i64,
created_at: String,
observations: Option<Vec<ObservationData>>,
}
#[derive(Debug, Deserialize)]
struct ObservationData {
title: String,
content: String,
trend: Option<String>,
evidence: Option<Vec<EvidenceData>>,
}
#[derive(Debug, Deserialize)]
struct EvidenceData {
quote: String,
}
/// List mental models for a bank
pub fn list(
client: &ApiClient,
bank_id: &str,
subtype: Option<String>,
tags: Option<Vec<String>>,
tags_match: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching mental models..."))
} else {
None
};
let response = client.list_mental_models(
bank_id,
subtype.as_deref(),
tags,
tags_match.as_deref(),
verbose,
);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Mental Models: {}", bank_id));
if result.items.is_empty() {
println!(" {}", ui::dim("No mental models found."));
} else {
for model in &result.items {
let subtype_str = &model.subtype;
let obs_count = model.observations.len();
println!(
" {} {} {}",
ui::gradient_start(&model.id),
ui::dim(&format!("[{}]", subtype_str)),
model.name
);
if !model.description.is_empty() {
println!(" {}", ui::dim(&model.description));
}
println!(
" {} observations, v{}",
obs_count,
model.version
);
// Show freshness status
if let Some(freshness) = &model.freshness {
let status = if freshness.is_up_to_date {
ui::gradient_start("up to date")
} else {
ui::gradient_end("needs refresh")
};
println!(" {}", status);
}
println!();
}
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Get a specific mental model
pub fn get(
client: &ApiClient,
bank_id: &str,
model_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching mental model..."))
} else {
None
};
let response = client.get_mental_model(bank_id, model_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(model) => {
if output_format == OutputFormat::Pretty {
print_mental_model_detail(&model);
} else {
output::print_output(&model, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Create a new mental model
pub fn create(
client: &ApiClient,
bank_id: &str,
name: &str,
description: &str,
subtype: Option<String>,
tags: Option<Vec<String>>,
observations_file: Option<PathBuf>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Creating mental model..."))
} else {
None
};
// Parse observations from file if provided
let observations = if let Some(path) = observations_file {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read observations file: {}", path.display()))?;
let obs: Vec<types::ObservationInput> = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse observations JSON from: {}", path.display()))?;
Some(obs)
} else {
None
};
let request = types::CreateMentalModelRequest {
name: name.to_string(),
description: description.to_string(),
subtype: subtype.unwrap_or_else(|| "pinned".to_string()),
tags: tags.unwrap_or_default(),
observations,
};
let response = client.create_mental_model(bank_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(model) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Mental model '{}' created successfully", model.id));
println!();
print_mental_model_detail(&model);
} else {
output::print_output(&model, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Delete a mental model
pub fn delete(
client: &ApiClient,
bank_id: &str,
model_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
// Confirmation prompt unless -y flag is used
if !yes && output_format == OutputFormat::Pretty {
let message = format!(
"Are you sure you want to delete mental model '{}'? This cannot be undone.",
model_id
);
let confirmed = ui::prompt_confirmation(&message)?;
if !confirmed {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Deleting mental model..."))
} else {
None
};
let response = client.delete_mental_model(bank_id, model_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
if result.success {
ui::print_success(&format!("Mental model '{}' deleted successfully", model_id));
} else {
ui::print_error("Failed to delete mental model");
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Update a mental model's name or description
pub fn update(
client: &ApiClient,
bank_id: &str,
model_id: &str,
name: Option<String>,
description: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() && description.is_none() {
anyhow::bail!("At least one of --name or --description must be provided");
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating mental model..."))
} else {
None
};
let request = types::UpdateMentalModelRequest { name, description };
let response = client.update_mental_model(bank_id, model_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(model) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Mental model '{}' updated successfully", model_id));
println!();
print_mental_model_detail(&model);
} else {
output::print_output(&model, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Refresh all mental models (or filtered by subtype)
pub fn refresh_all(
client: &ApiClient,
bank_id: &str,
subtype: Option<String>,
tags: Option<Vec<String>>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Submitting refresh request..."))
} else {
None
};
let response = client.refresh_mental_models(bank_id, subtype.as_deref(), tags, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success("Refresh operation submitted");
println!(" Operation ID: {}", result.operation_id);
println!(" Status: {}", result.status);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Refresh a specific mental model
pub fn refresh(
client: &ApiClient,
bank_id: &str,
model_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Submitting refresh request..."))
} else {
None
};
let response = client.refresh_mental_model(bank_id, model_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Refresh submitted for model '{}'", model_id));
println!(" Operation ID: {}", result.operation_id);
println!(" Status: {}", result.status);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// List version history for a mental model
pub fn versions(
client: &ApiClient,
bank_id: &str,
model_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching versions..."))
} else {
None
};
let response = client.list_mental_model_versions(bank_id, model_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(value) => {
if output_format == OutputFormat::Pretty {
let result: VersionListResponse = serde_json::from_value(value)
.with_context(|| "Failed to parse version list response")?;
ui::print_section_header(&format!("Version History: {}", model_id));
if result.versions.is_empty() {
println!(" {}", ui::dim("No versions found."));
} else {
for version in &result.versions {
let obs_count = version.observations_count.unwrap_or(0);
println!(
" {} v{} - {} observations",
ui::gradient_start(&format!("v{}", version.version)),
version.version,
obs_count
);
println!(" {}", ui::dim(&version.created_at));
}
}
} else {
output::print_output(&value, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Get a specific version of a mental model
pub fn version(
client: &ApiClient,
bank_id: &str,
model_id: &str,
version_num: i64,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching version..."))
} else {
None
};
let response = client.get_mental_model_version(bank_id, model_id, version_num, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(value) => {
if output_format == OutputFormat::Pretty {
let result: VersionDetailResponse = serde_json::from_value(value)
.with_context(|| "Failed to parse version response")?;
ui::print_section_header(&format!("{} v{}", model_id, version_num));
println!(" {} {}", ui::dim("Created:"), result.created_at);
println!();
if let Some(observations) = &result.observations {
if observations.is_empty() {
println!(" {}", ui::dim("No observations in this version."));
} else {
for (i, obs) in observations.iter().enumerate() {
print_observation_data(i + 1, obs);
}
}
}
} else {
output::print_output(&value, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
// Helper function to print mental model details
fn print_mental_model_detail(model: &types::MentalModelResponse) {
ui::print_section_header(&model.name);
let subtype_str = &model.subtype;
println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&model.id));
println!(" {} {}", ui::dim("Subtype:"), subtype_str);
println!(" {} v{}", ui::dim("Version:"), model.version);
if !model.description.is_empty() {
println!(" {} {}", ui::dim("Description:"), &model.description);
}
if !model.tags.is_empty() {
println!(" {} {}", ui::dim("Tags:"), model.tags.join(", "));
}
// Freshness status
if let Some(freshness) = &model.freshness {
println!();
println!("{}", ui::gradient_text("─── Freshness ───"));
let status = if freshness.is_up_to_date {
ui::gradient_start("Up to date")
} else {
ui::gradient_end("Needs refresh")
};
println!(" {} {}", ui::dim("Status:"), status);
if let Some(last_refresh) = &freshness.last_refresh_at {
println!(" {} {}", ui::dim("Last refresh:"), last_refresh);
}
if freshness.memories_since_refresh > 0 {
println!(" {} {}", ui::dim("New memories:"), freshness.memories_since_refresh);
}
if !freshness.reasons.is_empty() {
println!(" {} {}", ui::dim("Reasons:"), freshness.reasons.join(", "));
}
}
// Observations
println!();
println!("{}", ui::gradient_text("─── Observations ───"));
println!();
if model.observations.is_empty() {
println!(" {}", ui::dim("No observations yet."));
} else {
for (i, obs) in model.observations.iter().enumerate() {
print_observation(i + 1, obs);
}
}
println!();
}
fn print_observation(index: usize, obs: &types::MentalModelObservationResponse) {
let trend_str = &obs.trend;
let trend_colored = match trend_str.as_str() {
"strengthening" => ui::gradient_start(trend_str),
"stable" => ui::gradient_mid(trend_str),
"weakening" | "stale" => ui::gradient_end(trend_str),
_ => trend_str.to_string(),
};
println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored)));
println!(" {}", obs.content);
// Show evidence if available
if !obs.evidence.is_empty() {
println!(" {} evidence items:", ui::dim(&obs.evidence.len().to_string()));
for ev in obs.evidence.iter().take(2) {
// Show first 2 evidence items
let quote_preview: String = ev.quote.chars().take(60).collect();
let ellipsis = if ev.quote.len() > 60 { "..." } else { "" };
println!("\"{}{}\"", quote_preview, ellipsis);
}
if obs.evidence.len() > 2 {
println!(" {} more...", ui::dim(&format!("+ {}", obs.evidence.len() - 2)));
}
}
println!();
}
fn print_observation_data(index: usize, obs: &ObservationData) {
let trend_str = obs.trend.as_deref().unwrap_or("unknown");
let trend_colored = match trend_str {
"strengthening" => ui::gradient_start(trend_str),
"stable" => ui::gradient_mid(trend_str),
"weakening" | "stale" => ui::gradient_end(trend_str),
_ => trend_str.to_string(),
};
println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored)));
println!(" {}", obs.content);
// Show evidence if available
if let Some(evidence) = &obs.evidence {
if !evidence.is_empty() {
println!(" {} evidence items:", ui::dim(&evidence.len().to_string()));
for ev in evidence.iter().take(2) {
// Show first 2 evidence items
let quote_preview: String = ev.quote.chars().take(60).collect();
let ellipsis = if ev.quote.len() > 60 { "..." } else { "" };
println!("\"{}{}\"", quote_preview, ellipsis);
}
if evidence.len() > 2 {
println!(" {} more...", ui::dim(&format!("+ {}", evidence.len() - 2)));
}
}
}
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_observation_input_serialization() {
let obs = types::ObservationInput {
title: "Test observation".to_string(),
content: "Test content".to_string(),
};
let json = serde_json::to_string(&obs).unwrap();
assert!(json.contains("Test observation"));
assert!(json.contains("Test content"));
}
#[test]
fn test_version_list_response_deserialization() {
let json = r#"{
"versions": [
{"version": 1, "created_at": "2024-01-10T10:00:00Z", "observations_count": 5},
{"version": 2, "created_at": "2024-01-15T10:00:00Z", "observations_count": 8}
]
}"#;
let value: serde_json::Value = serde_json::from_str(json).unwrap();
let result: VersionListResponse = serde_json::from_value(value).unwrap();
assert_eq!(result.versions.len(), 2);
assert_eq!(result.versions[0].version, 1);
assert_eq!(result.versions[1].version, 2);
assert_eq!(result.versions[1].observations_count, Some(8));
}
#[test]
fn test_version_detail_response_deserialization() {
let json = r#"{
"version": 1,
"created_at": "2024-01-10T10:00:00Z",
"observations": [
{
"title": "Test observation",
"content": "Test content",
"trend": "stable",
"evidence": [{"quote": "test evidence"}]
}
]
}"#;
let value: serde_json::Value = serde_json::from_str(json).unwrap();
let result: VersionDetailResponse = serde_json::from_value(value).unwrap();
assert_eq!(result.created_at, "2024-01-10T10:00:00Z");
let observations = result.observations.unwrap();
assert_eq!(observations.len(), 1);
assert_eq!(observations[0].title, "Test observation");
assert_eq!(observations[0].trend, Some("stable".to_string()));
}
#[test]
fn test_observation_data_deserialization() {
let json = r#"{
"title": "Test Title",
"content": "Test Content",
"trend": "strengthening",
"evidence": [
{"quote": "Evidence 1"},
{"quote": "Evidence 2"}
]
}"#;
let result: ObservationData = serde_json::from_str(json).unwrap();
assert_eq!(result.title, "Test Title");
assert_eq!(result.content, "Test Content");
assert_eq!(result.trend, Some("strengthening".to_string()));
let evidence = result.evidence.unwrap();
assert_eq!(evidence.len(), 2);
assert_eq!(evidence[0].quote, "Evidence 1");
}
#[test]
fn test_create_mental_model_request() {
let request = types::CreateMentalModelRequest {
name: "Test Model".to_string(),
description: "A test model".to_string(),
subtype: "pinned".to_string(),
tags: vec!["test".to_string()],
observations: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("Test Model"));
assert!(json.contains("pinned"));
assert!(json.contains("test"));
}
#[test]
fn test_update_mental_model_request() {
let request = types::UpdateMentalModelRequest {
name: Some("Updated Name".to_string()),
description: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("Updated Name"));
}
#[test]
fn test_async_operation_submit_response_deserialization() {
let json = r#"{
"operation_id": "op-123",
"status": "pending"
}"#;
let result: types::AsyncOperationSubmitResponse = serde_json::from_str(json).unwrap();
assert_eq!(result.operation_id, "op-123");
assert_eq!(result.status, "pending");
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
pub mod bank;
pub mod chunk;
pub mod directive;
pub mod document;
pub mod entity;
pub mod explore;
pub mod health;
pub mod memory;
pub mod mental_model;
pub mod operation;
pub mod reflection;
pub mod tag;
+274
View File
@@ -0,0 +1,274 @@
//! Reflection commands for managing user-curated summaries.
use anyhow::Result;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
use hindsight_client::types;
/// List reflections for a bank
pub fn list(
client: &ApiClient,
bank_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching reflections..."))
} else {
None
};
let response = client.list_reflections(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Reflections: {}", bank_id));
if result.items.is_empty() {
println!(" {}", ui::dim("No reflections found."));
} else {
for reflection in &result.items {
println!(
" {} {}",
ui::gradient_start(&reflection.id),
reflection.name
);
// Show content preview
let preview: String = reflection.content.chars().take(80).collect();
let ellipsis = if reflection.content.len() > 80 { "..." } else { "" };
println!(" {}{}", ui::dim(&preview), ellipsis);
println!();
}
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Get a specific reflection
pub fn get(
client: &ApiClient,
bank_id: &str,
reflection_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching reflection..."))
} else {
None
};
let response = client.get_reflection(bank_id, reflection_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(reflection) => {
if output_format == OutputFormat::Pretty {
print_reflection_detail(&reflection);
} else {
output::print_output(&reflection, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Create a new reflection
pub fn create(
client: &ApiClient,
bank_id: &str,
name: &str,
source_query: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Creating reflection..."))
} else {
None
};
let request = types::CreateReflectionRequest {
name: name.to_string(),
source_query: source_query.to_string(),
max_tokens: 2048,
tags: vec![],
};
let response = client.create_reflection(bank_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Reflection created, operation_id: {}", result.operation_id));
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Update a reflection
pub fn update(
client: &ApiClient,
bank_id: &str,
reflection_id: &str,
name: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() {
anyhow::bail!("--name must be provided");
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating reflection..."))
} else {
None
};
let request = types::UpdateReflectionRequest { name };
let response = client.update_reflection(bank_id, reflection_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(reflection) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Reflection '{}' updated successfully", reflection_id));
println!();
print_reflection_detail(&reflection);
} else {
output::print_output(&reflection, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Delete a reflection
pub fn delete(
client: &ApiClient,
bank_id: &str,
reflection_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
// Confirmation prompt unless -y flag is used
if !yes && output_format == OutputFormat::Pretty {
let message = format!(
"Are you sure you want to delete reflection '{}'? This cannot be undone.",
reflection_id
);
let confirmed = ui::prompt_confirmation(&message)?;
if !confirmed {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Deleting reflection..."))
} else {
None
};
let response = client.delete_reflection(bank_id, reflection_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(_) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Reflection '{}' deleted successfully", reflection_id));
} else {
println!("{{\"success\": true}}");
}
Ok(())
}
Err(e) => Err(e),
}
}
/// Refresh a reflection
pub fn refresh(
client: &ApiClient,
bank_id: &str,
reflection_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Refreshing reflection..."))
} else {
None
};
let response = client.refresh_reflection(bank_id, reflection_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(reflection) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Reflection '{}' refreshed successfully", reflection_id));
println!();
print_reflection_detail(&reflection);
} else {
output::print_output(&reflection, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
// 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(&reflection.id));
println!(" {} {}", ui::dim("Source Query:"), &reflection.source_query);
println!();
println!("{}", ui::gradient_text("─── Content ───"));
println!();
println!("{}", &reflection.content);
println!();
}
+174 -163
View File
@@ -75,10 +75,6 @@ enum Commands {
#[command(subcommand)]
Memory(MemoryCommands),
/// Manage mental models (list, get, create, update, delete, refresh, versions)
#[command(subcommand)]
MentalModel(MentalModelCommands),
/// Manage documents (list, get, delete)
#[command(subcommand)]
Document(DocumentCommands),
@@ -99,6 +95,14 @@ enum Commands {
#[command(subcommand)]
Operation(OperationCommands),
/// Manage reflections (user-curated summaries)
#[command(subcommand)]
Reflection(ReflectionCommands),
/// Manage directives (behavioral rules)
#[command(subcommand)]
Directive(DirectiveCommands),
/// Check API health status
Health,
@@ -504,134 +508,6 @@ enum OperationCommands {
},
}
#[derive(Subcommand)]
enum MentalModelCommands {
/// List mental models for a bank
List {
/// Bank ID
bank_id: String,
/// Filter by subtype (structural, emergent, pinned, learned, directive)
#[arg(long)]
subtype: Option<String>,
/// Filter by tags
#[arg(long, value_delimiter = ',')]
tags: Option<Vec<String>>,
/// Tag matching mode (any, all, any_strict, all_strict)
#[arg(long, default_value = "any")]
tags_match: Option<String>,
},
/// Get a specific mental model
Get {
/// Bank ID
bank_id: String,
/// Mental model ID
model_id: String,
},
/// Create a new mental model (pinned or directive subtype)
Create {
/// Bank ID
bank_id: String,
/// Model name
name: String,
/// Model description
description: String,
/// Subtype (pinned or directive)
#[arg(long, default_value = "pinned")]
subtype: Option<String>,
/// Tags for the model
#[arg(long, value_delimiter = ',')]
tags: Option<Vec<String>>,
/// Path to JSON file containing initial observations
#[arg(long)]
observations: Option<PathBuf>,
},
/// Update a mental model's name or description
Update {
/// Bank ID
bank_id: String,
/// Mental model ID
model_id: String,
/// New name
#[arg(long)]
name: Option<String>,
/// New description
#[arg(long)]
description: Option<String>,
},
/// Delete a mental model
Delete {
/// Bank ID
bank_id: String,
/// Mental model ID
model_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
/// Refresh all mental models (async operation)
RefreshAll {
/// Bank ID
bank_id: String,
/// Filter by subtype
#[arg(long)]
subtype: Option<String>,
/// Filter by tags
#[arg(long, value_delimiter = ',')]
tags: Option<Vec<String>>,
},
/// Refresh a specific mental model (async operation)
Refresh {
/// Bank ID
bank_id: String,
/// Mental model ID
model_id: String,
},
/// List version history for a mental model
Versions {
/// Bank ID
bank_id: String,
/// Mental model ID
model_id: String,
},
/// Get a specific version of a mental model
Version {
/// Bank ID
bank_id: String,
/// Mental model ID
model_id: String,
/// Version number
version: i64,
},
}
#[derive(Subcommand)]
enum TagCommands {
/// List tags in a bank
@@ -662,6 +538,131 @@ enum ChunkCommands {
},
}
#[derive(Subcommand)]
enum ReflectionCommands {
/// List reflections for a bank
List {
/// Bank ID
bank_id: String,
},
/// Get a specific reflection
Get {
/// Bank ID
bank_id: String,
/// Reflection ID
reflection_id: String,
},
/// Create a new reflection
Create {
/// Bank ID
bank_id: String,
/// Reflection name
name: String,
/// Source query to generate the reflection from
source_query: String,
},
/// Update a reflection
Update {
/// Bank ID
bank_id: String,
/// Reflection ID
reflection_id: String,
/// New name
#[arg(long)]
name: Option<String>,
},
/// Delete a reflection
Delete {
/// Bank ID
bank_id: String,
/// Reflection ID
reflection_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
/// Refresh a reflection (re-run the source query)
Refresh {
/// Bank ID
bank_id: String,
/// Reflection ID
reflection_id: String,
},
}
#[derive(Subcommand)]
enum DirectiveCommands {
/// List directives for a bank
List {
/// Bank ID
bank_id: String,
},
/// Get a specific directive
Get {
/// Bank ID
bank_id: String,
/// Directive ID
directive_id: String,
},
/// Create a new directive
Create {
/// Bank ID
bank_id: String,
/// Directive name
name: String,
/// Directive content (the text to inject into prompts)
content: String,
},
/// Update a directive
Update {
/// Bank ID
bank_id: String,
/// Directive ID
directive_id: String,
/// New name
#[arg(long)]
name: Option<String>,
/// New content
#[arg(long)]
content: Option<String>,
},
/// Delete a directive
Delete {
/// Bank ID
bank_id: String,
/// Directive ID
directive_id: String,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
fn main() {
if let Err(_) = run() {
std::process::exit(1);
@@ -763,37 +764,6 @@ fn run() -> Result<()> {
}
},
// Mental Model commands
Commands::MentalModel(mm_cmd) => match mm_cmd {
MentalModelCommands::List { bank_id, subtype, tags, tags_match } => {
commands::mental_model::list(&client, &bank_id, subtype, tags, tags_match, verbose, output_format)
}
MentalModelCommands::Get { bank_id, model_id } => {
commands::mental_model::get(&client, &bank_id, &model_id, verbose, output_format)
}
MentalModelCommands::Create { bank_id, name, description, subtype, tags, observations } => {
commands::mental_model::create(&client, &bank_id, &name, &description, subtype, tags, observations, verbose, output_format)
}
MentalModelCommands::Update { bank_id, model_id, name, description } => {
commands::mental_model::update(&client, &bank_id, &model_id, name, description, verbose, output_format)
}
MentalModelCommands::Delete { bank_id, model_id, yes } => {
commands::mental_model::delete(&client, &bank_id, &model_id, yes, verbose, output_format)
}
MentalModelCommands::RefreshAll { bank_id, subtype, tags } => {
commands::mental_model::refresh_all(&client, &bank_id, subtype, tags, verbose, output_format)
}
MentalModelCommands::Refresh { bank_id, model_id } => {
commands::mental_model::refresh(&client, &bank_id, &model_id, verbose, output_format)
}
MentalModelCommands::Versions { bank_id, model_id } => {
commands::mental_model::versions(&client, &bank_id, &model_id, verbose, output_format)
}
MentalModelCommands::Version { bank_id, model_id, version } => {
commands::mental_model::version(&client, &bank_id, &model_id, version, verbose, output_format)
}
},
// Document commands
Commands::Document(doc_cmd) => match doc_cmd {
DocumentCommands::List { bank_id, query, limit, offset } => {
@@ -846,6 +816,47 @@ fn run() -> Result<()> {
commands::operation::cancel(&client, &bank_id, &operation_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)
}
ReflectionCommands::Get { bank_id, reflection_id } => {
commands::reflection::get(&client, &bank_id, &reflection_id, verbose, output_format)
}
ReflectionCommands::Create { bank_id, name, source_query } => {
commands::reflection::create(&client, &bank_id, &name, &source_query, verbose, output_format)
}
ReflectionCommands::Update { bank_id, reflection_id, name } => {
commands::reflection::update(&client, &bank_id, &reflection_id, name, verbose, output_format)
}
ReflectionCommands::Delete { bank_id, reflection_id, yes } => {
commands::reflection::delete(&client, &bank_id, &reflection_id, yes, verbose, output_format)
}
ReflectionCommands::Refresh { bank_id, reflection_id } => {
commands::reflection::refresh(&client, &bank_id, &reflection_id, verbose, output_format)
}
},
// Directive commands
Commands::Directive(dir_cmd) => match dir_cmd {
DirectiveCommands::List { bank_id } => {
commands::directive::list(&client, &bank_id, verbose, output_format)
}
DirectiveCommands::Get { bank_id, directive_id } => {
commands::directive::get(&client, &bank_id, &directive_id, verbose, output_format)
}
DirectiveCommands::Create { bank_id, name, content } => {
commands::directive::create(&client, &bank_id, &name, &content, verbose, output_format)
}
DirectiveCommands::Update { bank_id, directive_id, name, content } => {
commands::directive::update(&client, &bank_id, &directive_id, name, content, verbose, output_format)
}
DirectiveCommands::Delete { bank_id, directive_id, yes } => {
commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format)
}
},
};
// Handle API errors with nice messages
+1 -1
View File
@@ -173,7 +173,7 @@ pub fn print_think_response(response: &ReflectResponse) {
println!();
if let Some(based_on) = &response.based_on {
let count = based_on.memories.len() + based_on.mental_models.len();
let count = based_on.memories.len();
if count > 0 {
println!("{}", dim(&format!("Based on {} memory units", count)));
}
@@ -1,19 +1,19 @@
hindsight_client_api/__init__.py
hindsight_client_api/api/__init__.py
hindsight_client_api/api/banks_api.py
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
@@ -24,11 +24,15 @@ hindsight_client_api/models/cancel_operation_response.py
hindsight_client_api/models/chunk_data.py
hindsight_client_api/models/chunk_include_options.py
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_mental_model_request.py
hindsight_client_api/models/created_mental_model.py
hindsight_client_api/models/create_directive_request.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
hindsight_client_api/models/directive_response.py
hindsight_client_api/models/disposition_traits.py
hindsight_client_api/models/document_response.py
hindsight_client_api/models/entity_detail_response.py
@@ -38,6 +42,7 @@ hindsight_client_api/models/entity_list_item.py
hindsight_client_api/models/entity_list_response.py
hindsight_client_api/models/entity_observation_response.py
hindsight_client_api/models/entity_state_response.py
hindsight_client_api/models/features_info.py
hindsight_client_api/models/graph_data_response.py
hindsight_client_api/models/http_validation_error.py
hindsight_client_api/models/include_options.py
@@ -45,12 +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_freshness_response.py
hindsight_client_api/models/mental_model_list_response.py
hindsight_client_api/models/mental_model_observation_response.py
hindsight_client_api/models/mental_model_response.py
hindsight_client_api/models/observation_evidence_response.py
hindsight_client_api/models/observation_input.py
hindsight_client_api/models/operation_response.py
hindsight_client_api/models/operation_status_response.py
hindsight_client_api/models/operations_list_response.py
@@ -66,15 +65,18 @@ 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/refresh_mental_models_request.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
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
hindsight_client_api/rest.py
hindsight_client_api_README.md
@@ -10,7 +10,7 @@ from typing import Optional, List, Dict, Any, Literal
from datetime import datetime
import hindsight_client_api
from hindsight_client_api.api import memory_api, banks_api, mental_models_api
from hindsight_client_api.api import memory_api, banks_api
from hindsight_client_api.models import (
recall_request,
retain_request,
@@ -23,9 +23,6 @@ from hindsight_client_api.models.recall_result import RecallResult
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
def _run_async(coro):
@@ -81,7 +78,6 @@ class Hindsight:
self._api_client.set_default_header("Authorization", f"Bearer {api_key}")
self._memory_api = memory_api.MemoryApi(self._api_client)
self._banks_api = banks_api.BanksApi(self._api_client)
self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client)
def __enter__(self):
"""Context manager entry."""
@@ -356,236 +352,6 @@ class Hindsight:
request_obj = create_bank_request.CreateBankRequest(mission=mission)
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
def list_mental_models(
self,
bank_id: str,
subtype: Optional[Literal["structural", "emergent", "pinned", "learned", "directive"]] = None,
tags: Optional[List[str]] = None,
tags_match: Optional[Literal["any", "all", "exact"]] = None,
) -> MentalModelListResponse:
"""
List mental models for a bank.
Args:
bank_id: The memory bank ID
subtype: Optional filter by subtype (structural, emergent, pinned, learned, directive)
tags: Optional list of tags to filter by
tags_match: How to match tags - 'any' (OR), 'all' (AND), or 'exact'
Returns:
MentalModelListResponse with list of mental models
"""
return _run_async(self._mental_models_api.list_mental_models(
bank_id=bank_id,
subtype=subtype,
tags=tags,
tags_match=tags_match,
))
def get_mental_model(
self,
bank_id: str,
model_id: str,
) -> MentalModelResponse:
"""
Get a specific mental model by ID.
Args:
bank_id: The memory bank ID
model_id: The mental model ID
Returns:
MentalModelResponse with full mental model details including observations
"""
return _run_async(self._mental_models_api.get_mental_model(
bank_id=bank_id,
model_id=model_id,
))
def create_mental_model(
self,
bank_id: str,
name: str,
description: str,
subtype: Literal["pinned", "directive"] = "pinned",
observations: Optional[List[Dict[str, str]]] = None,
tags: Optional[List[str]] = None,
) -> MentalModelResponse:
"""
Create a mental model.
Args:
bank_id: The memory bank ID
name: Human-readable name for the mental model
description: One-liner description for quick scanning
subtype: Type of mental model - 'pinned' (LLM-generated observations) or 'directive' (user-provided observations)
observations: For directives only - list of observations with 'title' and 'content' keys
tags: Optional list of tags for scoped visibility
Returns:
MentalModelResponse with created mental model
"""
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.observation_input import ObservationInput
obs_list = None
if observations:
obs_list = [ObservationInput(title=o.get("title", ""), content=o.get("content", "")) for o in observations]
request_obj = CreateMentalModelRequest(
name=name,
description=description,
subtype=subtype,
observations=obs_list,
tags=tags or [],
)
return _run_async(self._mental_models_api.create_mental_model(
bank_id=bank_id,
create_mental_model_request=request_obj,
))
def update_mental_model(
self,
bank_id: str,
model_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
) -> MentalModelResponse:
"""
Update a mental model's name and/or description.
Args:
bank_id: The memory bank ID
model_id: The mental model ID
name: Optional new name
description: Optional new description
Returns:
MentalModelResponse with updated mental model
"""
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
request_obj = UpdateMentalModelRequest(
name=name,
description=description,
)
return _run_async(self._mental_models_api.update_mental_model(
bank_id=bank_id,
model_id=model_id,
update_mental_model_request=request_obj,
))
def delete_mental_model(
self,
bank_id: str,
model_id: str,
):
"""
Delete a mental model.
Args:
bank_id: The memory bank ID
model_id: The mental model ID
Returns:
DeleteResponse confirming deletion
"""
return _run_async(self._mental_models_api.delete_mental_model(
bank_id=bank_id,
model_id=model_id,
))
def refresh_mental_models(
self,
bank_id: str,
subtype: Optional[Literal["structural", "emergent", "pinned", "learned"]] = None,
tags: Optional[List[str]] = None,
) -> AsyncOperationSubmitResponse:
"""
Submit a background job to refresh mental models for a bank.
Args:
bank_id: The memory bank ID
subtype: Optional - only refresh models of this subtype
tags: Optional - tags to apply to newly created mental models
Returns:
AsyncOperationSubmitResponse with operation_id to track progress
"""
from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest
request_obj = RefreshMentalModelsRequest(
subtype=subtype,
tags=tags,
)
return _run_async(self._mental_models_api.refresh_mental_models(
bank_id=bank_id,
refresh_mental_models_request=request_obj,
))
def refresh_mental_model(
self,
bank_id: str,
model_id: str,
) -> AsyncOperationSubmitResponse:
"""
Submit a background job to refresh content for a specific mental model.
Args:
bank_id: The memory bank ID
model_id: The mental model ID to refresh
Returns:
AsyncOperationSubmitResponse with operation_id to track progress
"""
return _run_async(self._mental_models_api.refresh_mental_model(
bank_id=bank_id,
model_id=model_id,
))
def list_mental_model_versions(
self,
bank_id: str,
model_id: str,
):
"""
List all saved versions of a mental model's observations.
Args:
bank_id: The memory bank ID
model_id: The mental model ID
Returns:
List of version objects ordered by version descending
"""
return _run_async(self._mental_models_api.list_mental_model_versions(
bank_id=bank_id,
model_id=model_id,
))
def get_mental_model_version(
self,
bank_id: str,
model_id: str,
version: int,
):
"""
Get observations from a specific version of a mental model.
Args:
bank_id: The memory bank ID
model_id: The mental model ID
version: The version number
Returns:
Version object with observations at that version
"""
return _run_async(self._mental_models_api.get_mental_model_version(
bank_id=bank_id,
model_id=model_id,
version=version,
))
# Async methods (native async, no _run_async wrapper)
async def aretain_batch(
@@ -18,12 +18,13 @@ __version__ = "0.0.7"
# import apis into sdk package
from hindsight_client_api.api.banks_api import BanksApi
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
@@ -38,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
@@ -49,11 +49,15 @@ from hindsight_client_api.models.cancel_operation_response import CancelOperatio
from hindsight_client_api.models.chunk_data import ChunkData
from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions
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_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.created_mental_model import CreatedMentalModel
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
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
from hindsight_client_api.models.directive_response import DirectiveResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.document_response import DocumentResponse
from hindsight_client_api.models.entity_detail_response import EntityDetailResponse
@@ -63,6 +67,7 @@ from hindsight_client_api.models.entity_list_item import EntityListItem
from hindsight_client_api.models.entity_list_response import EntityListResponse
from hindsight_client_api.models.entity_observation_response import EntityObservationResponse
from hindsight_client_api.models.entity_state_response import EntityStateResponse
from hindsight_client_api.models.features_info import FeaturesInfo
from hindsight_client_api.models.graph_data_response import GraphDataResponse
from hindsight_client_api.models.http_validation_error import HTTPValidationError
from hindsight_client_api.models.include_options import IncludeOptions
@@ -70,12 +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_freshness_response import MentalModelFreshnessResponse
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse
from hindsight_client_api.models.observation_input import ObservationInput
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
@@ -91,13 +90,16 @@ 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.refresh_mental_models_request import RefreshMentalModelsRequest
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
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
@@ -2,10 +2,11 @@
# import apis into api package
from hindsight_client_api.api.banks_api import BanksApi
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
@@ -23,6 +23,7 @@ from hindsight_client_api.models.background_response import BackgroundResponse
from hindsight_client_api.models.bank_list_response import BankListResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.bank_stats_response import BankStatsResponse
from hindsight_client_api.models.consolidation_response import ConsolidationResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.delete_response import DeleteResponse
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
@@ -354,6 +355,284 @@ class BanksApi:
@validate_call
async def clear_mental_models(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> DeleteResponse:
"""Clear all mental models
Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._clear_mental_models_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "DeleteResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def clear_mental_models_with_http_info(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[DeleteResponse]:
"""Clear all mental models
Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._clear_mental_models_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "DeleteResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def clear_mental_models_without_preload_content(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Clear all mental models
Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._clear_mental_models_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "DeleteResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _clear_mental_models_serialize(
self,
bank_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='DELETE',
resource_path='/v1/default/banks/{bank_id}/mental-models',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def create_or_update_bank(
self,
@@ -1757,6 +2036,284 @@ class BanksApi:
@validate_call
async def trigger_consolidation(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ConsolidationResponse:
"""Trigger consolidation
Run memory consolidation to create/update mental models from recent memories.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._trigger_consolidation_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "ConsolidationResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def trigger_consolidation_with_http_info(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[ConsolidationResponse]:
"""Trigger consolidation
Run memory consolidation to create/update mental models from recent memories.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._trigger_consolidation_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "ConsolidationResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def trigger_consolidation_without_preload_content(
self,
bank_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Trigger consolidation
Run memory consolidation to create/update mental models from recent memories.
:param bank_id: (required)
:type bank_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._trigger_consolidation_serialize(
bank_id=bank_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "ConsolidationResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _trigger_consolidation_serialize(
self,
bank_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='POST',
resource_path='/v1/default/banks/{bank_id}/consolidate',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def update_bank(
self,
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from typing_extensions import Annotated
from typing import Any
from hindsight_client_api.models.version_response import VersionResponse
from hindsight_client_api.api_client import ApiClient, RequestSerialized
from hindsight_client_api.api_response import ApiResponse
@@ -36,6 +37,251 @@ class MonitoringApi:
self.api_client = api_client
@validate_call
async def get_version(
self,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> VersionResponse:
"""Get API version and feature flags
Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment.
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_version_serialize(
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "VersionResponse",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def get_version_with_http_info(
self,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[VersionResponse]:
"""Get API version and feature flags
Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment.
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_version_serialize(
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "VersionResponse",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def get_version_without_preload_content(
self,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Get API version and feature flags
Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment.
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_version_serialize(
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "VersionResponse",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _get_version_serialize(
self,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
# process the query parameters
# process the header parameters
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='GET',
resource_path='/version',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def health_endpoint_health_get(
self,
@@ -16,8 +16,9 @@ from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt
from typing import Any, Dict, List, Optional, Tuple, Union
from typing_extensions import Annotated
from pydantic import StrictStr
from pydantic import Field, StrictStr
from typing import Optional
from typing_extensions import Annotated
from hindsight_client_api.models.cancel_operation_response import CancelOperationResponse
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
from hindsight_client_api.models.operations_list_response import OperationsListResponse
@@ -630,6 +631,9 @@ class OperationsApi:
async def list_operations(
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None,
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -646,10 +650,16 @@ class OperationsApi:
) -> OperationsListResponse:
"""List async operations
Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:type status: str
:param limit: Maximum number of operations to return
:type limit: int
:param offset: Number of operations to skip
:type offset: int
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -676,6 +686,9 @@ class OperationsApi:
_param = self._list_operations_serialize(
bank_id=bank_id,
status=status,
limit=limit,
offset=offset,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -702,6 +715,9 @@ class OperationsApi:
async def list_operations_with_http_info(
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None,
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -718,10 +734,16 @@ class OperationsApi:
) -> ApiResponse[OperationsListResponse]:
"""List async operations
Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:type status: str
:param limit: Maximum number of operations to return
:type limit: int
:param offset: Number of operations to skip
:type offset: int
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -748,6 +770,9 @@ class OperationsApi:
_param = self._list_operations_serialize(
bank_id=bank_id,
status=status,
limit=limit,
offset=offset,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -774,6 +799,9 @@ class OperationsApi:
async def list_operations_without_preload_content(
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None,
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -790,10 +818,16 @@ class OperationsApi:
) -> RESTResponseType:
"""List async operations
Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:type status: str
:param limit: Maximum number of operations to return
:type limit: int
:param offset: Number of operations to skip
:type offset: int
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -820,6 +854,9 @@ class OperationsApi:
_param = self._list_operations_serialize(
bank_id=bank_id,
status=status,
limit=limit,
offset=offset,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -841,6 +878,9 @@ class OperationsApi:
def _list_operations_serialize(
self,
bank_id,
status,
limit,
offset,
authorization,
_request_auth,
_content_type,
@@ -866,6 +906,18 @@ class OperationsApi:
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
if status is not None:
_query_params.append(('status', status))
if limit is not None:
_query_params.append(('limit', limit))
if offset is not None:
_query_params.append(('offset', offset))
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
@@ -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
@@ -26,11 +25,15 @@ from hindsight_client_api.models.cancel_operation_response import CancelOperatio
from hindsight_client_api.models.chunk_data import ChunkData
from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions
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_mental_model_request import CreateMentalModelRequest
from hindsight_client_api.models.created_mental_model import CreatedMentalModel
from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
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
from hindsight_client_api.models.directive_response import DirectiveResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.document_response import DocumentResponse
from hindsight_client_api.models.entity_detail_response import EntityDetailResponse
@@ -40,6 +43,7 @@ from hindsight_client_api.models.entity_list_item import EntityListItem
from hindsight_client_api.models.entity_list_response import EntityListResponse
from hindsight_client_api.models.entity_observation_response import EntityObservationResponse
from hindsight_client_api.models.entity_state_response import EntityStateResponse
from hindsight_client_api.models.features_info import FeaturesInfo
from hindsight_client_api.models.graph_data_response import GraphDataResponse
from hindsight_client_api.models.http_validation_error import HTTPValidationError
from hindsight_client_api.models.include_options import IncludeOptions
@@ -47,12 +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_freshness_response import MentalModelFreshnessResponse
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
from hindsight_client_api.models.mental_model_response import MentalModelResponse
from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse
from hindsight_client_api.models.observation_input import ObservationInput
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
@@ -68,13 +66,16 @@ 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.refresh_mental_models_request import RefreshMentalModelsRequest
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
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
@@ -17,8 +17,8 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
@@ -36,7 +36,10 @@ class BankStatsResponse(BaseModel):
links_breakdown: Dict[str, Dict[str, StrictInt]]
pending_operations: StrictInt
failed_operations: StrictInt
__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: Optional[StrictStr] = None
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,
@@ -77,6 +80,11 @@ class BankStatsResponse(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# set to None if last_consolidated_at (nullable) is None
# and model_fields_set contains the field
if self.last_consolidated_at is None and "last_consolidated_at" in self.model_fields_set:
_dict['last_consolidated_at'] = None
return _dict
@classmethod
@@ -98,7 +106,10 @@ class BankStatsResponse(BaseModel):
"links_by_fact_type": obj.get("links_by_fact_type"),
"links_breakdown": obj.get("links_breakdown"),
"pending_operations": obj.get("pending_operations"),
"failed_operations": obj.get("failed_operations")
"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_mental_models": obj.get("total_mental_models") if obj.get("total_mental_models") is not None else 0
})
return _obj
@@ -17,20 +17,21 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
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
class ObservationEvidenceResponse(BaseModel):
class ConsolidationResponse(BaseModel):
"""
A single piece of evidence supporting an observation.
Response model for consolidation trigger endpoint.
""" # noqa: E501
memory_id: StrictStr = Field(description="ID of the memory unit this evidence comes from")
quote: StrictStr = Field(description="Exact quote from the memory supporting the observation")
relevance: StrictStr = Field(description="Brief explanation of how this quote supports the observation")
timestamp: StrictStr = Field(description="When the source memory was created (ISO format)")
__properties: ClassVar[List[str]] = ["memory_id", "quote", "relevance", "timestamp"]
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,
@@ -50,7 +51,7 @@ class ObservationEvidenceResponse(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ObservationEvidenceResponse from a JSON string"""
"""Create an instance of ConsolidationResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -75,7 +76,7 @@ class ObservationEvidenceResponse(BaseModel):
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ObservationEvidenceResponse from a dict"""
"""Create an instance of ConsolidationResponse from a dict"""
if obj is None:
return None
@@ -83,10 +84,11 @@ class ObservationEvidenceResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"memory_id": obj.get("memory_id"),
"quote": obj.get("quote"),
"relevance": obj.get("relevance"),
"timestamp": obj.get("timestamp")
"status": obj.get("status"),
"processed": obj.get("processed"),
"created": obj.get("created"),
"updated": obj.get("updated"),
"message": obj.get("message")
})
return _obj
@@ -0,0 +1,95 @@
# 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, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class CreateDirectiveRequest(BaseModel):
"""
Request model for creating a directive.
""" # noqa: E501
name: StrictStr = Field(description="Human-readable name for the directive")
content: StrictStr = Field(description="The directive text to inject into prompts")
priority: Optional[StrictInt] = Field(default=0, description="Higher priority directives are injected first")
is_active: Optional[StrictBool] = Field(default=True, description="Whether this directive is active")
tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for filtering")
__properties: ClassVar[List[str]] = ["name", "content", "priority", "is_active", "tags"]
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 CreateDirectiveRequest 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 CreateDirectiveRequest 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"),
"content": obj.get("content"),
"priority": obj.get("priority") if obj.get("priority") is not None else 0,
"is_active": obj.get("is_active") if obj.get("is_active") is not None else True,
"tags": obj.get("tags")
})
return _obj
@@ -0,0 +1,94 @@
# 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 typing import Optional, Set
from typing_extensions import Self
class CreateReflectionRequest(BaseModel):
"""
Request model for creating a reflection.
""" # noqa: E501
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")
__properties: ClassVar[List[str]] = ["name", "source_query", "tags", "max_tokens"]
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 CreateReflectionRequest 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 CreateReflectionRequest 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"),
"tags": obj.get("tags"),
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 2048
})
return _obj
@@ -22,13 +22,12 @@ from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class ObservationInput(BaseModel):
class CreateReflectionResponse(BaseModel):
"""
Input model for a single observation.
Response model for reflection creation.
""" # noqa: E501
title: StrictStr = Field(description="Short title/header for the observation")
content: StrictStr = Field(description="Content of the observation")
__properties: ClassVar[List[str]] = ["title", "content"]
operation_id: StrictStr = Field(description="Operation ID to track progress")
__properties: ClassVar[List[str]] = ["operation_id"]
model_config = ConfigDict(
populate_by_name=True,
@@ -48,7 +47,7 @@ class ObservationInput(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of ObservationInput 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]:
@@ -73,7 +72,7 @@ class ObservationInput(BaseModel):
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ObservationInput from a dict"""
"""Create an instance of CreateReflectionResponse from a dict"""
if obj is None:
return None
@@ -81,8 +80,7 @@ class ObservationInput(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"title": obj.get("title"),
"content": obj.get("content")
"operation_id": obj.get("operation_id")
})
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.directive_response import DirectiveResponse
from typing import Optional, Set
from typing_extensions import Self
class MentalModelListResponse(BaseModel):
class DirectiveListResponse(BaseModel):
"""
Response model for listing mental models.
Response model for listing directives.
""" # noqa: E501
items: List[MentalModelResponse]
items: List[DirectiveResponse]
__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 DirectiveListResponse 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 DirectiveListResponse 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": [DirectiveResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None
})
return _obj
@@ -17,28 +17,25 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr, field_validator
from pydantic import BaseModel, ConfigDict, StrictBool, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class RefreshMentalModelsRequest(BaseModel):
class DirectiveResponse(BaseModel):
"""
Request model for refresh mental models endpoint.
Response model for a directive.
""" # noqa: E501
id: StrictStr
bank_id: StrictStr
name: StrictStr
content: StrictStr
priority: Optional[StrictInt] = 0
is_active: Optional[StrictBool] = True
tags: Optional[List[StrictStr]] = None
subtype: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["tags", "subtype"]
@field_validator('subtype')
def subtype_validate_enum(cls, value):
"""Validates the enum"""
if value is None:
return value
if value not in set(['structural', 'emergent', 'pinned', 'learned']):
raise ValueError("must be one of enum values ('structural', 'emergent', 'pinned', 'learned')")
return value
created_at: Optional[StrictStr] = None
updated_at: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["id", "bank_id", "name", "content", "priority", "is_active", "tags", "created_at", "updated_at"]
model_config = ConfigDict(
populate_by_name=True,
@@ -58,7 +55,7 @@ class RefreshMentalModelsRequest(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of RefreshMentalModelsRequest from a JSON string"""
"""Create an instance of DirectiveResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -79,21 +76,21 @@ class RefreshMentalModelsRequest(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# set to None if tags (nullable) is None
# set to None if created_at (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
if self.created_at is None and "created_at" in self.model_fields_set:
_dict['created_at'] = None
# set to None if subtype (nullable) is None
# set to None if updated_at (nullable) is None
# and model_fields_set contains the field
if self.subtype is None and "subtype" in self.model_fields_set:
_dict['subtype'] = None
if self.updated_at is None and "updated_at" in self.model_fields_set:
_dict['updated_at'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of RefreshMentalModelsRequest from a dict"""
"""Create an instance of DirectiveResponse from a dict"""
if obj is None:
return None
@@ -101,8 +98,15 @@ class RefreshMentalModelsRequest(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"bank_id": obj.get("bank_id"),
"name": obj.get("name"),
"content": obj.get("content"),
"priority": obj.get("priority") if obj.get("priority") is not None else 0,
"is_active": obj.get("is_active") if obj.get("is_active") is not None else True,
"tags": obj.get("tags"),
"subtype": obj.get("subtype")
"created_at": obj.get("created_at"),
"updated_at": obj.get("updated_at")
})
return _obj
@@ -17,18 +17,19 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictStr
from pydantic import BaseModel, ConfigDict, Field, StrictBool
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class AsyncOperationSubmitResponse(BaseModel):
class FeaturesInfo(BaseModel):
"""
Response model for submitting an async operation.
Feature flags indicating which capabilities are enabled.
""" # noqa: E501
operation_id: StrictStr
status: StrictStr
__properties: ClassVar[List[str]] = ["operation_id", "status"]
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]] = ["mental_models", "mcp", "worker"]
model_config = ConfigDict(
populate_by_name=True,
@@ -48,7 +49,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 FeaturesInfo from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -73,7 +74,7 @@ class AsyncOperationSubmitResponse(BaseModel):
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of AsyncOperationSubmitResponse from a dict"""
"""Create an instance of FeaturesInfo from a dict"""
if obj is None:
return None
@@ -81,8 +82,9 @@ class AsyncOperationSubmitResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"operation_id": obj.get("operation_id"),
"status": obj.get("status")
"mental_models": obj.get("mental_models"),
"mcp": obj.get("mcp"),
"worker": obj.get("worker")
})
return _obj
@@ -1,107 +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, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse
from typing import Optional, Set
from typing_extensions import Self
class MentalModelObservationResponse(BaseModel):
"""
An observation within a mental model with its supporting evidence.
""" # noqa: E501
title: StrictStr = Field(description="Short summary title for the observation")
content: StrictStr = Field(description="The observation content - detailed explanation")
evidence: Optional[List[ObservationEvidenceResponse]] = Field(default=None, description="Supporting evidence with quotes")
created_at: StrictStr = Field(description="When this observation was first created (ISO format)")
trend: StrictStr = Field(description="Computed trend: stable, strengthening, weakening, new, stale")
evidence_count: StrictInt = Field(description="Number of evidence items supporting this observation")
evidence_span: Dict[str, Any] = Field(description="Time span of evidence: {from: iso_date, to: iso_date}")
__properties: ClassVar[List[str]] = ["title", "content", "evidence", "created_at", "trend", "evidence_count", "evidence_span"]
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 MentalModelObservationResponse 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 each item in evidence (list)
_items = []
if self.evidence:
for _item_evidence in self.evidence:
if _item_evidence:
_items.append(_item_evidence.to_dict())
_dict['evidence'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelObservationResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"title": obj.get("title"),
"content": obj.get("content"),
"evidence": [ObservationEvidenceResponse.from_dict(_item) for _item in obj["evidence"]] if obj.get("evidence") is not None else None,
"created_at": obj.get("created_at"),
"trend": obj.get("trend"),
"evidence_count": obj.get("evidence_count"),
"evidence_span": obj.get("evidence_span")
})
return _obj
@@ -1,145 +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, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.mental_model_freshness_response import MentalModelFreshnessResponse
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
from typing import Optional, Set
from typing_extensions import Self
class MentalModelResponse(BaseModel):
"""
Response model for a mental model.
""" # noqa: E501
id: StrictStr
bank_id: StrictStr
subtype: StrictStr
name: StrictStr
description: StrictStr
observations: Optional[List[MentalModelObservationResponse]] = Field(default=None, description="Structured observations with per-observation fact attribution")
version: Optional[StrictInt] = Field(default=0, description="Version number of the mental model observations")
entity_id: Optional[StrictStr] = None
links: Optional[List[StrictStr]] = None
tags: Optional[List[StrictStr]] = None
last_updated: Optional[StrictStr] = None
last_refresh_at: Optional[StrictStr] = None
freshness: Optional[MentalModelFreshnessResponse] = None
created_at: StrictStr
__properties: ClassVar[List[str]] = ["id", "bank_id", "subtype", "name", "description", "observations", "version", "entity_id", "links", "tags", "last_updated", "last_refresh_at", "freshness", "created_at"]
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 MentalModelResponse 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 each item in observations (list)
_items = []
if self.observations:
for _item_observations in self.observations:
if _item_observations:
_items.append(_item_observations.to_dict())
_dict['observations'] = _items
# override the default output from pydantic by calling `to_dict()` of freshness
if self.freshness:
_dict['freshness'] = self.freshness.to_dict()
# set to None if entity_id (nullable) is None
# and model_fields_set contains the field
if self.entity_id is None and "entity_id" in self.model_fields_set:
_dict['entity_id'] = None
# set to None if last_updated (nullable) is None
# and model_fields_set contains the field
if self.last_updated is None and "last_updated" in self.model_fields_set:
_dict['last_updated'] = None
# set to None if last_refresh_at (nullable) is None
# and model_fields_set contains the field
if self.last_refresh_at is None and "last_refresh_at" in self.model_fields_set:
_dict['last_refresh_at'] = None
# set to None if freshness (nullable) is None
# and model_fields_set contains the field
if self.freshness is None and "freshness" in self.model_fields_set:
_dict['freshness'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelResponse 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"),
"bank_id": obj.get("bank_id"),
"subtype": obj.get("subtype"),
"name": obj.get("name"),
"description": obj.get("description"),
"observations": [MentalModelObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None,
"version": obj.get("version") if obj.get("version") is not None else 0,
"entity_id": obj.get("entity_id"),
"links": obj.get("links"),
"tags": obj.get("tags"),
"last_updated": obj.get("last_updated"),
"last_refresh_at": obj.get("last_refresh_at"),
"freshness": MentalModelFreshnessResponse.from_dict(obj["freshness"]) if obj.get("freshness") is not None else None,
"created_at": obj.get("created_at")
})
return _obj
@@ -29,8 +29,10 @@ class OperationsListResponse(BaseModel):
""" # noqa: E501
bank_id: StrictStr
total: StrictInt
limit: StrictInt
offset: StrictInt
operations: List[OperationResponse]
__properties: ClassVar[List[str]] = ["bank_id", "total", "operations"]
__properties: ClassVar[List[str]] = ["bank_id", "total", "limit", "offset", "operations"]
model_config = ConfigDict(
populate_by_name=True,
@@ -92,6 +94,8 @@ class OperationsListResponse(BaseModel):
_obj = cls.model_validate({
"bank_id": obj.get("bank_id"),
"total": obj.get("total"),
"limit": obj.get("limit"),
"offset": obj.get("offset"),
"operations": [OperationResponse.from_dict(_item) for _item in obj["operations"]] if obj.get("operations") is not None else None
})
return _obj
@@ -20,7 +20,6 @@ import json
from pydantic import BaseModel, ConfigDict, Field
from typing import Any, ClassVar, Dict, List, Optional
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
@@ -29,8 +28,7 @@ class ReflectBasedOn(BaseModel):
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 accessed during reflection")
__properties: ClassVar[List[str]] = ["memories", "mental_models"]
__properties: ClassVar[List[str]] = ["memories"]
model_config = ConfigDict(
populate_by_name=True,
@@ -78,13 +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
return _dict
@classmethod
@@ -97,8 +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
"memories": [ReflectFact.from_dict(_item) for _item in obj["memories"]] if obj.get("memories") is not None else None
})
return _obj
@@ -17,9 +17,8 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.created_mental_model import CreatedMentalModel
from hindsight_client_api.models.reflect_based_on import ReflectBasedOn
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.token_usage import TokenUsage
@@ -35,8 +34,7 @@ class ReflectResponse(BaseModel):
structured_output: Optional[Dict[str, Any]] = None
usage: Optional[TokenUsage] = None
trace: Optional[ReflectTrace] = None
mental_models_created: Optional[List[CreatedMentalModel]] = Field(default=None, description="Mental models created during this reflection (via the learn tool).")
__properties: ClassVar[List[str]] = ["text", "based_on", "structured_output", "usage", "trace", "mental_models_created"]
__properties: ClassVar[List[str]] = ["text", "based_on", "structured_output", "usage", "trace"]
model_config = ConfigDict(
populate_by_name=True,
@@ -86,13 +84,6 @@ class ReflectResponse(BaseModel):
# override the default output from pydantic by calling `to_dict()` of trace
if self.trace:
_dict['trace'] = self.trace.to_dict()
# override the default output from pydantic by calling `to_dict()` of each item in mental_models_created (list)
_items = []
if self.mental_models_created:
for _item_mental_models_created in self.mental_models_created:
if _item_mental_models_created:
_items.append(_item_mental_models_created.to_dict())
_dict['mental_models_created'] = _items
# set to None if based_on (nullable) is None
# and model_fields_set contains the field
if self.based_on is None and "based_on" in self.model_fields_set:
@@ -129,8 +120,7 @@ class ReflectResponse(BaseModel):
"based_on": ReflectBasedOn.from_dict(obj["based_on"]) if obj.get("based_on") is not None else None,
"structured_output": obj.get("structured_output"),
"usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None,
"trace": ReflectTrace.from_dict(obj["trace"]) if obj.get("trace") is not None else None,
"mental_models_created": [CreatedMentalModel.from_dict(_item) for _item in obj["mental_models_created"]] if obj.get("mental_models_created") is not None else None
"trace": ReflectTrace.from_dict(obj["trace"]) if obj.get("trace") is not None else None
})
return _obj
@@ -0,0 +1,95 @@
# 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
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.reflection_response import ReflectionResponse
from typing import Optional, Set
from typing_extensions import Self
class ReflectionListResponse(BaseModel):
"""
Response model for listing reflections.
""" # noqa: E501
items: List[ReflectionResponse]
__properties: ClassVar[List[str]] = ["items"]
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 ReflectionListResponse 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 each item in items (list)
_items = []
if self.items:
for _item_items in self.items:
if _item_items:
_items.append(_item_items.to_dict())
_dict['items'] = _items
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of ReflectionListResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"items": [ReflectionResponse.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None
})
return _obj
@@ -17,20 +17,25 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class MentalModelFreshnessResponse(BaseModel):
class ReflectionResponse(BaseModel):
"""
Freshness information for a mental model.
Response model for a reflection.
""" # noqa: E501
is_up_to_date: StrictBool = Field(description="Whether the model has been refreshed since the last memory was added")
last_refresh_at: Optional[StrictStr]
memories_since_refresh: StrictInt = Field(description="Number of memories added since last refresh")
reasons: Optional[List[StrictStr]] = Field(default=None, description="Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed")
__properties: ClassVar[List[str]] = ["is_up_to_date", "last_refresh_at", "memories_since_refresh", "reasons"]
id: StrictStr
bank_id: StrictStr
name: StrictStr
source_query: StrictStr
content: StrictStr
tags: Optional[List[StrictStr]] = 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", "last_refreshed_at", "created_at", "reflect_response"]
model_config = ConfigDict(
populate_by_name=True,
@@ -50,7 +55,7 @@ class MentalModelFreshnessResponse(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of MentalModelFreshnessResponse 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]:
@@ -71,16 +76,26 @@ class MentalModelFreshnessResponse(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# set to None if last_refresh_at (nullable) is None
# set to None if last_refreshed_at (nullable) is None
# and model_fields_set contains the field
if self.last_refresh_at is None and "last_refresh_at" in self.model_fields_set:
_dict['last_refresh_at'] = None
if self.last_refreshed_at is None and "last_refreshed_at" in self.model_fields_set:
_dict['last_refreshed_at'] = None
# set to None if created_at (nullable) is None
# and model_fields_set contains the field
if self.created_at is None and "created_at" in self.model_fields_set:
_dict['created_at'] = None
# set to None if reflect_response (nullable) is None
# and model_fields_set contains the field
if self.reflect_response is None and "reflect_response" in self.model_fields_set:
_dict['reflect_response'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of MentalModelFreshnessResponse from a dict"""
"""Create an instance of ReflectionResponse from a dict"""
if obj is None:
return None
@@ -88,10 +103,15 @@ class MentalModelFreshnessResponse(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"is_up_to_date": obj.get("is_up_to_date"),
"last_refresh_at": obj.get("last_refresh_at"),
"memories_since_refresh": obj.get("memories_since_refresh"),
"reasons": obj.get("reasons")
"id": obj.get("id"),
"bank_id": obj.get("bank_id"),
"name": obj.get("name"),
"source_query": obj.get("source_query"),
"content": obj.get("content"),
"tags": obj.get("tags"),
"last_refreshed_at": obj.get("last_refreshed_at"),
"created_at": obj.get("created_at"),
"reflect_response": obj.get("reflect_response")
})
return _obj
@@ -17,22 +17,21 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from pydantic import BaseModel, ConfigDict, StrictBool, StrictInt, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.observation_input import ObservationInput
from typing import Optional, Set
from typing_extensions import Self
class CreateMentalModelRequest(BaseModel):
class UpdateDirectiveRequest(BaseModel):
"""
Request model for creating a mental model.
Request model for updating a directive.
""" # noqa: E501
name: StrictStr = Field(description="Human-readable name for the mental model")
description: StrictStr = Field(description="One-liner description for quick scanning")
subtype: Optional[StrictStr] = Field(default='pinned', description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)")
observations: Optional[List[ObservationInput]] = None
tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility")
__properties: ClassVar[List[str]] = ["name", "description", "subtype", "observations", "tags"]
name: Optional[StrictStr] = None
content: Optional[StrictStr] = None
priority: Optional[StrictInt] = None
is_active: Optional[StrictBool] = None
tags: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["name", "content", "priority", "is_active", "tags"]
model_config = ConfigDict(
populate_by_name=True,
@@ -52,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 UpdateDirectiveRequest from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -73,23 +72,36 @@ class CreateMentalModelRequest(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of each item in observations (list)
_items = []
if self.observations:
for _item_observations in self.observations:
if _item_observations:
_items.append(_item_observations.to_dict())
_dict['observations'] = _items
# set to None if observations (nullable) is None
# set to None if name (nullable) is None
# and model_fields_set contains the field
if self.observations is None and "observations" in self.model_fields_set:
_dict['observations'] = None
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
# set to None if content (nullable) is None
# and model_fields_set contains the field
if self.content is None and "content" in self.model_fields_set:
_dict['content'] = None
# set to None if priority (nullable) is None
# and model_fields_set contains the field
if self.priority is None and "priority" in self.model_fields_set:
_dict['priority'] = None
# set to None if is_active (nullable) is None
# and model_fields_set contains the field
if self.is_active is None and "is_active" in self.model_fields_set:
_dict['is_active'] = 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
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 UpdateDirectiveRequest from a dict"""
if obj is None:
return None
@@ -98,9 +110,9 @@ class CreateMentalModelRequest(BaseModel):
_obj = cls.model_validate({
"name": obj.get("name"),
"description": obj.get("description"),
"subtype": obj.get("subtype") if obj.get("subtype") is not None else 'pinned',
"observations": [ObservationInput.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None,
"content": obj.get("content"),
"priority": obj.get("priority"),
"is_active": obj.get("is_active"),
"tags": obj.get("tags")
})
return _obj
@@ -22,13 +22,12 @@ from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class UpdateMentalModelRequest(BaseModel):
class UpdateReflectionRequest(BaseModel):
"""
Request model for updating a mental model.
Request model for updating a reflection.
""" # noqa: E501
name: Optional[StrictStr] = None
description: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name", "description"]
__properties: ClassVar[List[str]] = ["name"]
model_config = ConfigDict(
populate_by_name=True,
@@ -48,7 +47,7 @@ class UpdateMentalModelRequest(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of UpdateMentalModelRequest 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]:
@@ -74,16 +73,11 @@ class UpdateMentalModelRequest(BaseModel):
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
# set to None if description (nullable) is None
# and model_fields_set contains the field
if self.description is None and "description" in self.model_fields_set:
_dict['description'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of UpdateMentalModelRequest from a dict"""
"""Create an instance of UpdateReflectionRequest from a dict"""
if obj is None:
return None
@@ -91,8 +85,7 @@ class UpdateMentalModelRequest(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"name": obj.get("name"),
"description": obj.get("description")
"name": obj.get("name")
})
return _obj
@@ -19,17 +19,17 @@ import json
from pydantic import BaseModel, ConfigDict, Field, StrictStr
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.features_info import FeaturesInfo
from typing import Optional, Set
from typing_extensions import Self
class CreatedMentalModel(BaseModel):
class VersionResponse(BaseModel):
"""
A mental model created during reflection.
Response model for the version/info endpoint.
""" # noqa: E501
id: StrictStr = Field(description="Mental model ID")
name: StrictStr = Field(description="Human-readable name")
description: StrictStr = Field(description="What this model tracks")
__properties: ClassVar[List[str]] = ["id", "name", "description"]
api_version: StrictStr = Field(description="API version string")
features: FeaturesInfo = Field(description="Enabled feature flags")
__properties: ClassVar[List[str]] = ["api_version", "features"]
model_config = ConfigDict(
populate_by_name=True,
@@ -49,7 +49,7 @@ class CreatedMentalModel(BaseModel):
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of CreatedMentalModel from a JSON string"""
"""Create an instance of VersionResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
@@ -70,11 +70,14 @@ class CreatedMentalModel(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of features
if self.features:
_dict['features'] = self.features.to_dict()
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of CreatedMentalModel from a dict"""
"""Create an instance of VersionResponse from a dict"""
if obj is None:
return None
@@ -82,9 +85,8 @@ class CreatedMentalModel(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"id": obj.get("id"),
"name": obj.get("name"),
"description": obj.get("description")
"api_version": obj.get("api_version"),
"features": FeaturesInfo.from_dict(obj["features"]) if obj.get("features") is not None else None
})
return _obj
@@ -546,8 +546,8 @@ class TestDeleteBank:
assert memories.total == 0
class TestMentalModels:
"""Tests for mental model operations."""
class TestMission:
"""Tests for mission operations."""
def test_set_mission(self, client, bank_id):
"""Test setting a bank's mission."""
@@ -559,190 +559,3 @@ class TestMentalModels:
assert response is not None
assert response.bank_id == bank_id
assert response.mission == "Be a helpful PM tracking sprint progress and team capacity"
def test_create_pinned_mental_model(self, client, bank_id):
"""Test creating a pinned mental model."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
response = client.create_mental_model(
bank_id=bank_id,
name="Product Roadmap",
description="Track product priorities and feature decisions",
subtype="pinned",
tags=["test"],
)
assert response is not None
assert response.name == "Product Roadmap"
assert response.description == "Track product priorities and feature decisions"
assert response.subtype == "pinned"
def test_create_directive_mental_model(self, client, bank_id):
"""Test creating a directive mental model with observations."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
response = client.create_mental_model(
bank_id=bank_id,
name="Response Guidelines",
description="Rules for responding to users",
subtype="directive",
observations=[
{"title": "Always be polite", "content": "All responses must be courteous and professional"},
{"title": "Never share private info", "content": "Do not reveal internal details or user data"},
],
tags=["test"],
)
assert response is not None
assert response.name == "Response Guidelines"
assert response.subtype == "directive"
assert response.observations is not None
assert len(response.observations) == 2
def test_list_mental_models(self, client, bank_id):
"""Test listing mental models."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
# Create a model first
client.create_mental_model(
bank_id=bank_id,
name="Test Model",
description="A test mental model",
subtype="pinned",
)
response = client.list_mental_models(bank_id=bank_id)
assert response is not None
assert response.items is not None
assert len(response.items) >= 1
def test_get_mental_model(self, client, bank_id):
"""Test getting a specific mental model."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
# Create a model first
created = client.create_mental_model(
bank_id=bank_id,
name="Retrieve Test Model",
description="A model to retrieve",
subtype="pinned",
)
response = client.get_mental_model(
bank_id=bank_id,
model_id=created.id,
)
assert response is not None
assert response.id == created.id
assert response.name == "Retrieve Test Model"
def test_update_mental_model(self, client, bank_id):
"""Test updating a mental model."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
# Create a model first
created = client.create_mental_model(
bank_id=bank_id,
name="Update Test Model",
description="Original description",
subtype="pinned",
)
response = client.update_mental_model(
bank_id=bank_id,
model_id=created.id,
name="Updated Model Name",
description="Updated description",
)
assert response is not None
assert response.name == "Updated Model Name"
assert response.description == "Updated description"
def test_delete_mental_model(self, client, bank_id):
"""Test deleting a mental model."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
# Create a model first
created = client.create_mental_model(
bank_id=bank_id,
name="Delete Test Model",
description="A model to delete",
subtype="pinned",
)
response = client.delete_mental_model(
bank_id=bank_id,
model_id=created.id,
)
assert response is not None
assert response.success is True
def test_refresh_mental_models(self, client, bank_id):
"""Test refreshing all mental models (async operation)."""
# Set mission first (required for refresh) - this also creates the bank
client.set_mission(
bank_id=bank_id,
mission="Track team progress and decisions",
)
response = client.refresh_mental_models(
bank_id=bank_id,
tags=["test"],
)
assert response is not None
assert response.operation_id is not None
assert response.status == "queued"
def test_refresh_mental_model(self, client, bank_id):
"""Test refreshing a single mental model (async operation)."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
# Create a model first
created = client.create_mental_model(
bank_id=bank_id,
name="Refresh Single Test",
description="A model to refresh individually",
subtype="pinned",
)
response = client.refresh_mental_model(
bank_id=bank_id,
model_id=created.id,
)
assert response is not None
assert response.operation_id is not None
assert response.status == "queued"
def test_list_mental_model_versions(self, client, bank_id):
"""Test listing mental model versions."""
# Create bank first (required for mental models)
client.create_bank(bank_id=bank_id)
# Create a model first
created = client.create_mental_model(
bank_id=bank_id,
name="Versions Test Model",
description="A model to test version history",
subtype="pinned",
)
response = client.list_mental_model_versions(
bank_id=bank_id,
model_id=created.id,
)
# Newly created model should have version history
assert response is not None
+183 -95
View File
@@ -12,21 +12,30 @@ import type {
ClearBankMemoriesData,
ClearBankMemoriesErrors,
ClearBankMemoriesResponses,
CreateMentalModelData,
CreateMentalModelErrors,
CreateMentalModelResponses,
ClearMentalModelsData,
ClearMentalModelsErrors,
ClearMentalModelsResponses,
CreateDirectiveData,
CreateDirectiveErrors,
CreateDirectiveResponses,
CreateOrUpdateBankData,
CreateOrUpdateBankErrors,
CreateOrUpdateBankResponses,
CreateReflectionData,
CreateReflectionErrors,
CreateReflectionResponses,
DeleteBankData,
DeleteBankErrors,
DeleteBankResponses,
DeleteDirectiveData,
DeleteDirectiveErrors,
DeleteDirectiveResponses,
DeleteDocumentData,
DeleteDocumentErrors,
DeleteDocumentResponses,
DeleteMentalModelData,
DeleteMentalModelErrors,
DeleteMentalModelResponses,
DeleteReflectionData,
DeleteReflectionErrors,
DeleteReflectionResponses,
GetAgentStatsData,
GetAgentStatsErrors,
GetAgentStatsResponses,
@@ -36,6 +45,9 @@ import type {
GetChunkData,
GetChunkErrors,
GetChunkResponses,
GetDirectiveData,
GetDirectiveErrors,
GetDirectiveResponses,
GetDocumentData,
GetDocumentErrors,
GetDocumentResponses,
@@ -48,20 +60,22 @@ import type {
GetMemoryData,
GetMemoryErrors,
GetMemoryResponses,
GetMentalModelData,
GetMentalModelErrors,
GetMentalModelResponses,
GetMentalModelVersionData,
GetMentalModelVersionErrors,
GetMentalModelVersionResponses,
GetOperationStatusData,
GetOperationStatusErrors,
GetOperationStatusResponses,
GetReflectionData,
GetReflectionErrors,
GetReflectionResponses,
GetVersionData,
GetVersionResponses,
HealthEndpointHealthGetData,
HealthEndpointHealthGetResponses,
ListBanksData,
ListBanksErrors,
ListBanksResponses,
ListDirectivesData,
ListDirectivesErrors,
ListDirectivesResponses,
ListDocumentsData,
ListDocumentsErrors,
ListDocumentsResponses,
@@ -71,15 +85,12 @@ import type {
ListMemoriesData,
ListMemoriesErrors,
ListMemoriesResponses,
ListMentalModelsData,
ListMentalModelsErrors,
ListMentalModelsResponses,
ListMentalModelVersionsData,
ListMentalModelVersionsErrors,
ListMentalModelVersionsResponses,
ListOperationsData,
ListOperationsErrors,
ListOperationsResponses,
ListReflectionsData,
ListReflectionsErrors,
ListReflectionsResponses,
ListTagsData,
ListTagsErrors,
ListTagsResponses,
@@ -91,27 +102,30 @@ import type {
ReflectData,
ReflectErrors,
ReflectResponses,
RefreshMentalModelData,
RefreshMentalModelErrors,
RefreshMentalModelResponses,
RefreshMentalModelsData,
RefreshMentalModelsErrors,
RefreshMentalModelsResponses,
RefreshReflectionData,
RefreshReflectionErrors,
RefreshReflectionResponses,
RegenerateEntityObservationsData,
RegenerateEntityObservationsErrors,
RegenerateEntityObservationsResponses,
RetainMemoriesData,
RetainMemoriesErrors,
RetainMemoriesResponses,
TriggerConsolidationData,
TriggerConsolidationErrors,
TriggerConsolidationResponses,
UpdateBankData,
UpdateBankDispositionData,
UpdateBankDispositionErrors,
UpdateBankDispositionResponses,
UpdateBankErrors,
UpdateBankResponses,
UpdateMentalModelData,
UpdateMentalModelErrors,
UpdateMentalModelResponses,
UpdateDirectiveData,
UpdateDirectiveErrors,
UpdateDirectiveResponses,
UpdateReflectionData,
UpdateReflectionErrors,
UpdateReflectionResponses,
} from "./types.gen";
export type Options<
@@ -145,6 +159,19 @@ export const healthEndpointHealthGet = <ThrowOnError extends boolean = false>(
ThrowOnError
>({ url: "/health", ...options });
/**
* Get API version and feature flags
*
* Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment.
*/
export const getVersion = <ThrowOnError extends boolean = false>(
options?: Options<GetVersionData, ThrowOnError>,
) =>
(options?.client ?? client).get<GetVersionResponses, unknown, ThrowOnError>({
url: "/version",
...options,
});
/**
* Prometheus metrics endpoint
*
@@ -336,35 +363,33 @@ export const regenerateEntityObservations = <
});
/**
* List mental models
* List reflections
*
* List all mental models for a bank, optionally filtered by subtype or tags.
* 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. Supports two subtypes:
* - 'pinned' (default): User-defined topic, observations are LLM-generated on refresh
* - 'directive': User-defined hard rules, observations are provided at creation and never regenerated
* 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",
@@ -373,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/{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/{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 description. Useful for editing directives.
* 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/{model_id}",
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
...options,
headers: {
"Content-Type": "application/json",
@@ -428,19 +453,50 @@ export const updateMentalModel = <ThrowOnError extends boolean = false>(
});
/**
* Refresh mental models (async)
* Refresh reflection
*
* Submit a background job to refresh mental models for a bank. By default refreshes all subtypes. Optionally specify 'subtype' to only refresh 'structural' (from mission) or 'emergent' (from entities) models. Optionally pass tags to apply to newly created models. Use GET /banks/{bank_id}/operations to check progress.
* Re-run the source query through reflect and update the content.
*/
export const refreshMentalModels = <ThrowOnError extends boolean = false>(
options: Options<RefreshMentalModelsData, ThrowOnError>,
export const refreshReflection = <ThrowOnError extends boolean = false>(
options: Options<RefreshReflectionData, ThrowOnError>,
) =>
(options.client ?? client).post<
RefreshMentalModelsResponses,
RefreshMentalModelsErrors,
RefreshReflectionResponses,
RefreshReflectionErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/refresh",
url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh",
...options,
});
/**
* List directives
*
* List hard rules that are injected into prompts.
*/
export const listDirectives = <ThrowOnError extends boolean = false>(
options: Options<ListDirectivesData, ThrowOnError>,
) =>
(options.client ?? client).get<
ListDirectivesResponses,
ListDirectivesErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/directives", ...options });
/**
* Create directive
*
* Create a hard rule that will be injected into prompts.
*/
export const createDirective = <ThrowOnError extends boolean = false>(
options: Options<CreateDirectiveData, ThrowOnError>,
) =>
(options.client ?? client).post<
CreateDirectiveResponses,
CreateDirectiveErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/directives",
...options,
headers: {
"Content-Type": "application/json",
@@ -449,54 +505,58 @@ export const refreshMentalModels = <ThrowOnError extends boolean = false>(
});
/**
* Refresh mental model content (async)
* Delete directive
*
* Submit a background job to refresh content for a specific mental model. This is useful for newly created learned models or to refresh content for any model.
* Delete a directive.
*/
export const refreshMentalModel = <ThrowOnError extends boolean = false>(
options: Options<RefreshMentalModelData, ThrowOnError>,
export const deleteDirective = <ThrowOnError extends boolean = false>(
options: Options<DeleteDirectiveData, ThrowOnError>,
) =>
(options.client ?? client).post<
RefreshMentalModelResponses,
RefreshMentalModelErrors,
(options.client ?? client).delete<
DeleteDirectiveResponses,
DeleteDirectiveErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh",
url: "/v1/default/banks/{bank_id}/directives/{directive_id}",
...options,
});
/**
* List mental model version history
* Get directive
*
* List all saved versions of a mental model's observations, ordered by version descending.
* Get a specific directive by ID.
*/
export const listMentalModelVersions = <ThrowOnError extends boolean = false>(
options: Options<ListMentalModelVersionsData, ThrowOnError>,
export const getDirective = <ThrowOnError extends boolean = false>(
options: Options<GetDirectiveData, ThrowOnError>,
) =>
(options.client ?? client).get<
ListMentalModelVersionsResponses,
ListMentalModelVersionsErrors,
GetDirectiveResponses,
GetDirectiveErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions",
url: "/v1/default/banks/{bank_id}/directives/{directive_id}",
...options,
});
/**
* Get specific mental model version
* Update directive
*
* Get observations from a specific version of a mental model.
* Update a directive's properties.
*/
export const getMentalModelVersion = <ThrowOnError extends boolean = false>(
options: Options<GetMentalModelVersionData, ThrowOnError>,
export const updateDirective = <ThrowOnError extends boolean = false>(
options: Options<UpdateDirectiveData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetMentalModelVersionResponses,
GetMentalModelVersionErrors,
(options.client ?? client).patch<
UpdateDirectiveResponses,
UpdateDirectiveErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}",
url: "/v1/default/banks/{bank_id}/directives/{directive_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
@@ -579,7 +639,7 @@ export const getChunk = <ThrowOnError extends boolean = false>(
/**
* List async operations
*
* Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations
* Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
*/
export const listOperations = <ThrowOnError extends boolean = false>(
options: Options<ListOperationsData, ThrowOnError>,
@@ -738,6 +798,34 @@ export const createOrUpdateBank = <ThrowOnError extends boolean = false>(
},
});
/**
* Clear all mental models
*
* Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
*/
export const clearMentalModels = <ThrowOnError extends boolean = false>(
options: Options<ClearMentalModelsData, ThrowOnError>,
) =>
(options.client ?? client).delete<
ClearMentalModelsResponses,
ClearMentalModelsErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/mental-models", ...options });
/**
* Trigger consolidation
*
* Run memory consolidation to create/update mental models from recent memories.
*/
export const triggerConsolidation = <ThrowOnError extends boolean = false>(
options: Options<TriggerConsolidationData, ThrowOnError>,
) =>
(options.client ?? client).post<
TriggerConsolidationResponses,
TriggerConsolidationErrors,
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/consolidate", ...options });
/**
* Clear memory bank memories
*
File diff suppressed because it is too large Load Diff
-165
View File
@@ -40,10 +40,6 @@ import type {
BankProfileResponse,
CreateBankRequest,
Budget,
MentalModelResponse,
MentalModelListResponse,
AsyncOperationSubmitResponse,
ObservationInput,
} from '../generated/types.gen';
export interface HindsightClientOptions {
@@ -325,163 +321,6 @@ export class HindsightClient {
return this.validateResponse(response, 'setMission');
}
/**
* List mental models for a bank.
*/
async listMentalModels(
bankId: string,
options?: {
subtype?: 'structural' | 'emergent' | 'pinned' | 'learned' | 'directive';
tags?: string[];
tagsMatch?: 'any' | 'all' | 'exact';
}
): Promise<MentalModelListResponse> {
const response = await sdk.listMentalModels({
client: this.client,
path: { bank_id: bankId },
query: {
subtype: options?.subtype,
tags: options?.tags,
tags_match: options?.tagsMatch,
},
});
return this.validateResponse(response, 'listMentalModels');
}
/**
* Get a specific mental model by ID.
*/
async getMentalModel(bankId: string, modelId: string): Promise<MentalModelResponse> {
const response = await sdk.getMentalModel({
client: this.client,
path: { bank_id: bankId, model_id: modelId },
});
return this.validateResponse(response, 'getMentalModel');
}
/**
* Create a mental model.
*/
async createMentalModel(
bankId: string,
options: {
name: string;
description: string;
subtype?: 'pinned' | 'directive';
observations?: Array<{ title: string; content: string }>;
tags?: string[];
}
): Promise<MentalModelResponse> {
const response = await sdk.createMentalModel({
client: this.client,
path: { bank_id: bankId },
body: {
name: options.name,
description: options.description,
subtype: options.subtype,
observations: options.observations,
tags: options.tags,
},
});
return this.validateResponse(response, 'createMentalModel');
}
/**
* Update a mental model's name and/or description.
*/
async updateMentalModel(
bankId: string,
modelId: string,
options: {
name?: string;
description?: string;
}
): Promise<MentalModelResponse> {
const response = await sdk.updateMentalModel({
client: this.client,
path: { bank_id: bankId, model_id: modelId },
body: {
name: options.name,
description: options.description,
},
});
return this.validateResponse(response, 'updateMentalModel');
}
/**
* Delete a mental model.
*/
async deleteMentalModel(bankId: string, modelId: string): Promise<void> {
const response = await sdk.deleteMentalModel({
client: this.client,
path: { bank_id: bankId, model_id: modelId },
});
this.validateResponse(response, 'deleteMentalModel');
}
/**
* Submit a background job to refresh mental models for a bank.
*/
async refreshMentalModels(
bankId: string,
options?: {
subtype?: 'structural' | 'emergent' | 'pinned' | 'learned';
tags?: string[];
}
): Promise<AsyncOperationSubmitResponse> {
const response = await sdk.refreshMentalModels({
client: this.client,
path: { bank_id: bankId },
body: {
subtype: options?.subtype,
tags: options?.tags,
},
});
return this.validateResponse(response, 'refreshMentalModels');
}
/**
* Submit a background job to refresh content for a specific mental model.
*/
async refreshMentalModel(bankId: string, modelId: string): Promise<AsyncOperationSubmitResponse> {
const response = await sdk.refreshMentalModel({
client: this.client,
path: { bank_id: bankId, model_id: modelId },
});
return this.validateResponse(response, 'refreshMentalModel');
}
/**
* List all saved versions of a mental model's observations.
*/
async listMentalModelVersions(bankId: string, modelId: string): Promise<unknown> {
const response = await sdk.listMentalModelVersions({
client: this.client,
path: { bank_id: bankId, model_id: modelId },
});
return this.validateResponse(response, 'listMentalModelVersions');
}
/**
* Get observations from a specific version of a mental model.
*/
async getMentalModelVersion(bankId: string, modelId: string, version: number): Promise<unknown> {
const response = await sdk.getMentalModelVersion({
client: this.client,
path: { bank_id: bankId, model_id: modelId, version },
});
return this.validateResponse(response, 'getMentalModelVersion');
}
}
// Re-export types for convenience
@@ -497,10 +336,6 @@ export type {
BankProfileResponse,
CreateBankRequest,
Budget,
MentalModelResponse,
MentalModelListResponse,
AsyncOperationSubmitResponse,
ObservationInput,
};
// Also export low-level SDK functions for advanced usage
@@ -413,7 +413,7 @@ describe('TestDeleteBank', () => {
});
});
describe('TestMentalModels', () => {
describe('TestMission', () => {
test('set mission', async () => {
const bankId = randomBankId();
const response = await client.setMission(
@@ -425,173 +425,4 @@ describe('TestMentalModels', () => {
expect(response.bank_id).toBe(bankId);
expect(response.mission).toBe('Be a helpful PM tracking sprint progress and team capacity');
});
test('create pinned mental model', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
const response = await client.createMentalModel(bankId, {
name: 'Product Roadmap',
description: 'Track product priorities and feature decisions',
subtype: 'pinned',
tags: ['test'],
});
expect(response).not.toBeNull();
expect(response.name).toBe('Product Roadmap');
expect(response.description).toBe('Track product priorities and feature decisions');
expect(response.subtype).toBe('pinned');
});
test('create directive mental model', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
const response = await client.createMentalModel(bankId, {
name: 'Response Guidelines',
description: 'Rules for responding to users',
subtype: 'directive',
observations: [
{ title: 'Always be polite', content: 'All responses must be courteous and professional' },
{ title: 'Never share private info', content: 'Do not reveal internal details or user data' },
],
tags: ['test'],
});
expect(response).not.toBeNull();
expect(response.name).toBe('Response Guidelines');
expect(response.subtype).toBe('directive');
expect(response.observations).toBeDefined();
expect(response.observations!.length).toBe(2);
});
test('list mental models', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
// Create a model first
await client.createMentalModel(bankId, {
name: 'Test Model',
description: 'A test mental model',
subtype: 'pinned',
});
const response = await client.listMentalModels(bankId);
expect(response).not.toBeNull();
expect(response.items).toBeDefined();
expect(response.items!.length).toBeGreaterThanOrEqual(1);
});
test('get mental model', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
// Create a model first
const created = await client.createMentalModel(bankId, {
name: 'Retrieve Test Model',
description: 'A model to retrieve',
subtype: 'pinned',
});
const response = await client.getMentalModel(bankId, created.id);
expect(response).not.toBeNull();
expect(response.id).toBe(created.id);
expect(response.name).toBe('Retrieve Test Model');
});
test('update mental model', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
// Create a model first
const created = await client.createMentalModel(bankId, {
name: 'Update Test Model',
description: 'Original description',
subtype: 'pinned',
});
const response = await client.updateMentalModel(bankId, created.id, {
name: 'Updated Model Name',
description: 'Updated description',
});
expect(response).not.toBeNull();
expect(response.name).toBe('Updated Model Name');
expect(response.description).toBe('Updated description');
});
test('delete mental model', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
// Create a model first
const created = await client.createMentalModel(bankId, {
name: 'Delete Test Model',
description: 'A model to delete',
subtype: 'pinned',
});
// Delete should not throw
await expect(client.deleteMentalModel(bankId, created.id)).resolves.not.toThrow();
});
test('refresh mental models', async () => {
const bankId = randomBankId();
// Set mission first (required for refresh) - this also creates the bank
await client.setMission(bankId, 'Track team progress and decisions');
const response = await client.refreshMentalModels(bankId, {
tags: ['test'],
});
expect(response).not.toBeNull();
expect(response.operation_id).toBeDefined();
expect(response.status).toBe('queued');
});
test('refresh mental model', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
// Create a model first
const created = await client.createMentalModel(bankId, {
name: 'Refresh Single Test',
description: 'A model to refresh individually',
subtype: 'pinned',
});
const response = await client.refreshMentalModel(bankId, created.id);
expect(response).not.toBeNull();
expect(response.operation_id).toBeDefined();
expect(response.status).toBe('queued');
});
test('list mental model versions', async () => {
const bankId = randomBankId();
// Create bank first (required for mental models)
await client.createBank(bankId, {});
// Create a model first
const created = await client.createMentalModel(bankId, {
name: 'Versions Test Model',
description: 'A model to test version history',
subtype: 'pinned',
});
const response = await client.listMentalModelVersions(bankId, created.id);
// Newly created model should have version history
expect(response).not.toBeNull();
});
});
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
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 response = await sdk.triggerConsolidation({
client: lowLevelClient,
path: { bank_id: bankId },
});
if (response.error) {
console.error("API error triggering consolidation:", response.error);
return NextResponse.json({ error: "Failed to trigger consolidation" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error triggering consolidation:", error);
return NextResponse.json({ error: "Failed to trigger consolidation" }, { status: 500 });
}
}
@@ -0,0 +1,104 @@
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; directiveId: string }> }
) {
try {
const { bankId, directiveId } = await params;
if (!bankId || !directiveId) {
return NextResponse.json({ error: "bank_id and directive_id are required" }, { status: 400 });
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/directives/${directiveId}`,
{ method: "GET" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error getting directive:", errorText);
return NextResponse.json({ error: "Failed to get directive" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error getting directive:", error);
return NextResponse.json({ error: "Failed to get directive" }, { status: 500 });
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ bankId: string; directiveId: string }> }
) {
try {
const { bankId, directiveId } = await params;
if (!bankId || !directiveId) {
return NextResponse.json({ error: "bank_id and directive_id are required" }, { status: 400 });
}
const body = await request.json();
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/directives/${directiveId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error updating directive:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to update directive" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error updating directive:", error);
return NextResponse.json({ error: "Failed to update directive" }, { status: 500 });
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ bankId: string; directiveId: string }> }
) {
try {
const { bankId, directiveId } = await params;
if (!bankId || !directiveId) {
return NextResponse.json({ error: "bank_id and directive_id are required" }, { status: 400 });
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/directives/${directiveId}`,
{ method: "DELETE" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error deleting directive:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to delete directive" },
{ status: response.status }
);
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error deleting directive:", error);
return NextResponse.json({ error: "Failed to delete directive" }, { status: 500 });
}
}
@@ -0,0 +1,72 @@
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}/directives${queryParams.toString() ? `?${queryParams}` : ""}`;
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
const errorText = await response.text();
console.error("API error listing directives:", errorText);
return NextResponse.json({ error: "Failed to list directives" }, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error listing directives:", error);
return NextResponse.json({ error: "Failed to list directives" }, { 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}/directives`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error("API error creating directive:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to create directive" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 201 });
} catch (error) {
console.error("Error creating directive:", error);
return NextResponse.json({ error: "Failed to create directive" }, { status: 500 });
}
}
@@ -1,34 +0,0 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
export async function POST(
request: Request,
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
) {
try {
const { bankId, modelId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!modelId) {
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
}
const response = await sdk.refreshMentalModel({
client: lowLevelClient,
path: { bank_id: bankId, model_id: modelId },
});
if (response.error) {
console.error("API error refreshing mental model:", response.error);
return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error refreshing mental model:", error);
return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 });
}
}
@@ -1,40 +1,28 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
export async function PATCH(
export async function GET(
request: Request,
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
) {
try {
const { bankId, modelId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
if (!bankId || !modelId) {
return NextResponse.json({ error: "bank_id and model_id are required" }, { status: 400 });
}
if (!modelId) {
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
}
const body = await request.json();
// Call the dataplane API directly since SDK may not have the update method yet
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}
{ method: "GET" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error updating mental model:", errorText);
console.error("API error getting mental model:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to update mental model" },
{ error: "Failed to get mental model" },
{ status: response.status }
);
}
@@ -42,39 +30,7 @@ export async function PATCH(
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; modelId: string }> }
) {
try {
const { bankId, modelId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!modelId) {
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
}
const response = await sdk.deleteMentalModel({
client: lowLevelClient,
path: { bank_id: bankId, model_id: modelId },
});
if (response.error) {
console.error("API error deleting mental model:", response.error);
return NextResponse.json({ error: "Failed to delete mental model" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error deleting mental model:", error);
return NextResponse.json({ error: "Failed to delete mental model" }, { status: 500 });
console.error("Error getting mental model:", error);
return NextResponse.json({ error: "Failed to get mental model" }, { status: 500 });
}
}
@@ -1,47 +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; modelId: string; version: string }> }
) {
try {
const { bankId, modelId, version } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!modelId) {
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
}
if (!version) {
return NextResponse.json({ error: "version is required" }, { status: 400 });
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions/${version}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error getting mental model version:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to get mental model version" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error getting mental model version:", error);
return NextResponse.json({ error: "Failed to get mental model version" }, { status: 500 });
}
}
@@ -1,43 +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; modelId: string }> }
) {
try {
const { bankId, modelId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!modelId) {
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error listing mental model versions:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to list mental model versions" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error listing mental model versions:", error);
return NextResponse.json({ error: "Failed to list mental model versions" }, { status: 500 });
}
}
@@ -1,39 +0,0 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
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 });
}
// Parse request body for optional subtype filter
let body: { subtype?: "structural" | "emergent"; tags?: string[] } | undefined;
try {
const text = await request.text();
if (text) {
body = JSON.parse(text);
}
} catch {
// Empty body is fine
}
const response = await sdk.refreshMentalModels({
client: lowLevelClient,
path: { bank_id: bankId },
body: body,
});
if (response.error) {
console.error("API error refreshing mental models:", response.error);
return NextResponse.json({ error: "Failed to refresh mental models" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
} catch (error) {
console.error("Error refreshing mental models:", error);
return NextResponse.json({ error: "Failed to refresh mental models" }, { status: 500 });
}
}
@@ -1,42 +1,22 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
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 subtype = searchParams.get("subtype");
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
// If subtype is specified, call the dataplane API directly with the query param
if (subtype) {
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models?subtype=${subtype}`,
{ method: "GET" }
);
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 });
}
// Default: use SDK which excludes directives
const response = await sdk.listMentalModels({
// 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) {
@@ -44,14 +24,31 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
return NextResponse.json({ error: "Failed to list mental models" }, { status: 500 });
}
return NextResponse.json(response.data, { status: 200 });
// 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,
}));
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;
@@ -59,30 +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();
// Call the dataplane API directly since SDK may not have the new endpoint yet
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();
return NextResponse.json(data, { status: 201 });
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 });
}
}
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
export async function POST(
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}/refresh`,
{ method: "POST" }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error refreshing reflection:", errorText);
return NextResponse.json(
{ error: errorText || "Failed to refresh reflection" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
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 });
}
}
@@ -7,9 +7,15 @@ export async function GET(
) {
try {
const { agentId } = await params;
const searchParams = request.nextUrl.searchParams;
const status = searchParams.get("status") || undefined;
const limit = searchParams.get("limit") ? parseInt(searchParams.get("limit")!) : undefined;
const offset = searchParams.get("offset") ? parseInt(searchParams.get("offset")!) : undefined;
const response = await sdk.listOperations({
client: lowLevelClient,
path: { bank_id: agentId },
query: { status, limit, offset },
});
return NextResponse.json(response.data || {}, { status: 200 });
} catch (error) {

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