From 9db64ecda38f9a2410a68699bf0706ce9d1e2bb4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nicol=C3=B2=20Boschi?=
Date: Thu, 22 Jan 2026 17:13:16 +0100
Subject: [PATCH] feat: revisit mental models, directives and reflections
(#179)
* chore: run benchmarks with reflect mode
* chore: run benchmarks with reflect mode
* fixes
* new mm
* bunch of fixes
* initial commit
* fixes
* fixes
* fixes
* fix: sometimes memories gets extracted in the wrong language
---
.../m8h9i0j1k2l3_mental_model_id_to_text.py | 41 +
...k2l3m4_learnings_and_pinned_reflections.py | 134 +
...o0j1k2l3m4n5_migrate_mental_models_data.py | 113 +
...p1k2l3m4n5o6_new_knowledge_architecture.py | 194 ++
...q2l3m4n5o6p7_fix_mental_model_fact_type.py | 50 +
...7q8_add_reflect_response_to_reflections.py | 47 +
hindsight-api/hindsight_api/api/http.py | 1213 +++----
hindsight-api/hindsight_api/config.py | 24 +
.../engine/consolidation/__init__.py | 5 +
.../engine/consolidation/consolidator.py | 842 +++++
.../engine/consolidation/prompts.py | 91 +
.../engine/directives/__init__.py | 5 +
.../hindsight_api/engine/directives/models.py | 37 +
.../hindsight_api/engine/memory_engine.py | 2882 +++++++++--------
.../engine/mental_models/__init__.py | 14 +-
.../engine/mental_models/emergent.py | 311 --
.../engine/mental_models/models.py | 59 +-
.../engine/mental_models/structural.py | 228 --
.../hindsight_api/engine/reflect/agent.py | 282 +-
.../engine/reflect/mental_model_reflect.py | 1213 -------
.../hindsight_api/engine/reflect/models.py | 8 +-
.../engine/reflect/observations.py | 62 -
.../hindsight_api/engine/reflect/prompts.py | 554 +---
.../hindsight_api/engine/reflect/tools.py | 426 ++-
.../engine/reflect/tools_schema.py | 118 +-
.../hindsight_api/engine/response_models.py | 31 +-
.../engine/retain/fact_extraction.py | 24 +-
.../hindsight_api/engine/retain/link_utils.py | 13 +-
.../hindsight_api/engine/retain/types.py | 4 +-
.../hindsight_api/engine/search/tracer.py | 4 +-
.../hindsight_api/extensions/__init__.py | 4 -
.../extensions/operation_validator.py | 77 -
hindsight-api/hindsight_api/main.py | 3 +
hindsight-api/hindsight_api/worker/poller.py | 125 +-
hindsight-api/tests/test_consolidation.py | 1587 +++++++++
.../tests/test_emergent_filtering.py | 516 ---
hindsight-api/tests/test_extensions.py | 125 -
hindsight-api/tests/test_llm_tools.py | 20 +-
hindsight-api/tests/test_main_module.py | 4 -
hindsight-api/tests/test_mental_models.py | 1429 ++------
.../tests/test_observation_trends.py | 405 ---
hindsight-api/tests/test_reflect_agent.py | 1376 ++------
hindsight-api/tests/test_reflections.py | 359 ++
hindsight-api/tests/test_server_module.py | 4 -
hindsight-api/tests/test_sql_schema_safety.py | 2 +
hindsight-api/tests/test_worker.py | 235 ++
hindsight-cli/smoke-test.sh | 25 +-
hindsight-cli/src/api.rs | 245 +-
hindsight-cli/src/commands/directive.rs | 266 ++
hindsight-cli/src/commands/mental_model.rs | 721 -----
hindsight-cli/src/commands/mod.rs | 3 +-
hindsight-cli/src/commands/reflection.rs | 274 ++
hindsight-cli/src/main.rs | 337 +-
hindsight-cli/src/ui.rs | 2 +-
.../python/.openapi-generator/FILES | 26 +-
.../hindsight_client/hindsight_client.py | 236 +-
.../python/hindsight_client_api/__init__.py | 26 +-
.../hindsight_client_api/api/__init__.py | 3 +-
.../hindsight_client_api/api/banks_api.py | 557 ++++
.../api/directives_api.py | 1619 +++++++++
.../api/monitoring_api.py | 246 ++
.../api/operations_api.py | 60 +-
...ental_models_api.py => reflections_api.py} | 1430 ++------
.../hindsight_client_api/models/__init__.py | 23 +-
.../models/bank_stats_response.py | 19 +-
..._response.py => consolidation_response.py} | 30 +-
.../models/create_directive_request.py | 95 +
.../models/create_reflection_request.py | 94 +
...input.py => create_reflection_response.py} | 16 +-
...response.py => directive_list_response.py} | 14 +-
...odels_request.py => directive_response.py} | 52 +-
...on_submit_response.py => features_info.py} | 22 +-
.../mental_model_observation_response.py | 107 -
.../models/mental_model_response.py | 145 -
.../models/operations_list_response.py | 6 +-
.../models/reflect_based_on.py | 14 +-
.../models/reflect_response.py | 16 +-
.../models/reflection_list_response.py | 95 +
...ess_response.py => reflection_response.py} | 54 +-
...request.py => update_directive_request.py} | 62 +-
...equest.py => update_reflection_request.py} | 19 +-
...ed_mental_model.py => version_response.py} | 24 +-
.../python/tests/test_main_operations.py | 191 +-
.../typescript/generated/sdk.gen.ts | 278 +-
.../typescript/generated/types.gen.ts | 1331 ++++----
hindsight-clients/typescript/src/index.ts | 165 -
.../typescript/tests/main_operations.test.ts | 171 +-
.../api/banks/[bankId]/consolidate/route.ts | 27 +
.../directives/[directiveId]/route.ts | 104 +
.../api/banks/[bankId]/directives/route.ts | 72 +
.../mental-models/[modelId]/refresh/route.ts | 34 -
.../[bankId]/mental-models/[modelId]/route.ts | 60 +-
.../[modelId]/versions/[version]/route.ts | 47 -
.../mental-models/[modelId]/versions/route.ts | 43 -
.../[bankId]/mental-models/refresh/route.ts | 39 -
.../api/banks/[bankId]/mental-models/route.ts | 82 +-
.../[reflectionId]/refresh/route.ts | 39 +
.../reflections/[reflectionId]/route.ts | 113 +
.../api/banks/[bankId]/reflections/route.ts | 76 +
.../src/app/api/operations/[agentId]/route.ts | 6 +
.../src/app/api/version/route.ts | 20 +
.../src/app/banks/[bankId]/page.tsx | 20 +-
.../src/components/bank-profile-view.tsx | 837 ++++-
.../src/components/bank-selector.tsx | 369 ++-
.../src/components/data-view.tsx | 87 +-
.../src/components/memory-detail-panel.tsx | 86 +-
.../src/components/mental-models-view.tsx | 2123 ------------
.../src/components/reflections-view.tsx | 808 +++++
.../src/components/search-debug-view.tsx | 150 +-
.../src/components/think-view.tsx | 18 +-
hindsight-control-plane/src/lib/api.ts | 482 ++-
.../benchmarks/common/benchmark_runner.py | 188 +-
.../benchmarks/locomo/locomo_benchmark.py | 82 +-
.../longmemeval/longmemeval_benchmark.py | 57 +-
hindsight-docs/static/openapi.json | 1885 ++++++-----
115 files changed, 16609 insertions(+), 15764 deletions(-)
create mode 100644 hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py
create mode 100644 hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py
create mode 100644 hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py
create mode 100644 hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py
create mode 100644 hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py
create mode 100644 hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py
create mode 100644 hindsight-api/hindsight_api/engine/consolidation/__init__.py
create mode 100644 hindsight-api/hindsight_api/engine/consolidation/consolidator.py
create mode 100644 hindsight-api/hindsight_api/engine/consolidation/prompts.py
create mode 100644 hindsight-api/hindsight_api/engine/directives/__init__.py
create mode 100644 hindsight-api/hindsight_api/engine/directives/models.py
delete mode 100644 hindsight-api/hindsight_api/engine/mental_models/emergent.py
delete mode 100644 hindsight-api/hindsight_api/engine/mental_models/structural.py
delete mode 100644 hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py
create mode 100644 hindsight-api/tests/test_consolidation.py
delete mode 100644 hindsight-api/tests/test_emergent_filtering.py
delete mode 100644 hindsight-api/tests/test_observation_trends.py
create mode 100644 hindsight-api/tests/test_reflections.py
create mode 100644 hindsight-cli/src/commands/directive.rs
delete mode 100644 hindsight-cli/src/commands/mental_model.rs
create mode 100644 hindsight-cli/src/commands/reflection.rs
create mode 100644 hindsight-clients/python/hindsight_client_api/api/directives_api.py
rename hindsight-clients/python/hindsight_client_api/api/{mental_models_api.py => reflections_api.py} (56%)
rename hindsight-clients/python/hindsight_client_api/models/{observation_evidence_response.py => consolidation_response.py} (66%)
create mode 100644 hindsight-clients/python/hindsight_client_api/models/create_directive_request.py
create mode 100644 hindsight-clients/python/hindsight_client_api/models/create_reflection_request.py
rename hindsight-clients/python/hindsight_client_api/models/{observation_input.py => create_reflection_response.py} (80%)
rename hindsight-clients/python/hindsight_client_api/models/{mental_model_list_response.py => directive_list_response.py} (83%)
rename hindsight-clients/python/hindsight_client_api/models/{refresh_mental_models_request.py => directive_response.py} (60%)
rename hindsight-clients/python/hindsight_client_api/models/{async_operation_submit_response.py => features_info.py} (72%)
delete mode 100644 hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py
delete mode 100644 hindsight-clients/python/hindsight_client_api/models/mental_model_response.py
create mode 100644 hindsight-clients/python/hindsight_client_api/models/reflection_list_response.py
rename hindsight-clients/python/hindsight_client_api/models/{mental_model_freshness_response.py => reflection_response.py} (53%)
rename hindsight-clients/python/hindsight_client_api/models/{create_mental_model_request.py => update_directive_request.py} (54%)
rename hindsight-clients/python/hindsight_client_api/models/{update_mental_model_request.py => update_reflection_request.py} (77%)
rename hindsight-clients/python/hindsight_client_api/models/{created_mental_model.py => version_response.py} (71%)
create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts
create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts
create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts
delete mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts
delete mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts
delete mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts
delete mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/refresh/route.ts
create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/refresh/route.ts
create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/route.ts
create mode 100644 hindsight-control-plane/src/app/api/banks/[bankId]/reflections/route.ts
create mode 100644 hindsight-control-plane/src/app/api/version/route.ts
delete mode 100644 hindsight-control-plane/src/components/mental-models-view.tsx
create mode 100644 hindsight-control-plane/src/components/reflections-view.tsx
diff --git a/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py b/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py
new file mode 100644
index 00000000..5ab9bf79
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py
@@ -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)")
diff --git a/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py b/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py
new file mode 100644
index 00000000..1f29d8e8
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py
@@ -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")
diff --git a/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py b/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py
new file mode 100644
index 00000000..48545a6c
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py
@@ -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
diff --git a/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py b/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py
new file mode 100644
index 00000000..8d9002e4
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py
@@ -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
diff --git a/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py b/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py
new file mode 100644
index 00000000..9d3aa36e
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py
@@ -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'))
+ """)
diff --git a/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py b/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py
new file mode 100644
index 00000000..4c5a2cf6
--- /dev/null
+++ b/hindsight-api/hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py
@@ -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
+ """)
diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py
index e7c3ab6c..42dde613 100644
--- a/hindsight-api/hindsight_api/api/http.py
+++ b/hindsight-api/hindsight_api/api/http.py
@@ -92,7 +92,7 @@ class RecallRequest(BaseModel):
query: str
types: list[str] | None = Field(
default=None,
- description="List of fact types to recall: 'world', 'experience'. Defaults to both if not specified. "
+ description="List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. "
"Note: 'opinion' is accepted but ignored (opinions are excluded from recall).",
)
budget: Budget = Budget.MID
@@ -570,9 +570,6 @@ class ReflectBasedOn(BaseModel):
"""Evidence the response is based on: memories and mental models."""
memories: list[ReflectFact] = Field(default_factory=list, description="Memory facts used to generate the response")
- mental_models: list[ReflectMentalModel] = Field(
- default_factory=list, description="Mental models accessed during reflection"
- )
class ReflectTrace(BaseModel):
@@ -586,14 +583,6 @@ class ReflectTrace(BaseModel):
)
-class CreatedMentalModel(BaseModel):
- """A mental model created during reflection."""
-
- id: str = Field(description="Mental model ID")
- name: str = Field(description="Human-readable name")
- description: str = Field(description="What this model tracks")
-
-
class ReflectResponse(BaseModel):
"""Response model for think endpoint."""
@@ -625,9 +614,6 @@ class ReflectResponse(BaseModel):
"tool_calls": [{"tool": "recall", "input": {"query": "AI"}, "duration_ms": 150}],
"llm_calls": [{"scope": "agent_1", "duration_ms": 1200}],
},
- "mental_models_created": [
- {"id": "mm-new-1", "name": "AI Strategy", "description": "Track AI-related decisions and plans"}
- ],
}
}
)
@@ -649,10 +635,6 @@ class ReflectResponse(BaseModel):
default=None,
description="Execution trace of tool and LLM calls. Only present when include.tool_calls is set.",
)
- mental_models_created: list[CreatedMentalModel] = Field(
- default_factory=list,
- description="Mental models created during this reflection (via the learn tool).",
- )
class BanksResponse(BaseModel):
@@ -1032,6 +1014,9 @@ class BankStatsResponse(BaseModel):
"links_breakdown": {"fact": {"temporal": 100, "semantic": 60, "entity": 40}},
"pending_operations": 2,
"failed_operations": 0,
+ "last_consolidated_at": "2024-01-15T10:30:00Z",
+ "pending_consolidation": 0,
+ "total_mental_models": 45,
}
}
)
@@ -1046,6 +1031,10 @@ class BankStatsResponse(BaseModel):
links_breakdown: dict[str, dict[str, int]]
pending_operations: int
failed_operations: int
+ # Consolidation stats
+ last_consolidated_at: str | None = Field(default=None, description="When consolidation last ran (ISO format)")
+ pending_consolidation: int = Field(default=0, description="Number of memories not yet processed into mental models")
+ total_mental_models: int = Field(default=0, description="Total number of mental models")
# Mental Model models
@@ -1060,205 +1049,117 @@ class ObservationEvidenceResponse(BaseModel):
timestamp: str = Field(description="When the source memory was created (ISO format)")
-class MentalModelObservationResponse(BaseModel):
- """An observation within a mental model with its supporting evidence."""
-
- title: str = Field(description="Short summary title for the observation")
- content: str = Field(description="The observation content - detailed explanation")
- evidence: list[ObservationEvidenceResponse] = Field(
- default_factory=list, description="Supporting evidence with quotes"
- )
- created_at: str = Field(description="When this observation was first created (ISO format)")
- trend: str = Field(description="Computed trend: stable, strengthening, weakening, new, stale")
- evidence_count: int = Field(description="Number of evidence items supporting this observation")
- evidence_span: dict = Field(description="Time span of evidence: {from: iso_date, to: iso_date}")
+# =========================================================================
+# Directive Models
+# =========================================================================
-class MentalModelFreshnessResponse(BaseModel):
- """Freshness information for a mental model."""
-
- is_up_to_date: bool = Field(description="Whether the model has been refreshed since the last memory was added")
- last_refresh_at: str | None = Field(description="When the model was last refreshed (ISO format)")
- memories_since_refresh: int = Field(description="Number of memories added since last refresh")
- reasons: list[str] = Field(
- default_factory=list,
- description="Reasons why the model needs refresh (empty if up to date). "
- "Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed",
- )
-
-
-class MentalModelResponse(BaseModel):
- """Response model for a mental model."""
-
- model_config = ConfigDict(
- json_schema_extra={
- "example": {
- "id": "team-structure",
- "bank_id": "test-bank",
- "subtype": "structural",
- "name": "Team Structure",
- "description": "Who's on the team and their roles",
- "observations": [
- {
- "title": "Prefers async communication",
- "content": "The team prefers async communication over synchronous meetings",
- "evidence": [
- {
- "memory_id": "uuid1",
- "quote": "I prefer Slack over meetings",
- "relevance": "Shows async preference",
- "timestamp": "2024-01-10T08:00:00Z",
- }
- ],
- "created_at": "2024-01-15T10:30:00Z",
- "trend": "stable",
- "evidence_count": 1,
- "evidence_span": {"from": "2024-01-10T08:00:00Z", "to": "2024-01-10T08:00:00Z"},
- }
- ],
- "version": 1,
- "entity_id": None,
- "links": [],
- "tags": ["project-x"],
- "last_updated": "2024-01-15T10:30:00Z",
- "last_refresh_at": "2024-01-15T10:30:00Z",
- "freshness": {
- "is_up_to_date": True,
- "last_refresh_at": "2024-01-15T10:30:00Z",
- "memories_since_refresh": 0,
- "reasons": [],
- },
- "created_at": "2024-01-10T08:00:00Z",
- }
- }
- )
+class DirectiveResponse(BaseModel):
+ """Response model for a directive."""
id: str
bank_id: str
- subtype: str
name: str
- description: str
- observations: list[MentalModelObservationResponse] = Field(
- default_factory=list, description="Structured observations with per-observation fact attribution"
- )
- version: int = Field(default=0, description="Version number of the mental model observations")
- entity_id: str | None = None
- links: list[str] = []
- tags: list[str] = []
- last_updated: str | None = None
- last_refresh_at: str | None = Field(default=None, description="When observations were last refreshed (ISO format)")
- freshness: MentalModelFreshnessResponse | None = Field(
- default=None, description="Freshness info (null for directive subtypes which don't need refresh)"
- )
- created_at: str
+ content: str
+ priority: int = 0
+ is_active: bool = True
+ tags: list[str] = Field(default_factory=list)
+ created_at: str | None = None
+ updated_at: str | None = None
-class MentalModelListResponse(BaseModel):
- """Response model for listing mental models."""
+class DirectiveListResponse(BaseModel):
+ """Response model for listing directives."""
- items: list[MentalModelResponse]
+ items: list[DirectiveResponse]
-def _observation_to_response(obs: Observation) -> MentalModelObservationResponse:
- """Convert internal Observation model to API response model."""
- return MentalModelObservationResponse(
- title=obs.title,
- content=obs.content,
- evidence=[
- ObservationEvidenceResponse(
- memory_id=ev.memory_id,
- quote=ev.quote,
- relevance=ev.relevance,
- timestamp=ev.timestamp.isoformat(),
- )
- for ev in obs.evidence
- ],
- created_at=obs.created_at.isoformat(),
- trend=obs.trend.value,
- evidence_count=obs.evidence_count,
- evidence_span=obs.evidence_span,
- )
+class CreateDirectiveRequest(BaseModel):
+ """Request model for creating a directive."""
+
+ name: str = Field(description="Human-readable name for the directive")
+ 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 active")
+ tags: list[str] = Field(default_factory=list, description="Tags for filtering")
-def _prepare_mental_model_response(model: dict[str, Any]) -> MentalModelResponse:
- """Convert internal mental model dict to API response model.
+class UpdateDirectiveRequest(BaseModel):
+ """Request model for updating a directive."""
- Handles conversion of Observation models to MentalModelObservationResponse.
- """
- observations = model.get("observations", [])
- converted_observations = [
- _observation_to_response(obs) if isinstance(obs, Observation) else obs for obs in observations
- ]
- return MentalModelResponse(**{**model, "observations": converted_observations})
+ name: str | None = Field(default=None, description="New name")
+ content: str | None = Field(default=None, description="New content")
+ priority: int | None = Field(default=None, description="New priority")
+ is_active: bool | None = Field(default=None, description="New active status")
+ tags: list[str] | None = Field(default=None, description="New tags")
-class RefreshMentalModelsRequest(BaseModel):
- """Request model for refresh mental models endpoint."""
+# =========================================================================
+# Reflections Models
+# =========================================================================
- model_config = ConfigDict(json_schema_extra={"example": {"tags": ["project-x"], "subtype": "structural"}})
- tags: list[str] | None = Field(default=None, description="Tags to apply to newly created mental models")
- subtype: Literal["structural", "emergent", "pinned", "learned"] | None = Field(
+class ReflectionResponse(BaseModel):
+ """Response model for a reflection."""
+
+ id: str
+ bank_id: str
+ name: str
+ source_query: str
+ content: str
+ tags: list[str] = Field(default_factory=list)
+ last_refreshed_at: str | None = None
+ created_at: str | None = None
+ reflect_response: dict | None = Field(
default=None,
- description="Only refresh models of this subtype. If not specified, refreshes all subtypes.",
+ description="Full reflect API response payload including based_on facts and mental_models",
)
-class ObservationInput(BaseModel):
- """Input model for a single observation."""
+class ReflectionListResponse(BaseModel):
+ """Response model for listing reflections."""
- title: str = Field(description="Short title/header for the observation")
- content: str = Field(description="Content of the observation")
+ items: list[ReflectionResponse]
-class CreateMentalModelRequest(BaseModel):
- """Request model for creating a mental model."""
-
- model_config = ConfigDict(
- json_schema_extra={
- "examples": [
- {
- "name": "Product Roadmap",
- "description": "Key product priorities and upcoming features",
- "tags": ["project-x"],
- },
- {
- "name": "Meeting Rules",
- "description": "Rules about scheduling meetings",
- "subtype": "directive",
- "observations": [{"title": "Morning meetings", "content": "Never schedule meetings before 10am"}],
- },
- ]
- }
- )
-
- name: str = Field(description="Human-readable name for the mental model")
- description: str = Field(description="One-liner description for quick scanning")
- subtype: str = Field(
- default="pinned",
- description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)",
- )
- observations: list[ObservationInput] | None = Field(
- default=None,
- description="For directives only: list of user-provided observations. Required when subtype='directive'.",
- )
- tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
-
-
-class UpdateMentalModelRequest(BaseModel):
- """Request model for updating a mental model."""
+class CreateReflectionRequest(BaseModel):
+ """Request model for creating a reflection."""
model_config = ConfigDict(
json_schema_extra={
"example": {
- "name": "Updated Name",
- "description": "Updated description with new rules",
+ "name": "Team Communication Preferences",
+ "source_query": "How does the team prefer to communicate?",
+ "tags": ["team"],
+ "max_tokens": 2048,
}
}
)
- name: str | None = Field(default=None, description="New name for the mental model")
- description: str | None = Field(default=None, description="New description/rule text")
+ name: str = Field(description="Human-readable name for the reflection")
+ source_query: str = Field(description="The query to run to generate content")
+ tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
+ max_tokens: int = Field(default=2048, ge=256, le=8192, description="Maximum tokens for generated content")
+
+
+class CreateReflectionResponse(BaseModel):
+ """Response model for reflection creation."""
+
+ operation_id: str = Field(description="Operation ID to track progress")
+
+
+class UpdateReflectionRequest(BaseModel):
+ """Request model for updating a reflection."""
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "name": "Updated Team Communication Preferences",
+ }
+ }
+ )
+
+ name: str | None = Field(default=None, description="New name for the reflection")
class OperationResponse(BaseModel):
@@ -1287,6 +1188,16 @@ class OperationResponse(BaseModel):
error_message: str | None
+class ConsolidationResponse(BaseModel):
+ """Response model for consolidation trigger endpoint."""
+
+ status: str = Field(description="Status of the consolidation (completed or queued)")
+ processed: int = Field(description="Number of memories processed")
+ created: int = Field(description="Number of mental models created")
+ updated: int = Field(description="Number of mental models updated")
+ message: str = Field(description="Human-readable summary")
+
+
class OperationsListResponse(BaseModel):
"""Response model for list operations endpoint."""
@@ -1295,6 +1206,8 @@ class OperationsListResponse(BaseModel):
"example": {
"bank_id": "user123",
"total": 150,
+ "limit": 20,
+ "offset": 0,
"operations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
@@ -1310,6 +1223,8 @@ class OperationsListResponse(BaseModel):
bank_id: str
total: int
+ limit: int
+ offset: int
operations: list[OperationResponse]
@@ -1373,6 +1288,34 @@ class AsyncOperationSubmitResponse(BaseModel):
status: str
+class FeaturesInfo(BaseModel):
+ """Feature flags indicating which capabilities are enabled."""
+
+ mental_models: bool = Field(description="Whether mental models (auto-consolidation) are enabled")
+ mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
+ worker: bool = Field(description="Whether the background worker is enabled")
+
+
+class VersionResponse(BaseModel):
+ """Response model for the version/info endpoint."""
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "api_version": "1.0.0",
+ "features": {
+ "mental_models": False,
+ "mcp": True,
+ "worker": True,
+ },
+ }
+ }
+ )
+
+ api_version: str = Field(description="API version string")
+ features: FeaturesInfo = Field(description="Enabled feature flags")
+
+
def create_app(
memory: MemoryEngine,
initialize_memory: bool = True,
@@ -1586,6 +1529,34 @@ def _register_routes(app: FastAPI):
status_code = 200 if health.get("status") == "healthy" else 503
return JSONResponse(content=health, status_code=status_code)
+ @app.get(
+ "/version",
+ response_model=VersionResponse,
+ summary="Get API version and feature flags",
+ description="Returns API version information and enabled feature flags. "
+ "Use this to check which capabilities are available in this deployment.",
+ tags=["Monitoring"],
+ operation_id="get_version",
+ )
+ async def version_endpoint() -> VersionResponse:
+ """
+ Get API version and enabled features.
+
+ Returns version info and feature flags that can be used by clients
+ to determine which capabilities are available.
+ """
+ from hindsight_api.config import get_config
+
+ config = get_config()
+ return VersionResponse(
+ api_version="1.0.0",
+ features=FeaturesInfo(
+ mental_models=config.enable_mental_models,
+ mcp=config.mcp_enabled,
+ worker=config.worker_enabled,
+ ),
+ )
+
@app.get(
"/metrics",
summary="Prometheus metrics endpoint",
@@ -1821,7 +1792,10 @@ def _register_routes(app: FastAPI):
)
response = RecallResponse(
- results=recall_results, trace=core_result.trace, entities=entities_response, chunks=chunks_response
+ results=recall_results,
+ trace=core_result.trace,
+ entities=entities_response,
+ chunks=chunks_response,
)
handler_duration = time.time() - handler_start
@@ -1907,16 +1881,7 @@ def _register_routes(app: FastAPI):
occurred_end=fact.occurred_end,
)
)
- mental_models = [
- ReflectMentalModel(
- id=mm.id,
- name=mm.name,
- type=mm.type,
- subtype=mm.subtype,
- )
- for mm in core_result.mental_models
- ]
- based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models)
+ based_on_result = ReflectBasedOn(memories=memories)
# Build trace (tool_calls + llm_calls + mental_models) if tool_calls is requested
trace_result: ReflectTrace | None = None
@@ -1935,42 +1900,39 @@ def _register_routes(app: FastAPI):
llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace]
# Build map of directive observations by id
directive_observations = {d.id: d.rules for d in core_result.directives_applied}
- # Include all mental models (including directives with subtype='directive')
- trace_mental_models = [
- ReflectMentalModel(
- id=mm.id,
- name=mm.name,
- type=mm.type,
- subtype=mm.subtype,
- observations=directive_observations.get(mm.id) if mm.subtype == "directive" else None,
- )
- for mm in core_result.mental_models
- ]
+ # Build mental models from tool trace (get_mental_model outputs)
+ trace_mental_models: list[ReflectMentalModel] = []
+ seen_model_ids: set[str] = set()
+ for tc in core_result.tool_trace:
+ if tc.tool == "get_mental_model" and tc.output.get("found") and "model" in tc.output:
+ model = tc.output["model"]
+ model_id = model.get("id")
+ if model_id and model_id not in seen_model_ids:
+ seen_model_ids.add(model_id)
+ model_subtype = model.get("subtype", "structural")
+ trace_mental_models.append(
+ ReflectMentalModel(
+ id=model_id,
+ name=model.get("name", ""),
+ type=model.get("type", "concept"),
+ subtype=model_subtype,
+ observations=directive_observations.get(model_id)
+ if model_subtype == "directive"
+ else None,
+ )
+ )
trace_result = ReflectTrace(
tool_calls=tool_calls,
llm_calls=llm_calls,
mental_models=trace_mental_models,
)
- # Build mental_models_created from tool trace (learn tool outputs)
- created_models: list[CreatedMentalModel] = []
- for tc in core_result.tool_trace:
- if tc.tool == "learn" and isinstance(tc.output, dict) and tc.output.get("status") == "created":
- created_models.append(
- CreatedMentalModel(
- id=tc.output.get("model_id", ""),
- name=tc.input.get("name", ""),
- description=tc.input.get("description", ""),
- )
- )
-
return ReflectResponse(
text=core_result.text,
based_on=based_on_result,
structured_output=core_result.structured_output,
usage=core_result.usage,
trace=trace_result,
- mental_models_created=created_models,
)
except OperationValidationError as e:
@@ -2096,6 +2058,54 @@ def _register_routes(app: FastAPI):
)
total_documents = doc_count_result["count"] if doc_count_result else 0
+ # Get consolidation stats
+ bank_row = await conn.fetchrow(
+ f"""
+ SELECT last_consolidated_at
+ FROM {fq_table("banks")}
+ WHERE bank_id = $1
+ """,
+ bank_id,
+ )
+ last_consolidated_at = bank_row["last_consolidated_at"] if bank_row else None
+
+ # Count memories pending consolidation (created after last_consolidated_at)
+ if last_consolidated_at:
+ pending_consolidation_result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1
+ AND created_at > $2
+ AND fact_type IN ('experience', 'world')
+ """,
+ bank_id,
+ last_consolidated_at,
+ )
+ else:
+ # If never consolidated, count all experience/world memories
+ pending_consolidation_result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1
+ AND fact_type IN ('experience', 'world')
+ """,
+ bank_id,
+ )
+ pending_consolidation = pending_consolidation_result["count"] if pending_consolidation_result else 0
+
+ # Count total mental models
+ mental_model_count_result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ total_mental_models = mental_model_count_result["count"] if mental_model_count_result else 0
+
# Format results
nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats}
links_by_type = {row["link_type"]: row["count"] for row in link_stats}
@@ -2125,6 +2135,9 @@ def _register_routes(app: FastAPI):
links_breakdown=links_breakdown,
pending_operations=pending_operations,
failed_operations=failed_operations,
+ last_consolidated_at=(last_consolidated_at.isoformat() if last_consolidated_at else None),
+ pending_consolidation=pending_consolidation,
+ total_mental_models=total_mental_models,
)
except (AuthenticationError, HTTPException):
@@ -2223,128 +2236,111 @@ def _register_routes(app: FastAPI):
entity_id: str,
request_context: RequestContext = Depends(get_request_context),
):
- """Regenerate observations for an entity. DEPRECATED: Use mental models instead."""
+ """Regenerate observations for an entity. DEPRECATED."""
raise HTTPException(
status_code=410,
- detail="This endpoint is deprecated. Entity observations have been replaced by mental models. "
- "Use the /mental-models endpoints instead.",
+ detail="This endpoint is deprecated. Entity observations are no longer supported.",
)
# =========================================================================
- # Mental Models endpoints
+ # =========================================================================
+ # REFLECTIONS ENDPOINTS
# =========================================================================
@app.get(
- "/v1/default/banks/{bank_id}/mental-models",
- response_model=MentalModelListResponse,
- summary="List mental models",
- description="List all mental models for a bank, optionally filtered by subtype or tags.",
- operation_id="list_mental_models",
- tags=["Mental Models"],
+ "/v1/default/banks/{bank_id}/reflections",
+ response_model=ReflectionListResponse,
+ summary="List reflections",
+ description="List user-curated living documents that stay current.",
+ operation_id="list_reflections",
+ tags=["Reflections"],
)
- async def api_list_mental_models(
+ async def api_list_reflections(
bank_id: str,
- subtype: str | None = Query(None, description="Filter by subtype: structural, emergent, or pinned"),
- tags_filter: list[str] | None = Query(
- None, alias="tags", description="Filter by tags (includes untagged models)"
- ),
- tags_match: Literal["any", "all", "exact"] = Query(
- "any", description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'"
- ),
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
+ limit: int = Query(100, ge=1, le=1000),
+ offset: int = Query(0, ge=0),
request_context: RequestContext = Depends(get_request_context),
):
- """List mental models for a bank."""
+ """List reflections for a bank."""
try:
- models = await app.state.memory.list_mental_models(
+ reflections = await app.state.memory.list_reflections(
bank_id=bank_id,
- subtype=subtype,
tags=tags_filter,
tags_match=tags_match,
+ limit=limit,
+ offset=offset,
request_context=request_context,
)
-
- # Add freshness to each model (skip for directives)
- # Get data needed for freshness computation (once for all models)
- from hindsight_api.engine.reflect.mental_model_reflect import (
- BankProfile,
- DirectiveMentalModel,
- check_needs_refresh,
- )
-
- total_memories = await app.state.memory._count_memories_since(bank_id, None)
- bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Convert to typed models at the boundary
- bank_profile = BankProfile.model_validate(bank_profile_dict)
- directives = [DirectiveMentalModel.model_validate(m) for m in models if m.get("subtype") == "directive"]
-
- for model in models:
- if model.get("subtype") != "directive":
- last_refresh_at = model.get("last_refresh_at")
- memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at)
-
- # Use check_needs_refresh to get reasons
- stored_refresh_state = model.get("refresh_state")
- refresh_check = check_needs_refresh(
- stored_state=stored_refresh_state,
- current_memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- model["freshness"] = {
- "is_up_to_date": not refresh_check.needs_refresh,
- "last_refresh_at": last_refresh_at,
- "memories_since_refresh": memories_since,
- "reasons": refresh_check.reasons,
- }
- else:
- model["freshness"] = None
-
- return MentalModelListResponse(items=[_prepare_mental_model_response(m) for m in models])
+ return ReflectionListResponse(items=[ReflectionResponse(**r) for r in reflections])
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models: {error_detail}")
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.get(
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
+ response_model=ReflectionResponse,
+ summary="Get reflection",
+ description="Get a specific reflection by ID.",
+ operation_id="get_reflection",
+ tags=["Reflections"],
+ )
+ async def api_get_reflection(
+ bank_id: str,
+ reflection_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Get a reflection by ID."""
+ try:
+ reflection = await app.state.memory.get_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ request_context=request_context,
+ )
+ if reflection is None:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return ReflectionResponse(**reflection)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
- "/v1/default/banks/{bank_id}/mental-models",
- response_model=MentalModelResponse,
- summary="Create mental model",
- description=(
- "Create a mental model. Supports two subtypes:\n"
- "- 'pinned' (default): User-defined topic, observations are LLM-generated on refresh\n"
- "- 'directive': User-defined hard rules, observations are provided at creation and never regenerated"
- ),
- operation_id="create_mental_model",
- tags=["Mental Models"],
+ "/v1/default/banks/{bank_id}/reflections",
+ response_model=CreateReflectionResponse,
+ summary="Create reflection",
+ description="Create a reflection by running reflect with the source query in the background. "
+ "Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. "
+ "Use the operations endpoint to check completion status.",
+ operation_id="create_reflection",
+ tags=["Reflections"],
)
- async def api_create_mental_model(
+ async def api_create_reflection(
bank_id: str,
- body: CreateMentalModelRequest,
+ body: CreateReflectionRequest,
request_context: RequestContext = Depends(get_request_context),
):
- """Create a mental model (pinned or directive)."""
+ """Create a reflection (async - returns operation_id)."""
try:
- # Convert observations to list of dicts if provided
- observations_list = None
- if body.observations:
- observations_list = [{"title": obs.title, "content": obs.content} for obs in body.observations]
-
- model = await app.state.memory.create_mental_model(
+ result = await app.state.memory.submit_async_create_reflection(
bank_id=bank_id,
name=body.name,
- description=body.description,
- subtype=body.subtype,
- observations=observations_list,
- tags=body.tags,
+ source_query=body.source_query,
+ tags=body.tags if body.tags else None,
+ max_tokens=body.max_tokens,
request_context=request_context,
)
- return _prepare_mental_model_response(model)
+ return CreateReflectionResponse(operation_id=result["operation_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
@@ -2353,286 +2349,32 @@ def _register_routes(app: FastAPI):
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.get(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}",
- response_model=MentalModelResponse,
- summary="Get mental model",
- description="Get a specific mental model by ID.",
- operation_id="get_mental_model",
- tags=["Mental Models"],
- )
- async def api_get_mental_model(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Get a mental model by ID."""
- try:
- model = await app.state.memory.get_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- if model is None:
- raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
-
- # Compute freshness for non-directive models
- if model.get("subtype") != "directive":
- from hindsight_api.engine.reflect.mental_model_reflect import (
- BankProfile,
- DirectiveMentalModel,
- check_needs_refresh,
- )
-
- last_refresh_at = model.get("last_refresh_at")
- total_memories = await app.state.memory._count_memories_since(bank_id, None)
- memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at)
- bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
- directives_dicts = await app.state.memory.list_mental_models(
- bank_id, subtype="directive", request_context=request_context
- )
-
- # Convert to typed models at the boundary
- bank_profile = BankProfile.model_validate(bank_profile_dict)
- directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts]
-
- # Use check_needs_refresh to get reasons
- stored_refresh_state = model.get("refresh_state")
- refresh_check = check_needs_refresh(
- stored_state=stored_refresh_state,
- current_memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- model["freshness"] = {
- "is_up_to_date": not refresh_check.needs_refresh,
- "last_refresh_at": last_refresh_at,
- "memories_since_refresh": memories_since,
- "reasons": refresh_check.reasons,
- }
- else:
- # Directives don't need freshness - they're static
- model["freshness"] = None
-
- return _prepare_mental_model_response(model)
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/reflections: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
- "/v1/default/banks/{bank_id}/mental-models/refresh",
- response_model=AsyncOperationSubmitResponse,
- summary="Refresh mental models (async)",
- description="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.",
- operation_id="refresh_mental_models",
- tags=["Mental Models"],
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh",
+ response_model=ReflectionResponse,
+ summary="Refresh reflection",
+ description="Re-run the source query through reflect and update the content.",
+ operation_id="refresh_reflection",
+ tags=["Reflections"],
)
- async def api_refresh_mental_models(
+ async def api_refresh_reflection(
bank_id: str,
- body: RefreshMentalModelsRequest | None = None,
+ reflection_id: str,
request_context: RequestContext = Depends(get_request_context),
):
- """Submit a background job to refresh mental models for a bank.
-
- Requires a mission to be set for the bank first.
- Optionally pass tags to apply to newly created mental models.
- Optionally specify a subtype to only refresh models of that type.
- """
+ """Refresh a reflection by re-running its source query."""
try:
- result = await app.state.memory.refresh_mental_models(
+ reflection = await app.state.memory.refresh_reflection(
bank_id=bank_id,
- tags=body.tags if body else None,
- subtype=body.subtype if body else None,
+ reflection_id=reflection_id,
request_context=request_context,
)
- return AsyncOperationSubmitResponse(**result)
- except ValueError as e:
- # Mission not set or other validation error
- raise HTTPException(status_code=400, detail=str(e))
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/refresh: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.delete(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}",
- response_model=DeleteResponse,
- summary="Delete mental model",
- description="Delete a mental model.",
- operation_id="delete_mental_model",
- tags=["Mental Models"],
- )
- async def api_delete_mental_model(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Delete a mental model."""
- try:
- deleted = await app.state.memory.delete_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- if not deleted:
- raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
- return DeleteResponse(success=True, deleted_count=1)
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.patch(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}",
- response_model=MentalModelResponse,
- summary="Update mental model",
- description="Update a mental model's name and/or description. Useful for editing directives.",
- operation_id="update_mental_model",
- tags=["Mental Models"],
- )
- async def api_update_mental_model(
- bank_id: str,
- model_id: str,
- body: UpdateMentalModelRequest,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Update a mental model's name and/or description."""
- try:
- if body.name is None and body.description is None:
- raise HTTPException(status_code=400, detail="At least one of 'name' or 'description' must be provided")
-
- updated = await app.state.memory.update_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- name=body.name,
- description=body.description,
- request_context=request_context,
- )
- if not updated:
- raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
- return _prepare_mental_model_response(updated)
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.post(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh",
- response_model=AsyncOperationSubmitResponse,
- summary="Refresh mental model content (async)",
- description="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.",
- operation_id="refresh_mental_model",
- tags=["Mental Models"],
- )
- async def api_refresh_mental_model(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Refresh content for a specific mental model."""
- try:
- result = await app.state.memory.refresh_mental_model_async(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- return AsyncOperationSubmitResponse(
- operation_id=result["operation_id"],
- status=result.get("status", "queued"),
- )
- except ValueError as e:
- raise HTTPException(status_code=404, detail=str(e))
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/{model_id}/refresh: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.get(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions",
- summary="List mental model version history",
- description="List all saved versions of a mental model's observations, ordered by version descending.",
- operation_id="list_mental_model_versions",
- tags=["Mental Models"],
- )
- async def api_list_mental_model_versions(
- bank_id: str,
- model_id: str,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """List version history for a mental model."""
- try:
- versions = await app.state.memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- return {"versions": versions}
- except (AuthenticationError, HTTPException):
- raise
- except Exception as e:
- import traceback
-
- error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
- logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions: {error_detail}")
- raise HTTPException(status_code=500, detail=str(e))
-
- @app.get(
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}",
- summary="Get specific mental model version",
- description="Get observations from a specific version of a mental model.",
- operation_id="get_mental_model_version",
- tags=["Mental Models"],
- )
- async def api_get_mental_model_version(
- bank_id: str,
- model_id: str,
- version: int,
- request_context: RequestContext = Depends(get_request_context),
- ):
- """Get a specific version of a mental model."""
- try:
- version_data = await app.state.memory.get_mental_model_version(
- bank_id=bank_id,
- model_id=model_id,
- version=version,
- request_context=request_context,
- )
- if not version_data:
- raise HTTPException(
- status_code=404,
- detail=f"Version {version} not found for mental model '{model_id}'",
- )
- return version_data
+ if reflection is None:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return ReflectionResponse(**reflection)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -2640,10 +2382,254 @@ def _register_routes(app: FastAPI):
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
- f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}: {error_detail}"
+ f"Error in POST /v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))
+ @app.patch(
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
+ response_model=ReflectionResponse,
+ summary="Update reflection",
+ description="Update a reflection's name.",
+ operation_id="update_reflection",
+ tags=["Reflections"],
+ )
+ async def api_update_reflection(
+ bank_id: str,
+ reflection_id: str,
+ body: UpdateReflectionRequest,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Update a reflection."""
+ try:
+ reflection = await app.state.memory.update_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ name=body.name,
+ request_context=request_context,
+ )
+ if reflection is None:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return ReflectionResponse(**reflection)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.delete(
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}",
+ summary="Delete reflection",
+ description="Delete a reflection.",
+ operation_id="delete_reflection",
+ tags=["Reflections"],
+ )
+ async def api_delete_reflection(
+ bank_id: str,
+ reflection_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Delete a reflection."""
+ try:
+ deleted = await app.state.memory.delete_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ request_context=request_context,
+ )
+ if not deleted:
+ raise HTTPException(status_code=404, detail=f"Reflection '{reflection_id}' not found")
+ return {"status": "deleted"}
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/reflections/{reflection_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ # =========================================================================
+ # DIRECTIVES ENDPOINTS
+ # =========================================================================
+
+ @app.get(
+ "/v1/default/banks/{bank_id}/directives",
+ response_model=DirectiveListResponse,
+ summary="List directives",
+ description="List hard rules that are injected into prompts.",
+ operation_id="list_directives",
+ tags=["Directives"],
+ )
+ async def api_list_directives(
+ bank_id: str,
+ tags_filter: list[str] | None = Query(None, alias="tags", description="Filter by tags"),
+ tags_match: Literal["any", "all", "exact"] = Query("any", description="How to match tags"),
+ active_only: bool = Query(True, description="Only return active directives"),
+ limit: int = Query(100, ge=1, le=1000),
+ offset: int = Query(0, ge=0),
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """List directives for a bank."""
+ try:
+ directives = await app.state.memory.list_directives(
+ bank_id=bank_id,
+ tags=tags_filter,
+ tags_match=tags_match,
+ active_only=active_only,
+ limit=limit,
+ offset=offset,
+ request_context=request_context,
+ )
+ return DirectiveListResponse(items=[DirectiveResponse(**d) for d in directives])
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.get(
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
+ response_model=DirectiveResponse,
+ summary="Get directive",
+ description="Get a specific directive by ID.",
+ operation_id="get_directive",
+ tags=["Directives"],
+ )
+ async def api_get_directive(
+ bank_id: str,
+ directive_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Get a directive by ID."""
+ try:
+ directive = await app.state.memory.get_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ if directive is None:
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
+ return DirectiveResponse(**directive)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in GET /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.post(
+ "/v1/default/banks/{bank_id}/directives",
+ response_model=DirectiveResponse,
+ summary="Create directive",
+ description="Create a hard rule that will be injected into prompts.",
+ operation_id="create_directive",
+ tags=["Directives"],
+ )
+ async def api_create_directive(
+ bank_id: str,
+ body: CreateDirectiveRequest,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Create a directive."""
+ try:
+ directive = await app.state.memory.create_directive(
+ bank_id=bank_id,
+ name=body.name,
+ content=body.content,
+ priority=body.priority,
+ is_active=body.is_active,
+ tags=body.tags,
+ request_context=request_context,
+ )
+ return DirectiveResponse(**directive)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/directives: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.patch(
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
+ response_model=DirectiveResponse,
+ summary="Update directive",
+ description="Update a directive's properties.",
+ operation_id="update_directive",
+ tags=["Directives"],
+ )
+ async def api_update_directive(
+ bank_id: str,
+ directive_id: str,
+ body: UpdateDirectiveRequest,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Update a directive."""
+ try:
+ directive = await app.state.memory.update_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ name=body.name,
+ content=body.content,
+ priority=body.priority,
+ is_active=body.is_active,
+ tags=body.tags,
+ request_context=request_context,
+ )
+ if directive is None:
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
+ return DirectiveResponse(**directive)
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.delete(
+ "/v1/default/banks/{bank_id}/directives/{directive_id}",
+ summary="Delete directive",
+ description="Delete a directive.",
+ operation_id="delete_directive",
+ tags=["Directives"],
+ )
+ async def api_delete_directive(
+ bank_id: str,
+ directive_id: str,
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """Delete a directive."""
+ try:
+ deleted = await app.state.memory.delete_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ if not deleted:
+ raise HTTPException(status_code=404, detail=f"Directive '{directive_id}' not found")
+ return {"status": "deleted"}
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/directives/{directive_id}: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
@app.get(
"/v1/default/banks/{bank_id}/documents",
response_model=ListDocumentsResponse,
@@ -2844,17 +2830,27 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations",
response_model=OperationsListResponse,
summary="List async operations",
- description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
+ description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
operation_id="list_operations",
tags=["Operations"],
)
- async def api_list_operations(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
- """List all async operations (pending and failed) for a memory bank."""
+ async def api_list_operations(
+ bank_id: str,
+ status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
+ limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
+ offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
+ request_context: RequestContext = Depends(get_request_context),
+ ):
+ """List async operations for a memory bank with optional filtering and pagination."""
try:
- result = await app.state.memory.list_operations(bank_id, request_context=request_context)
+ result = await app.state.memory.list_operations(
+ bank_id, status=status, limit=limit, offset=offset, request_context=request_context
+ )
return OperationsListResponse(
bank_id=bank_id,
total=result["total"],
+ limit=limit,
+ offset=offset,
operations=[OperationResponse(**op) for op in result["operations"]],
)
except (AuthenticationError, HTTPException):
@@ -3175,6 +3171,63 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
+ @app.delete(
+ "/v1/default/banks/{bank_id}/mental-models",
+ response_model=DeleteResponse,
+ summary="Clear all mental models",
+ description="Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.",
+ operation_id="clear_mental_models",
+ tags=["Banks"],
+ )
+ async def api_clear_mental_models(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
+ """Clear all mental models for a bank."""
+ try:
+ result = await app.state.memory.clear_mental_models(bank_id, request_context=request_context)
+ return DeleteResponse(
+ success=True,
+ message=f"Cleared {result.get('deleted_count', 0)} mental models",
+ deleted_count=result.get("deleted_count", 0),
+ )
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ @app.post(
+ "/v1/default/banks/{bank_id}/consolidate",
+ response_model=ConsolidationResponse,
+ summary="Trigger consolidation",
+ description="Run memory consolidation to create/update mental models from recent memories.",
+ operation_id="trigger_consolidation",
+ tags=["Banks"],
+ )
+ async def api_trigger_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
+ """Trigger consolidation for a bank."""
+ try:
+ result = await app.state.memory.run_consolidation(bank_id, request_context=request_context)
+ processed = result.get("processed", 0)
+ created = result.get("created", 0)
+ updated = result.get("updated", 0)
+ return ConsolidationResponse(
+ status="completed",
+ processed=processed,
+ created=created,
+ updated=updated,
+ message=f"Consolidation completed: {processed} memories processed, {created} mental models created, {updated} updated",
+ )
+ except (AuthenticationError, HTTPException):
+ raise
+ except Exception as e:
+ import traceback
+
+ error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidate: {error_detail}")
+ raise HTTPException(status_code=500, detail=str(e))
+
@app.post(
"/v1/default/banks/{bank_id}/memories",
response_model=RetainResponse,
diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py
index d0992812..44df44f2 100644
--- a/hindsight-api/hindsight_api/config.py
+++ b/hindsight-api/hindsight_api/config.py
@@ -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
diff --git a/hindsight-api/hindsight_api/engine/consolidation/__init__.py b/hindsight-api/hindsight_api/engine/consolidation/__init__.py
new file mode 100644
index 00000000..7d98825c
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/consolidation/__init__.py
@@ -0,0 +1,5 @@
+"""Consolidation engine for automatic learning creation from memories."""
+
+from .consolidator import run_consolidation_job
+
+__all__ = ["run_consolidation_job"]
diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py
new file mode 100644
index 00000000..9b7ec6e3
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py
@@ -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}
diff --git a/hindsight-api/hindsight_api/engine/consolidation/prompts.py b/hindsight-api/hindsight_api/engine/consolidation/prompts.py
new file mode 100644
index 00000000..59e12977
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/consolidation/prompts.py
@@ -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"}}]"""
diff --git a/hindsight-api/hindsight_api/engine/directives/__init__.py b/hindsight-api/hindsight_api/engine/directives/__init__.py
new file mode 100644
index 00000000..6ca566f0
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/directives/__init__.py
@@ -0,0 +1,5 @@
+"""Directives module for hard rules injected into prompts."""
+
+from .models import Directive
+
+__all__ = ["Directive"]
diff --git a/hindsight-api/hindsight_api/engine/directives/models.py b/hindsight-api/hindsight_api/engine/directives/models.py
new file mode 100644
index 00000000..f8d7d763
--- /dev/null
+++ b/hindsight-api/hindsight_api/engine/directives/models.py
@@ -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
diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py
index f6211416..ecf0f6a4 100644
--- a/hindsight-api/hindsight_api/engine/memory_engine.py
+++ b/hindsight-api/hindsight_api/engine/memory_engine.py
@@ -11,6 +11,7 @@ This implements a sophisticated memory architecture that combines:
import asyncio
import contextvars
+import json
import logging
import time
import uuid
@@ -141,7 +142,7 @@ from .llm_wrapper import LLMConfig
from .query_analyzer import QueryAnalyzer
from .reflect import run_reflect_agent
from .reflect.models import MentalModelInput
-from .reflect.tools import tool_expand, tool_learn, tool_lookup, tool_recall
+from .reflect.tools import tool_expand, tool_recall, tool_search_mental_models
from .response_models import (
VALID_RECALL_FACT_TYPES,
EntityObservation,
@@ -501,269 +502,101 @@ class MemoryEngine(MemoryEngineInterface):
logger.info(f"[BATCH_RETAIN_TASK] Completed background batch retain for bank_id={bank_id}")
- async def _handle_refresh_mental_models(self, task_dict: dict[str, Any]):
+ async def _handle_consolidation(self, task_dict: dict[str, Any]):
"""
- Handler for refresh mental models tasks.
+ Handler for consolidation tasks.
- This is the main background job that:
- 1. Identifies mental models (structural from mission + emergent from entities)
- 2. Generates summaries for each mental model
+ Consolidates new memories into learnings for a bank.
Args:
- task_dict: Dict with 'bank_id', 'operation_id', optional 'tags', optional 'subtype'
- """
- import time
+ task_dict: Dict with 'bank_id'
+ Raises:
+ ValueError: If bank_id is missing
+ Exception: Any exception from consolidation (propagates to execute_task for retry)
+ """
bank_id = task_dict.get("bank_id")
- operation_id = task_dict.get("operation_id")
- tags = task_dict.get("tags") # Tags to apply to created mental models
- subtype = task_dict.get("subtype") # Optional filter: "structural", "emergent", "pinned", or "learned"
if not bank_id:
- raise ValueError("bank_id is required for refresh mental models task")
-
- refresh_structural = subtype is None or subtype == "structural"
- refresh_emergent = subtype is None or subtype == "emergent"
- refresh_pinned = subtype is None or subtype == "pinned"
- refresh_learned = subtype is None or subtype == "learned"
- subtype_desc = f" (subtype={subtype})" if subtype else " (all)"
+ raise ValueError("bank_id is required for consolidation task")
from hindsight_api.models import RequestContext
- internal_context = RequestContext()
- pool = await self._get_pool()
-
- from .mental_models.emergent import (
- detect_entity_candidates,
- evaluate_emergent_models,
- filter_candidates_by_mission,
- )
-
- # ===== Phase 1: Identify mental models (with buffered logging) =====
- phase1_start = time.perf_counter()
- id_log: list[str] = [] # Log buffer for identification phase
-
- # Step 1: Get the bank's mission (required - should have been validated before scheduling)
- profile = await self.get_bank_profile(bank_id, request_context=internal_context)
- mission = profile.get("mission") or ""
- if not mission:
- raise ValueError(f"Cannot refresh mental models: no mission is set for bank '{bank_id}'")
-
- structural_removed: list[str] = []
- emergent_removed: list[str] = []
- emergent_promoted: list[str] = []
-
- # Step 2: Derive structural models (LLM sees existing ones and decides what to keep)
- if refresh_structural:
- existing_structural = await self.list_mental_models(
- bank_id, subtype="structural", request_context=internal_context
- )
- id_log.append(f"structural: {len(existing_structural) if existing_structural else 0} existing")
- models_to_remove = await self._derive_structural_models_internal(
- bank_id, mission, pool, existing_models=existing_structural, tags=tags
- )
- for model_id in models_to_remove:
- structural_removed.append(model_id)
- await self.delete_mental_model(bank_id, model_id, request_context=internal_context)
- if structural_removed:
- id_log.append(f"structural removed: {structural_removed}")
- else:
- id_log.append("structural: skipped (subtype filter)")
-
- # Step 3: Evaluate existing emergent models
- removed_entity_ids: set[str] = set() # Track entity_ids we removed (to prevent re-promotion)
- if refresh_emergent:
- existing_emergent = await self.list_mental_models(
- bank_id, subtype="emergent", request_context=internal_context
- )
- if existing_emergent:
- id_log.append(f"emergent: {len(existing_emergent)} existing")
- # Build model_id -> entity_id mapping for tracking
- model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent}
- models_to_remove = await evaluate_emergent_models(self._llm_config, existing_emergent)
- for model_id in models_to_remove:
- emergent_removed.append(model_id)
- # Track the entity_id so we don't re-promote it
- entity_id = model_to_entity.get(model_id)
- if entity_id:
- removed_entity_ids.add(str(entity_id))
- await self.delete_mental_model(bank_id, model_id, request_context=internal_context)
- if emergent_removed:
- id_log.append(f"emergent removed: {emergent_removed}")
- else:
- id_log.append("emergent: 0 existing")
-
- # Step 4: Detect emergent candidates (entities worth promoting)
- candidates = await detect_entity_candidates(pool, bank_id)
- id_log.append(f"emergent candidates detected: {len(candidates)}")
-
- # Step 5: Filter candidates by mission relevance
- if candidates and mission:
- candidates = await filter_candidates_by_mission(self._llm_config, mission, candidates)
- id_log.append(f"emergent candidates after mission filter: {len(candidates)}")
-
- # Step 6: Filter out candidates whose entity was just removed (they failed evaluation)
- if removed_entity_ids:
- original_count = len(candidates)
- candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
- if len(candidates) < original_count:
- id_log.append(f"emergent excluded (failed evaluation): {original_count - len(candidates)}")
-
- # Step 7: Promote filtered candidates to mental models (with tags if provided)
- for candidate in candidates:
- if candidate.entity_id:
- emergent_promoted.append(candidate.name)
- await self._promote_entity_internal(bank_id, candidate.entity_id, pool, tags=tags)
- if emergent_promoted:
- id_log.append(f"emergent promoted: {emergent_promoted}")
- else:
- id_log.append("emergent: skipped (subtype filter)")
-
- phase1_duration_ms = int((time.perf_counter() - phase1_start) * 1000)
-
- # Output single log for Phase 1
- logger.info(
- f"[MENTAL_MODELS] Identification complete for bank={bank_id}{subtype_desc} "
- f"in {phase1_duration_ms}ms: {', '.join(id_log)}"
- )
-
- # ===== Phase 2: Generate summaries in parallel =====
- models = await self.list_mental_models(bank_id, request_context=internal_context)
-
- # Filter models to only those being refreshed based on subtype
- # NOTE: Directives (subtype='directive') are NEVER refreshed - they have user-provided content
- models_to_refresh = []
- for m in models:
- model_subtype = m["subtype"]
- # Skip directives - they have user-defined content that should never be regenerated
- if model_subtype == "directive":
- continue
- if model_subtype == "structural" and refresh_structural:
- models_to_refresh.append(m)
- elif model_subtype == "emergent" and refresh_emergent:
- models_to_refresh.append(m)
- elif model_subtype == "pinned" and refresh_pinned:
- models_to_refresh.append(m)
- elif model_subtype == "learned" and refresh_learned:
- models_to_refresh.append(m)
-
- # Get concurrency limit from config
- from ..config import get_config
-
- config = get_config()
- concurrency = config.mental_model_refresh_concurrency
-
- # Use semaphore to limit concurrent refreshes
- semaphore = asyncio.Semaphore(concurrency)
- # Track results with timing: model_id -> {status, duration_ms, iterations, tool_calls, observations}
- refresh_results: dict[str, dict[str, Any]] = {}
-
- async def refresh_with_semaphore(model: dict) -> None:
- """Refresh a single model with semaphore-controlled concurrency."""
- async with semaphore:
- model_id = model["id"]
- model_name = model["name"]
- start_time = time.perf_counter()
- try:
- result = await self.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=internal_context,
- _return_agent_result=True, # Get agent stats for logging
- )
- duration_ms = int((time.perf_counter() - start_time) * 1000)
- if result and isinstance(result, tuple):
- _, agent_result = result
- refresh_results[model_id] = {
- "status": "success",
- "name": model_name,
- "duration_ms": duration_ms,
- "phases": len(agent_result.phases_completed) if agent_result else 0,
- "memories_analyzed": agent_result.memories_analyzed if agent_result else 0,
- "observations": len(agent_result.observations) if agent_result else 0,
- }
- else:
- refresh_results[model_id] = {
- "status": "success",
- "name": model_name,
- "duration_ms": duration_ms,
- }
- except Exception as e:
- duration_ms = int((time.perf_counter() - start_time) * 1000)
- refresh_results[model_id] = {
- "status": "failed",
- "name": model_name,
- "duration_ms": duration_ms,
- "error": str(e),
- }
-
- # Run all refreshes in parallel (bounded by semaphore)
- phase2_start = time.perf_counter()
- await asyncio.gather(*[refresh_with_semaphore(m) for m in models_to_refresh])
- phase2_duration_ms = int((time.perf_counter() - phase2_start) * 1000)
-
- # Build summary for each model
- model_summaries: list[str] = []
- for model_id, info in refresh_results.items():
- if info["status"] == "success":
- parts = [f"{info['name']}"]
- if "iterations" in info:
- parts.append(f"iter={info['iterations']}")
- if "tool_calls" in info:
- parts.append(f"tools={info['tool_calls']}")
- if "observations" in info:
- parts.append(f"obs={info['observations']}")
- parts.append(f"{info['duration_ms']}ms")
- model_summaries.append(f"[{' '.join(parts)}]")
- else:
- model_summaries.append(
- f"[{info['name']} FAILED: {info.get('error', 'unknown')} {info['duration_ms']}ms]"
- )
-
- success_count = sum(1 for r in refresh_results.values() if r["status"] == "success")
- failed_count = len(refresh_results) - success_count
-
- # Output single log for Phase 2
- logger.info(
- f"[MENTAL_MODELS] Refresh complete for bank={bank_id}, operation={operation_id}: "
- f"{success_count}/{len(models_to_refresh)} succeeded in {phase2_duration_ms}ms (concurrency={concurrency}). "
- f"Models: {' '.join(model_summaries)}"
- )
-
- async def _handle_refresh_single_mental_model(self, task_dict: dict[str, Any]):
- """
- Handler for single mental model refresh tasks.
-
- Refreshes content for a specific mental model.
-
- Args:
- task_dict: Dict with 'bank_id', 'model_id', 'operation_id'
- """
- bank_id = task_dict.get("bank_id")
- model_id = task_dict.get("model_id")
- operation_id = task_dict.get("operation_id")
-
- if not bank_id or not model_id:
- raise ValueError("bank_id and model_id are required for refresh mental model task")
-
- logger.info(
- f"[MENTAL_MODEL_TASK] Starting refresh for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}"
- )
-
- from hindsight_api.models import RequestContext
+ from .consolidation import run_consolidation_job
internal_context = RequestContext()
-
- # Refresh content for the model
- result = await self.refresh_mental_model(
+ result = await run_consolidation_job(
+ memory_engine=self,
bank_id=bank_id,
- model_id=model_id,
request_context=internal_context,
)
- if result:
- logger.info(f"[MENTAL_MODEL_TASK] Completed refresh for model_id={model_id}, bank_id={bank_id}")
- else:
- logger.warning(f"[MENTAL_MODEL_TASK] Model not found: model_id={model_id}, bank_id={bank_id}")
+ logger.info(f"[CONSOLIDATION] bank={bank_id} completed: {result.get('memories_processed', 0)} processed")
+
+ async def _handle_create_reflection(self, task_dict: dict[str, Any]):
+ """
+ Handler for create_reflection tasks.
+
+ Runs reflect with the source query and updates the reflection with the generated content.
+ The reflection should already exist in the database (created during submit_async_create_reflection).
+
+ Args:
+ task_dict: Dict with 'bank_id', 'reflection_id', 'source_query', 'max_tokens', 'operation_id'
+
+ Raises:
+ ValueError: If required fields are missing
+ Exception: Any exception from reflect/update (propagates to execute_task for retry)
+ """
+ bank_id = task_dict.get("bank_id")
+ reflection_id = task_dict.get("reflection_id")
+ source_query = task_dict.get("source_query")
+ max_tokens = task_dict.get("max_tokens", 2048)
+
+ if not bank_id or not reflection_id or not source_query:
+ raise ValueError("bank_id, reflection_id, and source_query are required for create_reflection task")
+
+ logger.info(f"[CREATE_REFLECTION_TASK] Starting for bank_id={bank_id}, reflection_id={reflection_id}")
+
+ from hindsight_api.models import RequestContext
+
+ internal_context = RequestContext()
+
+ # Run reflect to generate content
+ reflect_result = await self.reflect_async(
+ bank_id=bank_id,
+ query=source_query,
+ max_tokens=max_tokens,
+ request_context=internal_context,
+ )
+
+ generated_content = reflect_result.text or "No content generated"
+
+ # Build reflect_response payload to store
+ reflect_response = {
+ "text": reflect_result.text,
+ "based_on": {
+ fact_type: [
+ {
+ "id": str(fact.id),
+ "text": fact.text,
+ "type": fact_type,
+ }
+ for fact in facts
+ ]
+ for fact_type, facts in reflect_result.based_on.items()
+ },
+ "mental_models": [], # Mental models are included in based_on["mental-models"]
+ }
+
+ # Update the reflection with the generated content and reflect_response
+ await self.update_reflection(
+ bank_id=bank_id,
+ reflection_id=reflection_id,
+ content=generated_content,
+ reflect_response=reflect_response,
+ request_context=internal_context,
+ )
+
+ logger.info(f"[CREATE_REFLECTION_TASK] Completed for bank_id={bank_id}, reflection_id={reflection_id}")
async def execute_task(self, task_dict: dict[str, Any]):
"""
@@ -801,10 +634,10 @@ class MemoryEngine(MemoryEngineInterface):
try:
if task_type == "batch_retain":
await self._handle_batch_retain(task_dict)
- elif task_type == "refresh_mental_models":
- await self._handle_refresh_mental_models(task_dict)
- elif task_type == "refresh_mental_model":
- await self._handle_refresh_single_mental_model(task_dict)
+ elif task_type == "consolidation":
+ await self._handle_consolidation(task_dict)
+ elif task_type == "create_reflection":
+ await self._handle_create_reflection(task_dict)
else:
logger.error(f"Unknown task type: {task_type}")
# Don't retry unknown task types
@@ -1483,6 +1316,17 @@ class MemoryEngine(MemoryEngineInterface):
except Exception as e:
logger.warning(f"Post-retain hook error (non-fatal): {e}")
+ # Trigger consolidation as a tracked async operation if enabled
+ from ..config import get_config
+
+ config = get_config()
+ if config.enable_mental_models:
+ try:
+ await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
+ except Exception as e:
+ # Log but don't fail the retain - consolidation is non-critical
+ logger.warning(f"Failed to submit consolidation task for bank {bank_id}: {e}")
+
if return_usage:
return result, total_usage
return result
@@ -1598,6 +1442,7 @@ class MemoryEngine(MemoryEngineInterface):
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
_connection_budget: int | None = None,
+ _quiet: bool = False,
) -> RecallResultModel:
"""
Recall memories using N*4-way parallel retrieval (N fact types × 4 retrieval methods).
@@ -1680,9 +1525,10 @@ class MemoryEngine(MemoryEngineInterface):
effective_budget = budget if budget is not None else Budget.MID
thinking_budget = budget_mapping[effective_budget]
- # Log recall start with tags if present
- tags_info = f", tags={tags} ({tags_match})" if tags else ""
- logger.info(f"[RECALL {bank_id[:8]}] Starting recall for query: {query[:50]}...{tags_info}")
+ # Log recall start with tags if present (skip if quiet mode for internal operations)
+ if not _quiet:
+ tags_info = f", tags={tags} ({tags_match})" if tags else ""
+ logger.info(f"[RECALL {bank_id[:8]}] Starting recall for query: {query[:50]}...{tags_info}")
# Backpressure: limit concurrent recalls to prevent overwhelming the database
result = None
@@ -1711,6 +1557,7 @@ class MemoryEngine(MemoryEngineInterface):
tags=tags,
tags_match=tags_match,
connection_budget=_connection_budget,
+ quiet=_quiet,
)
break # Success - exit retry loop
except Exception as e:
@@ -1832,6 +1679,7 @@ class MemoryEngine(MemoryEngineInterface):
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
connection_budget: int | None = None,
+ quiet: bool = False,
) -> RecallResultModel:
"""
Search implementation with modular retrieval and reranking.
@@ -2462,13 +2310,15 @@ class MemoryEngine(MemoryEngineInterface):
log_buffer.append(
f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s{wait_info}"
)
- logger.info("\n" + "\n".join(log_buffer))
+ if not quiet:
+ logger.info("\n" + "\n".join(log_buffer))
return RecallResultModel(results=memory_facts, trace=trace_dict, entities=entities_dict, chunks=chunks_dict)
except Exception as e:
log_buffer.append(f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {str(e)}")
- logger.error("\n" + "\n".join(log_buffer))
+ if not quiet:
+ logger.error("\n" + "\n".join(log_buffer))
raise Exception(f"Failed to search memories: {str(e)}")
def _filter_by_token_budget(
@@ -2724,6 +2574,79 @@ class MemoryEngine(MemoryEngineInterface):
except Exception as e:
raise Exception(f"Failed to delete agent data: {str(e)}")
+ async def clear_mental_models(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, int]:
+ """
+ Clear all mental models for a bank.
+
+ Args:
+ bank_id: Bank ID to clear mental models for
+ request_context: Request context for authentication.
+
+ Returns:
+ Dictionary with count of deleted mental models
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+ async with acquire_with_retry(pool) as conn:
+ async with conn.transaction():
+ # Count mental models before deletion
+ count = await conn.fetchval(
+ f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'mental_model'",
+ bank_id,
+ )
+
+ # Delete all mental models
+ await conn.execute(
+ f"DELETE FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'mental_model'",
+ bank_id,
+ )
+
+ # Reset consolidation timestamp
+ await conn.execute(
+ f"UPDATE {fq_table('banks')} SET last_consolidated_at = NULL WHERE bank_id = $1",
+ bank_id,
+ )
+
+ return {"deleted_count": count or 0}
+
+ async def run_consolidation(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, int]:
+ """
+ Run memory consolidation to create/update mental models.
+
+ Args:
+ bank_id: Bank ID to run consolidation for
+ request_context: Request context for authentication.
+
+ Returns:
+ Dictionary with consolidation stats
+ """
+ await self._authenticate_tenant(request_context)
+
+ from .consolidation import run_consolidation_job
+
+ result = await run_consolidation_job(
+ memory_engine=self,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ return {
+ "processed": result.get("processed", 0),
+ "created": result.get("created", 0),
+ "updated": result.get("updated", 0),
+ "skipped": result.get("skipped", 0),
+ }
+
async def get_graph_data(
self,
bank_id: str | None = None,
@@ -3417,6 +3340,7 @@ class MemoryEngine(MemoryEngineInterface):
request_context: "RequestContext",
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
+ exclude_reflection_ids: list[str] | None = None,
) -> ReflectResult:
"""
Reflect and formulate an answer using an agentic loop with tools.
@@ -3438,6 +3362,10 @@ class MemoryEngine(MemoryEngineInterface):
context: Additional context string to include in agent prompt
max_tokens: Max tokens (currently unused, reserved for future)
response_schema: Optional JSON Schema for structured output (not yet supported)
+ tags: Optional tags to filter memories
+ tags_match: How to match tags - "any" (OR), "all" (AND)
+ exclude_reflection_ids: Optional list of reflection IDs to exclude from search
+ (used when refreshing a reflection to avoid circular reference)
Returns:
ReflectResult containing:
@@ -3490,46 +3418,72 @@ class MemoryEngine(MemoryEngineInterface):
# (not held during LLM calls which can be slow)
pool = await self._get_pool()
+ # Get bank stats for freshness info
+ bank_stats = await self.get_bank_stats(bank_id, request_context=request_context)
+ last_consolidated_at = bank_stats.last_consolidated_at if hasattr(bank_stats, "last_consolidated_at") else None
+ pending_consolidation = bank_stats.pending_consolidation if hasattr(bank_stats, "pending_consolidation") else 0
+
# Create tool callbacks that acquire connections only when needed
- async def lookup_fn(model_id: str | None = None) -> dict[str, Any]:
+ from .reflect.tools import tool_search_reflections
+ from .retain import embedding_utils
+
+ async def search_reflections_fn(q: str, max_results: int = 5) -> dict[str, Any]:
+ # Generate embedding for the query
+ embeddings = await embedding_utils.generate_embeddings_batch(self.embeddings, [q])
+ query_embedding = embeddings[0]
async with pool.acquire() as conn:
- return await tool_lookup(conn, bank_id, model_id, tags=tags, tags_match=tags_match)
+ return await tool_search_reflections(
+ conn,
+ bank_id,
+ q,
+ query_embedding,
+ max_results=max_results,
+ tags=tags,
+ tags_match=tags_match,
+ exclude_ids=exclude_reflection_ids,
+ )
+
+ async def search_mental_models_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
+ return await tool_search_mental_models(
+ self,
+ bank_id,
+ q,
+ request_context,
+ max_tokens=max_tokens,
+ tags=tags,
+ tags_match=tags_match,
+ last_consolidated_at=last_consolidated_at,
+ pending_consolidation=pending_consolidation,
+ )
async def recall_fn(q: str, max_tokens: int = 4096) -> dict[str, Any]:
return await tool_recall(
self, bank_id, q, request_context, max_tokens=max_tokens, tags=tags, tags_match=tags_match
)
- async def learn_fn(input: MentalModelInput) -> dict[str, Any]:
- async with pool.acquire() as conn:
- result = await tool_learn(conn, bank_id, input, tags=tags)
- # If a new model was created, trigger background refresh
- if result.get("status") == "created" and result.get("model_id"):
- try:
- await self.refresh_mental_model_async(
- bank_id=bank_id,
- model_id=result["model_id"],
- request_context=request_context,
- )
- logger.info(f"[REFLECT] Triggered background refresh for learned model: {result['model_id']}")
- except Exception as e:
- logger.warning(f"[REFLECT] Failed to trigger refresh for {result['model_id']}: {e}")
- return result
-
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
async with pool.acquire() as conn:
return await tool_expand(conn, bank_id, memory_ids, depth)
- # Load directives (mental models with subtype='directive')
+ # Load directives from the dedicated directives table
# Directives are hard rules that must be followed in all responses
- # Filter by tags if provided (same logic as other mental models)
- directives = await self.list_mental_models(
+ directives_raw = await self.list_directives(
bank_id=bank_id,
- subtype="directive",
tags=tags,
tags_match=tags_match,
+ active_only=True,
request_context=request_context,
)
+ # Convert directive format to the expected format for reflect agent
+ # The agent expects: name, description (optional), observations (list of {title, content})
+ directives = [
+ {
+ "name": d["name"],
+ "description": d["content"], # Use content as description
+ "observations": [], # Directives use content directly, not observations
+ }
+ for d in directives_raw
+ ]
if directives:
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
@@ -3539,9 +3493,9 @@ class MemoryEngine(MemoryEngineInterface):
bank_id=bank_id,
query=query,
bank_profile=profile,
- lookup_fn=lookup_fn,
+ search_reflections_fn=search_reflections_fn,
+ search_mental_models_fn=search_mental_models_fn,
recall_fn=recall_fn,
- learn_fn=learn_fn,
expand_fn=expand_fn,
context=context,
max_iterations=max_iterations,
@@ -3598,11 +3552,10 @@ class MemoryEngine(MemoryEngineInterface):
)
)
- # Extract mental models from lookup tool outputs - only include models the agent actually used
- # agent_result.used_model_ids contains validated IDs from the done action
- used_model_ids_set = set(agent_result.used_model_ids) if agent_result.used_model_ids else set()
- based_on["mental_model"] = []
- mental_models_result: list[MentalModelRef] = []
+ # Extract mental models from tool outputs - only include models the agent actually used
+ # agent_result.used_mental_model_ids contains validated IDs from the done action
+ used_model_ids_set = set(agent_result.used_mental_model_ids) if agent_result.used_mental_model_ids else set()
+ based_on["mental-models"] = []
seen_model_ids: set[str] = set()
for tc in agent_result.tool_trace:
if tc.tool == "get_mental_model":
@@ -3615,32 +3568,69 @@ class MemoryEngine(MemoryEngineInterface):
if used_model_ids_set and model_id not in used_model_ids_set:
continue # Skip models not actually used by the agent
seen_model_ids.add(model_id)
- # Add to based_on as MemoryFact with type "mental_model"
+ # Add to based_on as MemoryFact with type "mental-models"
model_name = model.get("name", "")
model_summary = model.get("summary") or model.get("description", "")
- based_on["mental_model"].append(
+ based_on["mental-models"].append(
MemoryFact(
id=model_id,
text=f"{model_name}: {model_summary}",
- fact_type="mental_model",
+ fact_type="mental-models",
context=f"{model.get('type', 'concept')} ({model.get('subtype', 'structural')})",
occurred_start=None,
occurred_end=None,
)
)
- mental_models_result.append(
- MentalModelRef(
+ elif tc.tool == "search_mental_models":
+ # Search mental models - include all returned models (filtered by used_model_ids_set if specified)
+ for model in tc.output.get("mental_models", []):
+ model_id = model.get("id")
+ if model_id and model_id not in seen_model_ids:
+ # Only include models that the agent declared as used (or all if none specified)
+ if used_model_ids_set and model_id not in used_model_ids_set:
+ continue # Skip models not actually used by the agent
+ seen_model_ids.add(model_id)
+ # Add to based_on as MemoryFact with type "mental-models"
+ model_name = model.get("name", "")
+ model_summary = model.get("summary") or model.get("description", "")
+ based_on["mental-models"].append(
+ MemoryFact(
id=model_id,
- name=model_name,
- type=model.get("type", "concept"),
- subtype=model.get("subtype", "structural"),
- description=model.get("description", ""),
- summary=model.get("summary"),
+ text=f"{model_name}: {model_summary}",
+ fact_type="mental-models",
+ context=f"{model.get('type', 'concept')} ({model.get('subtype', 'structural')})",
+ occurred_start=None,
+ occurred_end=None,
+ )
+ )
+ elif tc.tool == "search_reflections":
+ # Search reflections - include all returned reflections (filtered by used_reflection_ids_set if specified)
+ used_reflection_ids_set = (
+ set(agent_result.used_reflection_ids) if agent_result.used_reflection_ids else set()
+ )
+ for reflection in tc.output.get("reflections", []):
+ reflection_id = reflection.get("id")
+ if reflection_id and reflection_id not in seen_model_ids:
+ # Only include reflections that the agent declared as used (or all if none specified)
+ if used_reflection_ids_set and reflection_id not in used_reflection_ids_set:
+ continue # Skip reflections not actually used by the agent
+ seen_model_ids.add(reflection_id)
+ # Add to based_on as MemoryFact with type "mental-models" (reflections are synthesized knowledge)
+ reflection_name = reflection.get("name", "")
+ reflection_content = reflection.get("content", "")
+ based_on["mental-models"].append(
+ MemoryFact(
+ id=reflection_id,
+ text=f"{reflection_name}: {reflection_content}",
+ fact_type="mental-models",
+ context="reflection (user-curated)",
+ occurred_start=None,
+ occurred_end=None,
)
)
# List all models lookup - don't add to based_on (too verbose, just a listing)
- # Add directives to mental_models list (they are mental models with subtype='directive')
+ # Add directives to based_on["mental-models"] (they are mental models with subtype='directive')
for directive in directives:
# Extract summary from observations
summary_parts: list[str] = []
@@ -3661,14 +3651,16 @@ class MemoryEngine(MemoryEngineInterface):
if not summary_parts and directive.get("description"):
summary_parts.append(directive["description"])
- mental_models_result.append(
- MentalModelRef(
+ directive_name = directive.get("name", "")
+ directive_summary = "; ".join(summary_parts) if summary_parts else ""
+ based_on["mental-models"].append(
+ MemoryFact(
id=directive.get("id", ""),
- name=directive.get("name", ""),
- type="directive",
- subtype="directive",
- description=directive.get("description", ""),
- summary="; ".join(summary_parts) if summary_parts else None,
+ text=f"{directive_name}: {directive_summary}",
+ fact_type="mental-models",
+ context="directive (directive)",
+ occurred_start=None,
+ occurred_end=None,
)
)
@@ -3688,7 +3680,6 @@ class MemoryEngine(MemoryEngineInterface):
usage=None, # Token tracking not yet implemented for agentic loop
tool_trace=tool_trace_result,
llm_trace=llm_trace_result,
- mental_models=mental_models_result,
directives_applied=directives_applied_result,
)
@@ -4078,955 +4069,6 @@ class MemoryEngine(MemoryEngineInterface):
"observations": observations,
}
- # =========================================================================
- # Mental Models
- # =========================================================================
-
- async def list_mental_models(
- self,
- bank_id: str,
- *,
- subtype: str | None = None,
- tags: list[str] | None = None,
- tags_match: TagsMatch = "any",
- request_context: "RequestContext",
- ) -> list[dict[str, Any]]:
- """List mental models for a bank, optionally filtered by subtype or tags.
-
- Args:
- bank_id: Bank identifier
- subtype: Filter by subtype (structural, emergent, pinned)
- tags: Filter by tags - returns models that match according to tags_match
- tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- query = f"""
- SELECT id, bank_id, subtype, name, description, observations,
- version, entity_id, links, tags, last_updated, created_at
- FROM {fq_table("mental_models")}
- WHERE bank_id = $1
- """
- params: list[Any] = [bank_id]
-
- if subtype:
- query += f" AND subtype = ${len(params) + 1}"
- params.append(subtype)
- # Note: Directives are included in API listing for admin visibility.
- # They are excluded from the reflect agent's tool_lookup (in tools.py) since they're in the system prompt.
-
- # Tags filtering: include untagged models OR models with matching tags
- if tags:
- if tags_match == "any":
- # OR match: model has no tags OR model has at least one matching tag
- query += f" AND (tags = '{{}}' OR tags && ${len(params) + 1})"
- elif tags_match == "all":
- # AND match: model has no tags OR model has all specified tags
- query += f" AND (tags = '{{}}' OR tags @> ${len(params) + 1})"
- elif tags_match == "any_strict":
- # OR match, strict: model must have at least one matching tag (no untagged)
- query += f" AND tags && ${len(params) + 1}"
- elif tags_match == "all_strict":
- # AND match, strict: model must have all specified tags (no untagged)
- query += f" AND tags @> ${len(params) + 1}"
- else: # exact
- # Exact match: model has no tags OR model has exactly the specified tags
- query += f" AND (tags = '{{}}' OR tags = ${len(params) + 1})"
- params.append(tags)
-
- query += " ORDER BY created_at ASC"
-
- async with acquire_with_retry(pool) as conn:
- rows = await conn.fetch(query, *params)
-
- return [self._row_to_mental_model(row) for row in rows]
-
- async def get_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> dict[str, Any] | None:
- """Get a mental model by ID."""
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- row = await conn.fetchrow(
- f"""
- SELECT id, bank_id, subtype, name, description, observations,
- version, entity_id, links, tags, last_updated, created_at
- FROM {fq_table("mental_models")}
- WHERE bank_id = $1 AND id = $2
- """,
- bank_id,
- model_id,
- )
-
- return self._row_to_mental_model(row) if row else None
-
- async def refresh_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- _return_agent_result: bool = False,
- ) -> dict[str, Any] | tuple[dict[str, Any] | None, Any] | None:
- """Refresh the observations for a mental model using the 4-phase reflect loop.
-
- The 4-phase loop:
- 1. SEED: Get diverse memory sample, generate candidate observations
- 2. EVIDENCE HUNT: For each candidate, search for supporting/contradicting evidence
- 3. VALIDATE: Keep/discard/merge candidates based on evidence, extract quotes
- 4. COMPARE: Merge new observations with existing mental model
-
- Uses the model's stored tags to filter recall results.
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID
- request_context: Request context for authentication
- _return_agent_result: Internal flag to return (model, agent_result) tuple for logging
-
- Returns:
- Updated mental model dict, or (model, agent_result) tuple if _return_agent_result=True
- """
- await self._authenticate_tenant(request_context)
-
- # Validate operation if validator is configured
- if self._operation_validator:
- from hindsight_api.extensions.operation_validator import RefreshMentalModelContext
-
- ctx = RefreshMentalModelContext(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- await self._validate_operation(self._operation_validator.validate_refresh_mental_model(ctx))
-
- pool = await self._get_pool()
- start_time = time.time()
-
- # Get the mental model
- model = await self.get_mental_model(bank_id, model_id, request_context=request_context)
- if not model:
- return None
-
- # Don't refresh directives - their observations are user-provided and static
- if model.get("subtype") == "directive":
- logger.info(f"[MENTAL_MODELS] Skipping refresh for directive '{model_id}' - observations are static")
- if _return_agent_result:
- return (model, None)
- return model
-
- # Import refresh state functions and typed models
- from .reflect.mental_model_reflect import (
- BankProfile,
- DirectiveMentalModel,
- check_needs_refresh,
- compute_refresh_state,
- )
-
- # Check if refresh is actually needed by comparing state hashes
- # Get current state inputs
- total_memories = await self._count_memories_since(bank_id, None, pool)
- bank_profile_dict = await self.get_bank_profile(bank_id, request_context=request_context)
- directives_dicts = await self.list_mental_models(bank_id, subtype="directive", request_context=request_context)
-
- # Convert to typed models at the boundary
- bank_profile = BankProfile.model_validate(bank_profile_dict)
- directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts]
-
- # Get stored refresh_state from the model
- stored_refresh_state = model.get("refresh_state")
-
- # Check if refresh is needed
- refresh_check = check_needs_refresh(
- stored_state=stored_refresh_state,
- current_memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- if not refresh_check.needs_refresh:
- logger.info(f"[MENTAL_MODELS] Skipping refresh for '{model_id}' - nothing changed since last refresh")
- if _return_agent_result:
- return (model, None)
- return model
-
- logger.info(
- f"[MENTAL_MODELS] Refresh needed for '{model_id}': {', '.join(refresh_check.reasons)} "
- f"(memories: {total_memories})"
- )
-
- # Use the model's stored tags for filtering recall
- model_tags = model.get("tags") or None
- current_version = model.get("version", 0)
-
- # Import the 4-phase mental model reflect
- from .reflect.mental_model_reflect import run_mental_model_reflect
- from .reflect.tools import tool_recall
-
- metrics = get_metrics_collector()
-
- # Get existing observations (convert Observation models to dicts for the reflect loop)
- from .reflect.observations import Observation
-
- raw_observations = model.get("observations", [])
- existing_observations = [obs.model_dump() if isinstance(obs, Observation) else obs for obs in raw_observations]
-
- # Create callback for getting diverse memories
- async def get_diverse_memories() -> list[dict]:
- """Get a diverse sample of memories for seeding observations."""
- # Get recent memories (last 30 days)
- recent_result = await tool_recall(
- self,
- bank_id,
- "recent activity and events",
- request_context,
- max_tokens=4096,
- tags=model_tags,
- tags_match="any" if model_tags else None,
- )
- recent_memories = recent_result.get("memories", [])
-
- # Get memories related to the mental model topic
- topic_result = await tool_recall(
- self,
- bank_id,
- model.get("name", "") + " " + model.get("description", ""),
- request_context,
- max_tokens=4096,
- tags=model_tags,
- tags_match="any" if model_tags else None,
- )
- topic_memories = topic_result.get("memories", [])
-
- # Combine and deduplicate
- seen_ids = set()
- diverse_memories = []
- for mem in recent_memories + topic_memories:
- mem_id = mem.get("id")
- if mem_id and mem_id not in seen_ids:
- seen_ids.add(mem_id)
- diverse_memories.append(mem)
-
- return diverse_memories
-
- # Create callback for recall
- async def recall_fn(query: str, max_tokens: int) -> dict:
- return await tool_recall(
- self,
- bank_id,
- query,
- request_context,
- max_tokens=max_tokens,
- tags=model_tags,
- tags_match="any" if model_tags else None,
- )
-
- with metrics.record_operation("mental_model_refresh_4phase", bank_id=bank_id, source="api"):
- result = await run_mental_model_reflect(
- llm_config=self._reflect_llm_config,
- bank_id=bank_id,
- mental_model_id=model_id,
- mental_model_name=model.get("name", ""),
- existing_observations=existing_observations,
- current_version=current_version,
- get_diverse_memories_fn=get_diverse_memories,
- recall_fn=recall_fn,
- topic=model.get("description"),
- )
-
- # Update the model with the new observations
- import json
-
- # Convert observations to serializable format
- observations_list = [
- {
- "title": obs.title,
- "content": obs.content,
- "evidence": [
- {
- "memory_id": ev.memory_id,
- "quote": ev.quote,
- "relevance": ev.relevance,
- "timestamp": ev.timestamp.isoformat(),
- }
- for ev in obs.evidence
- ],
- "created_at": obs.created_at.isoformat(),
- }
- for obs in result.observations
- ]
-
- # Compute refresh_state snapshot (using values fetched at start of refresh)
- refresh_state = compute_refresh_state(
- memories_count=total_memories,
- bank_profile=bank_profile,
- directives=directives,
- )
-
- observations_json = {
- "observations": observations_list,
- "version": result.version,
- "last_refresh_at": refresh_state.last_refresh_at,
- "refresh_state": refresh_state.model_dump(),
- }
-
- async with acquire_with_retry(pool) as conn:
- # Save the new version first
- await self.save_mental_model_version(
- conn,
- bank_id,
- model_id,
- observations_list,
- result.version,
- )
-
- # Update the mental model with new observations and version
- updated_row = await conn.fetchrow(
- f"""
- UPDATE {fq_table("mental_models")}
- SET observations = $1::jsonb, version = $2, last_updated = NOW()
- WHERE bank_id = $3 AND id = $4
- RETURNING id, bank_id, subtype, name, description, observations,
- version, entity_id, links, tags, last_updated, created_at
- """,
- json.dumps(observations_json),
- result.version,
- bank_id,
- model_id,
- )
-
- model_result = self._row_to_mental_model(updated_row) if updated_row else None
-
- # Call post-operation hook if validator is configured
- if self._operation_validator:
- from hindsight_api.extensions.operation_validator import RefreshMentalModelResult
-
- duration_ms = int((time.time() - start_time) * 1000)
- result_ctx = RefreshMentalModelResult(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- model_name=model.get("name"),
- observations_count=len(result.observations),
- input_tokens=result.input_tokens,
- output_tokens=result.output_tokens,
- total_tokens=result.total_tokens,
- duration_ms=duration_ms,
- success=True,
- error=None,
- )
- try:
- await self._operation_validator.on_refresh_mental_model_complete(result_ctx)
- except Exception as e:
- logger.warning(f"Post-refresh-mental-model hook error (non-fatal): {e}")
-
- if _return_agent_result:
- return (model_result, result)
- return model_result
-
- async def refresh_mental_model_async(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> dict[str, Any]:
- """
- Submit a background job to refresh a specific mental model.
-
- This is useful for:
- - Refreshing content for newly created learned models
- - Refreshing content for pinned models after description changes
- - Manual refresh of a specific model without touching others
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID to refresh
-
- Returns:
- Dict with operation_id to track progress
- """
- await self._authenticate_tenant(request_context)
-
- # Verify the model exists
- model = await self.get_mental_model(bank_id, model_id, request_context=request_context)
- if not model:
- raise ValueError(f"Mental model '{model_id}' not found in bank '{bank_id}'")
-
- pool = await self._get_pool()
-
- import json
-
- operation_id = uuid.uuid4()
-
- # Insert operation record into database
- async with acquire_with_retry(pool) as conn:
- await conn.execute(
- f"""
- INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata)
- VALUES ($1, $2, $3, $4)
- """,
- operation_id,
- bank_id,
- "refresh_mental_model",
- json.dumps({"model_id": model_id}),
- )
-
- # Submit task to background queue
- task_payload = {
- "type": "refresh_mental_model",
- "operation_id": str(operation_id),
- "bank_id": bank_id,
- "model_id": model_id,
- }
-
- await self._task_backend.submit_task(task_payload)
-
- logger.info(
- f"[MENTAL_MODEL] Refresh task queued for model_id={model_id}, bank_id={bank_id}, operation_id={operation_id}"
- )
-
- return {
- "operation_id": str(operation_id),
- "model_id": model_id,
- "status": "queued",
- }
-
- async def refresh_mental_models(
- self,
- bank_id: str,
- *,
- tags: list[str] | None = None,
- subtype: str | None = None,
- request_context: "RequestContext",
- ) -> dict[str, Any]:
- """
- Submit a background job to refresh mental models for a bank.
-
- The background job will (depending on subtype filter):
- 1. Derive structural models from the bank's mission (if subtype is None or "structural")
- 2. Detect emergent candidates (entities worth promoting) (if subtype is None or "emergent")
- 3. Filter candidates by mission relevance
- 4. Create/update mental models with specified tags
- 5. Generate summaries for refreshed mental models
-
- Args:
- bank_id: Bank identifier
- tags: Tags to apply to newly created mental models
- subtype: Only refresh models of this subtype ("structural" or "emergent").
- If None, refreshes all subtypes.
-
- Raises:
- ValueError: If no mission is set for the bank
-
- Returns:
- Dict with operation_id to track progress
- """
- await self._authenticate_tenant(request_context)
-
- # Check that mission is set before scheduling the task
- profile = await self.get_bank_profile(bank_id, request_context=request_context)
- mission = profile.get("mission") or ""
- if not mission:
- raise ValueError(
- f"Cannot refresh mental models: no mission is set for bank '{bank_id}'. Set a mission first."
- )
-
- pool = await self._get_pool()
-
- import json
-
- operation_id = uuid.uuid4()
-
- # Insert operation record into database
- async with acquire_with_retry(pool) as conn:
- await conn.execute(
- f"""
- INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata)
- VALUES ($1, $2, $3, $4)
- """,
- operation_id,
- bank_id,
- "refresh_mental_models",
- json.dumps({}),
- )
-
- # Submit task to background queue
- task_payload = {
- "type": "refresh_mental_models",
- "operation_id": str(operation_id),
- "bank_id": bank_id,
- }
- if tags:
- task_payload["tags"] = tags
- if subtype:
- task_payload["subtype"] = subtype
-
- await self._task_backend.submit_task(task_payload)
-
- logger.info(f"[MENTAL_MODELS] Refresh task queued for bank_id={bank_id}, operation_id={operation_id}")
-
- return {
- "operation_id": str(operation_id),
- "status": "queued",
- }
-
- async def _derive_structural_models_internal(
- self,
- bank_id: str,
- mission: str,
- pool,
- existing_models: list[dict[str, Any]] | None = None,
- tags: list[str] | None = None,
- ) -> list[str]:
- """
- Internal method to derive structural models without auth check.
-
- Args:
- bank_id: Bank identifier
- mission: The bank's mission
- pool: Database connection pool
- existing_models: Optional list of existing structural models
- tags: Tags to apply to created mental models
-
- Returns:
- List of model IDs to remove (existing models not in LLM output)
- """
- from .mental_models.models import MentalModelSubtype
- from .mental_models.structural import derive_structural_models
-
- templates, models_to_remove = await derive_structural_models(
- self._llm_config, mission, existing_models=existing_models
- )
-
- model_tags = tags or []
- created_count = 0
- async with acquire_with_retry(pool) as conn:
- for template in templates:
- try:
- await conn.fetchrow(
- f"""
- INSERT INTO {fq_table("mental_models")}
- (id, bank_id, subtype, name, description, tags)
- VALUES ($1, $2, $3, $4, $5, $6)
- ON CONFLICT (id, bank_id) DO UPDATE SET
- name = EXCLUDED.name,
- description = EXCLUDED.description,
- tags = EXCLUDED.tags
- RETURNING id
- """,
- template.id,
- bank_id,
- MentalModelSubtype.STRUCTURAL.value,
- template.name,
- template.description,
- model_tags,
- )
- created_count += 1
- except Exception as e:
- logger.warning(f"[MENTAL_MODELS] Failed to create structural model {template.id}: {e}")
-
- logger.info(f"[MENTAL_MODELS] Created/updated {created_count} structural models for bank {bank_id}")
- return models_to_remove
-
- async def _promote_entity_internal(
- self, bank_id: str, entity_id: str, pool, tags: list[str] | None = None
- ) -> dict[str, Any] | None:
- """Internal method to promote entity to mental model without auth check.
-
- Args:
- bank_id: Bank identifier
- entity_id: Entity ID to promote
- pool: Database connection pool
- tags: Tags to apply to the created mental model
- """
- from .mental_models.models import MentalModelSubtype
-
- async with acquire_with_retry(pool) as conn:
- # Get entity info
- entity = await conn.fetchrow(
- f"SELECT id, canonical_name FROM {fq_table('entities')} WHERE id = $1 AND bank_id = $2",
- uuid.UUID(entity_id),
- bank_id,
- )
-
- if not entity:
- return None
-
- # Create mental model from entity
- model_id = f"entity-{entity['canonical_name'].lower().replace(' ', '-')}"
- row = await conn.fetchrow(
- f"""
- INSERT INTO {fq_table("mental_models")}
- (id, bank_id, subtype, name, description, entity_id, tags)
- VALUES ($1, $2, $3, $4, $5, $6, $7)
- ON CONFLICT (id, bank_id) DO NOTHING
- RETURNING id, bank_id, subtype, name, description, observations,
- entity_id, links, tags, last_updated, created_at
- """,
- model_id,
- bank_id,
- MentalModelSubtype.EMERGENT.value,
- entity["canonical_name"],
- f"Mental model for {entity['canonical_name']}",
- entity["id"],
- tags or [], # Apply tags from refresh operation
- )
-
- return self._row_to_mental_model(row) if row else None
-
- async def create_mental_model(
- self,
- bank_id: str,
- name: str,
- description: str,
- *,
- subtype: str = "pinned",
- observations: list[dict[str, Any]] | None = None,
- tags: list[str] | None = None,
- request_context: "RequestContext",
- ) -> dict[str, Any]:
- """
- Create a mental model.
-
- Supports two subtypes:
- - 'pinned': User-defined topic, observations are LLM-generated on refresh
- - 'directive': User-defined hard rules, observations are provided at creation
-
- For directives, observations must be provided and will NOT be regenerated.
- For pinned models, observations are generated by the reflect agent on refresh.
-
- Args:
- bank_id: Bank identifier
- name: Human-readable name for the mental model
- description: One-liner description for quick scanning
- subtype: 'pinned' (default) or 'directive'
- observations: For directives, list of {title, text} dicts. Ignored for pinned.
- tags: Tags for scoped visibility
-
- Returns:
- The created mental model
- """
- import json
-
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- from .mental_models.models import MentalModelSubtype
-
- # Validate subtype
- if subtype not in ("pinned", "directive"):
- raise ValueError(f"Invalid subtype '{subtype}'. Must be 'pinned' or 'directive'.")
-
- # For directives, observations must be provided
- if subtype == "directive":
- if not observations:
- raise ValueError("Directives require observations to be provided")
- subtype_enum = MentalModelSubtype.DIRECTIVE
- model_id = f"directive-{name.lower().replace(' ', '-').replace('/', '-')}"
- # Format observations for storage
- observations_json = json.dumps({"observations": observations})
- else:
- subtype_enum = MentalModelSubtype.PINNED
- model_id = f"pinned-{name.lower().replace(' ', '-').replace('/', '-')}"
- observations_json = None
-
- async with acquire_with_retry(pool) as conn:
- # Check if model already exists
- existing = await conn.fetchrow(
- f"SELECT id FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
- bank_id,
- model_id,
- )
- if existing:
- raise ValueError(f"Mental model with name '{name}' already exists")
-
- row = await conn.fetchrow(
- f"""
- INSERT INTO {fq_table("mental_models")}
- (id, bank_id, subtype, name, description, observations, tags, last_updated)
- VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
- RETURNING id, bank_id, subtype, name, description, observations,
- entity_id, links, tags, last_updated, created_at
- """,
- model_id,
- bank_id,
- subtype_enum.value,
- name,
- description,
- observations_json,
- tags or [],
- datetime.now(UTC) if subtype == "directive" else None,
- )
-
- logger.info(f"[MENTAL_MODELS] Created {subtype} mental model '{name}' (id={model_id}) for bank {bank_id}")
- return self._row_to_mental_model(row)
-
- async def delete_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> bool:
- """Delete a mental model."""
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- result = await conn.execute(
- f"DELETE FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
- bank_id,
- model_id,
- )
-
- return result == "DELETE 1"
-
- async def update_mental_model(
- self,
- bank_id: str,
- model_id: str,
- *,
- name: str | None = None,
- description: str | None = None,
- request_context: "RequestContext",
- ) -> dict | None:
- """Update a mental model's name and/or description.
-
- Returns the updated mental model dict, or None if not found.
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- # Build dynamic update query
- updates = []
- params = [bank_id, model_id]
- param_idx = 3
-
- if name is not None:
- updates.append(f"name = ${param_idx}")
- params.append(name)
- param_idx += 1
-
- if description is not None:
- updates.append(f"description = ${param_idx}")
- params.append(description)
- param_idx += 1
-
- if not updates:
- return None
-
- query = f"""
- UPDATE {fq_table("mental_models")}
- SET {", ".join(updates)}
- WHERE bank_id = $1 AND id = $2
- RETURNING id, bank_id, subtype, name, description, observations, version, entity_id, links, tags, last_updated, created_at
- """
-
- row = await conn.fetchrow(query, *params)
-
- if not row:
- return None
-
- return self._row_to_mental_model(row)
-
- async def save_mental_model_version(
- self,
- conn,
- bank_id: str,
- model_id: str,
- observations: list[dict],
- new_version: int,
- ) -> None:
- """Save a new version of mental model observations.
-
- Args:
- conn: Database connection
- bank_id: Bank identifier
- model_id: Mental model ID
- observations: List of observation dicts
- new_version: Version number to save
- """
- import json
-
- from ..config import get_config
-
- config = get_config()
- max_versions = getattr(config, "mental_model_max_versions", 10)
-
- # Save the new version
- await conn.execute(
- f"""
- INSERT INTO {fq_table("mental_model_versions")}
- (mental_model_id, bank_id, version, observations)
- VALUES ($1, $2, $3, $4::jsonb)
- ON CONFLICT (mental_model_id, bank_id, version) DO UPDATE
- SET observations = EXCLUDED.observations, created_at = NOW()
- """,
- model_id,
- bank_id,
- new_version,
- json.dumps({"observations": observations}),
- )
-
- # Clean up old versions (keep only max_versions)
- await conn.execute(
- f"""
- DELETE FROM {fq_table("mental_model_versions")}
- WHERE mental_model_id = $1 AND bank_id = $2 AND version <= $3::int - $4::int
- """,
- model_id,
- bank_id,
- new_version,
- max_versions,
- )
-
- async def get_mental_model_versions(
- self,
- bank_id: str,
- model_id: str,
- *,
- request_context: "RequestContext",
- ) -> list[dict]:
- """List version history for a mental model.
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID
-
- Returns:
- List of version summaries sorted by version descending
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- rows = await conn.fetch(
- f"""
- SELECT version, created_at,
- jsonb_array_length(observations->'observations') as observation_count
- FROM {fq_table("mental_model_versions")}
- WHERE mental_model_id = $1 AND bank_id = $2
- ORDER BY version DESC
- """,
- model_id,
- bank_id,
- )
-
- return [
- {
- "version": row["version"],
- "created_at": row["created_at"].isoformat() if row["created_at"] else None,
- "observation_count": row["observation_count"] or 0,
- }
- for row in rows
- ]
-
- async def get_mental_model_version(
- self,
- bank_id: str,
- model_id: str,
- version: int,
- *,
- request_context: "RequestContext",
- ) -> dict | None:
- """Get a specific version of mental model observations.
-
- Args:
- bank_id: Bank identifier
- model_id: Mental model ID
- version: Version number to retrieve
-
- Returns:
- Version data with observations, or None if not found
- """
- await self._authenticate_tenant(request_context)
- pool = await self._get_pool()
-
- async with acquire_with_retry(pool) as conn:
- row = await conn.fetchrow(
- f"""
- SELECT version, observations, created_at
- FROM {fq_table("mental_model_versions")}
- WHERE mental_model_id = $1 AND bank_id = $2 AND version = $3
- """,
- model_id,
- bank_id,
- version,
- )
-
- if not row:
- return None
-
- import json
-
- observations_data = row["observations"]
- if isinstance(observations_data, str):
- observations_data = json.loads(observations_data)
-
- observations = observations_data.get("observations", []) if isinstance(observations_data, dict) else []
-
- return {
- "version": row["version"],
- "observations": self._parse_observations(observations),
- "created_at": row["created_at"].isoformat() if row["created_at"] else None,
- }
-
- def _row_to_mental_model(self, row) -> dict[str, Any]:
- """Convert a database row to a mental model dict."""
- import json
-
- # Parse observations JSON - can be a dict {"observations": [...]} or a list []
- observations_data = row.get("observations")
- last_refresh_at = None
- refresh_state = None
- if observations_data is None:
- observations_raw = []
- elif isinstance(observations_data, str):
- observations_data = json.loads(observations_data)
- observations_raw = (
- observations_data.get("observations", []) if isinstance(observations_data, dict) else observations_data
- )
- if isinstance(observations_data, dict):
- last_refresh_at = observations_data.get("last_refresh_at")
- refresh_state = observations_data.get("refresh_state")
- elif isinstance(observations_data, list):
- observations_raw = observations_data
- elif isinstance(observations_data, dict):
- observations_raw = observations_data.get("observations", [])
- last_refresh_at = observations_data.get("last_refresh_at")
- refresh_state = observations_data.get("refresh_state")
- else:
- observations_raw = []
-
- # Parse observations into typed models
- observations = self._parse_observations(observations_raw)
-
- return {
- "id": row["id"],
- "bank_id": row["bank_id"],
- "subtype": row["subtype"],
- "name": row["name"],
- "description": row["description"],
- "observations": observations,
- "version": row.get("version", 0),
- "entity_id": str(row["entity_id"]) if row["entity_id"] else None,
- "links": row["links"] or [],
- "tags": list(row["tags"]) if row.get("tags") else [],
- "last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
- "last_refresh_at": last_refresh_at,
- "refresh_state": refresh_state,
- "created_at": row["created_at"].isoformat(),
- }
-
def _parse_observations(self, observations_raw: list):
"""Parse raw observation dicts into typed Observation models.
@@ -5115,10 +4157,10 @@ class MemoryEngine(MemoryEngineInterface):
fact_ids: list[str],
) -> int:
"""
- Remove fact IDs from mental model observations when memories are deleted.
+ Remove fact IDs from mental model source_memory_ids when memories are deleted.
- Uses JSONB path operations to find and update mental models that reference
- the deleted fact IDs in their observations.
+ Mental models are now stored in memory_units with fact_type='mental_model'
+ and have a source_memory_ids column (UUID[]) tracking their source memories.
Args:
conn: Database connection
@@ -5131,45 +4173,29 @@ class MemoryEngine(MemoryEngineInterface):
if not fact_ids:
return 0
- # Convert fact_ids to a jsonb array for efficient comparison
- import json
+ # Convert string IDs to UUIDs for the array comparison
+ import uuid as uuid_module
- fact_ids_json = json.dumps(fact_ids)
+ fact_uuids = [uuid_module.UUID(fid) for fid in fact_ids]
- # Update mental models by removing the deleted fact IDs from all observations
- # This uses jsonb_set to update each observation's fact_ids array
+ # Update mental models (memory_units with fact_type='mental_model')
+ # by removing the deleted fact IDs from source_memory_ids
+ # Use array subtraction: source_memory_ids - deleted_ids
result = await conn.execute(
f"""
- UPDATE {fq_table("mental_models")}
- SET observations = jsonb_set(
- observations,
- '{{observations}}',
- (
- SELECT COALESCE(jsonb_agg(
- jsonb_set(
- observation,
- '{{fact_ids}}',
- (
- SELECT COALESCE(jsonb_agg(fid), '[]'::jsonb)
- FROM jsonb_array_elements_text(observation->'fact_ids') AS fid
- WHERE NOT (fid::text = ANY($2::text[]))
- )
- )
- ), '[]'::jsonb)
- FROM jsonb_array_elements(observations->'observations') AS observation
- )
+ UPDATE {fq_table("memory_units")}
+ SET source_memory_ids = (
+ SELECT COALESCE(array_agg(elem), ARRAY[]::uuid[])
+ FROM unnest(source_memory_ids) AS elem
+ WHERE elem != ALL($2::uuid[])
),
- last_updated = NOW()
+ updated_at = NOW()
WHERE bank_id = $1
- AND EXISTS (
- SELECT 1
- FROM jsonb_array_elements(observations->'observations') AS observation,
- jsonb_array_elements_text(observation->'fact_ids') AS fid
- WHERE fid::text = ANY($2::text[])
- )
+ AND fact_type = 'mental_model'
+ AND source_memory_ids && $2::uuid[]
""",
bank_id,
- fact_ids,
+ fact_uuids,
)
# Parse the result to get number of updated rows
@@ -5180,34 +4206,1113 @@ class MemoryEngine(MemoryEngineInterface):
)
return updated_count
- async def list_operations(
+ # =========================================================================
+ # LEARNINGS CRUD
+ # =========================================================================
+
+ async def list_learnings(
self,
bank_id: str,
*,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ limit: int = 100,
+ offset: int = 0,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
- """List async operations for a bank."""
+ """List learnings for a bank.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of learning dicts
+ """
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
- # Get total count
- total_row = await conn.fetchrow(
- f"SELECT COUNT(*) as total FROM {fq_table('async_operations')} WHERE bank_id = $1",
+ # Build tag filter
+ tag_filter = ""
+ params: list[Any] = [bank_id, limit, offset]
+ if tags:
+ if tags_match == "all":
+ tag_filter = " AND tags @> $4::varchar[]"
+ elif tags_match == "exact":
+ tag_filter = " AND tags = $4::varchar[]"
+ else: # any
+ tag_filter = " AND tags && $4::varchar[]"
+ params.append(tags)
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ FROM {fq_table("learnings")}
+ WHERE bank_id = $1 {tag_filter}
+ ORDER BY proof_count DESC, updated_at DESC
+ LIMIT $2 OFFSET $3
+ """,
+ *params,
+ )
+
+ return [self._row_to_learning(row) for row in rows]
+
+ async def get_learning(
+ self,
+ bank_id: str,
+ learning_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single learning by ID.
+
+ Args:
+ bank_id: Bank identifier
+ learning_id: Learning UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Learning dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ FROM {fq_table("learnings")}
+ WHERE bank_id = $1 AND id = $2
+ """,
bank_id,
+ learning_id,
+ )
+
+ return self._row_to_learning(row) if row else None
+
+ async def create_learning(
+ self,
+ bank_id: str,
+ text: str,
+ *,
+ proof_count: int = 1,
+ tags: list[str] | None = None,
+ mission_context: str | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Create a new learning.
+
+ Args:
+ bank_id: Bank identifier
+ text: The learning text
+ proof_count: Initial proof count (default 1)
+ tags: Optional tags for scoped visibility
+ mission_context: Hash of mission when created
+ request_context: Request context for authentication
+
+ Returns:
+ The created learning dict
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ # Generate embedding for the learning text
+ embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
+ # Convert embedding to string for asyncpg vector type
+ embedding_str = str(embedding[0]) if embedding else None
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO {fq_table("learnings")}
+ (bank_id, text, proof_count, mission_context, embedding, tags)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ """,
+ bank_id,
+ text,
+ proof_count,
+ mission_context,
+ embedding_str,
+ tags or [],
+ )
+
+ logger.info(f"[LEARNINGS] Created learning for bank {bank_id}: {text[:50]}...")
+ return self._row_to_learning(row)
+
+ async def update_learning(
+ self,
+ bank_id: str,
+ learning_id: str,
+ *,
+ text: str | None = None,
+ increment_proof: bool = False,
+ add_history: dict[str, Any] | None = None,
+ mark_pre_mission_change: bool = False,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Update a learning.
+
+ Args:
+ bank_id: Bank identifier
+ learning_id: Learning UUID
+ text: New text (if changing)
+ increment_proof: Whether to increment proof_count
+ add_history: History entry to append (for contradictions)
+ mark_pre_mission_change: Whether to mark as pre-mission-change
+ request_context: Request context for authentication
+
+ Returns:
+ Updated learning dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build dynamic update
+ updates = ["updated_at = NOW()"]
+ params: list[Any] = [bank_id, learning_id]
+ param_idx = 3
+
+ if text is not None:
+ updates.append(f"text = ${param_idx}")
+ params.append(text)
+ param_idx += 1
+ # Also update embedding (convert to string for asyncpg vector type)
+ embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [text])
+ if embedding:
+ updates.append(f"embedding = ${param_idx}")
+ params.append(str(embedding[0]))
+ param_idx += 1
+
+ if increment_proof:
+ updates.append("proof_count = proof_count + 1")
+
+ if add_history:
+ import json
+
+ updates.append(f"history = history || ${param_idx}::jsonb")
+ params.append(json.dumps([add_history]))
+ param_idx += 1
+
+ if mark_pre_mission_change:
+ updates.append("pre_mission_change = TRUE")
+
+ query = f"""
+ UPDATE {fq_table("learnings")}
+ SET {", ".join(updates)}
+ WHERE bank_id = $1 AND id = $2
+ RETURNING id, bank_id, text, proof_count, history, mission_context,
+ pre_mission_change, tags, created_at, updated_at
+ """
+
+ row = await conn.fetchrow(query, *params)
+
+ return self._row_to_learning(row) if row else None
+
+ async def delete_learning(
+ self,
+ bank_id: str,
+ learning_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> bool:
+ """Delete a learning.
+
+ Args:
+ bank_id: Bank identifier
+ learning_id: Learning UUID
+ request_context: Request context for authentication
+
+ Returns:
+ True if deleted, False if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"DELETE FROM {fq_table('learnings')} WHERE bank_id = $1 AND id = $2",
+ bank_id,
+ learning_id,
+ )
+
+ return result == "DELETE 1"
+
+ def _row_to_learning(self, row) -> dict[str, Any]:
+ """Convert a database row to a learning dict."""
+ import json
+
+ # Parse history - asyncpg may return JSONB as string in some cases
+ history = row["history"]
+ if isinstance(history, str):
+ history = json.loads(history)
+ elif history is None:
+ history = []
+
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "text": row["text"],
+ "proof_count": row["proof_count"],
+ "history": history,
+ "mission_context": row["mission_context"],
+ "pre_mission_change": row["pre_mission_change"],
+ "tags": row["tags"] or [],
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
+ }
+
+ async def mark_learnings_pre_mission_change(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> int:
+ """Mark all learnings as pre-mission-change when mission changes.
+
+ Args:
+ bank_id: Bank identifier
+ request_context: Request context for authentication
+
+ Returns:
+ Number of learnings marked
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"""
+ UPDATE {fq_table("learnings")}
+ SET pre_mission_change = TRUE, updated_at = NOW()
+ WHERE bank_id = $1 AND pre_mission_change = FALSE
+ """,
+ bank_id,
+ )
+
+ # Also update bank's mission_changed_at
+ await conn.execute(
+ f"""
+ UPDATE {fq_table("banks")}
+ SET mission_changed_at = NOW()
+ WHERE bank_id = $1
+ """,
+ bank_id,
+ )
+
+ count = int(result.split()[-1]) if result and "UPDATE" in result else 0
+ if count > 0:
+ logger.info(f"[LEARNINGS] Marked {count} learnings as pre-mission-change for bank {bank_id}")
+ return count
+
+ # =========================================================================
+ # MENTAL MODELS (CONSOLIDATED) - Read-only access to auto-consolidated mental models
+ # =========================================================================
+
+ async def list_mental_models_consolidated(
+ self,
+ bank_id: str,
+ *,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ limit: int = 100,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> list[dict[str, Any]]:
+ """List auto-consolidated mental models for a bank.
+
+ Mental models are stored in memory_units with fact_type='mental_model'.
+ They are automatically created and updated by the consolidation engine.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of mental model dicts
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build tag filter
+ tag_filter = ""
+ params: list[Any] = [bank_id, limit, offset]
+ if tags:
+ if tags_match == "all":
+ tag_filter = " AND tags @> $4::varchar[]"
+ elif tags_match == "exact":
+ tag_filter = " AND tags = $4::varchar[]"
+ else: # any
+ tag_filter = " AND tags && $4::varchar[]"
+ params.append(tags)
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND fact_type = 'mental_model' {tag_filter}
+ ORDER BY updated_at DESC NULLS LAST
+ LIMIT $2 OFFSET $3
+ """,
+ *params,
+ )
+
+ return [self._row_to_mental_model_consolidated(row) for row in rows]
+
+ async def get_mental_model_consolidated(
+ self,
+ bank_id: str,
+ model_id: str,
+ *,
+ include_source_memories: bool = True,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single mental model by ID.
+
+ Args:
+ bank_id: Bank identifier
+ model_id: Mental model ID
+ include_source_memories: Whether to include full source memory details
+ request_context: Request context for authentication
+
+ Returns:
+ Mental model dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND id = $2 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ model_id,
+ )
+
+ if not row:
+ return None
+
+ result = self._row_to_mental_model_consolidated(row)
+
+ # Fetch source memories if requested and source_memory_ids exist
+ if include_source_memories and result.get("source_memory_ids"):
+ source_ids = [uuid.UUID(sid) if isinstance(sid, str) else sid for sid in result["source_memory_ids"]]
+ source_rows = await conn.fetch(
+ f"""
+ SELECT id, text, fact_type, context, occurred_start, mentioned_at
+ FROM {fq_table("memory_units")}
+ WHERE id = ANY($1::uuid[])
+ ORDER BY mentioned_at DESC NULLS LAST
+ """,
+ source_ids,
+ )
+ result["source_memories"] = [
+ {
+ "id": str(r["id"]),
+ "text": r["text"],
+ "type": r["fact_type"],
+ "context": r["context"],
+ "occurred_start": r["occurred_start"].isoformat() if r["occurred_start"] else None,
+ "mentioned_at": r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
+ }
+ for r in source_rows
+ ]
+
+ return result
+
+ def _row_to_mental_model_consolidated(self, row: Any) -> dict[str, Any]:
+ """Convert a database row to a mental model dict."""
+ import json
+
+ history = row["history"]
+ if isinstance(history, str):
+ history = json.loads(history)
+ elif history is None:
+ history = []
+
+ # Convert source_memory_ids to strings
+ source_memory_ids = row.get("source_memory_ids") or []
+ source_memory_ids = [str(sid) for sid in source_memory_ids]
+
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "text": row["text"],
+ "proof_count": row["proof_count"] or 1,
+ "history": history,
+ "tags": row["tags"] or [],
+ "source_memory_ids": source_memory_ids,
+ "source_memories": [], # Populated separately when fetching full details
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
+ }
+
+ # =========================================================================
+ # REFLECTIONS CRUD
+ # =========================================================================
+
+ async def list_reflections(
+ self,
+ bank_id: str,
+ *,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ limit: int = 100,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> list[dict[str, Any]]:
+ """List pinned reflections for a bank.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of pinned reflection dicts
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build tag filter
+ tag_filter = ""
+ params: list[Any] = [bank_id, limit, offset]
+ if tags:
+ if tags_match == "all":
+ tag_filter = " AND tags @> $4::varchar[]"
+ elif tags_match == "exact":
+ tag_filter = " AND tags = $4::varchar[]"
+ else: # any
+ tag_filter = " AND tags && $4::varchar[]"
+ params.append(tags)
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at, reflect_response
+ FROM {fq_table("reflections")}
+ WHERE bank_id = $1 {tag_filter}
+ ORDER BY last_refreshed_at DESC
+ LIMIT $2 OFFSET $3
+ """,
+ *params,
+ )
+
+ return [self._row_to_reflection(row) for row in rows]
+
+ async def get_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single pinned reflection by ID.
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Pinned reflection dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at, reflect_response
+ FROM {fq_table("reflections")}
+ WHERE bank_id = $1 AND id = $2
+ """,
+ bank_id,
+ reflection_id,
+ )
+
+ return self._row_to_reflection(row) if row else None
+
+ async def create_reflection(
+ self,
+ bank_id: str,
+ name: str,
+ source_query: str,
+ content: str,
+ *,
+ tags: list[str] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Create a new pinned reflection.
+
+ Args:
+ bank_id: Bank identifier
+ name: Human-readable name for the reflection
+ source_query: The query that generated this reflection
+ content: The synthesized content
+ tags: Optional tags for scoped visibility
+ request_context: Request context for authentication
+
+ Returns:
+ The created pinned reflection dict
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ # Generate embedding for the content
+ embedding_text = f"{name} {content}"
+ embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
+ # Convert embedding to string for asyncpg vector type
+ embedding_str = str(embedding[0]) if embedding else None
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO {fq_table("reflections")}
+ (bank_id, name, source_query, content, embedding, tags)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at
+ """,
+ bank_id,
+ name,
+ source_query,
+ content,
+ embedding_str,
+ tags or [],
+ )
+
+ logger.info(f"[REFLECTIONS] Created pinned reflection '{name}' for bank {bank_id}")
+ return self._row_to_reflection(row)
+
+ async def refresh_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Refresh a pinned reflection by re-running its source query.
+
+ This method:
+ 1. Gets the pinned reflection
+ 2. Runs the source_query through reflect
+ 3. Updates the content with the new synthesis
+ 4. Updates last_refreshed_at
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Updated pinned reflection dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+
+ # Get the current reflection
+ reflection = await self.get_reflection(bank_id, reflection_id, request_context=request_context)
+ if not reflection:
+ return None
+
+ # Run reflect with the source query, excluding the reflection being refreshed
+ reflect_result = await self.reflect_async(
+ bank_id=bank_id,
+ query=reflection["source_query"],
+ request_context=request_context,
+ exclude_reflection_ids=[reflection_id],
+ )
+
+ # Build reflect_response payload to store
+ reflect_response_payload = {
+ "text": reflect_result.text,
+ "based_on": {
+ fact_type: [
+ {
+ "id": str(fact.id),
+ "text": fact.text,
+ "type": fact_type,
+ }
+ for fact in facts
+ ]
+ for fact_type, facts in reflect_result.based_on.items()
+ },
+ "mental_models": [], # Mental models are included in based_on["mental-models"]
+ }
+
+ # Update the reflection with new content and reflect_response
+ return await self.update_reflection(
+ bank_id,
+ reflection_id,
+ content=reflect_result.text,
+ reflect_response=reflect_response_payload,
+ request_context=request_context,
+ )
+
+ async def update_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ name: str | None = None,
+ content: str | None = None,
+ reflect_response: dict[str, Any] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Update a pinned reflection.
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ name: New name (if changing)
+ content: New content (if changing)
+ reflect_response: Full reflect API response payload (if changing)
+ request_context: Request context for authentication
+
+ Returns:
+ Updated pinned reflection dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build dynamic update
+ updates = []
+ params: list[Any] = [bank_id, reflection_id]
+ param_idx = 3
+
+ if name is not None:
+ updates.append(f"name = ${param_idx}")
+ params.append(name)
+ param_idx += 1
+
+ if content is not None:
+ updates.append(f"content = ${param_idx}")
+ params.append(content)
+ param_idx += 1
+ updates.append("last_refreshed_at = NOW()")
+ # Also update embedding (convert to string for asyncpg vector type)
+ embedding_text = f"{name or ''} {content}"
+ embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
+ if embedding:
+ updates.append(f"embedding = ${param_idx}")
+ params.append(str(embedding[0]))
+ param_idx += 1
+
+ if reflect_response is not None:
+ updates.append(f"reflect_response = ${param_idx}")
+ params.append(json.dumps(reflect_response))
+ param_idx += 1
+
+ if not updates:
+ return None
+
+ query = f"""
+ UPDATE {fq_table("reflections")}
+ SET {", ".join(updates)}
+ WHERE bank_id = $1 AND id = $2
+ RETURNING id, bank_id, name, source_query, content, tags,
+ last_refreshed_at, created_at, reflect_response
+ """
+
+ row = await conn.fetchrow(query, *params)
+
+ return self._row_to_reflection(row) if row else None
+
+ async def delete_reflection(
+ self,
+ bank_id: str,
+ reflection_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> bool:
+ """Delete a pinned reflection.
+
+ Args:
+ bank_id: Bank identifier
+ reflection_id: Pinned reflection UUID
+ request_context: Request context for authentication
+
+ Returns:
+ True if deleted, False if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"DELETE FROM {fq_table('reflections')} WHERE bank_id = $1 AND id = $2",
+ bank_id,
+ reflection_id,
+ )
+
+ return result == "DELETE 1"
+
+ def _row_to_reflection(self, row) -> dict[str, Any]:
+ """Convert a database row to a reflection dict."""
+ reflect_response = row.get("reflect_response")
+ # Parse JSON string to dict if needed (asyncpg may return JSONB as string)
+ if isinstance(reflect_response, str):
+ try:
+ reflect_response = json.loads(reflect_response)
+ except json.JSONDecodeError:
+ reflect_response = None
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "name": row["name"],
+ "source_query": row["source_query"],
+ "content": row["content"],
+ "tags": row["tags"] or [],
+ "last_refreshed_at": row["last_refreshed_at"].isoformat() if row["last_refreshed_at"] else None,
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "reflect_response": reflect_response,
+ }
+
+ # =========================================================================
+ # Directives - Hard rules injected into prompts
+ # =========================================================================
+
+ async def list_directives(
+ self,
+ bank_id: str,
+ *,
+ tags: list[str] | None = None,
+ tags_match: str = "any",
+ active_only: bool = True,
+ limit: int = 100,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> list[dict[str, Any]]:
+ """List directives for a bank.
+
+ Args:
+ bank_id: Bank identifier
+ tags: Optional tags to filter by
+ tags_match: How to match tags - 'any', 'all', or 'exact'
+ active_only: Only return active directives (default True)
+ limit: Maximum number of results
+ offset: Offset for pagination
+ request_context: Request context for authentication
+
+ Returns:
+ List of directive dicts
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build filters
+ filters = ["bank_id = $1"]
+ params: list[Any] = [bank_id]
+ param_idx = 2
+
+ if active_only:
+ filters.append("is_active = TRUE")
+
+ if tags:
+ if tags_match == "all":
+ filters.append(f"tags @> ${param_idx}::varchar[]")
+ elif tags_match == "exact":
+ filters.append(f"tags = ${param_idx}::varchar[]")
+ else: # any
+ filters.append(f"tags && ${param_idx}::varchar[]")
+ params.append(tags)
+ param_idx += 1
+
+ params.extend([limit, offset])
+
+ rows = await conn.fetch(
+ f"""
+ SELECT id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ FROM {fq_table("directives")}
+ WHERE {" AND ".join(filters)}
+ ORDER BY priority DESC, created_at DESC
+ LIMIT ${param_idx} OFFSET ${param_idx + 1}
+ """,
+ *params,
+ )
+
+ return [self._row_to_directive(row) for row in rows]
+
+ async def get_directive(
+ self,
+ bank_id: str,
+ directive_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Get a single directive by ID.
+
+ Args:
+ bank_id: Bank identifier
+ directive_id: Directive UUID
+ request_context: Request context for authentication
+
+ Returns:
+ Directive dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ SELECT id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ FROM {fq_table("directives")}
+ WHERE bank_id = $1 AND id = $2
+ """,
+ bank_id,
+ directive_id,
+ )
+
+ return self._row_to_directive(row) if row else None
+
+ async def create_directive(
+ self,
+ bank_id: str,
+ name: str,
+ content: str,
+ *,
+ priority: int = 0,
+ is_active: bool = True,
+ tags: list[str] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Create a new directive.
+
+ Args:
+ bank_id: Bank identifier
+ name: Human-readable name for the directive
+ content: The directive text to inject into prompts
+ priority: Higher priority directives are injected first (default 0)
+ is_active: Whether this directive is active (default True)
+ tags: Optional tags for filtering
+ request_context: Request context for authentication
+
+ Returns:
+ The created directive dict
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO {fq_table("directives")}
+ (bank_id, name, content, priority, is_active, tags)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ """,
+ bank_id,
+ name,
+ content,
+ priority,
+ is_active,
+ tags or [],
+ )
+
+ logger.info(f"[DIRECTIVES] Created directive '{name}' for bank {bank_id}")
+ return self._row_to_directive(row)
+
+ async def update_directive(
+ self,
+ bank_id: str,
+ directive_id: str,
+ *,
+ name: str | None = None,
+ content: str | None = None,
+ priority: int | None = None,
+ is_active: bool | None = None,
+ tags: list[str] | None = None,
+ request_context: "RequestContext",
+ ) -> dict[str, Any] | None:
+ """Update a directive.
+
+ Args:
+ bank_id: Bank identifier
+ directive_id: Directive UUID
+ name: New name (optional)
+ content: New content (optional)
+ priority: New priority (optional)
+ is_active: New active status (optional)
+ tags: New tags (optional)
+ request_context: Request context for authentication
+
+ Returns:
+ Updated directive dict or None if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ # Build update query dynamically
+ updates = ["updated_at = now()"]
+ params: list[Any] = []
+ param_idx = 1
+
+ if name is not None:
+ updates.append(f"name = ${param_idx}")
+ params.append(name)
+ param_idx += 1
+
+ if content is not None:
+ updates.append(f"content = ${param_idx}")
+ params.append(content)
+ param_idx += 1
+
+ if priority is not None:
+ updates.append(f"priority = ${param_idx}")
+ params.append(priority)
+ param_idx += 1
+
+ if is_active is not None:
+ updates.append(f"is_active = ${param_idx}")
+ params.append(is_active)
+ param_idx += 1
+
+ if tags is not None:
+ updates.append(f"tags = ${param_idx}")
+ params.append(tags)
+ param_idx += 1
+
+ params.extend([bank_id, directive_id])
+
+ async with acquire_with_retry(pool) as conn:
+ row = await conn.fetchrow(
+ f"""
+ UPDATE {fq_table("directives")}
+ SET {", ".join(updates)}
+ WHERE bank_id = ${param_idx} AND id = ${param_idx + 1}
+ RETURNING id, bank_id, name, content, priority, is_active, tags, created_at, updated_at
+ """,
+ *params,
+ )
+
+ return self._row_to_directive(row) if row else None
+
+ async def delete_directive(
+ self,
+ bank_id: str,
+ directive_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> bool:
+ """Delete a directive.
+
+ Args:
+ bank_id: Bank identifier
+ directive_id: Directive UUID
+ request_context: Request context for authentication
+
+ Returns:
+ True if deleted, False if not found
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ result = await conn.execute(
+ f"DELETE FROM {fq_table('directives')} WHERE bank_id = $1 AND id = $2",
+ bank_id,
+ directive_id,
+ )
+
+ return result == "DELETE 1"
+
+ def _row_to_directive(self, row) -> dict[str, Any]:
+ """Convert a database row to a directive dict."""
+ return {
+ "id": str(row["id"]),
+ "bank_id": row["bank_id"],
+ "name": row["name"],
+ "content": row["content"],
+ "priority": row["priority"],
+ "is_active": row["is_active"],
+ "tags": row["tags"] or [],
+ "created_at": row["created_at"].isoformat() if row["created_at"] else None,
+ "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
+ }
+
+ async def list_operations(
+ self,
+ bank_id: str,
+ *,
+ status: str | None = None,
+ limit: int = 20,
+ offset: int = 0,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """List async operations for a bank with optional filtering and pagination.
+
+ Args:
+ bank_id: Bank identifier
+ status: Optional status filter (pending, completed, failed)
+ limit: Maximum number of operations to return (default 20)
+ offset: Number of operations to skip (default 0)
+ request_context: Request context for authentication
+
+ Returns:
+ Dict with total count and list of operations, sorted by most recent first
+ """
+ await self._authenticate_tenant(request_context)
+ pool = await self._get_pool()
+
+ async with acquire_with_retry(pool) as conn:
+ # Build WHERE clause
+ where_conditions = ["bank_id = $1"]
+ params: list[Any] = [bank_id]
+
+ if status:
+ # Map API status to DB statuses (pending includes processing)
+ if status == "pending":
+ where_conditions.append("status IN ('pending', 'processing')")
+ else:
+ where_conditions.append(f"status = ${len(params) + 1}")
+ params.append(status)
+
+ where_clause = " AND ".join(where_conditions)
+
+ # Get total count (with filter)
+ total_row = await conn.fetchrow(
+ f"SELECT COUNT(*) as total FROM {fq_table('async_operations')} WHERE {where_clause}",
+ *params,
)
total = total_row["total"] if total_row else 0
- # Get recent operations
+ # Get operations with pagination
operations = await conn.fetch(
f"""
SELECT operation_id, operation_type, created_at, status, error_message
FROM {fq_table("async_operations")}
- WHERE bank_id = $1
+ WHERE {where_clause}
ORDER BY created_at DESC
- LIMIT 50
+ LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
""",
- bank_id,
+ *params,
+ limit,
+ offset,
)
return {
@@ -5219,7 +5324,8 @@ class MemoryEngine(MemoryEngineInterface):
"items_count": 0,
"document_id": None,
"created_at": row["created_at"].isoformat(),
- "status": row["status"],
+ # Map DB status to API status (processing -> pending for simplicity)
+ "status": "pending" if row["status"] in ("pending", "processing") else row["status"],
"error_message": row["error_message"],
}
for row in operations
@@ -5353,19 +5459,57 @@ class MemoryEngine(MemoryEngineInterface):
# Return updated profile
return await self.get_bank_profile(bank_id, request_context=request_context)
- async def submit_async_retain(
+ async def _submit_async_operation(
self,
bank_id: str,
- contents: list[dict[str, Any]],
+ operation_type: str,
+ task_type: str,
+ task_payload: dict[str, Any],
*,
- request_context: "RequestContext",
- document_tags: list[str] | None = None,
+ result_metadata: dict[str, Any] | None = None,
+ dedupe_by_bank: bool = False,
) -> dict[str, Any]:
- """Submit a batch retain operation to run asynchronously."""
- await self._authenticate_tenant(request_context)
+ """Generic helper to submit an async operation.
+
+ Args:
+ bank_id: Bank identifier
+ operation_type: Operation type for the async_operations record (e.g., 'consolidation', 'retain')
+ task_type: Task type for the task payload (e.g., 'consolidation', 'batch_retain')
+ task_payload: Additional task payload fields (operation_id and bank_id are added automatically)
+ result_metadata: Optional metadata to store with the operation record
+ dedupe_by_bank: If True, skip creating a new task if one is already pending for this bank+operation_type
+
+ Returns:
+ Dict with operation_id and optionally deduplicated=True if an existing task was found
+ """
+ import json
+
pool = await self._get_pool()
- import json
+ # Check for existing pending task if deduplication is enabled
+ # Note: We only check 'pending', not 'processing', because a processing task
+ # uses a watermark from when it started - new memories added after that point
+ # would need another consolidation run to be processed.
+ if dedupe_by_bank:
+ async with acquire_with_retry(pool) as conn:
+ existing = await conn.fetchrow(
+ f"""
+ SELECT operation_id FROM {fq_table("async_operations")}
+ WHERE bank_id = $1 AND operation_type = $2 AND status = 'pending'
+ LIMIT 1
+ """,
+ bank_id,
+ operation_type,
+ )
+ if existing:
+ logger.debug(
+ f"{operation_type} task already pending for bank_id={bank_id}, "
+ f"skipping duplicate (existing operation_id={existing['operation_id']})"
+ )
+ return {
+ "operation_id": str(existing["operation_id"]),
+ "deduplicated": True,
+ }
operation_id = uuid.uuid4()
@@ -5378,25 +5522,131 @@ class MemoryEngine(MemoryEngineInterface):
""",
operation_id,
bank_id,
- "retain",
- json.dumps({"items_count": len(contents)}),
+ operation_type,
+ json.dumps(result_metadata or {}),
)
- # Submit task to background queue
- task_payload = {
- "type": "batch_retain",
+ # Build and submit task payload
+ full_payload = {
+ "type": task_type,
"operation_id": str(operation_id),
"bank_id": bank_id,
- "contents": contents,
+ **task_payload,
}
- if document_tags:
- task_payload["document_tags"] = document_tags
- await self._task_backend.submit_task(task_payload)
+ await self._task_backend.submit_task(full_payload)
- logger.info(f"Retain task queued for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}")
+ logger.info(f"{operation_type} task queued for bank_id={bank_id}, operation_id={operation_id}")
return {
"operation_id": str(operation_id),
- "items_count": len(contents),
}
+
+ async def submit_async_retain(
+ self,
+ bank_id: str,
+ contents: list[dict[str, Any]],
+ *,
+ request_context: "RequestContext",
+ document_tags: list[str] | None = None,
+ ) -> dict[str, Any]:
+ """Submit a batch retain operation to run asynchronously."""
+ await self._authenticate_tenant(request_context)
+
+ task_payload: dict[str, Any] = {"contents": contents}
+ if document_tags:
+ task_payload["document_tags"] = document_tags
+
+ result = await self._submit_async_operation(
+ bank_id=bank_id,
+ operation_type="retain",
+ task_type="batch_retain",
+ task_payload=task_payload,
+ result_metadata={"items_count": len(contents)},
+ dedupe_by_bank=False,
+ )
+
+ result["items_count"] = len(contents)
+ return result
+
+ async def submit_async_consolidation(
+ self,
+ bank_id: str,
+ *,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Submit a consolidation operation to run asynchronously.
+
+ Deduplicates by bank_id - if there's already a pending consolidation for this bank,
+ returns the existing operation_id instead of creating a new one.
+
+ Args:
+ bank_id: Bank identifier
+ request_context: Request context for authentication
+
+ Returns:
+ Dict with operation_id
+ """
+ await self._authenticate_tenant(request_context)
+ return await self._submit_async_operation(
+ bank_id=bank_id,
+ operation_type="consolidation",
+ task_type="consolidation",
+ task_payload={},
+ dedupe_by_bank=True,
+ )
+
+ async def submit_async_create_reflection(
+ self,
+ bank_id: str,
+ name: str,
+ source_query: str,
+ *,
+ tags: list[str] | None = None,
+ max_tokens: int = 2048,
+ request_context: "RequestContext",
+ ) -> dict[str, Any]:
+ """Submit an async reflection creation operation.
+
+ This:
+ 1. Creates the reflection in the database immediately (with placeholder content)
+ 2. Schedules a background task to run reflect and update the content
+ 3. Returns operation_id for tracking
+
+ Args:
+ bank_id: Bank identifier
+ name: Human-readable name for the reflection
+ source_query: The query to run to generate content
+ tags: Optional tags for scoped visibility
+ max_tokens: Maximum tokens for the reflect response
+ request_context: Request context for authentication
+
+ Returns:
+ Dict with operation_id
+ """
+ await self._authenticate_tenant(request_context)
+
+ # 1. Create the reflection in the database with placeholder content
+ reflection = await self.create_reflection(
+ bank_id=bank_id,
+ name=name,
+ source_query=source_query,
+ content="Generating content...", # Placeholder
+ tags=tags,
+ request_context=request_context,
+ )
+ reflection_id = reflection["id"]
+
+ # 2. Submit async operation
+ return await self._submit_async_operation(
+ bank_id=bank_id,
+ operation_type="create_reflection",
+ task_type="create_reflection",
+ task_payload={
+ "reflection_id": reflection_id,
+ "source_query": source_query,
+ "max_tokens": max_tokens,
+ },
+ result_metadata={"reflection_id": reflection_id, "name": name, "source_query": source_query},
+ dedupe_by_bank=False,
+ )
diff --git a/hindsight-api/hindsight_api/engine/mental_models/__init__.py b/hindsight-api/hindsight_api/engine/mental_models/__init__.py
index cd0fba08..61f54af2 100644
--- a/hindsight-api/hindsight_api/engine/mental_models/__init__.py
+++ b/hindsight-api/hindsight_api/engine/mental_models/__init__.py
@@ -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
diff --git a/hindsight-api/hindsight_api/engine/mental_models/emergent.py b/hindsight-api/hindsight_api/engine/mental_models/emergent.py
deleted file mode 100644
index ec5c8925..00000000
--- a/hindsight-api/hindsight_api/engine/mental_models/emergent.py
+++ /dev/null
@@ -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
diff --git a/hindsight-api/hindsight_api/engine/mental_models/models.py b/hindsight-api/hindsight_api/engine/mental_models/models.py
index 12353111..e1ff9fea 100644
--- a/hindsight-api/hindsight_api/engine/mental_models/models.py
+++ b/hindsight-api/hindsight_api/engine/mental_models/models.py
@@ -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.)")
diff --git a/hindsight-api/hindsight_api/engine/mental_models/structural.py b/hindsight-api/hindsight_api/engine/mental_models/structural.py
deleted file mode 100644
index e27d0789..00000000
--- a/hindsight-api/hindsight_api/engine/mental_models/structural.py
+++ /dev/null
@@ -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
diff --git a/hindsight-api/hindsight_api/engine/reflect/agent.py b/hindsight-api/hindsight_api/engine/reflect/agent.py
index bd9d38af..4f09d5b6 100644
--- a/hindsight-api/hindsight_api/engine/reflect/agent.py
+++ b/hindsight-api/hindsight_api/engine/reflect/agent.py
@@ -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)
diff --git a/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py b/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py
deleted file mode 100644
index df7fab15..00000000
--- a/hindsight-api/hindsight_api/engine/reflect/mental_model_reflect.py
+++ /dev/null
@@ -1,1213 +0,0 @@
-"""
-Diff-Based Mental Model Reflect Agent.
-
-This module implements a multi-phase agentic loop for generating and updating
-mental model observations with evidence-grounded quotes and computed trends.
-
-Phases:
-0. UPDATE EXISTING: Search for new evidence for existing observations
-1. SEED: Generate NEW candidate observations (skipping already-tracked patterns)
-2. EVIDENCE HUNT: For each new candidate, search for supporting/contradicting evidence
-3. VALIDATE: Validate new candidates and extract quotes
-4. COMPARE: Merge updated existing + new validated observations
-"""
-
-import asyncio
-import hashlib
-import json
-import logging
-import time
-from datetime import datetime, timezone
-from typing import TYPE_CHECKING, Any, Awaitable, Callable
-
-from pydantic import BaseModel, Field, field_validator
-
-from .observations import (
- CandidateObservation,
- CandidateWithEvidence,
- Observation,
- ObservationEvidence,
- verify_evidence_quotes,
-)
-from .prompts import (
- COMPARE_PHASE_SYSTEM_PROMPT,
- SEED_PHASE_SYSTEM_PROMPT,
- UPDATE_EXISTING_SYSTEM_PROMPT,
- VALIDATE_PHASE_SYSTEM_PROMPT,
- build_compare_phase_prompt,
- build_seed_phase_prompt,
- build_update_existing_prompt,
- build_validate_phase_prompt,
-)
-
-if TYPE_CHECKING:
- from ..llm_wrapper import LLMProvider
-
-logger = logging.getLogger(__name__)
-
-
-# =============================================================================
-# Typed Models for Refresh State Tracking
-# =============================================================================
-
-
-class DispositionTraits(BaseModel):
- """Disposition traits for a memory bank."""
-
- skepticism: int = Field(default=3, ge=1, le=5)
- literalism: int = Field(default=3, ge=1, le=5)
- empathy: int = Field(default=3, ge=1, le=5)
-
-
-class BankProfile(BaseModel):
- """Bank profile with mission and disposition."""
-
- bank_id: str = Field(default="")
- name: str = Field(default="")
- mission: str | None = Field(default=None)
- disposition: DispositionTraits = Field(default_factory=DispositionTraits)
-
- @field_validator("disposition", mode="before")
- @classmethod
- def parse_disposition(cls, v: DispositionTraits | dict | None) -> DispositionTraits:
- """Parse disposition from various formats."""
- if v is None:
- return DispositionTraits()
- if isinstance(v, DispositionTraits):
- return v
- if isinstance(v, dict):
- return DispositionTraits.model_validate(v)
- # Handle Pydantic v1 style models
- if hasattr(v, "model_dump"):
- return DispositionTraits.model_validate(v.model_dump())
- return DispositionTraits()
-
-
-class DirectiveObservation(BaseModel):
- """A single observation in a directive mental model."""
-
- title: str = Field(default="")
- content: str = Field(default="")
- text: str = Field(default="") # Legacy field
-
- @property
- def effective_content(self) -> str:
- """Get content, falling back to text for legacy data."""
- return self.content or self.text
-
-
-class DirectiveMentalModel(BaseModel):
- """A directive mental model with its observations."""
-
- id: str = Field(default="")
- name: str = Field(default="")
- observations: list[DirectiveObservation] = Field(default_factory=list)
-
- @field_validator("observations", mode="before")
- @classmethod
- def parse_observations(cls, v: list | None) -> list[DirectiveObservation]:
- """Parse observations from various formats."""
- if not v:
- return []
- result = []
- for obs in v:
- if isinstance(obs, DirectiveObservation):
- result.append(obs)
- elif isinstance(obs, dict):
- result.append(DirectiveObservation.model_validate(obs))
- return result
-
-
-class RefreshState(BaseModel):
- """State snapshot at the time of last refresh.
-
- Used to determine if a refresh is needed by comparing current state
- against this stored snapshot.
- """
-
- last_refresh_at: str = Field(description="ISO timestamp of last refresh")
- memories_count: int = Field(default=0, description="Total memory count at refresh time")
- mission_hash: str = Field(default="", description="Hash of bank mission text")
- disposition_hash: str = Field(default="", description="Hash of bank disposition values")
- directives_hash: str = Field(default="", description="Hash of all directive observations")
-
-
-class RefreshCheckResult(BaseModel):
- """Result of checking if a mental model needs refresh."""
-
- needs_refresh: bool = Field(description="Whether a refresh is needed")
- reasons: list[str] = Field(default_factory=list, description="Reasons why refresh is needed")
- current_state: RefreshState | None = Field(default=None, description="Current state for comparison")
-
-
-def _hash_string(s: str) -> str:
- """Create a short hash of a string."""
- if not s:
- return ""
- return hashlib.sha256(s.encode()).hexdigest()[:16]
-
-
-def _hash_mission(mission: str | None) -> str:
- """Hash the bank mission text."""
- return _hash_string(mission or "")
-
-
-def _hash_disposition(disposition: DispositionTraits) -> str:
- """Hash the bank disposition values."""
- # Create deterministic string from disposition values
- return _hash_string(
- f"skepticism:{disposition.skepticism}|literalism:{disposition.literalism}|empathy:{disposition.empathy}"
- )
-
-
-def _hash_directives(directives: list[DirectiveMentalModel]) -> str:
- """Hash all directive observations."""
- if not directives:
- return ""
-
- # Create deterministic string from all directive observations
- parts = []
- for directive in sorted(directives, key=lambda d: d.id or d.name):
- directive_id = directive.id or directive.name
- for obs in directive.observations:
- parts.append(f"{directive_id}:{obs.title}:{obs.effective_content}")
-
- return _hash_string("|".join(parts))
-
-
-def compute_refresh_state(
- memories_count: int,
- bank_profile: BankProfile,
- directives: list[DirectiveMentalModel],
-) -> RefreshState:
- """Compute the current refresh state from inputs.
-
- Args:
- memories_count: Total number of memories in the bank
- bank_profile: Bank profile with mission and disposition
- directives: List of directive mental models
- """
- return RefreshState(
- last_refresh_at=datetime.now(timezone.utc).isoformat(),
- memories_count=memories_count,
- mission_hash=_hash_mission(bank_profile.mission),
- disposition_hash=_hash_disposition(bank_profile.disposition),
- directives_hash=_hash_directives(directives),
- )
-
-
-def check_needs_refresh(
- stored_state: dict | RefreshState | None,
- current_memories_count: int,
- bank_profile: BankProfile,
- directives: list[DirectiveMentalModel],
-) -> RefreshCheckResult:
- """Check if a mental model needs refresh by comparing states.
-
- Args:
- stored_state: Previously stored refresh state (or None if never refreshed)
- current_memories_count: Current total memory count
- bank_profile: Current bank profile
- directives: Current directive mental models
-
- Returns:
- RefreshCheckResult with needs_refresh flag and reasons
- """
- # Compute current state
- current_state = compute_refresh_state(current_memories_count, bank_profile, directives)
-
- # Never refreshed = definitely needs refresh
- if stored_state is None:
- return RefreshCheckResult(
- needs_refresh=True,
- reasons=["never_refreshed"],
- current_state=current_state,
- )
-
- # Parse stored state if dict
- if isinstance(stored_state, dict):
- try:
- stored = RefreshState.model_validate(stored_state)
- except Exception:
- return RefreshCheckResult(
- needs_refresh=True,
- reasons=["invalid_stored_state"],
- current_state=current_state,
- )
- else:
- stored = stored_state
-
- # Compare states
- reasons: list[str] = []
-
- if current_memories_count > stored.memories_count:
- reasons.append("new_memories")
-
- if current_state.mission_hash != stored.mission_hash:
- reasons.append("mission_changed")
-
- if current_state.disposition_hash != stored.disposition_hash:
- reasons.append("disposition_changed")
-
- if current_state.directives_hash != stored.directives_hash:
- reasons.append("directives_changed")
-
- return RefreshCheckResult(
- needs_refresh=len(reasons) > 0,
- reasons=reasons,
- current_state=current_state,
- )
-
-
-class PhaseTokenUsage(BaseModel):
- """Token usage from a phase's LLM calls."""
-
- input_tokens: int = Field(default=0)
- output_tokens: int = Field(default=0)
- total_tokens: int = Field(default=0)
-
- def __add__(self, other: "PhaseTokenUsage") -> "PhaseTokenUsage":
- """Allow aggregating token usage."""
- return PhaseTokenUsage(
- input_tokens=self.input_tokens + other.input_tokens,
- output_tokens=self.output_tokens + other.output_tokens,
- total_tokens=self.total_tokens + other.total_tokens,
- )
-
-
-class SeedPhaseResult(BaseModel):
- """Result from the seed phase."""
-
- candidates: list[CandidateObservation] = Field(default_factory=list)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class UpdateExistingResult(BaseModel):
- """Result from the update existing phase."""
-
- updated_observations: list[dict] = Field(default_factory=list)
- contradicted_titles: list[str] = Field(default_factory=list)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class ValidatePhaseResult(BaseModel):
- """Result from the validate phase."""
-
- verified_observations: list[dict] = Field(default_factory=list)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class ComparePhaseResult(BaseModel):
- """Result from the compare phase."""
-
- observations: list[Observation] = Field(default_factory=list)
- changes: dict = Field(default_factory=dict)
- token_usage: PhaseTokenUsage = Field(default_factory=PhaseTokenUsage)
-
-
-class MentalModelReflectResult(BaseModel):
- """Result from the mental model reflect process."""
-
- observations: list[Observation] = Field(default_factory=list, description="Final validated observations")
- version: int = Field(default=1, description="New version number for this mental model")
- changes: dict = Field(default_factory=dict, description="Summary of changes made")
- phases_completed: list[str] = Field(default_factory=list, description="Which phases were completed")
- duration_ms: int = Field(default=0, description="Total duration in milliseconds")
- memories_analyzed: int = Field(default=0, description="Number of memories analyzed")
- candidates_generated: int = Field(default=0, description="Number of candidate observations generated")
- candidates_validated: int = Field(default=0, description="Number of candidates that passed validation")
- # Token usage tracking
- input_tokens: int = Field(default=0, description="Total input tokens used across all LLM calls")
- output_tokens: int = Field(default=0, description="Total output tokens used across all LLM calls")
- total_tokens: int = Field(default=0, description="Total tokens used (input + output)")
-
-
-class SeedPhaseOutput(BaseModel):
- """Output from the seed phase."""
-
- candidates: list[CandidateObservation] = Field(default_factory=list)
-
-
-class ValidatePhaseOutput(BaseModel):
- """Output from the validate phase."""
-
- observations: list[dict] = Field(default_factory=list)
- discarded: list[dict] = Field(default_factory=list)
- merged: list[dict] = Field(default_factory=list)
-
-
-class ComparePhaseOutput(BaseModel):
- """Output from the compare phase."""
-
- observations: list[dict] = Field(default_factory=list)
- changes: dict = Field(default_factory=dict)
-
-
-class NewEvidenceItem(BaseModel):
- """A new evidence item from the update existing phase."""
-
- memory_id: str = Field(description="ID of the memory this quote is from")
- quote: str = Field(description="Exact quote from the memory")
- relevance: str = Field(default="", description="Why this quote supports the observation")
- timestamp: str = Field(default="", description="When the memory was created (ISO format)")
-
-
-class UpdatedObservation(BaseModel):
- """Output for a single updated observation."""
-
- title: str = Field(default="")
- content: str = Field(default="")
- existing_evidence_count: int = Field(default=0)
- new_evidence: list[NewEvidenceItem] = Field(default_factory=list)
- has_contradiction: bool = Field(default=False)
- contradiction_note: str | None = Field(default=None)
-
-
-class UpdateExistingPhaseOutput(BaseModel):
- """Output from the update existing phase."""
-
- updated_observations: list[UpdatedObservation] = Field(default_factory=list)
-
-
-class ObservationWithEvidence(BaseModel):
- """An existing observation with newly found evidence for the update phase."""
-
- observation: dict = Field(description="The original observation dict")
- supporting_memories: list[dict] = Field(default_factory=list, description="Newly found supporting memories")
- contradicting_memories: list[dict] = Field(default_factory=list, description="Newly found contradicting memories")
-
-
-async def run_mental_model_reflect(
- llm_config: "LLMProvider",
- bank_id: str,
- mental_model_id: str,
- mental_model_name: str,
- existing_observations: list[dict],
- current_version: int,
- get_diverse_memories_fn: Callable[[], Awaitable[list[dict]]],
- recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
- topic: str | None = None,
- max_candidates: int = 15,
-) -> MentalModelReflectResult:
- """
- Execute the diff-based mental model reflect loop.
-
- This is a 5-phase process that efficiently updates existing observations
- and discovers new patterns without redundant work:
-
- Phase 0: UPDATE EXISTING - Search for new evidence for existing observations
- Phase 1: SEED - Generate NEW candidate observations (skipping already-tracked patterns)
- Phase 2: EVIDENCE HUNT - Search for evidence for new candidates
- Phase 3: VALIDATE - Validate new candidates and extract quotes
- Phase 4: COMPARE - Merge updated existing + new validated observations
-
- Args:
- llm_config: LLM provider for agent calls
- bank_id: Bank identifier
- mental_model_id: ID of the mental model being updated
- mental_model_name: Name of the mental model (for context)
- existing_observations: Current observations in the mental model
- current_version: Current version number of the mental model
- get_diverse_memories_fn: Async function to get diverse memory sample
- recall_fn: Async function for semantic search (query, max_tokens) -> memories
- topic: Optional topic focus for the mental model
- max_candidates: Maximum number of candidate observations to generate
-
- Returns:
- MentalModelReflectResult with final observations and metadata
- """
- reflect_id = f"mm-{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
- start_time = time.time()
- phases_completed: list[str] = []
- updated_existing: list[dict] = []
- contradicted_observations: list[str] = []
- total_usage = PhaseTokenUsage() # Aggregate token usage across all phases
-
- logger.info(
- f"[MM-REFLECT {reflect_id}] Starting diff-based reflect for mental model '{mental_model_name}' "
- f"({len(existing_observations)} existing observations)"
- )
-
- # ==========================================================================
- # PHASE 0: UPDATE EXISTING (if there are existing observations)
- # ==========================================================================
- if existing_observations:
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 0: UPDATE EXISTING - Searching for new evidence")
- phase0_start = time.time()
-
- update_result = await _run_update_existing_phase(
- llm_config=llm_config,
- existing_observations=existing_observations,
- recall_fn=recall_fn,
- reflect_id=reflect_id,
- )
- updated_existing = update_result.updated_observations
- contradicted_observations = update_result.contradicted_titles
- total_usage = total_usage + update_result.token_usage
-
- phase0_duration = int((time.time() - phase0_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 0 complete: "
- f"{len(updated_existing)} observations updated, "
- f"{len(contradicted_observations)} flagged for removal ({phase0_duration}ms)"
- )
- phases_completed.append("update_existing")
-
- # ==========================================================================
- # PHASE 1: SEED (with existing observations context)
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 1: SEED - Getting diverse memories")
- phase1_start = time.time()
-
- # Get diverse memory sample
- seed_memories = await get_diverse_memories_fn()
- if not seed_memories:
- logger.warning(f"[MM-REFLECT {reflect_id}] No memories found for seeding")
- # Return updated existing observations if we have them
- if updated_existing:
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in updated_existing],
- version=current_version + 1,
- changes={
- "note": "Updated existing observations, no new patterns found",
- "updated": len(updated_existing),
- },
- phases_completed=phases_completed + ["seed_empty"],
- duration_ms=int((time.time() - start_time) * 1000),
- )
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in existing_observations],
- version=current_version + 1, # Always increment version when refresh runs
- changes={"note": "No memories available for analysis"},
- phases_completed=["seed_empty"],
- duration_ms=int((time.time() - start_time) * 1000),
- )
-
- # Generate NEW candidate observations (pass existing to avoid rediscovering them)
- seed_result = await _run_seed_phase(
- llm_config=llm_config,
- memories=seed_memories,
- topic=topic or mental_model_name,
- max_candidates=max_candidates,
- reflect_id=reflect_id,
- existing_observations=existing_observations, # Pass existing to skip them
- )
- candidates = seed_result.candidates
- total_usage = total_usage + seed_result.token_usage
-
- phase1_duration = int((time.time() - phase1_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 1 complete: {len(candidates)} NEW candidates from {len(seed_memories)} memories ({phase1_duration}ms)"
- )
- phases_completed.append("seed")
-
- # If no new candidates but we updated existing, return those
- if not candidates and updated_existing:
- logger.info(f"[MM-REFLECT {reflect_id}] No new patterns found, returning updated existing observations")
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in updated_existing],
- version=current_version + 1,
- changes={
- "note": "Updated existing observations with new evidence, no new patterns discovered",
- "updated": len(updated_existing),
- "contradicted": contradicted_observations,
- },
- phases_completed=phases_completed,
- duration_ms=int((time.time() - start_time) * 1000),
- memories_analyzed=len(seed_memories),
- input_tokens=total_usage.input_tokens,
- output_tokens=total_usage.output_tokens,
- total_tokens=total_usage.total_tokens,
- )
-
- # If no candidates and no existing, return empty
- if not candidates:
- logger.warning(f"[MM-REFLECT {reflect_id}] No candidates generated in seed phase")
- return MentalModelReflectResult(
- observations=[_dict_to_observation(obs) for obs in existing_observations],
- version=current_version + 1, # Always increment version when refresh runs
- changes={"note": "No candidate observations could be generated"},
- phases_completed=phases_completed,
- duration_ms=int((time.time() - start_time) * 1000),
- memories_analyzed=len(seed_memories),
- input_tokens=total_usage.input_tokens,
- output_tokens=total_usage.output_tokens,
- total_tokens=total_usage.total_tokens,
- )
-
- # ==========================================================================
- # PHASE 2: EVIDENCE HUNT (for new candidates only)
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 2: EVIDENCE HUNT - Searching for evidence")
- phase2_start = time.time()
-
- candidates_with_evidence = await _run_evidence_hunt_phase(
- candidates=candidates,
- recall_fn=recall_fn,
- reflect_id=reflect_id,
- )
-
- phase2_duration = int((time.time() - phase2_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 2 complete: Evidence gathered for {len(candidates_with_evidence)} candidates ({phase2_duration}ms)"
- )
- phases_completed.append("evidence_hunt")
-
- # ==========================================================================
- # PHASE 3: VALIDATE & REFINE (for new candidates only)
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 3: VALIDATE - Validating candidates")
- phase3_start = time.time()
-
- validate_result = await _run_validate_phase(
- llm_config=llm_config,
- candidates_with_evidence=candidates_with_evidence,
- reflect_id=reflect_id,
- )
- validated_observations = validate_result.verified_observations
- total_usage = total_usage + validate_result.token_usage
-
- phase3_duration = int((time.time() - phase3_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 3 complete: {len(validated_observations)} observations validated ({phase3_duration}ms)"
- )
- phases_completed.append("validate")
-
- # ==========================================================================
- # PHASE 4: COMPARE & MERGE
- # ==========================================================================
- logger.info(f"[MM-REFLECT {reflect_id}] Phase 4: COMPARE - Merging updated existing + new observations")
- phase4_start = time.time()
-
- # Use updated existing (with new evidence) instead of original existing
- observations_for_compare = updated_existing if updated_existing else existing_observations
-
- compare_result = await _run_compare_phase(
- llm_config=llm_config,
- existing_observations=observations_for_compare,
- new_observations=validated_observations,
- reflect_id=reflect_id,
- )
- final_observations = compare_result.observations
- changes = compare_result.changes
- total_usage = total_usage + compare_result.token_usage
-
- # Add contradiction info to changes
- if contradicted_observations:
- changes["contradicted"] = contradicted_observations
-
- phase4_duration = int((time.time() - phase4_start) * 1000)
- logger.info(
- f"[MM-REFLECT {reflect_id}] Phase 4 complete: {len(final_observations)} final observations ({phase4_duration}ms)"
- )
- phases_completed.append("compare")
-
- # ==========================================================================
- # FINALIZE
- # ==========================================================================
- total_duration = int((time.time() - start_time) * 1000)
- new_version = current_version + 1
-
- logger.info(
- f"[MM-REFLECT {reflect_id}] Complete: "
- f"v{current_version}→v{new_version}, "
- f"{len(final_observations)} observations, "
- f"{total_duration}ms total"
- )
-
- return MentalModelReflectResult(
- observations=final_observations,
- version=new_version,
- changes=changes,
- phases_completed=phases_completed,
- duration_ms=total_duration,
- memories_analyzed=len(seed_memories),
- candidates_generated=len(candidates),
- candidates_validated=len(validated_observations),
- input_tokens=total_usage.input_tokens,
- output_tokens=total_usage.output_tokens,
- total_tokens=total_usage.total_tokens,
- )
-
-
-async def _run_seed_phase(
- llm_config: "LLMProvider",
- memories: list[dict],
- topic: str,
- max_candidates: int,
- reflect_id: str,
- existing_observations: list[dict] | None = None,
-) -> SeedPhaseResult:
- """Phase 1: Generate NEW candidate observations from diverse memories.
-
- If existing_observations are provided, the LLM will be instructed to skip
- patterns that are already tracked, focusing only on genuinely new discoveries.
- """
- prompt = build_seed_phase_prompt(memories, topic, existing_observations)
-
- try:
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": SEED_PHASE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=SeedPhaseOutput,
- scope="mm_reflect_seed",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response
- if hasattr(response, "candidates"):
- candidates = response.candidates[:max_candidates]
- elif isinstance(response, dict) and "candidates" in response:
- candidates = [
- CandidateObservation(
- content=c.get("content", ""),
- seed_memory_ids=c.get("seed_memory_ids", []),
- )
- for c in response["candidates"][:max_candidates]
- ]
- else:
- # Try to parse as JSON
- try:
- data = json.loads(str(response))
- candidates = [
- CandidateObservation(
- content=c.get("content", ""),
- seed_memory_ids=c.get("seed_memory_ids", []),
- )
- for c in data.get("candidates", [])[:max_candidates]
- ]
- except (json.JSONDecodeError, TypeError):
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse seed phase response")
- candidates = []
-
- return SeedPhaseResult(candidates=candidates, token_usage=usage)
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Seed phase failed: {e}")
- return SeedPhaseResult()
-
-
-def _parse_update_existing_response(response: Any, reflect_id: str) -> UpdateExistingPhaseOutput:
- """Parse LLM response into UpdateExistingPhaseOutput.
-
- Handles multiple response formats: Pydantic model, dict, or JSON string.
- """
- # Already a Pydantic model
- if isinstance(response, UpdateExistingPhaseOutput):
- return response
-
- # Dict response - validate and convert
- if isinstance(response, dict):
- try:
- return UpdateExistingPhaseOutput.model_validate(response)
- except Exception as e:
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to validate dict response: {e}")
- return UpdateExistingPhaseOutput()
-
- # String response - try to parse as JSON
- try:
- data = json.loads(str(response))
- return UpdateExistingPhaseOutput.model_validate(data)
- except (json.JSONDecodeError, TypeError, Exception) as e:
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse update existing response: {e}")
- return UpdateExistingPhaseOutput()
-
-
-async def _run_update_existing_phase(
- llm_config: "LLMProvider",
- existing_observations: list[dict],
- recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
- reflect_id: str,
-) -> UpdateExistingResult:
- """Phase 0: Search for new evidence for existing observations.
-
- For each existing observation:
- 1. Search for new supporting evidence
- 2. Search for contradicting evidence
- 3. Extract new quotes and add to the observation
- 4. Flag observations with strong contradictions
- """
- if not existing_observations:
- return UpdateExistingResult()
-
- # Search for evidence for all existing observations in parallel
- async def search_evidence_for_observation(obs: dict) -> ObservationWithEvidence:
- content = obs.get("content", "")
- existing_evidence = obs.get("evidence", [])
- existing_memory_ids = {e.get("memory_id") for e in existing_evidence if isinstance(e, dict)}
-
- # Search for supporting and contradicting evidence
- supporting_query = f"evidence supporting: {content}"
- contradicting_query = f"evidence against: {content}"
-
- supporting_result, contradicting_result = await asyncio.gather(
- recall_fn(supporting_query, 2048),
- recall_fn(contradicting_query, 2048),
- return_exceptions=True,
- )
-
- supporting_memories: list[dict] = []
- contradicting_memories: list[dict] = []
-
- if isinstance(supporting_result, dict) and "memories" in supporting_result:
- # Filter out memories we already have evidence from
- supporting_memories = [
- m
- for m in supporting_result["memories"]
- if isinstance(m, dict) and m.get("id") not in existing_memory_ids
- ]
- if isinstance(contradicting_result, dict) and "memories" in contradicting_result:
- contradicting_memories = [m for m in contradicting_result["memories"] if isinstance(m, dict)]
-
- return ObservationWithEvidence(
- observation=obs,
- supporting_memories=supporting_memories,
- contradicting_memories=contradicting_memories,
- )
-
- # Run all searches in parallel
- tasks = [search_evidence_for_observation(obs) for obs in existing_observations]
- search_results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # Filter out exceptions and collect valid results
- valid_results: list[ObservationWithEvidence] = [r for r in search_results if isinstance(r, ObservationWithEvidence)]
-
- # Log any errors
- errors = [r for r in search_results if isinstance(r, Exception)]
- if errors:
- logger.warning(f"[MM-REFLECT {reflect_id}] {len(errors)} evidence search errors: {errors[:3]}")
-
- # If no new evidence found for any observation, return originals unchanged
- has_new_evidence = any(result.supporting_memories or result.contradicting_memories for result in valid_results)
-
- if not has_new_evidence:
- logger.info(f"[MM-REFLECT {reflect_id}] No new evidence found for existing observations")
- return UpdateExistingResult(updated_observations=existing_observations)
-
- # Build prompt data for LLM
- prompt_data = [
- {
- "observation": result.observation,
- "supporting_memories": result.supporting_memories,
- "contradicting_memories": result.contradicting_memories,
- }
- for result in valid_results
- ]
-
- # Call LLM to extract quotes from new evidence
- try:
- prompt = build_update_existing_prompt(prompt_data)
-
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": UPDATE_EXISTING_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=UpdateExistingPhaseOutput,
- scope="mm_reflect_update_existing",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response into typed model
- parsed_response = _parse_update_existing_response(response, reflect_id)
-
- if not parsed_response.updated_observations:
- logger.info(f"[MM-REFLECT {reflect_id}] No updated observations in LLM response")
- return UpdateExistingResult(updated_observations=existing_observations, token_usage=usage)
-
- # Build memory content map for quote verification
- memory_content_map: dict[str, str] = {}
- for result in valid_results:
- for mem in result.supporting_memories + result.contradicting_memories:
- mem_id = mem.get("id", "")
- mem_content = mem.get("content", mem.get("text", ""))
- if mem_id and mem_content:
- memory_content_map[mem_id] = mem_content
-
- # Process updated observations
- updated_observations: list[dict] = []
- contradicted_titles: list[str] = []
- total_new_evidence = 0
-
- for i, updated in enumerate(parsed_response.updated_observations):
- if i >= len(existing_observations):
- break
-
- original_obs = existing_observations[i]
-
- # Check for contradiction
- if updated.has_contradiction:
- title = original_obs.get("title", f"Observation {i + 1}")
- contradicted_titles.append(title)
- logger.info(f"[MM-REFLECT {reflect_id}] Observation '{title}' flagged for contradiction")
-
- # Verify and add new evidence
- existing_evidence = original_obs.get("evidence", [])
- verified_new_evidence: list[dict] = []
-
- for ev in updated.new_evidence:
- memory_content = memory_content_map.get(ev.memory_id, "")
-
- # Verify quote exists in memory
- if (
- ev.quote
- and memory_content
- and (ev.quote in memory_content or _fuzzy_quote_match(ev.quote, memory_content))
- ):
- verified_new_evidence.append(
- {
- "memory_id": ev.memory_id,
- "quote": ev.quote,
- "relevance": ev.relevance,
- "timestamp": ev.timestamp,
- }
- )
-
- total_new_evidence += len(verified_new_evidence)
-
- # Merge existing and new evidence
- merged_evidence = existing_evidence + verified_new_evidence
-
- # Create updated observation
- updated_obs = {
- **original_obs,
- "evidence": merged_evidence,
- }
- updated_observations.append(updated_obs)
-
- # For any observations not in the response, keep them unchanged
- for i in range(len(parsed_response.updated_observations), len(existing_observations)):
- updated_observations.append(existing_observations[i])
-
- logger.info(
- f"[MM-REFLECT {reflect_id}] Updated {len(updated_observations)} observations, "
- f"added {total_new_evidence} new evidence items"
- )
-
- return UpdateExistingResult(
- updated_observations=updated_observations,
- contradicted_titles=contradicted_titles,
- token_usage=usage,
- )
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Update existing phase failed: {e}", exc_info=True)
- return UpdateExistingResult(updated_observations=existing_observations)
-
-
-async def _run_evidence_hunt_phase(
- candidates: list[CandidateObservation],
- recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
- reflect_id: str,
-) -> list[CandidateWithEvidence]:
- """Phase 2: For each candidate, search for supporting and contradicting evidence."""
- results: list[CandidateWithEvidence] = []
-
- # Run evidence searches in parallel for all candidates
- async def search_evidence_for_candidate(candidate: CandidateObservation) -> CandidateWithEvidence:
- # Search for supporting evidence
- supporting_query = f"evidence supporting: {candidate.content}"
- contradicting_query = f"evidence against: {candidate.content}"
-
- supporting_result, contradicting_result = await asyncio.gather(
- recall_fn(supporting_query, 2048),
- recall_fn(contradicting_query, 2048),
- return_exceptions=True,
- )
-
- supporting_memories = []
- contradicting_memories = []
-
- if isinstance(supporting_result, dict) and "memories" in supporting_result:
- supporting_memories = supporting_result["memories"]
- if isinstance(contradicting_result, dict) and "memories" in contradicting_result:
- contradicting_memories = contradicting_result["memories"]
-
- return CandidateWithEvidence(
- candidate=candidate,
- supporting_memories=supporting_memories,
- contradicting_memories=contradicting_memories,
- )
-
- # Run all searches in parallel
- tasks = [search_evidence_for_candidate(c) for c in candidates]
- gather_results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # Filter out exceptions
- valid_results: list[CandidateWithEvidence] = [r for r in gather_results if isinstance(r, CandidateWithEvidence)]
-
- logger.info(f"[MM-REFLECT {reflect_id}] Evidence hunt: {len(valid_results)}/{len(candidates)} candidates processed")
- return valid_results
-
-
-async def _run_validate_phase(
- llm_config: "LLMProvider",
- candidates_with_evidence: list[CandidateWithEvidence],
- reflect_id: str,
-) -> ValidatePhaseResult:
- """Phase 3: Validate candidates and extract exact quotes."""
- # Convert to dict format for prompt
- candidates_data = [
- {
- "candidate": {
- "content": c.candidate.content,
- },
- "supporting_memories": c.supporting_memories,
- "contradicting_memories": c.contradicting_memories,
- }
- for c in candidates_with_evidence
- ]
-
- prompt = build_validate_phase_prompt(candidates_data)
-
- try:
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": VALIDATE_PHASE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=ValidatePhaseOutput,
- scope="mm_reflect_validate",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response
- if hasattr(response, "observations"):
- observations = response.observations
- elif isinstance(response, dict) and "observations" in response:
- observations = response["observations"]
- else:
- # Try to parse as JSON
- try:
- data = json.loads(str(response))
- observations = data.get("observations", [])
- except (json.JSONDecodeError, TypeError):
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse validate phase response")
- observations = []
-
- # Build memory content map for quote verification
- memory_content_map: dict[str, str] = {}
- for cwe in candidates_with_evidence:
- for mem in cwe.supporting_memories + cwe.contradicting_memories:
- mem_id = mem.get("id", "")
- content = mem.get("content", mem.get("text", ""))
- if mem_id and content:
- memory_content_map[mem_id] = content
-
- # Verify quotes in observations
- verified_observations = []
- for obs in observations:
- evidence = obs.get("evidence", [])
- verified_evidence = []
-
- for ev in evidence:
- mem_id = ev.get("memory_id", "")
- quote = ev.get("quote", "")
- memory_content = memory_content_map.get(mem_id, "")
-
- # Check if quote exists in memory (allow partial match for flexibility)
- if quote and memory_content and (quote in memory_content or _fuzzy_quote_match(quote, memory_content)):
- verified_evidence.append(ev)
- else:
- logger.debug(f"[MM-REFLECT {reflect_id}] Quote verification failed for memory {mem_id}")
-
- if verified_evidence:
- obs["evidence"] = verified_evidence
- verified_observations.append(obs)
- else:
- logger.debug(
- f"[MM-REFLECT {reflect_id}] Observation discarded - no verified evidence: {obs.get('content', '')[:50]}"
- )
-
- return ValidatePhaseResult(verified_observations=verified_observations, token_usage=usage)
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Validate phase failed: {e}")
- return ValidatePhaseResult()
-
-
-def _fuzzy_quote_match(quote: str, content: str, threshold: float = 0.8) -> bool:
- """Check if a quote roughly matches content (handles minor LLM variations)."""
- # Normalize both strings
- quote_words = set(quote.lower().split())
- content_words = set(content.lower().split())
-
- if not quote_words:
- return False
-
- # Check word overlap
- overlap = len(quote_words & content_words)
- similarity = overlap / len(quote_words)
-
- return similarity >= threshold
-
-
-async def _run_compare_phase(
- llm_config: "LLMProvider",
- existing_observations: list[dict],
- new_observations: list[dict],
- reflect_id: str,
-) -> ComparePhaseResult:
- """Phase 4: Merge new observations with existing mental model."""
- # If no existing observations, just convert new ones
- if not existing_observations:
- final_obs = [_dict_to_observation(obs) for obs in new_observations]
- changes = {
- "added": [obs.get("content", "") for obs in new_observations],
- "kept": [],
- "updated": [],
- "removed": [],
- "merged": [],
- }
- return ComparePhaseResult(observations=final_obs, changes=changes)
-
- # If no new observations, keep existing
- if not new_observations:
- final_obs = [_dict_to_observation(obs) for obs in existing_observations]
- changes = {
- "added": [],
- "kept": [obs.get("content", obs.get("text", "")) for obs in existing_observations],
- "updated": [],
- "removed": [],
- "merged": [],
- }
- return ComparePhaseResult(observations=final_obs, changes=changes)
-
- prompt = build_compare_phase_prompt(existing_observations, new_observations)
-
- try:
- response, token_usage = await llm_config.call(
- messages=[
- {"role": "system", "content": COMPARE_PHASE_SYSTEM_PROMPT},
- {"role": "user", "content": prompt},
- ],
- response_format=ComparePhaseOutput,
- scope="mm_reflect_compare",
- return_usage=True,
- )
-
- usage = PhaseTokenUsage(
- input_tokens=token_usage.input_tokens if token_usage else 0,
- output_tokens=token_usage.output_tokens if token_usage else 0,
- total_tokens=token_usage.total_tokens if token_usage else 0,
- )
-
- # Parse response
- if hasattr(response, "observations"):
- observations_data = response.observations
- changes = response.changes if hasattr(response, "changes") else {}
- elif isinstance(response, dict):
- observations_data = response.get("observations", [])
- changes = response.get("changes", {})
- else:
- # Try to parse as JSON
- try:
- data = json.loads(str(response))
- observations_data = data.get("observations", [])
- changes = data.get("changes", {})
- except (json.JSONDecodeError, TypeError):
- logger.warning(f"[MM-REFLECT {reflect_id}] Failed to parse compare phase response")
- # Fallback: just use new observations
- observations_data = new_observations
- changes = {"note": "Compare phase parsing failed, using new observations"}
-
- # Convert to Observation objects
- final_observations = [_dict_to_observation(obs) for obs in observations_data]
- return ComparePhaseResult(observations=final_observations, changes=changes, token_usage=usage)
-
- except Exception as e:
- logger.error(f"[MM-REFLECT {reflect_id}] Compare phase failed: {e}")
- # Fallback: merge by keeping all
- all_obs = existing_observations + new_observations
- final_obs = [_dict_to_observation(obs) for obs in all_obs]
- changes = {"note": f"Compare phase failed: {e}, keeping all observations"}
- return ComparePhaseResult(observations=final_obs, changes=changes)
-
-
-def _dict_to_observation(data: dict) -> Observation:
- """Convert a dict to an Observation model."""
- # Handle both new format (title, content, evidence) and legacy format (title, text, memory_ids)
- title = data.get("title", "")
- content = data.get("content", "")
-
- if not content:
- # Legacy format: use text as content
- text = data.get("text", "")
- content = text
-
- if not title:
- # Generate title from content (first ~50 chars)
- title = content[:50].strip() + ("..." if len(content) > 50 else "")
-
- # Parse evidence
- evidence: list[ObservationEvidence] = []
- evidence_data = data.get("evidence", [])
-
- if evidence_data:
- for ev in evidence_data:
- try:
- timestamp = ev.get("timestamp")
- if isinstance(timestamp, str):
- timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
- elif timestamp is None:
- timestamp = datetime.now(timezone.utc)
-
- evidence.append(
- ObservationEvidence(
- memory_id=ev.get("memory_id", ""),
- quote=ev.get("quote", ""),
- relevance=ev.get("relevance", ""),
- timestamp=timestamp,
- )
- )
- except Exception:
- pass
- else:
- # Legacy format: memory_ids without quotes
- memory_ids = data.get("memory_ids", []) or data.get("fact_ids", [])
- for mid in memory_ids:
- evidence.append(
- ObservationEvidence(
- memory_id=mid,
- quote="[migrated - quote not available]",
- relevance="[migrated]",
- timestamp=datetime.now(timezone.utc),
- )
- )
-
- # Parse created_at
- created_at = data.get("created_at")
- if isinstance(created_at, str):
- try:
- created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
- except ValueError:
- created_at = datetime.now(timezone.utc)
- elif created_at is None:
- created_at = datetime.now(timezone.utc)
-
- return Observation(
- title=title,
- content=content,
- evidence=evidence,
- created_at=created_at,
- )
diff --git a/hindsight-api/hindsight_api/engine/reflect/models.py b/hindsight-api/hindsight_api/engine/reflect/models.py
index 02a16868..71e6d044 100644
--- a/hindsight-api/hindsight_api/engine/reflect/models.py
+++ b/hindsight-api/hindsight_api/engine/reflect/models.py
@@ -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"
)
diff --git a/hindsight-api/hindsight_api/engine/reflect/observations.py b/hindsight-api/hindsight_api/engine/reflect/observations.py
index 67708b83..12035360 100644
--- a/hindsight-api/hindsight_api/engine/reflect/observations.py
+++ b/hindsight-api/hindsight_api/engine/reflect/observations.py
@@ -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
diff --git a/hindsight-api/hindsight_api/engine/reflect/prompts.py b/hindsight-api/hindsight_api/engine/reflect/prompts.py
index 8ff572d9..71099ed4 100644
--- a/hindsight-api/hindsight_api/engine/reflect/prompts.py
+++ b/hindsight-api/hindsight_api/engine/reflect/prompts.py
@@ -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)
diff --git a/hindsight-api/hindsight_api/engine/reflect/tools.py b/hindsight-api/hindsight_api/engine/reflect/tools.py
index c7fba3ff..ef74ba24 100644
--- a/hindsight-api/hindsight_api/engine/reflect/tools.py
+++ b/hindsight-api/hindsight_api/engine/reflect/tools.py
@@ -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,
diff --git a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py
index 060e3bdd..4b85e31e 100644
--- a/hindsight-api/hindsight_api/engine/reflect/tools_schema.py
+++ b/hindsight-api/hindsight_api/engine/reflect/tools_schema.py
@@ -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:
diff --git a/hindsight-api/hindsight_api/engine/response_models.py b/hindsight-api/hindsight_api/engine/response_models.py
index 42fa4cff..12c827ee 100644
--- a/hindsight-api/hindsight_api/engine/response_models.py
+++ b/hindsight-api/hindsight_api/engine/response_models.py
@@ -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.",
diff --git a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py
index 205f9791..b798ae67 100644
--- a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py
+++ b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py
@@ -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")
diff --git a/hindsight-api/hindsight_api/engine/retain/link_utils.py b/hindsight-api/hindsight_api/engine/retain/link_utils.py
index 19c5ccc9..79dce42e 100644
--- a/hindsight-api/hindsight_api/engine/retain/link_utils.py
+++ b/hindsight-api/hindsight_api/engine/retain/link_utils.py
@@ -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__}) "
diff --git a/hindsight-api/hindsight_api/engine/retain/types.py b/hindsight-api/hindsight_api/engine/retain/types.py
index 528a2d02..19157dee 100644
--- a/hindsight-api/hindsight_api/engine/retain/types.py
+++ b/hindsight-api/hindsight_api/engine/retain/types.py
@@ -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
diff --git a/hindsight-api/hindsight_api/engine/search/tracer.py b/hindsight-api/hindsight_api/engine/search/tracer.py
index c2d436b1..19247998 100644
--- a/hindsight-api/hindsight_api/engine/search/tracer.py
+++ b/hindsight-api/hindsight_api/engine/search/tracer.py
@@ -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,
diff --git a/hindsight-api/hindsight_api/extensions/__init__.py b/hindsight-api/hindsight_api/extensions/__init__.py
index 87a271e3..fd84a272 100644
--- a/hindsight-api/hindsight_api/extensions/__init__.py
+++ b/hindsight-api/hindsight_api/extensions/__init__.py
@@ -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",
diff --git a/hindsight-api/hindsight_api/extensions/operation_validator.py b/hindsight-api/hindsight_api/extensions/operation_validator.py
index 8dd88eaa..a1dec0eb 100644
--- a/hindsight-api/hindsight_api/extensions/operation_validator.py
+++ b/hindsight-api/hindsight_api/extensions/operation_validator.py
@@ -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
diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py
index 44a2872b..728dba9e 100644
--- a/hindsight-api/hindsight_api/main.py
+++ b/hindsight-api/hindsight_api/main.py
@@ -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,
diff --git a/hindsight-api/hindsight_api/worker/poller.py b/hindsight-api/hindsight_api/worker/poller.py
index e85f8a18..52cbecbc 100644
--- a/hindsight-api/hindsight_api/worker/poller.py
+++ b/hindsight-api/hindsight_api/worker/poller.py
@@ -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."""
diff --git a/hindsight-api/tests/test_consolidation.py b/hindsight-api/tests/test_consolidation.py
new file mode 100644
index 00000000..0535b0d4
--- /dev/null
+++ b/hindsight-api/tests/test_consolidation.py
@@ -0,0 +1,1587 @@
+"""Integration tests for the consolidation engine.
+
+These tests exercise the real consolidation implementation with actual database operations.
+Note: Consolidation runs automatically after retain via SyncTaskBackend in tests.
+"""
+
+import uuid
+from unittest.mock import patch
+
+import pytest
+
+from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
+from hindsight_api.engine.memory_engine import MemoryEngine
+from hindsight_api.engine.reflect.tools import (
+ tool_recall,
+ tool_search_mental_models,
+ tool_search_reflections,
+)
+
+
+@pytest.fixture(autouse=True)
+def enable_mental_models():
+ """Enable mental models for all tests in this module."""
+ from hindsight_api.config import get_config
+
+ config = get_config()
+ original_value = config.enable_mental_models
+ config.enable_mental_models = True
+ yield
+ config.enable_mental_models = original_value
+
+
+class TestConsolidationIntegration:
+ """Integration tests for consolidation with real database.
+
+ These tests verify that consolidation creates mental models correctly.
+ Since we use SyncTaskBackend in tests, consolidation runs synchronously
+ after retain completes.
+ """
+
+ @pytest.mark.asyncio
+ async def test_consolidation_creates_mental_model_after_retain(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation creates a mental model after retain."""
+ bank_id = f"test-consolidation-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically after
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Peter loves hiking in the mountains every weekend.",
+ request_context=request_context,
+ )
+
+ # Verify mental model exists in memory_units
+ # (consolidation already ran as part of retain via SyncTaskBackend)
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, proof_count, fact_type
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ # Mental model may or may not be created depending on LLM relevance judgment
+ # The important thing is no errors occurred
+ if mental_models:
+ mm = mental_models[0]
+ assert mm["proof_count"] >= 1
+ assert mm["fact_type"] == "mental_model"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_processes_multiple_memories(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation processes multiple related memories."""
+ bank_id = f"test-consolidation-multi-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain first memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Peter enjoys hiking on mountain trails.",
+ request_context=request_context,
+ )
+
+ # Retain a second related memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Peter went hiking in the Alps last weekend and loved it.",
+ request_context=request_context,
+ )
+
+ # Check mental models after both retains
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, proof_count
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY proof_count DESC
+ """,
+ bank_id,
+ )
+
+ # Should have at least one mental model
+ # If the LLM determined both memories support the same model,
+ # proof_count might be > 1
+ if mental_models:
+ # Verify structure is correct
+ assert all(mm["text"] for mm in mental_models)
+ assert all(mm["proof_count"] >= 1 for mm in mental_models)
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_no_new_memories(self, memory: MemoryEngine, request_context):
+ """Test that consolidation handles case when no new memories exist."""
+ bank_id = f"test-consolidation-empty-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Run consolidation without any memories
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ assert result["status"] == "no_new_memories"
+ assert result["memories_processed"] == 0
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_respects_last_consolidated_at(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation only processes memories created after last_consolidated_at."""
+ bank_id = f"test-consolidation-timestamp-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Alice works at a technology company.",
+ request_context=request_context,
+ )
+
+ # Run consolidation again - should have no new memories
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ # Should report no new memories since consolidation already ran
+ assert result["status"] == "no_new_memories"
+ assert result["memories_processed"] == 0
+
+ # Add a new memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Alice got promoted to senior engineer.",
+ request_context=request_context,
+ )
+
+ # Run consolidation again - should also have no new memories
+ # because consolidation ran automatically after the second retain
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ assert result["status"] == "no_new_memories"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_copies_entity_links(self, memory: MemoryEngine, request_context):
+ """Test that mental models inherit entity links from source memories."""
+ bank_id = f"test-consolidation-entities-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory with a named entity
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="John Smith is the CEO of Acme Corporation.",
+ request_context=request_context,
+ )
+
+ # Check mental model and its entity links
+ async with memory._pool.acquire() as conn:
+ mental_model = await conn.fetchrow(
+ """
+ SELECT id
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ LIMIT 1
+ """,
+ bank_id,
+ )
+
+ if mental_model:
+ # Check if entity links were copied
+ entity_links = await conn.fetch(
+ """
+ SELECT entity_id
+ FROM unit_entities
+ WHERE unit_id = $1
+ """,
+ mental_model["id"],
+ )
+ # Mental model should have inherited entity links from source memory
+ # (may be empty if no entities were extracted, which is fine)
+ assert entity_links is not None
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_mental_models_included_in_recall(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models created by consolidation are returned in recall."""
+ bank_id = f"test-consolidation-recall-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah is an expert Python programmer who specializes in machine learning.",
+ request_context=request_context,
+ )
+
+ # Recall with mental models included
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does Sarah do?",
+ fact_type=["world", "experience", "mental_model"],
+ request_context=request_context,
+ )
+
+ # Mental models come back as regular results with fact_type='mental_model'
+ assert hasattr(recall_result, "results")
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_creates_memory_links(self, memory: MemoryEngine, request_context):
+ """Test that mental models get bidirectional links to their source memories."""
+ bank_id = f"test-consolidation-links-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Maria works as a software engineer at Microsoft.",
+ request_context=request_context,
+ )
+
+ # Check memory_links between mental model and source memory
+ async with memory._pool.acquire() as conn:
+ mental_model = await conn.fetchrow(
+ """
+ SELECT id, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ LIMIT 1
+ """,
+ bank_id,
+ )
+
+ if mental_model and mental_model["source_memory_ids"]:
+ source_memory_id = mental_model["source_memory_ids"][0]
+
+ # Check that bidirectional links exist
+ link_from_memory = await conn.fetchrow(
+ """
+ SELECT * FROM memory_links
+ WHERE from_unit_id = $1 AND to_unit_id = $2
+ """,
+ source_memory_id,
+ mental_model["id"],
+ )
+ link_to_memory = await conn.fetchrow(
+ """
+ SELECT * FROM memory_links
+ WHERE from_unit_id = $1 AND to_unit_id = $2
+ """,
+ mental_model["id"],
+ source_memory_id,
+ )
+
+ # Both directions should have links
+ assert link_from_memory is not None, "Expected link from source memory to mental model"
+ assert link_to_memory is not None, "Expected link from mental model to source memory"
+ assert link_from_memory["link_type"] == "semantic"
+ assert link_to_memory["link_type"] == "semantic"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_merges_only_redundant_facts(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation only merges truly redundant facts.
+
+ Mental models should be fine-grained (almost 1:1 with memories).
+ Only merge when facts are truly redundant (saying the same thing differently)
+ or when one directly updates another (e.g., location change).
+
+ Given:
+ - "Nicolò lives in Italy"
+ - "Nicolò moved to the US recently" (updates the living location)
+
+ The second fact should UPDATE the first, not create a separate model.
+ But unrelated facts like "Nicolò works at Vectorize" should stay separate.
+ """
+ bank_id = f"test-consolidation-merge-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory about living location
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò lives in Italy.",
+ request_context=request_context,
+ )
+
+ # Retain an unrelated memory (different topic - should NOT merge)
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò works at Vectorize as an engineer.",
+ request_context=request_context,
+ )
+
+ # Check mental models - should have 2 separate models
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Add a memory that UPDATES the living location (should merge with first)
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò recently moved to the United States.",
+ request_context=request_context,
+ )
+
+ # Check mental models after consolidation
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, proof_count, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Key assertions:
+ # 1. Consolidation ran without errors
+ # 2. Mental models exist
+ assert len(mental_models) >= 1, "Expected at least one mental model"
+
+ # The work-related fact should remain separate from location facts
+ # (LLM behavior varies, so we check structure rather than exact count)
+ for mm in mental_models:
+ assert mm["text"], "Mental model should have text"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_keeps_different_people_separate(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation NEVER merges facts about different people.
+
+ Each person's facts should stay in separate mental models.
+ """
+ bank_id = f"test-consolidation-people-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Add facts about different people
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="John lives in New York.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Mary lives in Boston.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Bob works at Google.",
+ request_context=request_context,
+ )
+
+ # Check mental models - should have separate models for each person
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Should have multiple mental models (one per person/fact)
+ # Not everything merged into one
+ assert len(mental_models) >= 2, (
+ f"Expected multiple mental models for different people, got {len(mental_models)}"
+ )
+
+ # No single mental model should mention multiple different people
+ # (This is a structural check - each model should be focused)
+ for mm in mental_models:
+ text = mm["text"].lower()
+ people_mentioned = sum([
+ 1 for name in ["john", "mary", "bob"]
+ if name in text
+ ])
+ assert people_mentioned <= 1, (
+ f"Mental model should not merge different people: {mm['text']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_merges_contradictions(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that contradictions about the same topic are merged with history.
+
+ When facts contradict each other (same person, same topic, opposite info),
+ they should be merged into ONE mental model that captures the change.
+
+ Example:
+ - "Nicolò loves pizza"
+ - "Nicolò hates pizza"
+ → Should become: "Nicolò used to love pizza but now hates it" (or similar)
+ """
+ bank_id = f"test-consolidation-contradict-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Add initial fact
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò loves pizza.",
+ request_context=request_context,
+ )
+
+ # Check we have one mental model
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+
+ # Add contradicting fact (same person, same topic, opposite sentiment)
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Nicolò hates pizza.",
+ request_context=request_context,
+ )
+
+ # Check mental models after consolidation
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, source_memory_ids, history
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Key assertion: Should NOT have more mental models than before
+ # The contradiction should be merged, not create a new model
+ assert len(mental_models) <= count_before, (
+ f"Contradiction should merge, not create new model. "
+ f"Before: {count_before}, After: {len(mental_models)}. "
+ f"Models: {[mm['text'] for mm in mental_models]}"
+ )
+
+ # The merged model should capture both sentiments or the change
+ if mental_models:
+ merged_text = mental_models[0]["text"].lower()
+ # Should mention the change or both states
+ has_history = (
+ ("used to" in merged_text or "now" in merged_text or "but" in merged_text)
+ or ("love" in merged_text and "hate" in merged_text)
+ or (len(mental_models[0]["source_memory_ids"] or []) > 1)
+ )
+ assert has_history, (
+ f"Merged model should capture the change. Got: {mental_models[0]['text']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestConsolidationDisabled:
+ """Test consolidation when disabled via config."""
+
+ @pytest.mark.asyncio
+ async def test_consolidation_returns_disabled_status(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that consolidation returns disabled status when enable_mental_models is False."""
+ from unittest.mock import patch
+
+ bank_id = f"test-consolidation-disabled-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Disable mental models via config
+ with patch("hindsight_api.config.get_config") as mock_config:
+ mock_config.return_value.enable_mental_models = False
+
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ assert result["status"] == "disabled"
+ assert result["bank_id"] == bank_id
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestRecallMentalModelFactType:
+ """Test recall with mental_model as a fact type."""
+
+ @pytest.mark.asyncio
+ async def test_recall_with_mental_model_fact_type(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental_model can be used as a fact type in recall.
+
+ When mental_model is in the types list, the recall should:
+ 1. Return mental models in the results field with fact_type='mental_model'
+ 2. Not raise validation errors for None context fields
+ """
+ bank_id = f"test-recall-mm-type-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Alex is a data scientist who specializes in deep learning and neural networks.",
+ request_context=request_context,
+ )
+
+ # Recall with mental_model in types
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does Alex do?",
+ fact_type=["mental_model"],
+ request_context=request_context,
+ )
+
+ # Mental models come back as regular results with fact_type='mental_model'
+ assert recall_result is not None
+ assert recall_result.results is not None
+ # Check that results include mental models
+ if recall_result.results:
+ for mm in recall_result.results:
+ assert mm.id is not None
+ assert mm.text is not None
+ assert mm.fact_type == "mental_model"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_recall_with_mixed_fact_types_including_mental_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test recall with mental_model alongside world and experience types."""
+ bank_id = f"test-recall-mixed-types-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain memories - consolidation runs automatically
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Jordan is a professional musician who plays guitar in a rock band.",
+ request_context=request_context,
+ )
+
+ # Recall with all types including mental_model
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does Jordan do?",
+ fact_type=["world", "experience", "mental_model"],
+ enable_trace=True,
+ request_context=request_context,
+ )
+
+ # Should return results without errors
+ assert recall_result is not None
+ # Should have results from world/experience facts
+ assert recall_result.results is not None
+ # Mental models come back as regular results with fact_type='mental_model'
+ # when mental_model is included in fact_type parameter
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_recall_mental_model_only_with_trace(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that recall with only mental_model type and trace enabled works.
+
+ This specifically tests the tracer handling of mental models with None context.
+ """
+ bank_id = f"test-recall-mm-trace-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain memory - consolidation creates mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Chris works as a product manager at a startup focused on AI applications.",
+ request_context=request_context,
+ )
+
+ # Recall with mental_model only and trace enabled
+ # This tests the fix for the None context validation error
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="Where does Chris work?",
+ fact_type=["mental_model"],
+ enable_trace=True,
+ request_context=request_context,
+ )
+
+ # Should complete without validation errors
+ assert recall_result is not None
+ # Trace should be populated
+ assert recall_result.trace is not None or recall_result.mental_models is not None
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestConsolidationTagRouting:
+ """Test tag routing during consolidation.
+
+ Tag routing rules:
+ - Same scope (tags match): update existing mental model
+ - Fact scoped, model global (untagged): update global (it absorbs all)
+ - Different scopes (non-overlapping tags): create untagged cross-scope insight
+ - No match: create with fact's tags
+ """
+
+ async def _retain_with_tags(
+ self,
+ memory: MemoryEngine,
+ bank_id: str,
+ content: str,
+ tags: list[str],
+ request_context,
+ ):
+ """Helper to retain content with tags using retain_batch_async."""
+ await memory.retain_batch_async(
+ bank_id=bank_id,
+ contents=[{"content": content}],
+ document_tags=tags,
+ request_context=request_context,
+ )
+
+ @pytest.mark.asyncio
+ async def test_same_scope_updates_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that a tagged fact updates a mental model with the same tags.
+
+ Given:
+ - Memory with tags=['alice']: "Alice likes coffee"
+ - New memory with tags=['alice']: "Alice prefers espresso"
+
+ Expected:
+ - Mental model with tags=['alice'] is updated to reflect both facts
+ """
+ bank_id = f"test-tag-same-scope-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain first memory with tags
+ await self._retain_with_tags(
+ memory, bank_id, "Alice likes coffee.", ["alice"], request_context
+ )
+
+ # Check mental model has correct tags
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+ if mm_before:
+ assert "alice" in (mm_before[0]["tags"] or []), (
+ f"Expected mental model to have 'alice' tag, got: {mm_before[0]['tags']}"
+ )
+
+ # Retain related memory with same tags
+ await self._retain_with_tags(
+ memory, bank_id, "Alice prefers espresso over regular coffee.", ["alice"], request_context
+ )
+
+ # Check mental models - should NOT have increased (same scope update)
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ # Count of mental models should stay same or decrease (merge)
+ assert len(mm_after) <= count_before + 1, (
+ f"Same scope fact should update existing model, not create new. "
+ f"Before: {count_before}, After: {len(mm_after)}"
+ )
+
+ # The model(s) should still have alice tag
+ for mm in mm_after:
+ if "coffee" in mm["text"].lower() or "espresso" in mm["text"].lower():
+ assert "alice" in (mm["tags"] or []), (
+ f"Updated model should keep 'alice' tag: {mm['text']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_scoped_fact_updates_global_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that a scoped fact can update an untagged (global) mental model.
+
+ Given:
+ - Untagged memory: "Pizza is a popular food"
+ - New memory with tags=['history']: "Pizza originated in Naples"
+
+ Expected:
+ - The global mental model is updated (global absorbs all scopes)
+ """
+ bank_id = f"test-tag-global-absorb-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain untagged (global) memory
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Pizza is a popular Italian food.",
+ request_context=request_context,
+ )
+
+ # Check untagged mental model exists
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+ # Should be untagged or have empty tags
+ if mm_before:
+ assert not mm_before[0]["tags"] or len(mm_before[0]["tags"]) == 0, (
+ f"Expected untagged model, got: {mm_before[0]['tags']}"
+ )
+
+ # Retain scoped memory that relates to the global topic
+ await self._retain_with_tags(
+ memory, bank_id, "Pizza originated in Naples.", ["history"], request_context
+ )
+
+ # Check - global model should be updated OR new scoped model created
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # At least one model should exist
+ assert len(mm_after) >= 1, "Expected at least one mental model"
+
+ # Check that global model was updated (source_memory_ids increased)
+ # OR new model was created with appropriate tags
+ global_models = [m for m in mm_after if not m["tags"] or len(m["tags"]) == 0]
+ scoped_models = [m for m in mm_after if m["tags"] and len(m["tags"]) > 0]
+
+ # Either global was updated or scoped was created
+ assert len(global_models) >= 1 or len(scoped_models) >= 1, (
+ "Expected either global model update or scoped model creation"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_cross_scope_creates_untagged(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that cross-scope related facts create untagged (global) insights.
+
+ Given:
+ - Memory with tags=['alice']: "Alice recommends the Thai restaurant"
+ - Memory with tags=['bob']: "Bob tried the Thai restaurant Alice mentioned"
+
+ Expected:
+ - A new untagged mental model capturing the cross-scope insight
+ """
+ bank_id = f"test-tag-cross-scope-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain Alice's scoped memory
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice recommends the Thai restaurant on Main Street.",
+ ["alice"], request_context
+ )
+
+ # Check Alice's mental model exists with correct tags
+ async with memory._pool.acquire() as conn:
+ mm_alice = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_alice)
+
+ # Retain Bob's memory that relates to Alice's topic (cross-scope)
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Bob visited the Thai restaurant on Main Street and loved it.",
+ ["bob"], request_context
+ )
+
+ # Check mental models
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Should have multiple models (alice's, bob's, potentially global)
+ assert len(mm_after) >= 2, (
+ f"Expected at least 2 mental models for different scopes, got {len(mm_after)}"
+ )
+
+ # Check we have models with different tags (alice, bob, or untagged)
+ tag_sets = [frozenset(m["tags"] or []) for m in mm_after]
+
+ # Should NOT merge alice and bob into same model
+ models_with_both = [
+ m for m in mm_after
+ if m["tags"] and "alice" in m["tags"] and "bob" in m["tags"]
+ ]
+ assert len(models_with_both) == 0, (
+ "Should not merge different scopes into one model with both tags"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_no_match_creates_with_fact_tags(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that a new fact with no matching models creates a model with fact's tags.
+
+ Given:
+ - Empty bank
+ - Memory with tags=['project_x']: "Project X uses Python"
+
+ Expected:
+ - Mental model created with tags=['project_x']
+ """
+ bank_id = f"test-tag-new-scoped-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain tagged memory (no existing mental models)
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Project X uses Python for its backend services.",
+ ["project_x"], request_context
+ )
+
+ # Check mental model was created with correct tags
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, tags FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ assert len(mental_models) >= 1, "Expected mental model to be created"
+
+ # The model should have the fact's tags
+ mm = mental_models[0]
+ assert mm["tags"] is not None, "Mental model should have tags"
+ assert "project_x" in mm["tags"], (
+ f"Mental model should have 'project_x' tag, got: {mm['tags']}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_untagged_fact_can_update_scoped_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that an untagged fact can update a scoped mental model.
+
+ Given:
+ - Memory with tags=['alice']: "Alice works on machine learning"
+ - Untagged memory: "Machine learning involves neural networks"
+
+ Expected:
+ - The scoped model may be updated with the global insight
+ - OR a global model is created
+ """
+ bank_id = f"test-tag-untagged-update-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain scoped memory
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice works on machine learning projects.",
+ ["alice"], request_context
+ )
+
+ # Retain untagged memory on same topic
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Machine learning involves training neural networks.",
+ request_context=request_context,
+ )
+
+ # Check mental models
+ async with memory._pool.acquire() as conn:
+ mental_models = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Should have at least one model
+ assert len(mental_models) >= 1, "Expected at least one mental model"
+
+ # Either alice's model was updated OR a global model was created
+ # This is valid LLM behavior - just verify no errors and structure is correct
+ for mm in mental_models:
+ assert mm["text"], "Mental model should have text"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_tag_filtering_in_recall(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models respect tag filtering during recall.
+
+ Mental models should be filtered by tags just like memories.
+ """
+ bank_id = f"test-tag-recall-filter-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain memories with different tags
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice works as a software engineer.",
+ ["alice"], request_context
+ )
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Bob works as a product manager.",
+ ["bob"], request_context
+ )
+
+ # Recall with alice tag only
+ recall_result = await memory.recall_async(
+ bank_id=bank_id,
+ query="What does everyone do for work?",
+ tags=["alice"],
+ tags_match="any_strict", # Only alice's data
+ fact_type=["world", "experience", "mental_model"],
+ request_context=request_context,
+ )
+
+ # Results should only include alice-tagged content
+ # Mental models are now regular results with fact_type='mental_model'
+ mental_models = [r for r in recall_result.results if r.fact_type == "mental_model"]
+ for mm in mental_models:
+ # Mental model should be alice-scoped or global (untagged)
+ # Not bob-scoped
+ mm_tags = mm.tags or []
+ assert "bob" not in mm_tags, (
+ f"Recall with tags=['alice'] should not return bob's models: {mm.text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_multiple_actions_from_single_fact(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that one fact can trigger multiple consolidation actions.
+
+ Given:
+ - Global model: "Coffee is a popular beverage"
+ - Alice's model: "Alice drinks coffee every morning"
+ - New fact with tags=['alice']: "Alice switched to decaf coffee"
+
+ Expected:
+ - Update Alice's scoped model (same scope)
+ - Potentially update global model too (global absorbs all)
+ """
+ bank_id = f"test-tag-multi-action-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create global model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Coffee is a popular beverage worldwide.",
+ request_context=request_context,
+ )
+
+ # Create alice's scoped model
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice drinks coffee every morning.",
+ ["alice"], request_context
+ )
+
+ # Check models before
+ async with memory._pool.acquire() as conn:
+ mm_before = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ count_before = len(mm_before)
+
+ # Add fact that could relate to both
+ await self._retain_with_tags(
+ memory, bank_id,
+ "Alice switched to decaf coffee for health reasons.",
+ ["alice"], request_context
+ )
+
+ # Check models after
+ async with memory._pool.acquire() as conn:
+ mm_after = await conn.fetch(
+ """
+ SELECT id, text, tags, source_memory_ids, proof_count FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ ORDER BY created_at
+ """,
+ bank_id,
+ )
+
+ # Should have processed without errors
+ assert len(mm_after) >= 1, "Expected at least one mental model"
+
+ # Check that consolidation worked (either updates or maintains structure)
+ # The key is no errors and proper tag handling
+ for mm in mm_after:
+ assert mm["text"], "Mental model should have text"
+ # Tags should be consistent (not mixing alice and bob, etc.)
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_consolidation_inherits_dates_from_source_memory(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models inherit occurred_start and event_date from source memories.
+
+ When a mental model is created, it should inherit the temporal information
+ from the source memory that triggered its creation, not use the current time.
+ """
+ from datetime import datetime, timezone
+
+ bank_id = f"test-consolidation-dates-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Create a specific date in the past for testing
+ past_date = datetime(2023, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
+
+ # First, create a memory unit directly with a specific date
+ async with memory._pool.acquire() as conn:
+ memory_id = uuid.uuid4()
+ await conn.execute(
+ """
+ INSERT INTO memory_units (
+ id, bank_id, text, fact_type, occurred_start, event_date, created_at
+ )
+ VALUES ($1, $2, $3, 'experience', $4, $4, now())
+ """,
+ memory_id,
+ bank_id,
+ "Sarah went to Paris for vacation and loved the Eiffel Tower.",
+ past_date,
+ )
+
+ # Run consolidation manually
+ from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
+
+ result = await run_consolidation_job(
+ memory_engine=memory,
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+
+ # Verify consolidation processed the memory
+ assert result["status"] == "completed"
+ assert result["memories_processed"] >= 1
+
+ # Check that mental model inherited the date from source memory
+ async with memory._pool.acquire() as conn:
+ mental_model = await conn.fetchrow(
+ """
+ SELECT id, text, occurred_start, event_date, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ LIMIT 1
+ """,
+ bank_id,
+ )
+
+ if mental_model:
+ # Mental model should have inherited the date from the source memory
+ mm_occurred = mental_model["occurred_start"]
+ mm_event_date = mental_model["event_date"]
+
+ # Dates should match the source memory's date (2023-06-15), not today
+ assert mm_occurred is not None, "Mental model should have occurred_start"
+ assert mm_event_date is not None, "Mental model should have event_date"
+
+ # The date should be from 2023, not today
+ assert mm_occurred.year == 2023, (
+ f"Expected occurred_start year 2023, got {mm_occurred.year}. "
+ "Mental model should inherit date from source memory."
+ )
+ assert mm_occurred.month == 6, f"Expected month 6, got {mm_occurred.month}"
+ assert mm_occurred.day == 15, f"Expected day 15, got {mm_occurred.day}"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestMentalModelDrillDown:
+ """Test that reflect agent can drill down from mental models to source memories."""
+
+ @pytest.mark.asyncio
+ async def test_search_mental_models_returns_source_memory_ids(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that search_mental_models returns source_memory_ids for drill-down.
+
+ This verifies the agent can:
+ 1. Find a mental model
+ 2. Access its source_memory_ids
+ 3. Use those IDs to expand/recall for more details
+ """
+ from hindsight_api.engine.reflect.tools import tool_search_mental_models, tool_expand
+
+ bank_id = f"test-mm-drilldown-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Store memories with specific details that get summarized in mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah works at TechCorp as a senior software engineer since March 2020.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah's employee ID at TechCorp is EMP-12345.",
+ request_context=request_context,
+ )
+
+ # Search for mental models
+ result = await tool_search_mental_models(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="Sarah TechCorp",
+ request_context=request_context,
+ )
+
+ assert result["count"] > 0, "Expected at least one mental model"
+
+ # Verify source_memory_ids and proof_count are present
+ mm = result["mental_models"][0]
+ assert "source_memory_ids" in mm, "Mental model should have source_memory_ids"
+ assert "proof_count" in mm, "Mental model should have proof_count"
+ assert mm["proof_count"] >= 1, "proof_count should be at least 1"
+
+ # If source_memory_ids exist, verify they can be used with expand
+ if mm["source_memory_ids"]:
+ assert len(mm["source_memory_ids"]) >= 1, "Should have at least one source memory"
+
+ # Use expand tool to get source memory details
+ async with memory._pool.acquire() as conn:
+ expand_result = await tool_expand(
+ conn=conn,
+ bank_id=bank_id,
+ memory_ids=mm["source_memory_ids"][:2], # Take first 2
+ depth="chunk",
+ )
+
+ assert "results" in expand_result
+ assert len(expand_result["results"]) > 0, "Expand should return source memories"
+
+ # Verify we get the original detailed information
+ all_text = " ".join(r["memory"]["text"] for r in expand_result["results"] if "memory" in r)
+ # The expanded memories should contain details not necessarily in the mental model
+ assert "Sarah" in all_text or "TechCorp" in all_text, (
+ f"Expanded memories should contain source details. Got: {all_text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_mental_model_source_ids_match_contributing_memories(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that source_memory_ids actually point to the memories that built the mental model."""
+ bank_id = f"test-mm-source-ids-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Store two related memories
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Project Phoenix was started by the engineering team in January 2024.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Project Phoenix achieved 99.9% uptime in its first quarter.",
+ request_context=request_context,
+ )
+
+ # Get the mental model with source_memory_ids
+ async with memory._pool.acquire() as conn:
+ mm_rows = await conn.fetch(
+ """
+ SELECT id, text, proof_count, source_memory_ids
+ FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+
+ if mm_rows:
+ mm = mm_rows[0]
+ source_ids = mm["source_memory_ids"] or []
+
+ # Verify source_memory_ids point to actual memories
+ if source_ids:
+ async with memory._pool.acquire() as conn:
+ source_memories = await conn.fetch(
+ """
+ SELECT id, text FROM memory_units
+ WHERE id = ANY($1) AND fact_type IN ('world', 'experience')
+ """,
+ source_ids,
+ )
+
+ # Should have found the source memories
+ assert len(source_memories) >= 1, (
+ f"source_memory_ids should point to valid memories. "
+ f"IDs: {source_ids}, Found: {len(source_memories)}"
+ )
+
+ # The source memories should contain our original content
+ source_texts = [m["text"].lower() for m in source_memories]
+ has_phoenix = any("phoenix" in t for t in source_texts)
+ assert has_phoenix, f"Source memories should contain original content. Got: {source_texts}"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+
+class TestHierarchicalRetrieval:
+ """Test the reflect agent's hierarchical retrieval tools.
+
+ The hierarchy is:
+ 1. search_reflections - User-curated summaries (highest quality)
+ 2. search_mental_models - Auto-consolidated knowledge
+ 3. recall - Raw facts as ground truth
+
+ When a reflection matches the query, it should be used first.
+ """
+
+ @pytest.mark.asyncio
+ async def test_reflection_takes_priority_over_mental_model(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that reflections are found and would be used before mental models.
+
+ Given:
+ - A memory about "John's favorite color is blue"
+ - A mental model created from that memory (via consolidation)
+ - A reflection manually created about John
+
+ When searching, the reflection should be found first.
+ """
+ bank_id = f"test-hierarchy-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation creates a mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="John's favorite color is blue and he likes painting.",
+ request_context=request_context,
+ )
+
+ # Verify mental model was created
+ async with memory._pool.acquire() as conn:
+ mm_count = await conn.fetchval(
+ """
+ SELECT COUNT(*) FROM memory_units
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ assert mm_count >= 1, "Consolidation should have created a mental model"
+
+ # Create a reflection about John (higher quality, user-curated)
+ reflection = await memory.create_reflection(
+ bank_id=bank_id,
+ name="John's Preferences",
+ source_query="What are John's preferences?",
+ content="John is an artist who loves the color blue. He has been painting for 10 years and prefers watercolors.",
+ tags=[],
+ request_context=request_context,
+ )
+ assert reflection["id"] is not None
+
+ # Search reflections - should find our reflection
+ async with memory._pool.acquire() as conn:
+ query_embedding = memory.embeddings.encode(["What does John like?"])[0]
+ reflection_result = await tool_search_reflections(
+ conn=conn,
+ bank_id=bank_id,
+ query="What does John like?",
+ query_embedding=query_embedding,
+ max_results=5,
+ )
+
+ # Reflection should be found
+ assert reflection_result["count"] >= 1, "Reflection should be found"
+ found_reflection = reflection_result["reflections"][0]
+ assert "John" in found_reflection["content"] or "blue" in found_reflection["content"]
+
+ # Search mental models - should also find something
+ mm_result = await tool_search_mental_models(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="What does John like?",
+ request_context=request_context,
+ max_tokens=5000,
+ )
+ assert mm_result["count"] >= 1, "Mental model should also be found"
+
+ # Verify the reflection has higher quality content (more detail)
+ reflection_content = found_reflection["content"]
+ mm_content = mm_result["mental_models"][0]["text"]
+
+ # The reflection should contain the richer, user-curated content
+ assert "watercolors" in reflection_content or "10 years" in reflection_content, (
+ f"Reflection should have the rich user-curated content. Got: {reflection_content}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_fallback_to_mental_model_when_no_reflection(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that mental models are used when no reflection matches.
+
+ Given:
+ - A memory about "Sarah works at Google"
+ - A mental model created from that memory
+ - NO reflection about Sarah
+
+ When searching, mental models should provide the information.
+ """
+ bank_id = f"test-hierarchy-fallback-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain a memory - consolidation creates a mental model
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="Sarah works at Google as a software engineer.",
+ request_context=request_context,
+ )
+
+ # Search reflections - should find nothing
+ async with memory._pool.acquire() as conn:
+ query_embedding = memory.embeddings.encode(["Where does Sarah work?"])[0]
+ reflection_result = await tool_search_reflections(
+ conn=conn,
+ bank_id=bank_id,
+ query="Where does Sarah work?",
+ query_embedding=query_embedding,
+ max_results=5,
+ )
+
+ # No reflections exist
+ assert reflection_result["count"] == 0, "No reflections should exist"
+
+ # Search mental models - should find the consolidated knowledge
+ mm_result = await tool_search_mental_models(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="Where does Sarah work?",
+ request_context=request_context,
+ max_tokens=5000,
+ )
+
+ # Mental model should be found
+ assert mm_result["count"] >= 1, "Mental model should be found when no reflection exists"
+ mm_text = mm_result["mental_models"][0]["text"].lower()
+ assert "sarah" in mm_text or "google" in mm_text, (
+ f"Mental model should contain info about Sarah. Got: {mm_text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ @pytest.mark.asyncio
+ async def test_fallback_to_recall_for_fresh_data(
+ self, memory: MemoryEngine, request_context
+ ):
+ """Test that recall provides raw facts when needed for verification.
+
+ This tests the drill-down capability: when mental models are stale or
+ need verification, recall provides the original source facts.
+ """
+ bank_id = f"test-hierarchy-recall-{uuid.uuid4().hex[:8]}"
+
+ # Create the bank
+ await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
+
+ # Retain some specific memories
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="The quarterly revenue was $1.5M in Q3 2024.",
+ request_context=request_context,
+ )
+ await memory.retain_async(
+ bank_id=bank_id,
+ content="The quarterly revenue was $2.1M in Q4 2024.",
+ request_context=request_context,
+ )
+
+ # Use recall to get the raw facts
+ recall_result = await tool_recall(
+ memory_engine=memory,
+ bank_id=bank_id,
+ query="What was the quarterly revenue?",
+ request_context=request_context,
+ max_tokens=2048,
+ max_results=10,
+ )
+
+ # Should have raw facts with specific numbers
+ assert recall_result["count"] >= 1, "Recall should find the raw facts"
+
+ # Check that we get the actual numbers from the original memories
+ all_memory_text = " ".join([m["text"] for m in recall_result["memories"]])
+ assert "$1.5M" in all_memory_text or "$2.1M" in all_memory_text, (
+ f"Recall should return raw facts with specific data. Got: {all_memory_text}"
+ )
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
diff --git a/hindsight-api/tests/test_emergent_filtering.py b/hindsight-api/tests/test_emergent_filtering.py
deleted file mode 100644
index a26d7723..00000000
--- a/hindsight-api/tests/test_emergent_filtering.py
+++ /dev/null
@@ -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"}
diff --git a/hindsight-api/tests/test_extensions.py b/hindsight-api/tests/test_extensions.py
index 1b7bf103..88a2f5d3 100644
--- a/hindsight-api/tests/test_extensions.py
+++ b/hindsight-api/tests/test_extensions.py
@@ -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."""
diff --git a/hindsight-api/tests/test_llm_tools.py b/hindsight-api/tests/test_llm_tools.py
index 6947eb2e..400c52a4 100644
--- a/hindsight-api/tests/test_llm_tools.py
+++ b/hindsight-api/tests/test_llm_tools.py
@@ -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:
diff --git a/hindsight-api/tests/test_main_module.py b/hindsight-api/tests/test_main_module.py
index 7faccae7..0923fad0 100644
--- a/hindsight-api/tests/test_main_module.py
+++ b/hindsight-api/tests/test_main_module.py
@@ -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()
diff --git a/hindsight-api/tests/test_mental_models.py b/hindsight-api/tests/test_mental_models.py
index 14f95240..c391c34e 100644
--- a/hindsight-api/tests/test_mental_models.py
+++ b/hindsight-api/tests/test_mental_models.py
@@ -1,4 +1,8 @@
-"""Tests for mental model functionality (v4 system)."""
+"""Tests for directive functionality.
+
+Directives are hard rules injected into prompts.
+They are stored in the 'directives' table.
+"""
import uuid
@@ -8,20 +12,16 @@ from hindsight_api.engine.memory_engine import MemoryEngine
@pytest.fixture
-async def memory_with_mission(memory: MemoryEngine, request_context):
- """Memory engine with a bank that has a mission set.
+async def memory_with_bank(memory: MemoryEngine, request_context):
+ """Memory engine with a bank that has some data.
Uses a unique bank_id to avoid conflicts between parallel tests.
"""
# Use unique bank_id to avoid conflicts between parallel tests
- bank_id = f"test-mental-models-{uuid.uuid4().hex[:8]}"
+ bank_id = f"test-directives-{uuid.uuid4().hex[:8]}"
- # Set up the bank with a mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Be a PM for the engineering team",
- request_context=request_context,
- )
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
# Add some test data
await memory.retain_batch_async(
@@ -30,8 +30,6 @@ async def memory_with_mission(memory: MemoryEngine, request_context):
{"content": "The team has daily standups at 9am where everyone shares their progress."},
{"content": "Alice is the frontend engineer and specializes in React."},
{"content": "Bob is the backend engineer and owns the API services."},
- {"content": "Sprint retrospectives happen every two weeks to discuss improvements."},
- {"content": "John is the tech lead and makes final decisions on architecture."},
],
request_context=request_context,
)
@@ -70,340 +68,257 @@ class TestBankMission:
await memory.delete_bank(bank_id, request_context=request_context)
-class TestRefreshMentalModels:
- """Test the main refresh_mental_models flow."""
+class TestDirectives:
+ """Test directive functionality."""
- async def test_refresh_creates_structural_models(self, memory_with_mission, request_context):
- """Test that refresh creates structural models from the mission."""
- memory, bank_id = memory_with_mission
-
- # Refresh mental models (async - returns operation_id)
- result = await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Check that we got an operation ID back
- assert "operation_id" in result
- assert result["status"] == "queued"
-
- # Wait for background task to complete
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert len(models) > 0
-
- # Check that structural models were created
- structural_models = [m for m in models if m["subtype"] == "structural"]
- assert len(structural_models) > 0
-
- # Check that models have the expected structure
- for model in models:
- assert "id" in model
- assert "name" in model
- assert "description" in model
- assert model["subtype"] in ["structural", "emergent"]
-
- async def test_refresh_without_mission_fails(self, memory: MemoryEngine, request_context):
- """Test that refresh fails when no mission is set."""
- bank_id = f"test-no-mission-refresh-{uuid.uuid4().hex[:8]}"
-
- # Add some data but don't set a mission
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the frontend engineer."},
- {"content": "Bob is the backend engineer."},
- ],
- request_context=request_context,
- )
-
- # Wait for any background tasks from retain to complete
- await memory.wait_for_background_tasks()
-
- # Refresh mental models should fail without a mission
- with pytest.raises(ValueError) as exc_info:
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert "no mission is set" in str(exc_info.value).lower()
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
-
-class TestMentalModelCRUD:
- """Test basic CRUD operations for mental models."""
-
- async def test_list_mental_models(self, memory_with_mission, request_context):
- """Test listing mental models."""
- memory, bank_id = memory_with_mission
-
- # Refresh to create models (async)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # List all models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert len(models) > 0
-
- # Test filtering by subtype
- structural_models = await memory.list_mental_models(
- bank_id=bank_id,
- subtype="structural",
- request_context=request_context,
- )
-
- assert all(m["subtype"] == "structural" for m in structural_models)
-
- async def test_get_mental_model(self, memory_with_mission, request_context):
- """Test getting a mental model by ID."""
- memory, bank_id = memory_with_mission
-
- # Refresh to create models (async)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Get one by ID
- model_id = models[0]["id"]
- model = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- assert model is not None
- assert model["id"] == model_id
-
- # Test non-existent
- not_found = await memory.get_mental_model(
- bank_id=bank_id,
- model_id="non-existent",
- request_context=request_context,
- )
- assert not_found is None
-
- async def test_delete_mental_model(self, memory_with_mission, request_context):
- """Test deleting a mental model."""
- memory, bank_id = memory_with_mission
-
- # Refresh to create models (async)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Delete one
- model_id = models[0]["id"]
- deleted = await memory.delete_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert deleted is True
-
- # Verify it's gone
- model = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert model is None
-
- # Delete non-existent returns False
- deleted_again = await memory.delete_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert deleted_again is False
-
- async def test_create_pinned_mental_model(self, memory: MemoryEngine, request_context):
- """Test creating a pinned mental model."""
- bank_id = f"test-pinned-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists by getting its profile (auto-creates if needed)
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a pinned mental model
- model = await memory.create_mental_model(
- bank_id=bank_id,
- name="Product Roadmap",
- description="Key product priorities and upcoming features",
- tags=["project-x"],
- request_context=request_context,
- )
-
- assert model["name"] == "Product Roadmap"
- assert model["description"] == "Key product priorities and upcoming features"
- assert model["subtype"] == "pinned"
- assert model["tags"] == ["project-x"]
- assert model["id"] == "pinned-product-roadmap"
-
- # Verify it can be retrieved
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=model["id"],
- request_context=request_context,
- )
- assert retrieved is not None
- assert retrieved["subtype"] == "pinned"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_create_pinned_model_duplicate_fails(self, memory: MemoryEngine, request_context):
- """Test that creating a duplicate pinned model fails."""
- bank_id = f"test-pinned-dup-{uuid.uuid4().hex[:8]}"
+ async def test_create_directive(self, memory: MemoryEngine, request_context):
+ """Test creating a directive."""
+ bank_id = f"test-directive-{uuid.uuid4().hex[:8]}"
# Ensure bank exists
await memory.get_bank_profile(bank_id, request_context=request_context)
- # Create first model
- await memory.create_mental_model(
+ # Create a directive
+ directive = await memory.create_directive(
bank_id=bank_id,
- name="Test Model",
- description="First model",
+ name="Competitor Policy",
+ content="Never mention competitor product names directly. If asked about competitors, redirect to our features.",
request_context=request_context,
)
- # Try to create duplicate
- with pytest.raises(ValueError) as exc_info:
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Test Model",
- description="Second model",
- request_context=request_context,
- )
-
- assert "already exists" in str(exc_info.value).lower()
+ assert directive["name"] == "Competitor Policy"
+ assert "Never mention competitor" in directive["content"]
+ assert directive["is_active"] is True
+ assert directive["priority"] == 0
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
- async def test_pinned_models_survive_refresh(self, memory: MemoryEngine, request_context):
- """Test that pinned models are not deleted during refresh."""
- bank_id = f"test-pinned-refresh-{uuid.uuid4().hex[:8]}"
+ async def test_directive_crud(self, memory: MemoryEngine, request_context):
+ """Test basic CRUD operations for directives."""
+ bank_id = f"test-directive-crud-{uuid.uuid4().hex[:8]}"
- # Set a mission
- await memory.set_bank_mission(
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create
+ directive = await memory.create_directive(
bank_id=bank_id,
- mission="Track customer feedback",
+ name="Test Directive",
+ content="Follow this rule",
request_context=request_context,
)
+ directive_id = directive["id"]
- # Create a pinned model
- pinned_model = await memory.create_mental_model(
+ # Read
+ retrieved = await memory.get_directive(
bank_id=bank_id,
- name="Key Customers",
- description="Important customers to track",
- request_context=request_context,
- )
-
- # Refresh mental models
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Verify pinned model still exists
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=pinned_model["id"],
+ directive_id=directive_id,
request_context=request_context,
)
assert retrieved is not None
- assert retrieved["subtype"] == "pinned"
- assert retrieved["name"] == "Key Customers"
+ assert retrieved["name"] == "Test Directive"
+ assert retrieved["content"] == "Follow this rule"
+
+ # List
+ directives = await memory.list_directives(
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+ assert len(directives) == 1
+ assert directives[0]["id"] == directive_id
+
+ # Update
+ updated = await memory.update_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ content="Updated rule content",
+ request_context=request_context,
+ )
+ assert updated["content"] == "Updated rule content"
+
+ # Delete
+ deleted = await memory.delete_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ assert deleted is True
+
+ # Verify deletion
+ retrieved_after = await memory.get_directive(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ request_context=request_context,
+ )
+ assert retrieved_after is None
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ async def test_directive_priority(self, memory: MemoryEngine, request_context):
+ """Test that directive priority works correctly."""
+ bank_id = f"test-directive-priority-{uuid.uuid4().hex[:8]}"
+
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create directives with different priorities
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Low Priority",
+ content="Low priority rule",
+ priority=1,
+ request_context=request_context,
+ )
+
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="High Priority",
+ content="High priority rule",
+ priority=10,
+ request_context=request_context,
+ )
+
+ # List should order by priority (desc)
+ directives = await memory.list_directives(
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+ assert len(directives) == 2
+ assert directives[0]["name"] == "High Priority"
+ assert directives[1]["name"] == "Low Priority"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ async def test_directive_is_active(self, memory: MemoryEngine, request_context):
+ """Test that inactive directives are filtered by default."""
+ bank_id = f"test-directive-active-{uuid.uuid4().hex[:8]}"
+
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create active and inactive directives
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Active Rule",
+ content="This is active",
+ is_active=True,
+ request_context=request_context,
+ )
+
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Inactive Rule",
+ content="This is inactive",
+ is_active=False,
+ request_context=request_context,
+ )
+
+ # List active only (default)
+ active_directives = await memory.list_directives(
+ bank_id=bank_id,
+ active_only=True,
+ request_context=request_context,
+ )
+ assert len(active_directives) == 1
+ assert active_directives[0]["name"] == "Active Rule"
+
+ # List all
+ all_directives = await memory.list_directives(
+ bank_id=bank_id,
+ active_only=False,
+ request_context=request_context,
+ )
+ assert len(all_directives) == 2
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
-class TestMentalModelRefresh:
- """Test mental model summary refresh functionality."""
+class TestDirectiveTags:
+ """Test tags functionality for directives."""
- async def test_refresh_creates_models_with_summaries(self, memory_with_mission, request_context):
- """Test that refresh_mental_models creates models and generates summaries."""
- memory, bank_id = memory_with_mission
+ async def test_directive_with_tags(self, memory: MemoryEngine, request_context):
+ """Test creating a directive with tags."""
+ bank_id = f"test-directive-tags-{uuid.uuid4().hex[:8]}"
- # Refresh mental models (async - creates models and generates summaries)
- result = await memory.refresh_mental_models(
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create a directive with tags
+ directive = await memory.create_directive(
bank_id=bank_id,
+ name="Tagged Rule",
+ content="Follow this rule",
+ tags=["project-a", "team-x"],
request_context=request_context,
)
- assert "operation_id" in result
- assert result["status"] == "queued"
+ assert directive["tags"] == ["project-a", "team-x"]
- # Wait for background task to complete (includes summary generation)
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
+ # Retrieve and verify tags
+ retrieved = await memory.get_directive(
bank_id=bank_id,
+ directive_id=directive["id"],
+ request_context=request_context,
+ )
+ assert retrieved["tags"] == ["project-a", "team-x"]
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
+
+ async def test_list_directives_by_tags(self, memory: MemoryEngine, request_context):
+ """Test listing directives filtered by tags."""
+ bank_id = f"test-directive-tags-list-{uuid.uuid4().hex[:8]}"
+
+ # Ensure bank exists
+ await memory.get_bank_profile(bank_id, request_context=request_context)
+
+ # Create directives with different tags
+ await memory.create_directive(
+ bank_id=bank_id,
+ name="Rule A",
+ content="Rule for project A",
+ tags=["project-a"],
request_context=request_context,
)
- assert len(models) > 0
-
- # After async refresh completes, models should have summaries generated
- for model in models:
- assert "id" in model
- assert "name" in model
- # Summaries should be generated now (unless no relevant facts found)
- # We don't strictly assert on summary presence since it depends on data
-
- async def test_refresh_nonexistent_mental_model(self, memory: MemoryEngine, request_context):
- """Test refreshing a non-existent mental model returns None."""
- bank_id = f"test-refresh-noexist-{uuid.uuid4().hex[:8]}"
-
- result = await memory.refresh_mental_model(
+ await memory.create_directive(
bank_id=bank_id,
- model_id="does-not-exist",
+ name="Rule B",
+ content="Rule for project B",
+ tags=["project-b"],
request_context=request_context,
)
- assert result is None
+ # List all
+ all_directives = await memory.list_directives(
+ bank_id=bank_id,
+ request_context=request_context,
+ )
+ assert len(all_directives) == 2
+
+ # Filter by project-a tag
+ filtered = await memory.list_directives(
+ bank_id=bank_id,
+ tags=["project-a"],
+ request_context=request_context,
+ )
+ assert len(filtered) == 1
+ assert filtered[0]["name"] == "Rule A"
+
+ # Cleanup
+ await memory.delete_bank(bank_id, request_context=request_context)
class TestReflect:
- """Test reflect endpoint with mental models."""
+ """Test reflect endpoint."""
- async def test_reflect_basic(self, memory_with_mission, request_context):
- """Test basic reflect query - reflect works even without mental models."""
- memory, bank_id = memory_with_mission
+ async def test_reflect_basic(self, memory_with_bank, request_context):
+ """Test basic reflect query works."""
+ memory, bank_id = memory_with_bank
# Run a reflect query
result = await memory.reflect_async(
@@ -416,582 +331,6 @@ class TestReflect:
assert len(result.text) > 0
-class TestMentalModelLearnTool:
- """Test mental model learn tool - creates placeholders with background generation."""
-
- async def test_learn_creates_placeholder(self, memory: MemoryEngine, request_context):
- """Test that learn tool creates a placeholder mental model without observations."""
- bank_id = f"test-source-facts-{uuid.uuid4().hex[:8]}"
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the team lead."},
- {"content": "Bob is the engineer."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Directly use the learn tool to create a mental model placeholder
- from hindsight_api.engine.reflect.models import MentalModelInput
- from hindsight_api.engine.reflect.tools import tool_learn
-
- input_model = MentalModelInput(
- name="Team Members",
- description="Key team members and their roles",
- )
-
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- result = await tool_learn(conn, bank_id, input_model)
-
- assert result["status"] == "created"
- assert result["model_id"] == "team-members"
- assert result["name"] == "Team Members"
- assert result["pending_generation"] is True
-
- # Verify placeholder was stored in database with empty observations
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- row = await conn.fetchrow(
- "SELECT subtype, name, description, observations FROM mental_models WHERE id = $1 AND bank_id = $2",
- result["model_id"],
- bank_id,
- )
-
- assert row is not None
- assert row["subtype"] == "learned"
- assert row["name"] == "Team Members"
- assert row["description"] == "Key team members and their roles"
- # Observations should be empty - will be generated in background
- observations_data = row["observations"]
- # Handle both string and dict representations
- if isinstance(observations_data, str):
- import json
- observations_data = json.loads(observations_data) if observations_data else {}
- assert observations_data == {} or observations_data is None
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_learn_update_description(self, memory: MemoryEngine, request_context):
- """Test that updating a mental model updates the description."""
- bank_id = f"test-merge-facts-{uuid.uuid4().hex[:8]}"
-
- # Create bank by retaining some data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Test data"}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- from hindsight_api.engine.reflect.models import MentalModelInput
- from hindsight_api.engine.reflect.tools import tool_learn
-
- # First create a placeholder
- input_model = MentalModelInput(
- name="Team Members",
- description="Initial description",
- )
-
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- result1 = await tool_learn(conn, bank_id, input_model)
-
- assert result1["status"] == "created"
- assert result1["pending_generation"] is True
-
- # Now update with new description
- input_model2 = MentalModelInput(
- name="Team Members", # Same name = same ID
- description="Updated description with more context",
- )
-
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- result2 = await tool_learn(conn, bank_id, input_model2)
-
- assert result2["status"] == "updated"
- assert result2["model_id"] == "team-members"
-
- # Verify description was updated in database
- pool = await memory._get_pool()
- async with pool.acquire() as conn:
- row = await conn.fetchrow(
- "SELECT description FROM mental_models WHERE id = $1 AND bank_id = $2",
- result1["model_id"],
- bank_id,
- )
-
- assert row is not None
- assert row["description"] == "Updated description with more context"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
-
-class TestMentalModelTags:
- """Test mental model tags functionality."""
-
- @pytest.fixture
- async def memory_with_mission_and_tags(self, memory: MemoryEngine, request_context):
- """Memory engine with a bank that has a mission set and tagged content."""
- bank_id = f"test-mm-tags-{uuid.uuid4().hex[:8]}"
-
- # Set up the bank with a mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Be a PM for the engineering team",
- request_context=request_context,
- )
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the frontend engineer."},
- {"content": "Bob is the backend engineer."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- yield memory, bank_id
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_refresh_creates_models_with_tags(self, memory_with_mission_and_tags, request_context):
- """Test that refresh_mental_models creates models with specified tags."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Refresh mental models with tags
- result = await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["project-alpha", "sprint-1"],
- request_context=request_context,
- )
-
- assert "operation_id" in result
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- assert len(models) > 0
-
- # All models should have the tags we specified
- for model in models:
- assert "tags" in model
- assert "project-alpha" in model["tags"]
- assert "sprint-1" in model["tags"]
-
- async def test_list_mental_models_filters_by_tags(self, memory_with_mission_and_tags, request_context):
- """Test that list_mental_models correctly filters by tags."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Create models with different tags
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["project-alpha"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get all models
- all_models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(all_models) > 0
-
- # Filter by tags - should return models with matching tags
- filtered_models = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-alpha"],
- request_context=request_context,
- )
- assert len(filtered_models) == len(all_models) # All models have this tag
-
- # Filter by non-existent tag - should only return untagged models (none here)
- # But since all models have tags, and the filter includes untagged,
- # we need to test with a mix
- empty_filtered = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["non-existent-tag"],
- request_context=request_context,
- )
- # Should return empty since no models are untagged and none match
- # Actually, the logic includes untagged models, so let's verify the behavior
- # All our models have tags, so only checking for non-existent tag
- # should return nothing (since none match and none are untagged)
-
- async def test_untagged_models_included_in_filter(self, memory: MemoryEngine, request_context):
- """Test that untagged mental models are always included when filtering."""
- bank_id = f"test-untagged-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Track projects",
- request_context=request_context,
- )
-
- # Add some data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Project Alpha is important."}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # First refresh without tags (creates untagged models)
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context, # No tags
- )
- await memory.wait_for_background_tasks()
-
- # Get all models (should be untagged)
- all_models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- if len(all_models) > 0:
- # Verify models are untagged
- for model in all_models:
- assert model.get("tags", []) == []
-
- # Filter by any tag - untagged models should still be included
- filtered_models = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["some-tag"],
- request_context=request_context,
- )
- # Untagged models should be included in the results
- assert len(filtered_models) == len(all_models)
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_tags_match_any(self, memory: MemoryEngine, request_context):
- """Test tags_match='any' returns models with at least one matching tag."""
- bank_id = f"test-tags-any-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Track projects",
- request_context=request_context,
- )
-
- # Add data and create models with tags
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Alice works on frontend."}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["tag-a", "tag-b"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Filter with tags_match='any' - should match if any tag matches
- models = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["tag-a", "tag-c"], # tag-a matches, tag-c doesn't
- tags_match="any",
- request_context=request_context,
- )
-
- # Models with tag-a should be included
- for model in models:
- if model.get("tags"):
- # At least one of the filter tags should be in the model tags
- # OR model is untagged
- assert (
- any(t in model["tags"] for t in ["tag-a", "tag-c"])
- or model["tags"] == []
- )
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_reflect_with_tags_filter(self, memory_with_mission_and_tags, request_context):
- """Test that reflect filters memories by tags."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Create mental models with tags
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["project-x"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Reflect with matching tags
- result = await memory.reflect_async(
- bank_id=bank_id,
- query="Who are the engineers?",
- tags=["project-x"],
- request_context=request_context,
- )
-
- assert result.text is not None
- assert len(result.text) > 0
-
- # Reflect with non-matching tags - should still work
- result2 = await memory.reflect_async(
- bank_id=bank_id,
- query="Who are the engineers?",
- tags=["different-project"],
- request_context=request_context,
- )
-
- assert result2.text is not None
-
- async def test_mental_model_response_includes_tags(self, memory_with_mission_and_tags, request_context):
- """Test that mental model responses include the tags field."""
- memory, bank_id = memory_with_mission_and_tags
-
- # Create models with tags
- await memory.refresh_mental_models(
- bank_id=bank_id,
- tags=["test-tag"],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Verify tags field is present in response
- for model in models:
- assert "tags" in model
- assert isinstance(model["tags"], list)
-
- # Get single model
- if models:
- model = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=models[0]["id"],
- request_context=request_context,
- )
- assert "tags" in model
- assert isinstance(model["tags"], list)
-
-
-class TestDirectives:
- """Test directive mental model functionality."""
-
- async def test_create_directive(self, memory: MemoryEngine, request_context):
- """Test creating a directive mental model with user-provided observations."""
- bank_id = f"test-directive-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a directive with observations
- model = await memory.create_mental_model(
- bank_id=bank_id,
- name="Competitor Policy",
- description="Rules about mentioning competitors",
- subtype="directive",
- observations=[
- {"title": "Never mention", "content": "Never mention competitor product names directly"},
- {"title": "Redirect", "content": "If asked about competitors, redirect to our features"},
- ],
- request_context=request_context,
- )
-
- assert model["name"] == "Competitor Policy"
- assert model["description"] == "Rules about mentioning competitors"
- assert model["subtype"] == "directive"
- assert len(model["observations"]) == 2
- assert model["observations"][0].title == "Never mention"
- assert model["observations"][0].content == "Never mention competitor product names directly"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_included_in_list(self, memory: MemoryEngine, request_context):
- """Test that directives are included in list_mental_models for admin visibility."""
- bank_id = f"test-directive-list-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Test mission",
- request_context=request_context,
- )
-
- # Create a directive
- directive = await memory.create_mental_model(
- bank_id=bank_id,
- name="Test Directive",
- description="A test directive",
- subtype="directive",
- observations=[{"title": "Rule", "content": "Follow this rule"}],
- request_context=request_context,
- )
-
- # Create a pinned model
- pinned = await memory.create_mental_model(
- bank_id=bank_id,
- name="Test Pinned",
- description="A test pinned model",
- request_context=request_context,
- )
-
- # List without subtype filter - both should appear
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # Both should appear (directives included in API listing for admin visibility)
- model_ids = [m["id"] for m in models]
- assert pinned["id"] in model_ids
- assert directive["id"] in model_ids
-
- # List with directive subtype filter - should find only directive
- directives = await memory.list_mental_models(
- bank_id=bank_id,
- subtype="directive",
- request_context=request_context,
- )
- assert len(directives) == 1
- assert directives[0]["id"] == directive["id"]
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_get_includes_observations(self, memory: MemoryEngine, request_context):
- """Test that getting a directive returns its user-provided observations."""
- bank_id = f"test-directive-get-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a directive with observations
- created = await memory.create_mental_model(
- bank_id=bank_id,
- name="Meeting Rules",
- description="Rules for scheduling meetings",
- subtype="directive",
- observations=[
- {"title": "No mornings", "content": "Never schedule meetings before noon"},
- {"title": "Max duration", "content": "Meetings should be 30 minutes max"},
- ],
- request_context=request_context,
- )
-
- # Get the directive
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=created["id"],
- request_context=request_context,
- )
-
- assert retrieved is not None
- assert retrieved["subtype"] == "directive"
- assert len(retrieved["observations"]) == 2
- assert retrieved["observations"][0].title == "No mornings"
- assert retrieved["observations"][1].title == "Max duration"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_survives_refresh(self, memory: MemoryEngine, request_context):
- """Test that directives are not modified during refresh_mental_models."""
- bank_id = f"test-directive-refresh-{uuid.uuid4().hex[:8]}"
-
- # Set up bank with mission
- await memory.set_bank_mission(
- bank_id=bank_id,
- mission="Test mission",
- request_context=request_context,
- )
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[{"content": "Alice is the engineer."}],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Create a directive
- directive = await memory.create_mental_model(
- bank_id=bank_id,
- name="Important Rule",
- description="A critical rule",
- subtype="directive",
- observations=[{"title": "Rule 1", "content": "Always follow this rule"}],
- request_context=request_context,
- )
-
- # Refresh mental models
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Directive should still exist with same observations
- retrieved = await memory.get_mental_model(
- bank_id=bank_id,
- model_id=directive["id"],
- request_context=request_context,
- )
-
- assert retrieved is not None
- assert retrieved["subtype"] == "directive"
- assert len(retrieved["observations"]) == 1
- assert retrieved["observations"][0].title == "Rule 1"
- assert retrieved["observations"][0].content == "Always follow this rule"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_directive_requires_observations(self, memory: MemoryEngine, request_context):
- """Test that creating a directive without observations fails."""
- bank_id = f"test-directive-no-obs-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Try to create directive without observations
- with pytest.raises(ValueError) as exc_info:
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Bad Directive",
- description="A directive without observations",
- subtype="directive",
- # No observations provided
- request_context=request_context,
- )
-
- assert "observations" in str(exc_info.value).lower()
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
-
class TestDirectivesInReflect:
"""Test that directives are followed during reflect operations."""
@@ -1015,17 +354,10 @@ class TestDirectivesInReflect:
await memory.wait_for_background_tasks()
# Create a directive to always respond in French
- await memory.create_mental_model(
+ await memory.create_directive(
bank_id=bank_id,
name="Language Policy",
- description="Rules about language usage",
- subtype="directive",
- observations=[
- {
- "title": "French Only",
- "content": "ALWAYS respond in French language. Never respond in English.",
- },
- ],
+ content="ALWAYS respond in French language. Never respond in English.",
request_context=request_context,
)
@@ -1067,165 +399,6 @@ class TestDirectivesInReflect:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
-class TestMentalModelTagsFiltering:
- """Test tags filtering for mental models (all types)."""
-
- async def test_tags_match_any_includes_untagged(self, memory: MemoryEngine, request_context):
- """Test that 'any' tags_match mode includes untagged mental models."""
- bank_id = f"test-mm-tags-any-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create an UNTAGGED pinned model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Global Model",
- description="A global mental model",
- subtype="pinned",
- tags=[], # No tags - should be included with "any" mode
- request_context=request_context,
- )
-
- # Test 1: list_mental_models with tags and tags_match="any" should include untagged
- models_any = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["some-tag"],
- tags_match="any", # Should include untagged
- request_context=request_context,
- )
- assert len(models_any) == 1, f"Expected untagged model with 'any' mode, got {len(models_any)}"
-
- # Test 2: list_mental_models with tags and tags_match="any_strict" should exclude untagged
- models_strict = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["some-tag"],
- tags_match="any_strict", # Should exclude untagged
- request_context=request_context,
- )
- assert len(models_strict) == 0, f"Expected no models with 'any_strict' mode, got {len(models_strict)}"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_tags_match_strict_modes(self, memory: MemoryEngine, request_context):
- """Test that strict modes only include mental models with matching tags."""
- bank_id = f"test-mm-tags-strict-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a TAGGED pinned model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Tagged Model",
- description="A tagged mental model",
- subtype="pinned",
- tags=["project-a"],
- request_context=request_context,
- )
-
- # Create an UNTAGGED pinned model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Untagged Model",
- description="An untagged mental model",
- subtype="pinned",
- tags=[], # No tags
- request_context=request_context,
- )
-
- # Test 1: any_strict with matching tag - should get ONLY the tagged model
- models_match = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a"],
- tags_match="any_strict",
- request_context=request_context,
- )
- assert len(models_match) == 1, f"Expected 1 model with matching tag, got {len(models_match)}"
- assert models_match[0]["name"] == "Tagged Model"
-
- # Test 2: any_strict with different tag - should get NO models
- models_no_match = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-b"],
- tags_match="any_strict",
- request_context=request_context,
- )
- assert len(models_no_match) == 0, f"Expected no models with non-matching tag, got {len(models_no_match)}"
-
- # Test 3: any (non-strict) with any tag - should get BOTH models
- models_any = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a"],
- tags_match="any",
- request_context=request_context,
- )
- assert len(models_any) == 2, f"Expected 2 models with 'any' mode, got {len(models_any)}"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_tags_match_all_strict(self, memory: MemoryEngine, request_context):
- """Test that 'all_strict' requires ALL tags to be present."""
- bank_id = f"test-mm-tags-all-{uuid.uuid4().hex[:8]}"
-
- # Ensure bank exists
- await memory.get_bank_profile(bank_id, request_context=request_context)
-
- # Create a model with multiple tags
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Multi-Tag Model",
- description="Has project-a and project-b tags",
- subtype="pinned",
- tags=["project-a", "project-b"],
- request_context=request_context,
- )
-
- # Create a model with only one tag
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Single-Tag Model",
- description="Has only project-a tag",
- subtype="pinned",
- tags=["project-a"],
- request_context=request_context,
- )
-
- # Test 1: all_strict with both tags - should get ONLY the multi-tag model
- models_all = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a", "project-b"],
- tags_match="all_strict",
- request_context=request_context,
- )
- assert len(models_all) == 1, f"Expected 1 model with all tags, got {len(models_all)}"
- assert models_all[0]["name"] == "Multi-Tag Model"
-
- # Test 2: all (non-strict) with both tags - should include untagged too
- # Add an untagged model
- await memory.create_mental_model(
- bank_id=bank_id,
- name="Untagged Model",
- description="No tags",
- subtype="pinned",
- tags=[],
- request_context=request_context,
- )
-
- models_all_non_strict = await memory.list_mental_models(
- bank_id=bank_id,
- tags=["project-a", "project-b"],
- tags_match="all",
- request_context=request_context,
- )
- # Should get Multi-Tag Model + Untagged Model
- assert len(models_all_non_strict) == 2, f"Expected 2 models with 'all' mode, got {len(models_all_non_strict)}"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
class TestDirectivesPromptInjection:
"""Test that directives are properly injected into the system prompt."""
@@ -1237,43 +410,24 @@ class TestDirectivesPromptInjection:
result = build_directives_section([])
assert result == ""
- def test_build_directives_section_with_observations(self):
- """Test that directives with observations are formatted correctly."""
+ def test_build_directives_section_with_content(self):
+ """Test that directives with content are formatted correctly."""
from hindsight_api.engine.reflect.prompts import build_directives_section
directives = [
{
"name": "Competitor Policy",
- "observations": [
- {"title": "Never mention", "content": "Never mention competitor names"},
- {"title": "Redirect", "content": "Redirect to our features"},
- ],
+ "content": "Never mention competitor names. Redirect to our features.",
}
]
result = build_directives_section(directives)
assert "## DIRECTIVES (MANDATORY)" in result
- assert "**Never mention**: Never mention competitor names" in result
- assert "**Redirect**: Redirect to our features" in result
+ assert "Competitor Policy" in result
+ assert "Never mention competitor names" in result
assert "NEVER violate these directives" in result
- def test_build_directives_section_fallback_to_description(self):
- """Test that directives without observations fall back to description."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Simple Rule",
- "description": "Just a simple rule description",
- "observations": [],
- }
- ]
-
- result = build_directives_section(directives)
-
- assert "**Simple Rule**: Just a simple rule description" in result
-
def test_system_prompt_includes_directives(self):
"""Test that build_system_prompt_for_tools includes directives."""
from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools
@@ -1282,7 +436,7 @@ class TestDirectivesPromptInjection:
directives = [
{
"name": "Test Directive",
- "observations": [{"title": "Rule", "content": "Follow this rule"}],
+ "content": "Follow this rule",
}
]
@@ -1292,143 +446,8 @@ class TestDirectivesPromptInjection:
)
assert "## DIRECTIVES (MANDATORY)" in prompt
- assert "**Rule**: Follow this rule" in prompt
+ assert "Follow this rule" in prompt
# Directives should appear before CRITICAL RULES
directives_pos = prompt.find("## DIRECTIVES")
critical_rules_pos = prompt.find("## CRITICAL RULES")
assert directives_pos < critical_rules_pos
-
-
-class TestMentalModelVersioning:
- """Test mental model versioning functionality."""
-
- async def test_refresh_creates_version(self, memory_with_mission, request_context):
- """Test that refreshing a mental model creates a version entry."""
- memory, bank_id = memory_with_mission
-
- # First create a mental model via refresh_mental_models
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Get the created models
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(models) > 0
-
- model_id = models[0]["id"]
-
- # Refresh the specific model to trigger versioning
- result = await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- assert result is not None
- # Version should be incremented
- assert result.get("version", 0) >= 1
-
- # Check version history
- versions = await memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- assert len(versions) >= 1
- assert versions[0]["version"] >= 1
- assert "created_at" in versions[0]
- assert "observation_count" in versions[0]
-
- async def test_get_specific_version(self, memory_with_mission, request_context):
- """Test retrieving a specific version of a mental model."""
- memory, bank_id = memory_with_mission
-
- # Create and refresh a mental model
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(models) > 0
-
- model_id = models[0]["id"]
-
- # Refresh to create version
- await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- # Get versions
- versions = await memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
- assert len(versions) >= 1
-
- # Get specific version
- version_num = versions[0]["version"]
- version_data = await memory.get_mental_model_version(
- bank_id=bank_id,
- model_id=model_id,
- version=version_num,
- request_context=request_context,
- )
-
- assert version_data is not None
- assert version_data["version"] == version_num
- assert "observations" in version_data
-
- async def test_version_cleanup_keeps_max_versions(self, memory_with_mission, request_context):
- """Test that old versions are cleaned up when max is exceeded."""
- memory, bank_id = memory_with_mission
-
- # Create a mental model
- await memory.refresh_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
- assert len(models) > 0
-
- model_id = models[0]["id"]
-
- # Refresh multiple times to create versions
- for _ in range(3):
- await memory.refresh_mental_model(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- # Get versions - should have multiple but within max limit
- versions = await memory.get_mental_model_versions(
- bank_id=bank_id,
- model_id=model_id,
- request_context=request_context,
- )
-
- # Should have versions (exact count depends on config, but at least some)
- assert len(versions) >= 1
- # Versions should be in descending order
- if len(versions) > 1:
- assert versions[0]["version"] > versions[1]["version"]
-
diff --git a/hindsight-api/tests/test_observation_trends.py b/hindsight-api/tests/test_observation_trends.py
deleted file mode 100644
index 7510ae1b..00000000
--- a/hindsight-api/tests/test_observation_trends.py
+++ /dev/null
@@ -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
diff --git a/hindsight-api/tests/test_reflect_agent.py b/hindsight-api/tests/test_reflect_agent.py
index 132022f8..5ebd79ef 100644
--- a/hindsight-api/tests/test_reflect_agent.py
+++ b/hindsight-api/tests/test_reflect_agent.py
@@ -1,1150 +1,284 @@
-"""Tests for the reflect agent and its tools."""
+"""
+Tests for the reflect agent with mocked LLM outputs.
-import uuid
-from unittest.mock import AsyncMock, MagicMock
+These tests verify:
+1. Tool name normalization for various LLM output formats
+2. Recovery from unknown tool calls
+3. Recovery from tool execution errors
+"""
import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
-from hindsight_api.engine.reflect.agent import run_reflect_agent
-from hindsight_api.engine.reflect.models import (
- AnswerSection,
- MentalModelInput,
- MentalModelObservation,
- ReflectAction,
- ReflectActionBatch,
- ReflectAgentResult,
-)
-from hindsight_api.engine.reflect.tools import (
- generate_model_id,
- tool_expand,
- tool_learn,
- tool_lookup,
- tool_recall,
+from hindsight_api.engine.reflect.agent import (
+ _normalize_tool_name,
+ _is_done_tool,
+ run_reflect_agent,
)
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult
-class TestGenerateModelId:
- """Test model ID generation."""
-
- def test_basic_name(self):
- """Test simple name conversion."""
- assert generate_model_id("My Model") == "my-model"
-
- def test_special_characters(self):
- """Test name with special characters."""
- assert generate_model_id("Alice's Project (2024)") == "alice-s-project-2024"
-
- def test_truncation(self):
- """Test long name truncation."""
- long_name = "A" * 100
- result = generate_model_id(long_name)
- assert len(result) <= 50
-
- def test_leading_trailing_hyphens(self):
- """Test that leading/trailing hyphens are stripped."""
- assert generate_model_id("--Test--") == "test"
-
-
-class TestToolLookup:
- """Test the lookup tool."""
-
- @pytest.fixture
- def mock_conn(self):
- """Create a mock database connection."""
- conn = AsyncMock()
- return conn
-
- async def test_list_all_models(self, mock_conn):
- """Test listing all mental models (compact: id, name, description only)."""
- mock_conn.fetch.return_value = [
- {
- "id": "model-1",
- "subtype": "learned",
- "name": "Model 1",
- "description": "First model",
- },
- {
- "id": "model-2",
- "subtype": "structural",
- "name": "Model 2",
- "description": "Second model",
- },
- ]
-
- result = await tool_lookup(mock_conn, "test-bank")
-
- assert result["count"] == 2
- assert len(result["models"]) == 2
- assert result["models"][0]["id"] == "model-1"
- assert result["models"][0]["name"] == "Model 1"
- assert "observation_titles" not in result["models"][0] # No observation_titles in list view
- assert result["models"][1]["id"] == "model-2"
-
- async def test_get_specific_model(self, mock_conn):
- """Test getting a specific mental model."""
- mock_conn.fetchrow.return_value = {
- "id": "model-1",
- "subtype": "learned",
- "name": "Model 1",
- "description": "First model",
- "observations": {
- "observations": [
- {
- "title": "Overview",
- "content": "Full summary of model 1",
- "evidence": [
- {"memory_id": "mem-1", "quote": "quote 1", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"},
- {"memory_id": "mem-2", "quote": "quote 2", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"},
- ],
- "created_at": "2024-01-01T00:00:00Z",
- }
- ]
- },
- "entity_id": None,
- "last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"),
- }
-
- result = await tool_lookup(mock_conn, "test-bank", "model-1")
-
- assert result["found"] is True
- assert result["model"]["id"] == "model-1"
- assert len(result["model"]["observations"]) == 1
- # Observations are now Observation objects
- obs = result["model"]["observations"][0]
- assert obs.content == "Full summary of model 1"
- assert obs.title == "Overview"
- assert len(obs.evidence) == 2
- assert obs.evidence[0].memory_id == "mem-1"
-
- async def test_model_not_found(self, mock_conn):
- """Test looking up non-existent model."""
- mock_conn.fetchrow.return_value = None
-
- result = await tool_lookup(mock_conn, "test-bank", "non-existent")
-
- assert result["found"] is False
- assert result["model_id"] == "non-existent"
-
-
-class TestToolLearn:
- """Test the learn tool.
-
- The learn tool creates placeholder mental models with name/description only.
- Actual content is generated in the background via refresh.
- """
-
- @pytest.fixture
- def mock_conn(self):
- """Create a mock database connection."""
- conn = AsyncMock()
- return conn
-
- async def test_create_new_model(self, mock_conn):
- """Test creating a new mental model placeholder."""
- mock_conn.fetchrow.return_value = None # Model doesn't exist
-
- input_model = MentalModelInput(
- name="Test Model",
- description="A test model to track important patterns",
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "created"
- assert result["model_id"] == "test-model"
- assert result["name"] == "Test Model"
- assert result["pending_generation"] is True
- mock_conn.execute.assert_called_once()
-
- async def test_update_existing_model(self, mock_conn):
- """Test updating an existing mental model."""
- mock_conn.fetchrow.return_value = {"id": "test-model"} # Model exists
-
- input_model = MentalModelInput(
- name="Test Model",
- description="Updated description",
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "updated"
- assert result["model_id"] == "test-model"
-
- async def test_learn_with_entity_id(self, mock_conn):
- """Test creating model linked to an entity."""
- mock_conn.fetchrow.return_value = None
-
- entity_uuid = str(uuid.uuid4())
- input_model = MentalModelInput(
- name="Entity Model",
- description="Model linked to entity",
- entity_id=entity_uuid,
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "created"
- assert result["pending_generation"] is True
- # Verify entity_uuid was passed to the execute call
- call_args = mock_conn.execute.call_args
- assert uuid.UUID(entity_uuid) in call_args[0]
-
- async def test_learn_creates_empty_observations(self, mock_conn):
- """Test that learn creates model with empty observations (content generated later)."""
- mock_conn.fetchrow.return_value = None # Model doesn't exist
-
- input_model = MentalModelInput(
- name="Model With Sources",
- description="A model to track source facts",
- )
-
- result = await tool_learn(mock_conn, "test-bank", input_model)
-
- assert result["status"] == "created"
- assert result["pending_generation"] is True
- # Verify the INSERT query was called with empty observations
- call_args = mock_conn.execute.call_args
- # observations should be empty JSON
- assert "'{}'::jsonb" in call_args[0][0]
-
-
-class TestToolExpand:
- """Test the expand tool."""
-
- @pytest.fixture
- def mock_conn(self):
- """Create a mock database connection."""
- conn = AsyncMock()
- return conn
-
- async def test_empty_memory_ids(self, mock_conn):
- """Test expand with empty memory_ids list."""
- result = await tool_expand(mock_conn, "test-bank", [], "chunk")
-
- assert "error" in result
- assert "memory_ids is required" in result["error"]
-
- async def test_invalid_memory_id(self, mock_conn):
- """Test expand with invalid UUID format."""
- result = await tool_expand(mock_conn, "test-bank", ["not-a-uuid"], "chunk")
-
- assert "error" in result
- assert "No valid memory IDs provided" in result["error"]
-
- async def test_memory_not_found(self, mock_conn):
- """Test expand with non-existent memory."""
- mock_conn.fetch.return_value = [] # No memories found
- memory_id = str(uuid.uuid4())
-
- result = await tool_expand(mock_conn, "test-bank", [memory_id], "chunk")
-
- assert "results" in result
- assert len(result["results"]) == 1
- assert "error" in result["results"][0]
- assert "Memory not found" in result["results"][0]["error"]
-
- async def test_expand_to_chunk(self, mock_conn):
- """Test expanding memory to chunk level."""
- memory_id = uuid.uuid4()
- # Mock batch fetch for memories
- mock_conn.fetch.side_effect = [
- # First call: get memories
- [
- {
- "id": memory_id,
- "text": "Memory text",
- "chunk_id": "chunk-1",
- "document_id": "doc-1",
- "fact_type": "experience",
- "context": "some context",
- }
- ],
- # Second call: get chunks
- [
- {
- "chunk_id": "chunk-1",
- "chunk_text": "Full chunk text with more context",
- "chunk_index": 0,
- "document_id": "doc-1",
- }
- ],
- ]
-
- result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "chunk")
-
- assert "results" in result
- assert len(result["results"]) == 1
- assert result["results"][0]["memory"]["text"] == "Memory text"
- assert result["results"][0]["chunk"]["text"] == "Full chunk text with more context"
- assert "document" not in result["results"][0] # depth=chunk doesn't include document
-
- async def test_expand_to_document(self, mock_conn):
- """Test expanding memory to document level."""
- memory_id = uuid.uuid4()
- mock_conn.fetch.side_effect = [
- # First call: get memories
- [
- {
- "id": memory_id,
- "text": "Memory text",
- "chunk_id": "chunk-1",
- "document_id": "doc-1",
- "fact_type": "experience",
- "context": None,
- }
- ],
- # Second call: get chunks
- [
- {
- "chunk_id": "chunk-1",
- "chunk_text": "Chunk text",
- "chunk_index": 0,
- "document_id": "doc-1",
- }
- ],
- # Third call: get documents
- [
- {
- "id": "doc-1",
- "original_text": "Full document text here",
- "metadata": {"source": "test"},
- "retain_params": {},
- }
- ],
- ]
-
- result = await tool_expand(mock_conn, "test-bank", [str(memory_id)], "document")
-
- assert "results" in result
- assert len(result["results"]) == 1
- assert "memory" in result["results"][0]
- assert "chunk" in result["results"][0]
- assert "document" in result["results"][0]
- assert result["results"][0]["document"]["full_text"] == "Full document text here"
-
- async def test_expand_multiple_memories(self, mock_conn):
- """Test expanding multiple memories in a single batch."""
- memory_id_1 = uuid.uuid4()
- memory_id_2 = uuid.uuid4()
- mock_conn.fetch.side_effect = [
- # First call: get memories
- [
- {
- "id": memory_id_1,
- "text": "Memory 1",
- "chunk_id": "chunk-1",
- "document_id": "doc-1",
- "fact_type": "experience",
- "context": None,
- },
- {
- "id": memory_id_2,
- "text": "Memory 2",
- "chunk_id": "chunk-2",
- "document_id": "doc-1",
- "fact_type": "world",
- "context": None,
- },
- ],
- # Second call: get chunks
- [
- {
- "chunk_id": "chunk-1",
- "chunk_text": "Chunk 1 text",
- "chunk_index": 0,
- "document_id": "doc-1",
- },
- {
- "chunk_id": "chunk-2",
- "chunk_text": "Chunk 2 text",
- "chunk_index": 1,
- "document_id": "doc-1",
- },
- ],
- ]
-
- result = await tool_expand(mock_conn, "test-bank", [str(memory_id_1), str(memory_id_2)], "chunk")
-
- assert "results" in result
- assert result["count"] == 2
- assert result["results"][0]["memory"]["text"] == "Memory 1"
- assert result["results"][1]["memory"]["text"] == "Memory 2"
-
-
-class TestToolRecall:
- """Test the recall tool."""
-
- async def test_recall_returns_memories(self):
- """Test recall searches and returns memories."""
- mock_engine = AsyncMock()
- mock_result = MagicMock()
- mock_result.results = [
- MagicMock(
- id=uuid.uuid4(),
- text="Memory 1",
- fact_type="experience",
- entities=["Alice"],
- occurred_start="2024-01-01",
- ),
- MagicMock(
- id=uuid.uuid4(),
- text="Memory 2",
- fact_type="world",
- entities=None,
- occurred_start=None,
- ),
- ]
- mock_engine.recall_async.return_value = mock_result
-
- mock_request_context = MagicMock()
-
- result = await tool_recall(mock_engine, "test-bank", "test query", mock_request_context)
-
- assert result["query"] == "test query"
- assert result["count"] == 2
- assert len(result["memories"]) == 2
- assert result["memories"][0]["text"] == "Memory 1"
- assert result["memories"][0]["entities"] == ["Alice"]
-
- # Verify recall_async was called with correct params
- mock_engine.recall_async.assert_called_once()
- call_kwargs = mock_engine.recall_async.call_args[1]
- assert call_kwargs["bank_id"] == "test-bank"
- assert call_kwargs["query"] == "test query"
- assert call_kwargs["fact_type"] == ["experience", "world"] # No opinions
-
-
-class TestPromptSize:
- """Test that prompts stay within reasonable size limits.
-
- Large prompts cause slow LLM responses (120s+ observed in production).
- The agent should not pre-load all mental models; use lookup() instead.
- """
-
- def test_initial_prompt_is_small(self):
- """Verify the initial prompt (no tool history) is reasonably small."""
- from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools
-
- # Typical bank profile
- bank_profile = {
- "name": "Test Assistant",
- "mission": "A helpful assistant for tracking engineering team activities. Help the team stay organized and informed.",
- }
-
- # First iteration: no context history
- context_history: list[dict] = []
- query = "Who should take ownership of storing load test scripts in Git?"
-
- # No additional context (mental models not pre-loaded)
- prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None)
- system_prompt = build_system_prompt_for_tools(bank_profile)
-
- total_chars = len(prompt) + len(system_prompt)
- estimated_tokens = total_chars // 4 # Rough estimate
-
- # Initial prompt should be under 3000 tokens (~12k chars)
- # This ensures fast LLM responses on the first iteration
- assert total_chars < 12000, f"Initial prompt too large: {total_chars} chars (~{estimated_tokens} tokens)"
- assert estimated_tokens < 3000, f"Initial prompt too large: ~{estimated_tokens} tokens"
-
- def test_prompt_with_tool_history_grows_reasonably(self):
- """Verify prompts grow reasonably with tool results."""
- from hindsight_api.engine.reflect.prompts import build_agent_prompt, build_system_prompt_for_tools
-
- bank_profile = {
- "name": "Test Assistant",
- "mission": "A helpful assistant. Help the team.",
- }
-
- # Simulate recall result with 50 memories (realistic scenario)
- recall_result = {
- "query": "test query",
- "count": 50,
- "memories": [
- {"id": f"mem-{i}", "text": f"This is memory number {i} with some content.", "type": "experience"}
- for i in range(50)
- ],
- }
-
- context_history = [{"tool": "recall", "input": {"query": "test"}, "output": recall_result}]
- query = "What do you know about the team?"
-
- prompt = build_agent_prompt(query, context_history, bank_profile, additional_context=None)
- system_prompt = build_system_prompt_for_tools(bank_profile)
-
- total_chars = len(prompt) + len(system_prompt)
- estimated_tokens = total_chars // 4
-
- # With tool results, prompt should still be manageable (<20k tokens)
- assert total_chars < 80000, f"Prompt with tools too large: {total_chars} chars (~{estimated_tokens} tokens)"
-
-
-class TestReflectAgent:
- """Test the reflect agent loop with native tool calling."""
+class TestToolNameNormalization:
+ """Test tool name normalization for various LLM output formats."""
+
+ def test_normalize_standard_name(self):
+ """Standard tool names should pass through unchanged."""
+ assert _normalize_tool_name("done") == "done"
+ assert _normalize_tool_name("recall") == "recall"
+ assert _normalize_tool_name("search_reflections") == "search_reflections"
+ assert _normalize_tool_name("search_mental_models") == "search_mental_models"
+ assert _normalize_tool_name("expand") == "expand"
+
+ def test_normalize_functions_prefix(self):
+ """Tool names with 'functions.' prefix should be normalized."""
+ assert _normalize_tool_name("functions.done") == "done"
+ assert _normalize_tool_name("functions.recall") == "recall"
+ assert _normalize_tool_name("functions.search_reflections") == "search_reflections"
+
+ def test_normalize_call_equals_prefix(self):
+ """Tool names with 'call=' prefix should be normalized."""
+ assert _normalize_tool_name("call=done") == "done"
+ assert _normalize_tool_name("call=recall") == "recall"
+
+ def test_normalize_call_equals_functions_prefix(self):
+ """Tool names with 'call=functions.' prefix should be normalized."""
+ assert _normalize_tool_name("call=functions.done") == "done"
+ assert _normalize_tool_name("call=functions.recall") == "recall"
+ assert _normalize_tool_name("call=functions.search_mental_models") == "search_mental_models"
+
+ def test_is_done_tool(self):
+ """Test _is_done_tool helper."""
+ # Standard
+ assert _is_done_tool("done") is True
+ assert _is_done_tool("recall") is False
+
+ # With prefixes
+ assert _is_done_tool("functions.done") is True
+ assert _is_done_tool("call=done") is True
+ assert _is_done_tool("call=functions.done") is True
+
+ # Not done
+ assert _is_done_tool("functions.recall") is False
+ assert _is_done_tool("call=functions.recall") is False
+
+
+class TestReflectAgentMocked:
+ """Test reflect agent with mocked LLM outputs."""
@pytest.fixture
def mock_llm(self):
"""Create a mock LLM provider."""
- llm = AsyncMock()
+ llm = MagicMock()
+ llm.call_with_tools = AsyncMock()
+ # Also mock call() for final iteration fallback
+ llm.call = AsyncMock(return_value="Fallback answer from final iteration")
return llm
@pytest.fixture
- def bank_profile(self):
- """Create a test bank profile."""
+ def mock_functions(self):
+ """Create mock search/recall functions."""
return {
- "name": "Test Assistant",
- "mission": "A helpful test assistant. Help with testing.",
+ "search_reflections_fn": AsyncMock(return_value={"reflections": []}),
+ "search_mental_models_fn": AsyncMock(return_value={"mental_models": []}),
+ "recall_fn": AsyncMock(return_value={"memories": [{"id": "mem-1", "content": "test memory"}]}),
+ "expand_fn": AsyncMock(return_value={"memories": []}),
}
- @pytest.fixture
- def mock_tools(self):
- """Create mock tool callbacks."""
- # Include memory IDs in recall results so guardrail passes
- memory_id = str(uuid.uuid4())
- return {
- "lookup_fn": AsyncMock(return_value={"count": 0, "models": []}),
- "recall_fn": AsyncMock(return_value={
- "query": "test",
- "count": 1,
- "memories": [{"id": memory_id, "text": "Memory", "type": "experience"}]
- }),
- "learn_fn": AsyncMock(return_value={"status": "created", "model_id": "new-model"}),
- "expand_fn": AsyncMock(return_value={
- "results": [{"memory_id": "123", "memory": {"id": "123", "text": "Memory text"}}],
- "count": 1
- }),
- }
+ @pytest.mark.asyncio
+ async def test_handles_functions_prefix_in_done(self, mock_llm, mock_functions):
+ """Test that 'functions.done' is handled correctly."""
+ # First call: LLM calls recall
+ # Second call: LLM calls functions.done
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="2",
+ name="functions.done",
+ arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
- def _make_tool_result(self, tool_calls: list[dict]) -> LLMToolCallResult:
- """Helper to create LLMToolCallResult from tool call dicts."""
- return LLMToolCallResult(
- tool_calls=[
- LLMToolCall(id=f"call_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
- for i, tc in enumerate(tool_calls)
- ],
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Test answer"
+ assert "mem-1" in result.used_memory_ids
+
+ @pytest.mark.asyncio
+ async def test_handles_call_equals_functions_prefix(self, mock_llm, mock_functions):
+ """Test that 'call=functions.done' is handled correctly."""
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="2",
+ name="call=functions.done",
+ arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Test answer"
+
+ @pytest.mark.asyncio
+ async def test_recovery_from_unknown_tool(self, mock_llm, mock_functions):
+ """Test that LLM can recover after calling an unknown tool."""
+ # First call: LLM calls unknown tool
+ # Second call: LLM calls valid recall after seeing error
+ # Third call: LLM calls done
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="invalid_tool", arguments={"foo": "bar"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="3",
+ name="done",
+ arguments={"answer": "Recovered successfully", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Recovered successfully"
+ # Verify the LLM was called 3 times (initial + recovery + done)
+ assert mock_llm.call_with_tools.call_count == 3
+
+ @pytest.mark.asyncio
+ async def test_recovery_from_tool_execution_error(self, mock_llm, mock_functions):
+ """Test that LLM can recover after a tool execution fails."""
+ # Make recall fail the first time, succeed the second time
+ mock_functions["recall_fn"].side_effect = [
+ Exception("Database connection failed"),
+ {"memories": [{"id": "mem-1", "content": "test memory"}]},
+ ]
+
+ mock_llm.call_with_tools.side_effect = [
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ # LLM tries again after seeing error
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="2", name="recall", arguments={"query": "test retry"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="3",
+ name="done",
+ arguments={"answer": "Recovered from error", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Recovered from error"
+ assert mock_llm.call_with_tools.call_count == 3
+
+ @pytest.mark.asyncio
+ async def test_normalizes_tool_names_in_other_tools(self, mock_llm, mock_functions):
+ """Test that tool names are normalized for all tools, not just done."""
+ mock_llm.call_with_tools.side_effect = [
+ # LLM calls 'functions.recall' instead of 'recall'
+ LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="functions.recall", arguments={"query": "test"})],
+ finish_reason="tool_calls",
+ ),
+ LLMToolCallResult(
+ tool_calls=[
+ LLMToolCall(
+ id="2",
+ name="done",
+ arguments={"answer": "Test answer", "memory_ids": ["mem-1"]},
+ )
+ ],
+ finish_reason="tool_calls",
+ ),
+ ]
+
+ result = await run_reflect_agent(
+ llm_config=mock_llm,
+ bank_id="test-bank",
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
+ **mock_functions,
+ )
+
+ assert result.text == "Test answer"
+ # Verify recall was actually called (normalization worked)
+ mock_functions["recall_fn"].assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_max_iterations_reached(self, mock_llm, mock_functions):
+ """Test that agent stops after max iterations even with errors."""
+ # LLM keeps calling unknown tools
+ mock_llm.call_with_tools.return_value = LLMToolCallResult(
+ tool_calls=[LLMToolCall(id="1", name="unknown_tool", arguments={})],
finish_reason="tool_calls",
)
- async def test_agent_done_immediately_rejected_by_guardrail(self, mock_llm, bank_profile, mock_tools):
- """Test that guardrail rejects done without evidence gathering."""
- # First call: agent tries to return done immediately (rejected by guardrail)
- # Second call: agent gathers evidence
- # Third call: agent returns done with evidence
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]),
- # After guardrail rejection, agent should gather evidence
- self._make_tool_result([{"name": "recall", "arguments": {"query": "test query"}}]),
- # Now with evidence, done is accepted
- self._make_tool_result([{"name": "done", "arguments": {"answer": "The answer is 42."}}]),
- ]
-
result = await run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
- query="What is the answer?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert isinstance(result, ReflectAgentResult)
- assert result.text == "The answer is 42."
- # 3 iterations: rejected done, recall, accepted done
- assert result.iterations == 3
- # Tools called: list_mental_models (auto at start of each iteration) + recall
- # The exact count may vary based on implementation
- assert result.tools_called >= 1 # At least recall was called
-
- async def test_agent_calls_tools_then_done(self, mock_llm, bank_profile, mock_tools):
- """Test agent that calls tools before completing."""
- # First call: lookup and recall
- # Second call: done
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([
- {"name": "list_mental_models", "arguments": {}},
- {"name": "recall", "arguments": {"query": "test query"}},
- ]),
- self._make_tool_result([
- {"name": "done", "arguments": {"answer": "Based on my research, the answer is yes."}},
- ]),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Is testing important?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert result.text == "Based on my research, the answer is yes."
- assert result.iterations == 2
- # Tools called: list_mental_models + recall (+ possibly auto list_mental_models)
- assert result.tools_called >= 2
- mock_tools["recall_fn"].assert_called_once_with("test query", 2048)
-
- async def test_agent_learns_model(self, mock_llm, bank_profile, mock_tools):
- """Test agent that creates a mental model placeholder."""
- mock_tools["learn_fn"].return_value = {"status": "created", "model_id": "new-insight", "pending_generation": True}
-
- mock_llm.call_with_tools.side_effect = [
- # First: gather evidence via recall (required by guardrail)
- self._make_tool_result([{"name": "recall", "arguments": {"query": "user preferences"}}]),
- # Then: learn from the gathered evidence
- self._make_tool_result([{
- "name": "learn",
- "arguments": {
- "name": "New Insight",
- "description": "Track patterns about user preferences and communication style",
- }
- }]),
- # Finally: done with the learning
- self._make_tool_result([{"name": "done", "arguments": {"answer": "I've learned something new."}}]),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What can you learn?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert result.mental_models_created == ["new-insight"]
- mock_tools["learn_fn"].assert_called_once()
- # Verify the learn_fn was called with name and description only
- call_args = mock_tools["learn_fn"].call_args
- mental_model_arg = call_args[0][0]
- assert mental_model_arg.name == "New Insight"
- assert "preferences" in mental_model_arg.description
-
- async def test_agent_max_iterations_forces_response(self, mock_llm, bank_profile, mock_tools):
- """Test that max iterations forces a text response."""
- # Return tools indefinitely, then final plain text call
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]),
- self._make_tool_result([{"name": "recall", "arguments": {"query": "query2"}}]),
- ]
- # On last iteration, LLM.call is used (not call_with_tools)
- mock_llm.call.return_value = "Forced final answer after max iterations."
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Test question",
- bank_profile=bank_profile,
+ query="test query",
+ bank_profile={"name": "Test", "mission": "Testing"},
max_iterations=3,
- **mock_tools,
+ **mock_functions,
)
- assert result.text == "Forced final answer after max iterations."
+ # Should have a result even if no memories found
+ assert result is not None
assert result.iterations == 3
-
- async def test_agent_handles_tool_error(self, mock_llm, bank_profile, mock_tools):
- """Test agent propagates tool execution errors."""
- # Make recall fail
- mock_tools["recall_fn"].side_effect = Exception("Database error")
-
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([{"name": "recall", "arguments": {"query": "query"}}]),
- ]
-
- # Tool errors are now propagated as RuntimeError
- with pytest.raises(RuntimeError) as exc_info:
- await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Test question",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert "Database error" in str(exc_info.value)
-
- async def test_agent_parallel_tool_calls(self, mock_llm, bank_profile, mock_tools):
- """Test agent executes multiple tools in parallel."""
- mock_llm.call_with_tools.side_effect = [
- self._make_tool_result([
- {"name": "list_mental_models", "arguments": {}},
- {"name": "recall", "arguments": {"query": "query1"}},
- {"name": "recall", "arguments": {"query": "query2"}},
- ]),
- self._make_tool_result([{"name": "done", "arguments": {"answer": "Done after parallel calls."}}]),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="Test question",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- # Tools called: list_mental_models + 2x recall (+ possibly auto list_mental_models)
- assert result.tools_called >= 3
- # recall should be called twice
- assert mock_tools["recall_fn"].call_count == 2
-
- async def test_agent_returns_validated_memory_ids(self, mock_llm, bank_profile):
- """Test agent returns only validated memory IDs that were actually recalled."""
- memory_id_1 = str(uuid.uuid4())
- memory_id_2 = str(uuid.uuid4())
-
- # Mock recall returns these specific memory IDs
- mock_recall = AsyncMock(
- return_value={
- "query": "test",
- "count": 2,
- "memories": [
- {"id": memory_id_1, "text": "Memory 1", "type": "experience"},
- {"id": memory_id_2, "text": "Memory 2", "type": "world"},
- ],
- }
- )
-
- mock_llm.call_with_tools.side_effect = [
- LLMToolCallResult(
- tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})],
- finish_reason="tool_calls",
- ),
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_1",
- name="done",
- arguments={"answer": "Based on the evidence...", "memory_ids": [memory_id_1, memory_id_2]}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What do we know?",
- bank_profile=bank_profile,
- lookup_fn=AsyncMock(return_value={"count": 0, "models": []}),
- recall_fn=mock_recall,
- expand_fn=AsyncMock(return_value={}),
- )
-
- # Both memory IDs should be in the result (they were recalled)
- assert memory_id_1 in result.used_memory_ids
- assert memory_id_2 in result.used_memory_ids
- assert len(result.used_memory_ids) == 2
-
- async def test_agent_filters_hallucinated_memory_ids(self, mock_llm, bank_profile):
- """Test agent filters out memory IDs that were not in recall results."""
- valid_memory_id = str(uuid.uuid4())
- hallucinated_memory_id = str(uuid.uuid4())
-
- # Mock recall returns only one memory ID
- mock_recall = AsyncMock(
- return_value={
- "query": "test",
- "count": 1,
- "memories": [
- {"id": valid_memory_id, "text": "Real memory", "type": "experience"},
- ],
- }
- )
-
- mock_llm.call_with_tools.side_effect = [
- LLMToolCallResult(
- tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "test query"})],
- finish_reason="tool_calls",
- ),
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_1",
- name="done",
- arguments={"answer": "Based on evidence...", "memory_ids": [valid_memory_id, hallucinated_memory_id]}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What do we know?",
- bank_profile=bank_profile,
- lookup_fn=AsyncMock(return_value={"count": 0, "models": []}),
- recall_fn=mock_recall,
- expand_fn=AsyncMock(return_value={}),
- )
-
- # Only the valid memory ID should be in the result
- assert valid_memory_id in result.used_memory_ids
- assert hallucinated_memory_id not in result.used_memory_ids
- assert len(result.used_memory_ids) == 1
-
- async def test_agent_returns_validated_model_ids(self, mock_llm, bank_profile):
- """Test agent returns only validated model IDs that were actually looked up."""
- model_id = "team-structure"
- hallucinated_model_id = "non-existent-model"
-
- # Mock lookup returns different results based on input
- # - None (or no arg): list_mental_models - returns list of models
- # - model_id: get_mental_model - returns specific model with found=True
- async def mock_lookup_impl(arg=None):
- if arg is None:
- return {"count": 1, "models": [{"id": model_id, "name": "Team Structure", "description": "desc"}]}
- else:
- return {"found": True, "model": {"id": model_id, "name": "Team Structure", "summary": "Full summary"}}
-
- mock_lookup = AsyncMock(side_effect=mock_lookup_impl)
-
- mock_llm.call_with_tools.side_effect = [
- LLMToolCallResult(
- tool_calls=[
- LLMToolCall(id="call_0", name="list_mental_models", arguments={}),
- LLMToolCall(id="call_1", name="get_mental_model", arguments={"model_id": model_id}),
- ],
- finish_reason="tool_calls",
- ),
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_2",
- name="done",
- arguments={"answer": "Based on team structure...", "model_ids": [model_id, hallucinated_model_id]}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="How is the team organized?",
- bank_profile=bank_profile,
- lookup_fn=mock_lookup,
- recall_fn=AsyncMock(return_value={"query": "test", "count": 0, "memories": []}),
- expand_fn=AsyncMock(return_value={}),
- )
-
- # Only the valid model ID should be in the result
- assert model_id in result.used_model_ids
- assert hallucinated_model_id not in result.used_model_ids
-
- async def test_agent_plain_text_answer(self, mock_llm, bank_profile, mock_tools):
- """Test agent with plain text answer format."""
- mock_llm.call_with_tools.side_effect = [
- # First: gather evidence via recall (required by guardrail)
- LLMToolCallResult(
- tool_calls=[LLMToolCall(id="call_0", name="recall", arguments={"query": "answer"})],
- finish_reason="tool_calls",
- ),
- # Then: done with plain text answer
- LLMToolCallResult(
- tool_calls=[LLMToolCall(
- id="call_1",
- name="done",
- arguments={"answer": "The answer is simple and direct."}
- )],
- finish_reason="tool_calls",
- ),
- ]
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What's the answer?",
- bank_profile=bank_profile,
- **mock_tools,
- )
-
- assert result.text == "The answer is simple and direct."
-
- async def test_agent_includes_directives_in_system_prompt(self, mock_llm, bank_profile, mock_tools):
- """Test that directives are included in the system prompt."""
- from hindsight_api.engine.reflect.observations import Observation
-
- # Create directive with Observation objects (new format)
- directives = [
- {
- "id": "response-rules",
- "name": "Response Rules",
- "description": "Rules for responses",
- "subtype": "directive",
- "observations": [
- Observation(
- title="No Speculation",
- content="Never speculate about information not in the memories.",
- evidence=[],
- ),
- Observation(
- title="Be Concise",
- content="Always keep responses under 100 words.",
- evidence=[],
- ),
- ],
- },
- ]
-
- # Capture the system prompt
- captured_messages = []
-
- async def capture_call(*args, **kwargs):
- if "messages" in kwargs:
- captured_messages.extend(kwargs["messages"])
- return self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}])
-
- mock_llm.call_with_tools.side_effect = [
- # First: gather evidence (guardrail requirement)
- self._make_tool_result([{"name": "recall", "arguments": {"query": "test"}}]),
- # Then: done
- self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}]),
- ]
-
- # Store original to check messages
- original_call = mock_llm.call_with_tools
-
- async def wrapped_call(*args, **kwargs):
- if "messages" in kwargs:
- captured_messages.extend(kwargs["messages"])
- return await original_call(*args, **kwargs)
-
- mock_llm.call_with_tools = wrapped_call
-
- result = await run_reflect_agent(
- llm_config=mock_llm,
- bank_id="test-bank",
- query="What do we know?",
- bank_profile=bank_profile,
- directives=directives,
- **mock_tools,
- )
-
- # Find the system message
- system_messages = [m for m in captured_messages if m.get("role") == "system"]
- assert len(system_messages) > 0, "No system message found"
-
- system_content = system_messages[0]["content"]
-
- # Verify directives are in the system prompt
- assert "DIRECTIVES" in system_content, "Directives section not found in system prompt"
- assert "No Speculation" in system_content, "Directive title not found"
- assert "Never speculate" in system_content, "Directive content not found"
- assert "Be Concise" in system_content, "Second directive title not found"
- assert "100 words" in system_content, "Second directive content not found"
- assert "NEVER violate these directives" in system_content, "Directive warning not found"
-
-
-class TestDirectivesSection:
- """Test the directives section builder."""
-
- def test_build_directives_section_with_observation_objects(self):
- """Test building directives section with Observation objects."""
- from hindsight_api.engine.reflect.observations import Observation
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Safety Rules",
- "observations": [
- Observation(
- title="No Harmful Content",
- content="Never generate harmful or dangerous content.",
- evidence=[],
- ),
- ],
- },
- ]
-
- result = build_directives_section(directives)
-
- assert "DIRECTIVES" in result
- assert "No Harmful Content" in result
- assert "Never generate harmful" in result
- assert "NEVER violate" in result
-
- def test_build_directives_section_with_dicts(self):
- """Test building directives section with dict observations."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Safety Rules",
- "observations": [
- {
- "title": "No Harmful Content",
- "content": "Never generate harmful or dangerous content.",
- },
- ],
- },
- ]
-
- result = build_directives_section(directives)
-
- assert "DIRECTIVES" in result
- assert "No Harmful Content" in result
- assert "Never generate harmful" in result
-
- def test_build_directives_section_fallback_to_description(self):
- """Test that directives without observations use description."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- directives = [
- {
- "name": "Simple Rule",
- "description": "This is a simple rule to follow.",
- "observations": [],
- },
- ]
-
- result = build_directives_section(directives)
-
- assert "Simple Rule" in result
- assert "simple rule to follow" in result
-
- def test_build_directives_section_empty(self):
- """Test that empty directives returns empty string."""
- from hindsight_api.engine.reflect.prompts import build_directives_section
-
- result = build_directives_section([])
- assert result == ""
-
- result = build_directives_section(None)
- assert result == ""
-
-
-@pytest.mark.integration
-class TestReflectIntegration:
- """Integration tests for reflect with real database.
-
- These tests require a running database and LLM provider.
- Skip with: pytest -m "not integration"
- """
-
- async def test_reflect_creates_learned_mental_model(self, memory, request_context):
- """Test that reflect can create a 'learned' mental model via the agent."""
- bank_id = f"test-reflect-{uuid.uuid4().hex[:8]}"
-
- # Add some test data
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Alice is the team lead and manages the engineering team."},
- {"content": "The team has weekly planning meetings on Monday."},
- {"content": "Alice prefers asynchronous communication via Slack."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Run reflect - this should use the agentic loop
- result = await memory.reflect_async(
- bank_id=bank_id,
- query="What do you know about Alice and how she manages the team?",
- request_context=request_context,
- )
-
- assert result.text is not None
- assert len(result.text) > 0
-
- # Check if any mental models were created (may or may not happen depending on LLM)
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- # If models were created, they should be 'learned' subtype
- for model in models:
- if model.get("subtype") == "learned":
- # Learned models are created as placeholders pending generation
- assert model.get("name") is not None
- assert model.get("description") is not None
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_reflect_learn_triggers_background_generation(self, memory, request_context):
- """Test that when reflect calls learn, background generation is triggered.
-
- This test verifies the full flow:
- 1. Agent decides to learn something important
- 2. learn tool creates a placeholder model
- 3. Background generation is automatically triggered
- """
- import asyncio
-
- bank_id = f"test-reflect-learn-{uuid.uuid4().hex[:8]}"
-
- # Add rich test data that should prompt the agent to learn something
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "Bob is the CEO and founder of the company."},
- {"content": "Bob started the company in 2015 after leaving Google."},
- {"content": "Bob holds weekly all-hands meetings every Friday at 3pm."},
- {"content": "Bob's management style is very hands-off and trusts his team."},
- {"content": "Bob prefers face-to-face communication over email."},
- {"content": "Bob has a strong focus on company culture and team building."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Run reflect with a query that should prompt learning
- result = await memory.reflect_async(
- bank_id=bank_id,
- query="Tell me everything about Bob's leadership style and how he runs the company. "
- "This is important information I'll need to reference frequently.",
- request_context=request_context,
- )
-
- assert result.text is not None
- assert len(result.text) > 0
-
- # Wait for any background tasks to complete
- await memory.wait_for_background_tasks()
- # Give a bit more time for async generation
- await asyncio.sleep(2)
-
- # Check if learned models were created
- models = await memory.list_mental_models(
- bank_id=bank_id,
- request_context=request_context,
- )
-
- learned_models = [m for m in models if m.get("subtype") == "learned"]
-
- # If learned models were created, verify they have proper structure
- for model in learned_models:
- assert model.get("name") is not None
- assert model.get("description") is not None
- # After background generation, the model should have been updated
- # (observations may or may not be populated depending on timing)
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
-
- async def test_reflect_excludes_opinions_from_recall(self, memory, request_context):
- """Test that reflect's recall tool doesn't return opinions."""
- bank_id = f"test-reflect-no-opinions-{uuid.uuid4().hex[:8]}"
-
- # Add test data (note: we can't directly add opinions since opinion
- # extraction was removed, but we can verify recall behavior)
- await memory.retain_batch_async(
- bank_id=bank_id,
- contents=[
- {"content": "The weather today is sunny and warm."},
- ],
- request_context=request_context,
- )
- await memory.wait_for_background_tasks()
-
- # Run recall directly to verify it excludes opinions
- recall_result = await memory.recall_async(
- bank_id=bank_id,
- query="weather",
- request_context=request_context,
- )
-
- # All returned facts should be experience or world, not opinion
- for fact in recall_result.results:
- assert fact.fact_type in ["experience", "world"]
- assert fact.fact_type != "opinion"
-
- # Cleanup
- await memory.delete_bank(bank_id, request_context=request_context)
diff --git a/hindsight-api/tests/test_reflections.py b/hindsight-api/tests/test_reflections.py
new file mode 100644
index 00000000..25437e06
--- /dev/null
+++ b/hindsight-api/tests/test_reflections.py
@@ -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}")
diff --git a/hindsight-api/tests/test_server_module.py b/hindsight-api/tests/test_server_module.py
index 0d2d95a2..bc1d29bf 100644
--- a/hindsight-api/tests/test_server_module.py
+++ b/hindsight-api/tests/test_server_module.py
@@ -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()
diff --git a/hindsight-api/tests/test_sql_schema_safety.py b/hindsight-api/tests/test_sql_schema_safety.py
index b7ddd436..70f78681 100644
--- a/hindsight-api/tests/test_sql_schema_safety.py
+++ b/hindsight-api/tests/test_sql_schema_safety.py
@@ -21,6 +21,8 @@ TABLES = [
"documents",
"chunks",
"async_operations",
+ "directives",
+ "reflections",
]
# Files to scan for SQL queries
diff --git a/hindsight-api/tests/test_worker.py b/hindsight-api/tests/test_worker.py
index f9748678..7302c358 100644
--- a/hindsight-api/tests/test_worker.py
+++ b/hindsight-api/tests/test_worker.py
@@ -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)."""
diff --git a/hindsight-cli/smoke-test.sh b/hindsight-cli/smoke-test.sh
index 8b4fd171..55efca1b 100755
--- a/hindsight-cli/smoke-test.sh
+++ b/hindsight-cli/smoke-test.sh
@@ -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 ""
diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs
index 78d1b42f..0acbf080 100644
--- a/hindsight-cli/src/api.rs
+++ b/hindsight-cli/src/api.rs
@@ -173,7 +173,7 @@ impl ApiClient {
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option)> {
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 {
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>,
- tags_match: Option<&str>,
- _verbose: bool,
- ) -> Result {
- 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 {
- 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 {
- 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 {
- 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 {
- 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>,
- _verbose: bool,
- ) -> Result {
- 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 {
- 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 {
- 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 {
- 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 {
@@ -574,6 +436,109 @@ impl ApiClient {
Ok(response.into_inner())
})
}
+
+ // --- Reflection Methods ---
+
+ pub fn list_reflections(&self, bank_id: &str, _verbose: bool) -> Result {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
diff --git a/hindsight-cli/src/commands/directive.rs b/hindsight-cli/src/commands/directive.rs
new file mode 100644
index 00000000..645b17b3
--- /dev/null
+++ b/hindsight-cli/src/commands/directive.rs
@@ -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,
+ content: Option,
+ 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!();
+}
diff --git a/hindsight-cli/src/commands/mental_model.rs b/hindsight-cli/src/commands/mental_model.rs
deleted file mode 100644
index 384aab19..00000000
--- a/hindsight-cli/src/commands/mental_model.rs
+++ /dev/null
@@ -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,
-}
-
-#[derive(Debug, Deserialize)]
-struct VersionItem {
- version: i64,
- created_at: String,
- observations_count: Option,
-}
-
-#[derive(Debug, Deserialize)]
-struct VersionDetailResponse {
- version: i64,
- created_at: String,
- observations: Option>,
-}
-
-#[derive(Debug, Deserialize)]
-struct ObservationData {
- title: String,
- content: String,
- trend: Option,
- evidence: Option>,
-}
-
-#[derive(Debug, Deserialize)]
-struct EvidenceData {
- quote: String,
-}
-
-/// List mental models for a bank
-pub fn list(
- client: &ApiClient,
- bank_id: &str,
- subtype: Option,
- tags: Option>,
- tags_match: Option,
- 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,
- tags: Option>,
- observations_file: Option,
- 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 = 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,
- description: Option,
- 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,
- tags: Option>,
- 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");
- }
-}
diff --git a/hindsight-cli/src/commands/mod.rs b/hindsight-cli/src/commands/mod.rs
index e72bb527..a7a63064 100644
--- a/hindsight-cli/src/commands/mod.rs
+++ b/hindsight-cli/src/commands/mod.rs
@@ -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;
diff --git a/hindsight-cli/src/commands/reflection.rs b/hindsight-cli/src/commands/reflection.rs
new file mode 100644
index 00000000..96e50554
--- /dev/null
+++ b/hindsight-cli/src/commands/reflection.rs
@@ -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,
+ 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!();
+}
diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs
index 75d190a1..38d2b010 100644
--- a/hindsight-cli/src/main.rs
+++ b/hindsight-cli/src/main.rs
@@ -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,
-
- /// Filter by tags
- #[arg(long, value_delimiter = ',')]
- tags: Option>,
-
- /// Tag matching mode (any, all, any_strict, all_strict)
- #[arg(long, default_value = "any")]
- tags_match: Option,
- },
-
- /// 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,
-
- /// Tags for the model
- #[arg(long, value_delimiter = ',')]
- tags: Option>,
-
- /// Path to JSON file containing initial observations
- #[arg(long)]
- observations: Option,
- },
-
- /// 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,
-
- /// New description
- #[arg(long)]
- description: Option,
- },
-
- /// 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,
-
- /// Filter by tags
- #[arg(long, value_delimiter = ',')]
- tags: Option>,
- },
-
- /// 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,
+ },
+
+ /// 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,
+
+ /// New content
+ #[arg(long)]
+ content: Option,
+ },
+
+ /// 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
diff --git a/hindsight-cli/src/ui.rs b/hindsight-cli/src/ui.rs
index 6223ae2e..e4f45f35 100644
--- a/hindsight-cli/src/ui.rs
+++ b/hindsight-cli/src/ui.rs
@@ -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)));
}
diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES
index 1b9dc649..b8bbaefd 100644
--- a/hindsight-clients/python/.openapi-generator/FILES
+++ b/hindsight-clients/python/.openapi-generator/FILES
@@ -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
diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py
index 1269aaad..9cfdd17e 100644
--- a/hindsight-clients/python/hindsight_client/hindsight_client.py
+++ b/hindsight-clients/python/hindsight_client/hindsight_client.py
@@ -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(
diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py
index a00f92d8..d6d3bcb9 100644
--- a/hindsight-clients/python/hindsight_client_api/__init__.py
+++ b/hindsight-clients/python/hindsight_client_api/__init__.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/api/__init__.py b/hindsight-clients/python/hindsight_client_api/api/__init__.py
index 15a79c5a..b7029893 100644
--- a/hindsight-clients/python/hindsight_client_api/api/__init__.py
+++ b/hindsight-clients/python/hindsight_client_api/api/__init__.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/api/banks_api.py b/hindsight-clients/python/hindsight_client_api/api/banks_api.py
index 4ca11c2f..a0bf82b0 100644
--- a/hindsight-clients/python/hindsight_client_api/api/banks_api.py
+++ b/hindsight-clients/python/hindsight_client_api/api/banks_api.py
@@ -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,
diff --git a/hindsight-clients/python/hindsight_client_api/api/directives_api.py b/hindsight-clients/python/hindsight_client_api/api/directives_api.py
new file mode 100644
index 00000000..74a038be
--- /dev/null
+++ b/hindsight-clients/python/hindsight_client_api/api/directives_api.py
@@ -0,0 +1,1619 @@
+# 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
+
+import warnings
+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 Field, StrictBool, StrictStr, field_validator
+from typing import Any, List, Optional
+from typing_extensions import Annotated
+from hindsight_client_api.models.create_directive_request import CreateDirectiveRequest
+from hindsight_client_api.models.directive_list_response import DirectiveListResponse
+from hindsight_client_api.models.directive_response import DirectiveResponse
+from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest
+
+from hindsight_client_api.api_client import ApiClient, RequestSerialized
+from hindsight_client_api.api_response import ApiResponse
+from hindsight_client_api.rest import RESTResponseType
+
+
+class DirectivesApi:
+ """NOTE: This class is auto generated by OpenAPI Generator
+ Ref: https://openapi-generator.tech
+
+ Do not edit the class manually.
+ """
+
+ def __init__(self, api_client=None) -> None:
+ if api_client is None:
+ api_client = ApiClient.get_default()
+ self.api_client = api_client
+
+
+ @validate_call
+ async def create_directive(
+ self,
+ bank_id: StrictStr,
+ create_directive_request: CreateDirectiveRequest,
+ 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,
+ ) -> DirectiveResponse:
+ """Create directive
+
+ Create a hard rule that will be injected into prompts.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param create_directive_request: (required)
+ :type create_directive_request: CreateDirectiveRequest
+ :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._create_directive_serialize(
+ bank_id=bank_id,
+ create_directive_request=create_directive_request,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveResponse",
+ '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 create_directive_with_http_info(
+ self,
+ bank_id: StrictStr,
+ create_directive_request: CreateDirectiveRequest,
+ 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[DirectiveResponse]:
+ """Create directive
+
+ Create a hard rule that will be injected into prompts.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param create_directive_request: (required)
+ :type create_directive_request: CreateDirectiveRequest
+ :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._create_directive_serialize(
+ bank_id=bank_id,
+ create_directive_request=create_directive_request,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveResponse",
+ '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 create_directive_without_preload_content(
+ self,
+ bank_id: StrictStr,
+ create_directive_request: CreateDirectiveRequest,
+ 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:
+ """Create directive
+
+ Create a hard rule that will be injected into prompts.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param create_directive_request: (required)
+ :type create_directive_request: CreateDirectiveRequest
+ :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._create_directive_serialize(
+ bank_id=bank_id,
+ create_directive_request=create_directive_request,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveResponse",
+ '422': "HTTPValidationError",
+ }
+ response_data = await self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ return response_data.response
+
+
+ def _create_directive_serialize(
+ self,
+ bank_id,
+ create_directive_request,
+ 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
+ if create_directive_request is not None:
+ _body_params = create_directive_request
+
+
+ # set the HTTP header `Accept`
+ if 'Accept' not in _header_params:
+ _header_params['Accept'] = self.api_client.select_header_accept(
+ [
+ 'application/json'
+ ]
+ )
+
+ # set the HTTP header `Content-Type`
+ if _content_type:
+ _header_params['Content-Type'] = _content_type
+ else:
+ _default_content_type = (
+ self.api_client.select_header_content_type(
+ [
+ 'application/json'
+ ]
+ )
+ )
+ if _default_content_type is not None:
+ _header_params['Content-Type'] = _default_content_type
+
+ # authentication setting
+ _auth_settings: List[str] = [
+ ]
+
+ return self.api_client.param_serialize(
+ method='POST',
+ resource_path='/v1/default/banks/{bank_id}/directives',
+ 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 delete_directive(
+ self,
+ bank_id: StrictStr,
+ directive_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,
+ ) -> object:
+ """Delete directive
+
+ Delete a directive.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_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._delete_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_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': "object",
+ '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 delete_directive_with_http_info(
+ self,
+ bank_id: StrictStr,
+ directive_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[object]:
+ """Delete directive
+
+ Delete a directive.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_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._delete_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_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': "object",
+ '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 delete_directive_without_preload_content(
+ self,
+ bank_id: StrictStr,
+ directive_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:
+ """Delete directive
+
+ Delete a directive.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_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._delete_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_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': "object",
+ '422': "HTTPValidationError",
+ }
+ response_data = await self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ return response_data.response
+
+
+ def _delete_directive_serialize(
+ self,
+ bank_id,
+ directive_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
+ if directive_id is not None:
+ _path_params['directive_id'] = directive_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}/directives/{directive_id}',
+ 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 get_directive(
+ self,
+ bank_id: StrictStr,
+ directive_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,
+ ) -> DirectiveResponse:
+ """Get directive
+
+ Get a specific directive by ID.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_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._get_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_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': "DirectiveResponse",
+ '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 get_directive_with_http_info(
+ self,
+ bank_id: StrictStr,
+ directive_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[DirectiveResponse]:
+ """Get directive
+
+ Get a specific directive by ID.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_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._get_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_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': "DirectiveResponse",
+ '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 get_directive_without_preload_content(
+ self,
+ bank_id: StrictStr,
+ directive_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:
+ """Get directive
+
+ Get a specific directive by ID.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_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._get_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_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': "DirectiveResponse",
+ '422': "HTTPValidationError",
+ }
+ response_data = await self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ return response_data.response
+
+
+ def _get_directive_serialize(
+ self,
+ bank_id,
+ directive_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
+ if directive_id is not None:
+ _path_params['directive_id'] = directive_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='GET',
+ resource_path='/v1/default/banks/{bank_id}/directives/{directive_id}',
+ 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 list_directives(
+ self,
+ bank_id: StrictStr,
+ tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None,
+ tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None,
+ active_only: Annotated[Optional[StrictBool], Field(description="Only return active directives")] = None,
+ limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None,
+ offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None,
+ 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,
+ ) -> DirectiveListResponse:
+ """List directives
+
+ List hard rules that are injected into prompts.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param tags: Filter by tags
+ :type tags: List[str]
+ :param tags_match: How to match tags
+ :type tags_match: str
+ :param active_only: Only return active directives
+ :type active_only: bool
+ :param limit:
+ :type limit: int
+ :param offset:
+ :type offset: int
+ :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._list_directives_serialize(
+ bank_id=bank_id,
+ tags=tags,
+ tags_match=tags_match,
+ active_only=active_only,
+ limit=limit,
+ offset=offset,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveListResponse",
+ '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 list_directives_with_http_info(
+ self,
+ bank_id: StrictStr,
+ tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None,
+ tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None,
+ active_only: Annotated[Optional[StrictBool], Field(description="Only return active directives")] = None,
+ limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None,
+ offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None,
+ 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[DirectiveListResponse]:
+ """List directives
+
+ List hard rules that are injected into prompts.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param tags: Filter by tags
+ :type tags: List[str]
+ :param tags_match: How to match tags
+ :type tags_match: str
+ :param active_only: Only return active directives
+ :type active_only: bool
+ :param limit:
+ :type limit: int
+ :param offset:
+ :type offset: int
+ :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._list_directives_serialize(
+ bank_id=bank_id,
+ tags=tags,
+ tags_match=tags_match,
+ active_only=active_only,
+ limit=limit,
+ offset=offset,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveListResponse",
+ '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 list_directives_without_preload_content(
+ self,
+ bank_id: StrictStr,
+ tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None,
+ tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None,
+ active_only: Annotated[Optional[StrictBool], Field(description="Only return active directives")] = None,
+ limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None,
+ offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None,
+ 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:
+ """List directives
+
+ List hard rules that are injected into prompts.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param tags: Filter by tags
+ :type tags: List[str]
+ :param tags_match: How to match tags
+ :type tags_match: str
+ :param active_only: Only return active directives
+ :type active_only: bool
+ :param limit:
+ :type limit: int
+ :param offset:
+ :type offset: int
+ :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._list_directives_serialize(
+ bank_id=bank_id,
+ tags=tags,
+ tags_match=tags_match,
+ active_only=active_only,
+ limit=limit,
+ offset=offset,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveListResponse",
+ '422': "HTTPValidationError",
+ }
+ response_data = await self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ return response_data.response
+
+
+ def _list_directives_serialize(
+ self,
+ bank_id,
+ tags,
+ tags_match,
+ active_only,
+ limit,
+ offset,
+ authorization,
+ _request_auth,
+ _content_type,
+ _headers,
+ _host_index,
+ ) -> RequestSerialized:
+
+ _host = None
+
+ _collection_formats: Dict[str, str] = {
+ 'tags': 'multi',
+ }
+
+ _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
+ if tags is not None:
+
+ _query_params.append(('tags', tags))
+
+ if tags_match is not None:
+
+ _query_params.append(('tags_match', tags_match))
+
+ if active_only is not None:
+
+ _query_params.append(('active_only', active_only))
+
+ 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
+ # 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='/v1/default/banks/{bank_id}/directives',
+ 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_directive(
+ self,
+ bank_id: StrictStr,
+ directive_id: StrictStr,
+ update_directive_request: UpdateDirectiveRequest,
+ 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,
+ ) -> DirectiveResponse:
+ """Update directive
+
+ Update a directive's properties.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_id: str
+ :param update_directive_request: (required)
+ :type update_directive_request: UpdateDirectiveRequest
+ :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._update_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ update_directive_request=update_directive_request,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveResponse",
+ '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 update_directive_with_http_info(
+ self,
+ bank_id: StrictStr,
+ directive_id: StrictStr,
+ update_directive_request: UpdateDirectiveRequest,
+ 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[DirectiveResponse]:
+ """Update directive
+
+ Update a directive's properties.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_id: str
+ :param update_directive_request: (required)
+ :type update_directive_request: UpdateDirectiveRequest
+ :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._update_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ update_directive_request=update_directive_request,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveResponse",
+ '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 update_directive_without_preload_content(
+ self,
+ bank_id: StrictStr,
+ directive_id: StrictStr,
+ update_directive_request: UpdateDirectiveRequest,
+ 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:
+ """Update directive
+
+ Update a directive's properties.
+
+ :param bank_id: (required)
+ :type bank_id: str
+ :param directive_id: (required)
+ :type directive_id: str
+ :param update_directive_request: (required)
+ :type update_directive_request: UpdateDirectiveRequest
+ :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._update_directive_serialize(
+ bank_id=bank_id,
+ directive_id=directive_id,
+ update_directive_request=update_directive_request,
+ authorization=authorization,
+ _request_auth=_request_auth,
+ _content_type=_content_type,
+ _headers=_headers,
+ _host_index=_host_index
+ )
+
+ _response_types_map: Dict[str, Optional[str]] = {
+ '200': "DirectiveResponse",
+ '422': "HTTPValidationError",
+ }
+ response_data = await self.api_client.call_api(
+ *_param,
+ _request_timeout=_request_timeout
+ )
+ return response_data.response
+
+
+ def _update_directive_serialize(
+ self,
+ bank_id,
+ directive_id,
+ update_directive_request,
+ 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
+ if directive_id is not None:
+ _path_params['directive_id'] = directive_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
+ if update_directive_request is not None:
+ _body_params = update_directive_request
+
+
+ # set the HTTP header `Accept`
+ if 'Accept' not in _header_params:
+ _header_params['Accept'] = self.api_client.select_header_accept(
+ [
+ 'application/json'
+ ]
+ )
+
+ # set the HTTP header `Content-Type`
+ if _content_type:
+ _header_params['Content-Type'] = _content_type
+ else:
+ _default_content_type = (
+ self.api_client.select_header_content_type(
+ [
+ 'application/json'
+ ]
+ )
+ )
+ if _default_content_type is not None:
+ _header_params['Content-Type'] = _default_content_type
+
+ # authentication setting
+ _auth_settings: List[str] = [
+ ]
+
+ return self.api_client.param_serialize(
+ method='PATCH',
+ resource_path='/v1/default/banks/{bank_id}/directives/{directive_id}',
+ 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
+ )
+
+
diff --git a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py b/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py
index 354c25af..edd24acf 100644
--- a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py
+++ b/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py
@@ -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,
diff --git a/hindsight-clients/python/hindsight_client_api/api/operations_api.py b/hindsight-clients/python/hindsight_client_api/api/operations_api.py
index 200c1abb..b3a7e63a 100644
--- a/hindsight-clients/python/hindsight_client_api/api/operations_api.py
+++ b/hindsight-clients/python/hindsight_client_api/api/operations_api.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py b/hindsight-clients/python/hindsight_client_api/api/reflections_api.py
similarity index 56%
rename from hindsight-clients/python/hindsight_client_api/api/mental_models_api.py
rename to hindsight-clients/python/hindsight_client_api/api/reflections_api.py
index 66aa1d50..e617b1b0 100644
--- a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py
+++ b/hindsight-clients/python/hindsight_client_api/api/reflections_api.py
@@ -16,23 +16,21 @@ 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 Field, StrictInt, StrictStr, field_validator
+from pydantic import Field, StrictStr, field_validator
from typing import Any, List, Optional
from typing_extensions import Annotated
-from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
-from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
-from hindsight_client_api.models.delete_response import DeleteResponse
-from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
-from hindsight_client_api.models.mental_model_response import MentalModelResponse
-from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest
-from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
+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.reflection_list_response import ReflectionListResponse
+from hindsight_client_api.models.reflection_response import ReflectionResponse
+from hindsight_client_api.models.update_reflection_request import UpdateReflectionRequest
from hindsight_client_api.api_client import ApiClient, RequestSerialized
from hindsight_client_api.api_response import ApiResponse
from hindsight_client_api.rest import RESTResponseType
-class MentalModelsApi:
+class ReflectionsApi:
"""NOTE: This class is auto generated by OpenAPI Generator
Ref: https://openapi-generator.tech
@@ -46,10 +44,10 @@ class MentalModelsApi:
@validate_call
- async def create_mental_model(
+ async def create_reflection(
self,
bank_id: StrictStr,
- create_mental_model_request: CreateMentalModelRequest,
+ create_reflection_request: CreateReflectionRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -63,15 +61,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> MentalModelResponse:
- """Create mental model
+ ) -> CreateReflectionResponse:
+ """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.
:param bank_id: (required)
:type bank_id: str
- :param create_mental_model_request: (required)
- :type create_mental_model_request: CreateMentalModelRequest
+ :param create_reflection_request: (required)
+ :type create_reflection_request: CreateReflectionRequest
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -96,9 +94,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._create_mental_model_serialize(
+ _param = self._create_reflection_serialize(
bank_id=bank_id,
- create_mental_model_request=create_mental_model_request,
+ create_reflection_request=create_reflection_request,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -107,7 +105,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
+ '200': "CreateReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -122,10 +120,10 @@ class MentalModelsApi:
@validate_call
- async def create_mental_model_with_http_info(
+ async def create_reflection_with_http_info(
self,
bank_id: StrictStr,
- create_mental_model_request: CreateMentalModelRequest,
+ create_reflection_request: CreateReflectionRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -139,15 +137,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> ApiResponse[MentalModelResponse]:
- """Create mental model
+ ) -> ApiResponse[CreateReflectionResponse]:
+ """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.
:param bank_id: (required)
:type bank_id: str
- :param create_mental_model_request: (required)
- :type create_mental_model_request: CreateMentalModelRequest
+ :param create_reflection_request: (required)
+ :type create_reflection_request: CreateReflectionRequest
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -172,9 +170,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._create_mental_model_serialize(
+ _param = self._create_reflection_serialize(
bank_id=bank_id,
- create_mental_model_request=create_mental_model_request,
+ create_reflection_request=create_reflection_request,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -183,7 +181,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
+ '200': "CreateReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -198,10 +196,10 @@ class MentalModelsApi:
@validate_call
- async def create_mental_model_without_preload_content(
+ async def create_reflection_without_preload_content(
self,
bank_id: StrictStr,
- create_mental_model_request: CreateMentalModelRequest,
+ create_reflection_request: CreateReflectionRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -216,14 +214,14 @@ class MentalModelsApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
- """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.
:param bank_id: (required)
:type bank_id: str
- :param create_mental_model_request: (required)
- :type create_mental_model_request: CreateMentalModelRequest
+ :param create_reflection_request: (required)
+ :type create_reflection_request: CreateReflectionRequest
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -248,9 +246,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._create_mental_model_serialize(
+ _param = self._create_reflection_serialize(
bank_id=bank_id,
- create_mental_model_request=create_mental_model_request,
+ create_reflection_request=create_reflection_request,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -259,7 +257,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
+ '200': "CreateReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -269,10 +267,10 @@ class MentalModelsApi:
return response_data.response
- def _create_mental_model_serialize(
+ def _create_reflection_serialize(
self,
bank_id,
- create_mental_model_request,
+ create_reflection_request,
authorization,
_request_auth,
_content_type,
@@ -303,8 +301,8 @@ class MentalModelsApi:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
- if create_mental_model_request is not None:
- _body_params = create_mental_model_request
+ if create_reflection_request is not None:
+ _body_params = create_reflection_request
# set the HTTP header `Accept`
@@ -335,7 +333,7 @@ class MentalModelsApi:
return self.api_client.param_serialize(
method='POST',
- resource_path='/v1/default/banks/{bank_id}/mental-models',
+ resource_path='/v1/default/banks/{bank_id}/reflections',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
@@ -352,10 +350,10 @@ class MentalModelsApi:
@validate_call
- async def delete_mental_model(
+ async def delete_reflection(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -369,15 +367,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> DeleteResponse:
- """Delete mental model
+ ) -> object:
+ """Delete reflection
- Delete a mental model.
+ Delete a reflection.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -402,9 +400,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._delete_mental_model_serialize(
+ _param = self._delete_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -413,7 +411,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "DeleteResponse",
+ '200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -428,10 +426,10 @@ class MentalModelsApi:
@validate_call
- async def delete_mental_model_with_http_info(
+ async def delete_reflection_with_http_info(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -445,15 +443,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> ApiResponse[DeleteResponse]:
- """Delete mental model
+ ) -> ApiResponse[object]:
+ """Delete reflection
- Delete a mental model.
+ Delete a reflection.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -478,9 +476,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._delete_mental_model_serialize(
+ _param = self._delete_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -489,7 +487,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "DeleteResponse",
+ '200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -504,10 +502,10 @@ class MentalModelsApi:
@validate_call
- async def delete_mental_model_without_preload_content(
+ async def delete_reflection_without_preload_content(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -522,14 +520,14 @@ class MentalModelsApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
- """Delete mental model
+ """Delete reflection
- Delete a mental model.
+ Delete a reflection.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -554,9 +552,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._delete_mental_model_serialize(
+ _param = self._delete_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -565,7 +563,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "DeleteResponse",
+ '200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -575,10 +573,10 @@ class MentalModelsApi:
return response_data.response
- def _delete_mental_model_serialize(
+ def _delete_reflection_serialize(
self,
bank_id,
- model_id,
+ reflection_id,
authorization,
_request_auth,
_content_type,
@@ -603,8 +601,8 @@ class MentalModelsApi:
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
- if model_id is not None:
- _path_params['model_id'] = model_id
+ if reflection_id is not None:
+ _path_params['reflection_id'] = reflection_id
# process the query parameters
# process the header parameters
if authorization is not None:
@@ -628,7 +626,7 @@ class MentalModelsApi:
return self.api_client.param_serialize(
method='DELETE',
- resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}',
+ resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
@@ -645,10 +643,10 @@ class MentalModelsApi:
@validate_call
- async def get_mental_model(
+ async def get_reflection(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -662,15 +660,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> MentalModelResponse:
- """Get mental model
+ ) -> ReflectionResponse:
+ """Get reflection
- Get a specific mental model by ID.
+ Get a specific reflection by ID.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -695,9 +693,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._get_mental_model_serialize(
+ _param = self._get_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -706,7 +704,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -721,10 +719,10 @@ class MentalModelsApi:
@validate_call
- async def get_mental_model_with_http_info(
+ async def get_reflection_with_http_info(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -738,15 +736,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> ApiResponse[MentalModelResponse]:
- """Get mental model
+ ) -> ApiResponse[ReflectionResponse]:
+ """Get reflection
- Get a specific mental model by ID.
+ Get a specific reflection by ID.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -771,9 +769,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._get_mental_model_serialize(
+ _param = self._get_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -782,7 +780,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -797,10 +795,10 @@ class MentalModelsApi:
@validate_call
- async def get_mental_model_without_preload_content(
+ async def get_reflection_without_preload_content(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -815,14 +813,14 @@ class MentalModelsApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
- """Get mental model
+ """Get reflection
- Get a specific mental model by ID.
+ Get a specific reflection by ID.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -847,9 +845,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._get_mental_model_serialize(
+ _param = self._get_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -858,7 +856,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -868,10 +866,10 @@ class MentalModelsApi:
return response_data.response
- def _get_mental_model_serialize(
+ def _get_reflection_serialize(
self,
bank_id,
- model_id,
+ reflection_id,
authorization,
_request_auth,
_content_type,
@@ -896,8 +894,8 @@ class MentalModelsApi:
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
- if model_id is not None:
- _path_params['model_id'] = model_id
+ if reflection_id is not None:
+ _path_params['reflection_id'] = reflection_id
# process the query parameters
# process the header parameters
if authorization is not None:
@@ -921,7 +919,7 @@ class MentalModelsApi:
return self.api_client.param_serialize(
method='GET',
- resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}',
+ resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
@@ -938,11 +936,13 @@ class MentalModelsApi:
@validate_call
- async def get_mental_model_version(
+ async def list_reflections(
self,
bank_id: StrictStr,
- model_id: StrictStr,
- version: StrictInt,
+ tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None,
+ tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None,
+ limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None,
+ offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -956,17 +956,21 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> object:
- """Get specific mental model version
+ ) -> ReflectionListResponse:
+ """List reflections
- Get observations from a specific version of a mental model.
+ List user-curated living documents that stay current.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
- :param version: (required)
- :type version: int
+ :param tags: Filter by tags
+ :type tags: List[str]
+ :param tags_match: How to match tags
+ :type tags_match: str
+ :param limit:
+ :type limit: int
+ :param offset:
+ :type offset: int
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -991,10 +995,12 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._get_mental_model_version_serialize(
+ _param = self._list_reflections_serialize(
bank_id=bank_id,
- model_id=model_id,
- version=version,
+ tags=tags,
+ tags_match=tags_match,
+ limit=limit,
+ offset=offset,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -1003,7 +1009,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "object",
+ '200': "ReflectionListResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -1018,11 +1024,13 @@ class MentalModelsApi:
@validate_call
- async def get_mental_model_version_with_http_info(
+ async def list_reflections_with_http_info(
self,
bank_id: StrictStr,
- model_id: StrictStr,
- version: StrictInt,
+ tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None,
+ tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None,
+ limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None,
+ offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -1036,17 +1044,21 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> ApiResponse[object]:
- """Get specific mental model version
+ ) -> ApiResponse[ReflectionListResponse]:
+ """List reflections
- Get observations from a specific version of a mental model.
+ List user-curated living documents that stay current.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
- :param version: (required)
- :type version: int
+ :param tags: Filter by tags
+ :type tags: List[str]
+ :param tags_match: How to match tags
+ :type tags_match: str
+ :param limit:
+ :type limit: int
+ :param offset:
+ :type offset: int
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -1071,10 +1083,12 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._get_mental_model_version_serialize(
+ _param = self._list_reflections_serialize(
bank_id=bank_id,
- model_id=model_id,
- version=version,
+ tags=tags,
+ tags_match=tags_match,
+ limit=limit,
+ offset=offset,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -1083,7 +1097,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "object",
+ '200': "ReflectionListResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -1098,11 +1112,13 @@ class MentalModelsApi:
@validate_call
- async def get_mental_model_version_without_preload_content(
+ async def list_reflections_without_preload_content(
self,
bank_id: StrictStr,
- model_id: StrictStr,
- version: StrictInt,
+ tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags")] = None,
+ tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags")] = None,
+ limit: Optional[Annotated[int, Field(le=1000, strict=True, ge=1)]] = None,
+ offset: Optional[Annotated[int, Field(strict=True, ge=0)]] = None,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -1117,16 +1133,20 @@ class MentalModelsApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
- """Get specific mental model version
+ """List reflections
- Get observations from a specific version of a mental model.
+ List user-curated living documents that stay current.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
- :param version: (required)
- :type version: int
+ :param tags: Filter by tags
+ :type tags: List[str]
+ :param tags_match: How to match tags
+ :type tags_match: str
+ :param limit:
+ :type limit: int
+ :param offset:
+ :type offset: int
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -1151,10 +1171,12 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._get_mental_model_version_serialize(
+ _param = self._list_reflections_serialize(
bank_id=bank_id,
- model_id=model_id,
- version=version,
+ tags=tags,
+ tags_match=tags_match,
+ limit=limit,
+ offset=offset,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -1163,7 +1185,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "object",
+ '200': "ReflectionListResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -1173,625 +1195,13 @@ class MentalModelsApi:
return response_data.response
- def _get_mental_model_version_serialize(
+ def _list_reflections_serialize(
self,
bank_id,
- model_id,
- version,
- 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
- if model_id is not None:
- _path_params['model_id'] = model_id
- if version is not None:
- _path_params['version'] = version
- # 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='GET',
- resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{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 list_mental_model_versions(
- self,
- bank_id: StrictStr,
- model_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,
- ) -> object:
- """List mental model version history
-
- List all saved versions of a mental model's observations, ordered by version descending.
-
- :param bank_id: (required)
- :type bank_id: str
- :param model_id: (required)
- :type model_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._list_mental_model_versions_serialize(
- bank_id=bank_id,
- model_id=model_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': "object",
- '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 list_mental_model_versions_with_http_info(
- self,
- bank_id: StrictStr,
- model_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[object]:
- """List mental model version history
-
- List all saved versions of a mental model's observations, ordered by version descending.
-
- :param bank_id: (required)
- :type bank_id: str
- :param model_id: (required)
- :type model_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._list_mental_model_versions_serialize(
- bank_id=bank_id,
- model_id=model_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': "object",
- '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 list_mental_model_versions_without_preload_content(
- self,
- bank_id: StrictStr,
- model_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:
- """List mental model version history
-
- List all saved versions of a mental model's observations, ordered by version descending.
-
- :param bank_id: (required)
- :type bank_id: str
- :param model_id: (required)
- :type model_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._list_mental_model_versions_serialize(
- bank_id=bank_id,
- model_id=model_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': "object",
- '422': "HTTPValidationError",
- }
- response_data = await self.api_client.call_api(
- *_param,
- _request_timeout=_request_timeout
- )
- return response_data.response
-
-
- def _list_mental_model_versions_serialize(
- self,
- bank_id,
- model_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
- if model_id is not None:
- _path_params['model_id'] = model_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='GET',
- resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/versions',
- 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 list_mental_models(
- self,
- bank_id: StrictStr,
- subtype: Annotated[Optional[StrictStr], Field(description="Filter by subtype: structural, emergent, or pinned")] = None,
- tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags (includes untagged models)")] = None,
- tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'")] = None,
- 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,
- ) -> MentalModelListResponse:
- """List mental models
-
- List all mental models for a bank, optionally filtered by subtype or tags.
-
- :param bank_id: (required)
- :type bank_id: str
- :param subtype: Filter by subtype: structural, emergent, or pinned
- :type subtype: str
- :param tags: Filter by tags (includes untagged models)
- :type tags: List[str]
- :param tags_match: How to match tags: 'any' (OR), 'all' (AND), or 'exact'
- :type tags_match: 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._list_mental_models_serialize(
- bank_id=bank_id,
- subtype=subtype,
- tags=tags,
- tags_match=tags_match,
- authorization=authorization,
- _request_auth=_request_auth,
- _content_type=_content_type,
- _headers=_headers,
- _host_index=_host_index
- )
-
- _response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelListResponse",
- '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 list_mental_models_with_http_info(
- self,
- bank_id: StrictStr,
- subtype: Annotated[Optional[StrictStr], Field(description="Filter by subtype: structural, emergent, or pinned")] = None,
- tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags (includes untagged models)")] = None,
- tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'")] = None,
- 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[MentalModelListResponse]:
- """List mental models
-
- List all mental models for a bank, optionally filtered by subtype or tags.
-
- :param bank_id: (required)
- :type bank_id: str
- :param subtype: Filter by subtype: structural, emergent, or pinned
- :type subtype: str
- :param tags: Filter by tags (includes untagged models)
- :type tags: List[str]
- :param tags_match: How to match tags: 'any' (OR), 'all' (AND), or 'exact'
- :type tags_match: 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._list_mental_models_serialize(
- bank_id=bank_id,
- subtype=subtype,
- tags=tags,
- tags_match=tags_match,
- authorization=authorization,
- _request_auth=_request_auth,
- _content_type=_content_type,
- _headers=_headers,
- _host_index=_host_index
- )
-
- _response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelListResponse",
- '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 list_mental_models_without_preload_content(
- self,
- bank_id: StrictStr,
- subtype: Annotated[Optional[StrictStr], Field(description="Filter by subtype: structural, emergent, or pinned")] = None,
- tags: Annotated[Optional[List[StrictStr]], Field(description="Filter by tags (includes untagged models)")] = None,
- tags_match: Annotated[Optional[StrictStr], Field(description="How to match tags: 'any' (OR), 'all' (AND), or 'exact'")] = None,
- 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:
- """List mental models
-
- List all mental models for a bank, optionally filtered by subtype or tags.
-
- :param bank_id: (required)
- :type bank_id: str
- :param subtype: Filter by subtype: structural, emergent, or pinned
- :type subtype: str
- :param tags: Filter by tags (includes untagged models)
- :type tags: List[str]
- :param tags_match: How to match tags: 'any' (OR), 'all' (AND), or 'exact'
- :type tags_match: 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._list_mental_models_serialize(
- bank_id=bank_id,
- subtype=subtype,
- tags=tags,
- tags_match=tags_match,
- authorization=authorization,
- _request_auth=_request_auth,
- _content_type=_content_type,
- _headers=_headers,
- _host_index=_host_index
- )
-
- _response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelListResponse",
- '422': "HTTPValidationError",
- }
- response_data = await self.api_client.call_api(
- *_param,
- _request_timeout=_request_timeout
- )
- return response_data.response
-
-
- def _list_mental_models_serialize(
- self,
- bank_id,
- subtype,
tags,
tags_match,
+ limit,
+ offset,
authorization,
_request_auth,
_content_type,
@@ -1818,10 +1228,6 @@ class MentalModelsApi:
if bank_id is not None:
_path_params['bank_id'] = bank_id
# process the query parameters
- if subtype is not None:
-
- _query_params.append(('subtype', subtype))
-
if tags is not None:
_query_params.append(('tags', tags))
@@ -1830,6 +1236,14 @@ class MentalModelsApi:
_query_params.append(('tags_match', tags_match))
+ 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
@@ -1852,7 +1266,7 @@ class MentalModelsApi:
return self.api_client.param_serialize(
method='GET',
- resource_path='/v1/default/banks/{bank_id}/mental-models',
+ resource_path='/v1/default/banks/{bank_id}/reflections',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
@@ -1869,10 +1283,10 @@ class MentalModelsApi:
@validate_call
- async def refresh_mental_model(
+ async def refresh_reflection(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -1886,15 +1300,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> AsyncOperationSubmitResponse:
- """Refresh mental model content (async)
+ ) -> ReflectionResponse:
+ """Refresh reflection
- 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.
+ Re-run the source query through reflect and update the content.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -1919,9 +1333,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._refresh_mental_model_serialize(
+ _param = self._refresh_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -1930,7 +1344,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "AsyncOperationSubmitResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -1945,10 +1359,10 @@ class MentalModelsApi:
@validate_call
- async def refresh_mental_model_with_http_info(
+ async def refresh_reflection_with_http_info(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -1962,15 +1376,15 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> ApiResponse[AsyncOperationSubmitResponse]:
- """Refresh mental model content (async)
+ ) -> ApiResponse[ReflectionResponse]:
+ """Refresh reflection
- 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.
+ Re-run the source query through reflect and update the content.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -1995,9 +1409,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._refresh_mental_model_serialize(
+ _param = self._refresh_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -2006,7 +1420,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "AsyncOperationSubmitResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -2021,10 +1435,10 @@ class MentalModelsApi:
@validate_call
- async def refresh_mental_model_without_preload_content(
+ async def refresh_reflection_without_preload_content(
self,
bank_id: StrictStr,
- model_id: StrictStr,
+ reflection_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -2039,14 +1453,14 @@ class MentalModelsApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
- """Refresh mental model content (async)
+ """Refresh reflection
- 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.
+ Re-run the source query through reflect and update the content.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -2071,9 +1485,9 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._refresh_mental_model_serialize(
+ _param = self._refresh_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
+ reflection_id=reflection_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -2082,7 +1496,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "AsyncOperationSubmitResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -2092,10 +1506,10 @@ class MentalModelsApi:
return response_data.response
- def _refresh_mental_model_serialize(
+ def _refresh_reflection_serialize(
self,
bank_id,
- model_id,
+ reflection_id,
authorization,
_request_auth,
_content_type,
@@ -2120,8 +1534,8 @@ class MentalModelsApi:
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
- if model_id is not None:
- _path_params['model_id'] = model_id
+ if reflection_id is not None:
+ _path_params['reflection_id'] = reflection_id
# process the query parameters
# process the header parameters
if authorization is not None:
@@ -2145,7 +1559,7 @@ class MentalModelsApi:
return self.api_client.param_serialize(
method='POST',
- resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh',
+ resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
@@ -2162,11 +1576,12 @@ class MentalModelsApi:
@validate_call
- async def refresh_mental_models(
+ async def update_reflection(
self,
bank_id: StrictStr,
+ reflection_id: StrictStr,
+ update_reflection_request: UpdateReflectionRequest,
authorization: Optional[StrictStr] = None,
- refresh_mental_models_request: Optional[RefreshMentalModelsRequest] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -2179,17 +1594,19 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> AsyncOperationSubmitResponse:
- """Refresh mental models (async)
+ ) -> ReflectionResponse:
+ """Update 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.
+ Update a reflection's name.
:param bank_id: (required)
:type bank_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
+ :param update_reflection_request: (required)
+ :type update_reflection_request: UpdateReflectionRequest
:param authorization:
:type authorization: str
- :param refresh_mental_models_request:
- :type refresh_mental_models_request: RefreshMentalModelsRequest
: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
@@ -2212,10 +1629,11 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._refresh_mental_models_serialize(
+ _param = self._update_reflection_serialize(
bank_id=bank_id,
+ reflection_id=reflection_id,
+ update_reflection_request=update_reflection_request,
authorization=authorization,
- refresh_mental_models_request=refresh_mental_models_request,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
@@ -2223,7 +1641,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "AsyncOperationSubmitResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -2238,11 +1656,12 @@ class MentalModelsApi:
@validate_call
- async def refresh_mental_models_with_http_info(
+ async def update_reflection_with_http_info(
self,
bank_id: StrictStr,
+ reflection_id: StrictStr,
+ update_reflection_request: UpdateReflectionRequest,
authorization: Optional[StrictStr] = None,
- refresh_mental_models_request: Optional[RefreshMentalModelsRequest] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -2255,17 +1674,19 @@ class MentalModelsApi:
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
- ) -> ApiResponse[AsyncOperationSubmitResponse]:
- """Refresh mental models (async)
+ ) -> ApiResponse[ReflectionResponse]:
+ """Update 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.
+ Update a reflection's name.
:param bank_id: (required)
:type bank_id: str
+ :param reflection_id: (required)
+ :type reflection_id: str
+ :param update_reflection_request: (required)
+ :type update_reflection_request: UpdateReflectionRequest
:param authorization:
:type authorization: str
- :param refresh_mental_models_request:
- :type refresh_mental_models_request: RefreshMentalModelsRequest
: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
@@ -2288,10 +1709,11 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._refresh_mental_models_serialize(
+ _param = self._update_reflection_serialize(
bank_id=bank_id,
+ reflection_id=reflection_id,
+ update_reflection_request=update_reflection_request,
authorization=authorization,
- refresh_mental_models_request=refresh_mental_models_request,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
@@ -2299,7 +1721,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "AsyncOperationSubmitResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -2314,325 +1736,11 @@ class MentalModelsApi:
@validate_call
- async def refresh_mental_models_without_preload_content(
+ async def update_reflection_without_preload_content(
self,
bank_id: StrictStr,
- authorization: Optional[StrictStr] = None,
- refresh_mental_models_request: Optional[RefreshMentalModelsRequest] = 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:
- """Refresh mental models (async)
-
- 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.
-
- :param bank_id: (required)
- :type bank_id: str
- :param authorization:
- :type authorization: str
- :param refresh_mental_models_request:
- :type refresh_mental_models_request: RefreshMentalModelsRequest
- :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._refresh_mental_models_serialize(
- bank_id=bank_id,
- authorization=authorization,
- refresh_mental_models_request=refresh_mental_models_request,
- _request_auth=_request_auth,
- _content_type=_content_type,
- _headers=_headers,
- _host_index=_host_index
- )
-
- _response_types_map: Dict[str, Optional[str]] = {
- '200': "AsyncOperationSubmitResponse",
- '422': "HTTPValidationError",
- }
- response_data = await self.api_client.call_api(
- *_param,
- _request_timeout=_request_timeout
- )
- return response_data.response
-
-
- def _refresh_mental_models_serialize(
- self,
- bank_id,
- authorization,
- refresh_mental_models_request,
- _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
- if refresh_mental_models_request is not None:
- _body_params = refresh_mental_models_request
-
-
- # set the HTTP header `Accept`
- if 'Accept' not in _header_params:
- _header_params['Accept'] = self.api_client.select_header_accept(
- [
- 'application/json'
- ]
- )
-
- # set the HTTP header `Content-Type`
- if _content_type:
- _header_params['Content-Type'] = _content_type
- else:
- _default_content_type = (
- self.api_client.select_header_content_type(
- [
- 'application/json'
- ]
- )
- )
- if _default_content_type is not None:
- _header_params['Content-Type'] = _default_content_type
-
- # authentication setting
- _auth_settings: List[str] = [
- ]
-
- return self.api_client.param_serialize(
- method='POST',
- resource_path='/v1/default/banks/{bank_id}/mental-models/refresh',
- 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_mental_model(
- self,
- bank_id: StrictStr,
- model_id: StrictStr,
- update_mental_model_request: UpdateMentalModelRequest,
- 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,
- ) -> MentalModelResponse:
- """Update mental model
-
- Update a mental model's name and/or description. Useful for editing directives.
-
- :param bank_id: (required)
- :type bank_id: str
- :param model_id: (required)
- :type model_id: str
- :param update_mental_model_request: (required)
- :type update_mental_model_request: UpdateMentalModelRequest
- :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._update_mental_model_serialize(
- bank_id=bank_id,
- model_id=model_id,
- update_mental_model_request=update_mental_model_request,
- authorization=authorization,
- _request_auth=_request_auth,
- _content_type=_content_type,
- _headers=_headers,
- _host_index=_host_index
- )
-
- _response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
- '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 update_mental_model_with_http_info(
- self,
- bank_id: StrictStr,
- model_id: StrictStr,
- update_mental_model_request: UpdateMentalModelRequest,
- 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[MentalModelResponse]:
- """Update mental model
-
- Update a mental model's name and/or description. Useful for editing directives.
-
- :param bank_id: (required)
- :type bank_id: str
- :param model_id: (required)
- :type model_id: str
- :param update_mental_model_request: (required)
- :type update_mental_model_request: UpdateMentalModelRequest
- :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._update_mental_model_serialize(
- bank_id=bank_id,
- model_id=model_id,
- update_mental_model_request=update_mental_model_request,
- authorization=authorization,
- _request_auth=_request_auth,
- _content_type=_content_type,
- _headers=_headers,
- _host_index=_host_index
- )
-
- _response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
- '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 update_mental_model_without_preload_content(
- self,
- bank_id: StrictStr,
- model_id: StrictStr,
- update_mental_model_request: UpdateMentalModelRequest,
+ reflection_id: StrictStr,
+ update_reflection_request: UpdateReflectionRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
@@ -2647,16 +1755,16 @@ class MentalModelsApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
- """Update mental model
+ """Update reflection
- Update a mental model's name and/or description. Useful for editing directives.
+ Update a reflection's name.
:param bank_id: (required)
:type bank_id: str
- :param model_id: (required)
- :type model_id: str
- :param update_mental_model_request: (required)
- :type update_mental_model_request: UpdateMentalModelRequest
+ :param reflection_id: (required)
+ :type reflection_id: str
+ :param update_reflection_request: (required)
+ :type update_reflection_request: UpdateReflectionRequest
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
@@ -2681,10 +1789,10 @@ class MentalModelsApi:
:return: Returns the result object.
""" # noqa: E501
- _param = self._update_mental_model_serialize(
+ _param = self._update_reflection_serialize(
bank_id=bank_id,
- model_id=model_id,
- update_mental_model_request=update_mental_model_request,
+ reflection_id=reflection_id,
+ update_reflection_request=update_reflection_request,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
@@ -2693,7 +1801,7 @@ class MentalModelsApi:
)
_response_types_map: Dict[str, Optional[str]] = {
- '200': "MentalModelResponse",
+ '200': "ReflectionResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
@@ -2703,11 +1811,11 @@ class MentalModelsApi:
return response_data.response
- def _update_mental_model_serialize(
+ def _update_reflection_serialize(
self,
bank_id,
- model_id,
- update_mental_model_request,
+ reflection_id,
+ update_reflection_request,
authorization,
_request_auth,
_content_type,
@@ -2732,16 +1840,16 @@ class MentalModelsApi:
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
- if model_id is not None:
- _path_params['model_id'] = model_id
+ if reflection_id is not None:
+ _path_params['reflection_id'] = reflection_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
- if update_mental_model_request is not None:
- _body_params = update_mental_model_request
+ if update_reflection_request is not None:
+ _body_params = update_reflection_request
# set the HTTP header `Accept`
@@ -2772,7 +1880,7 @@ class MentalModelsApi:
return self.api_client.param_serialize(
method='PATCH',
- resource_path='/v1/default/banks/{bank_id}/mental-models/{model_id}',
+ resource_path='/v1/default/banks/{bank_id}/reflections/{reflection_id}',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py
index e3cc2c5b..a6eebd19 100644
--- a/hindsight-clients/python/hindsight_client_api/models/__init__.py
+++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py
index c538ba07..bb0243bb 100644
--- a/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py
+++ b/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py b/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py
similarity index 66%
rename from hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py
rename to hindsight-clients/python/hindsight_client_api/models/consolidation_response.py
index e664abd6..e1f7c511 100644
--- a/hindsight-clients/python/hindsight_client_api/models/observation_evidence_response.py
+++ b/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py b/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py
new file mode 100644
index 00000000..813a8fa8
--- /dev/null
+++ b/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py
@@ -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
+
+
diff --git a/hindsight-clients/python/hindsight_client_api/models/create_reflection_request.py b/hindsight-clients/python/hindsight_client_api/models/create_reflection_request.py
new file mode 100644
index 00000000..70817ab7
--- /dev/null
+++ b/hindsight-clients/python/hindsight_client_api/models/create_reflection_request.py
@@ -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
+
+
diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_input.py b/hindsight-clients/python/hindsight_client_api/models/create_reflection_response.py
similarity index 80%
rename from hindsight-clients/python/hindsight_client_api/models/observation_input.py
rename to hindsight-clients/python/hindsight_client_api/models/create_reflection_response.py
index 17fe584d..a86a621b 100644
--- a/hindsight-clients/python/hindsight_client_api/models/observation_input.py
+++ b/hindsight-clients/python/hindsight_client_api/models/create_reflection_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py b/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py
similarity index 83%
rename from hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py
rename to hindsight-clients/python/hindsight_client_api/models/directive_list_response.py
index af4c7161..84c1467d 100644
--- a/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py
+++ b/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/refresh_mental_models_request.py b/hindsight-clients/python/hindsight_client_api/models/directive_response.py
similarity index 60%
rename from hindsight-clients/python/hindsight_client_api/models/refresh_mental_models_request.py
rename to hindsight-clients/python/hindsight_client_api/models/directive_response.py
index 87127ad2..c6ec0ef1 100644
--- a/hindsight-clients/python/hindsight_client_api/models/refresh_mental_models_request.py
+++ b/hindsight-clients/python/hindsight_client_api/models/directive_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py b/hindsight-clients/python/hindsight_client_api/models/features_info.py
similarity index 72%
rename from hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py
rename to hindsight-clients/python/hindsight_client_api/models/features_info.py
index 420d1e9e..c98d722f 100644
--- a/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py
+++ b/hindsight-clients/python/hindsight_client_api/models/features_info.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py
deleted file mode 100644
index 4c013adc..00000000
--- a/hindsight-clients/python/hindsight_client_api/models/mental_model_observation_response.py
+++ /dev/null
@@ -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
-
-
diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py
deleted file mode 100644
index 790fcad4..00000000
--- a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py
+++ /dev/null
@@ -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
-
-
diff --git a/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py b/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py
index 1a691898..56de4a9d 100644
--- a/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py
+++ b/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py b/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py
index 5a5143b1..a85dd282 100644
--- a/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py
+++ b/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py b/hindsight-clients/python/hindsight_client_api/models/reflect_response.py
index bb0c0003..889acc44 100644
--- a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py
+++ b/hindsight-clients/python/hindsight_client_api/models/reflect_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/reflection_list_response.py b/hindsight-clients/python/hindsight_client_api/models/reflection_list_response.py
new file mode 100644
index 00000000..bd65796c
--- /dev/null
+++ b/hindsight-clients/python/hindsight_client_api/models/reflection_list_response.py
@@ -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
+
+
diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py b/hindsight-clients/python/hindsight_client_api/models/reflection_response.py
similarity index 53%
rename from hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py
rename to hindsight-clients/python/hindsight_client_api/models/reflection_response.py
index cda3661f..4632c7c5 100644
--- a/hindsight-clients/python/hindsight_client_api/models/mental_model_freshness_response.py
+++ b/hindsight-clients/python/hindsight_client_api/models/reflection_response.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py
similarity index 54%
rename from hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py
rename to hindsight-clients/python/hindsight_client_api/models/update_directive_request.py
index 67032696..265102cf 100644
--- a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py
+++ b/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/update_reflection_request.py
similarity index 77%
rename from hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py
rename to hindsight-clients/python/hindsight_client_api/models/update_reflection_request.py
index ea0596ab..9017521b 100644
--- a/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py
+++ b/hindsight-clients/python/hindsight_client_api/models/update_reflection_request.py
@@ -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
diff --git a/hindsight-clients/python/hindsight_client_api/models/created_mental_model.py b/hindsight-clients/python/hindsight_client_api/models/version_response.py
similarity index 71%
rename from hindsight-clients/python/hindsight_client_api/models/created_mental_model.py
rename to hindsight-clients/python/hindsight_client_api/models/version_response.py
index abc1fafe..68e40187 100644
--- a/hindsight-clients/python/hindsight_client_api/models/created_mental_model.py
+++ b/hindsight-clients/python/hindsight_client_api/models/version_response.py
@@ -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
diff --git a/hindsight-clients/python/tests/test_main_operations.py b/hindsight-clients/python/tests/test_main_operations.py
index e728dd99..6dc86cf5 100644
--- a/hindsight-clients/python/tests/test_main_operations.py
+++ b/hindsight-clients/python/tests/test_main_operations.py
@@ -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
diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts
index 82b112f4..d3b83066 100644
--- a/hindsight-clients/typescript/generated/sdk.gen.ts
+++ b/hindsight-clients/typescript/generated/sdk.gen.ts
@@ -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
>({ 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 = (
+ options?: Options,
+) =>
+ (options?.client ?? client).get({
+ 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 = (
- options: Options,
+export const listReflections = (
+ options: Options,
) =>
(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 = (
- options: Options,
+export const createReflection = (
+ options: Options,
) =>
(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 = (
});
/**
- * Delete mental model
+ * Delete reflection
*
- * Delete a mental model.
+ * Delete a reflection.
*/
-export const deleteMentalModel = (
- options: Options,
+export const deleteReflection = (
+ options: Options,
) =>
(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 = (
- options: Options,
+export const getReflection = (
+ options: Options,
) =>
(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 = (
- options: Options,
+export const updateReflection = (
+ options: Options,
) =>
(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 = (
});
/**
- * 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 = (
- options: Options,
+export const refreshReflection = (
+ options: Options,
) =>
(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 = (
+ options: Options,
+) =>
+ (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 = (
+ options: Options,
+) =>
+ (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 = (
});
/**
- * 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 = (
- options: Options,
+export const deleteDirective = (
+ options: Options,
) =>
- (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 = (
- options: Options,
+export const getDirective = (
+ options: Options,
) =>
(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 = (
- options: Options,
+export const updateDirective = (
+ options: Options,
) =>
- (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 = (
/**
* 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 = (
options: Options,
@@ -738,6 +798,34 @@ export const createOrUpdateBank = (
},
});
+/**
+ * Clear all mental models
+ *
+ * Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.
+ */
+export const clearMentalModels = (
+ options: Options,
+) =>
+ (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 = (
+ options: Options,
+) =>
+ (options.client ?? client).post<
+ TriggerConsolidationResponses,
+ TriggerConsolidationErrors,
+ ThrowOnError
+ >({ url: "/v1/default/banks/{bank_id}/consolidate", ...options });
+
/**
* Clear memory bank memories
*
diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts
index 3b17254c..5d42fe45 100644
--- a/hindsight-clients/typescript/generated/types.gen.ts
+++ b/hindsight-clients/typescript/generated/types.gen.ts
@@ -24,22 +24,6 @@ export type AddBackgroundRequest = {
update_disposition?: boolean;
};
-/**
- * AsyncOperationSubmitResponse
- *
- * Response model for submitting an async operation.
- */
-export type AsyncOperationSubmitResponse = {
- /**
- * Operation Id
- */
- operation_id: string;
- /**
- * Status
- */
- status: string;
-};
-
/**
* BackgroundResponse
*
@@ -185,6 +169,24 @@ export type BankStatsResponse = {
* Failed Operations
*/
failed_operations: number;
+ /**
+ * Last Consolidated At
+ *
+ * When consolidation last ran (ISO format)
+ */
+ last_consolidated_at?: string | null;
+ /**
+ * Pending Consolidation
+ *
+ * Number of memories not yet processed into mental models
+ */
+ pending_consolidation?: number;
+ /**
+ * Total Mental Models
+ *
+ * Total number of mental models
+ */
+ total_mental_models?: number;
};
/**
@@ -286,6 +288,44 @@ export type ChunkResponse = {
created_at: string;
};
+/**
+ * ConsolidationResponse
+ *
+ * Response model for consolidation trigger endpoint.
+ */
+export type ConsolidationResponse = {
+ /**
+ * Status
+ *
+ * Status of the consolidation (completed or queued)
+ */
+ status: string;
+ /**
+ * Processed
+ *
+ * Number of memories processed
+ */
+ processed: number;
+ /**
+ * Created
+ *
+ * Number of mental models created
+ */
+ created: number;
+ /**
+ * Updated
+ *
+ * Number of mental models updated
+ */
+ updated: number;
+ /**
+ * Message
+ *
+ * Human-readable summary
+ */
+ message: string;
+};
+
/**
* CreateBankRequest
*
@@ -312,67 +352,87 @@ export type CreateBankRequest = {
};
/**
- * CreateMentalModelRequest
+ * CreateDirectiveRequest
*
- * Request model for creating a mental model.
+ * Request model for creating a directive.
*/
-export type CreateMentalModelRequest = {
+export type CreateDirectiveRequest = {
/**
* Name
*
- * Human-readable name for the mental model
+ * Human-readable name for the directive
*/
name: string;
/**
- * Description
+ * Content
*
- * One-liner description for quick scanning
+ * The directive text to inject into prompts
*/
- description: string;
+ content: string;
/**
- * Subtype
+ * Priority
*
- * Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)
+ * Higher priority directives are injected first
*/
- subtype?: string;
+ priority?: number;
/**
- * Observations
+ * Is Active
*
- * For directives only: list of user-provided observations. Required when subtype='directive'.
+ * Whether this directive is active
*/
- observations?: Array | null;
+ is_active?: boolean;
+ /**
+ * Tags
+ *
+ * Tags for filtering
+ */
+ tags?: Array;
+};
+
+/**
+ * CreateReflectionRequest
+ *
+ * Request model for creating a reflection.
+ */
+export type CreateReflectionRequest = {
+ /**
+ * Name
+ *
+ * Human-readable name for the reflection
+ */
+ name: string;
+ /**
+ * Source Query
+ *
+ * The query to run to generate content
+ */
+ source_query: string;
/**
* Tags
*
* Tags for scoped visibility
*/
tags?: Array;
+ /**
+ * Max Tokens
+ *
+ * Maximum tokens for generated content
+ */
+ max_tokens?: number;
};
/**
- * CreatedMentalModel
+ * CreateReflectionResponse
*
- * A mental model created during reflection.
+ * Response model for reflection creation.
*/
-export type CreatedMentalModel = {
+export type CreateReflectionResponse = {
/**
- * Id
+ * Operation Id
*
- * Mental model ID
+ * Operation ID to track progress
*/
- id: string;
- /**
- * Name
- *
- * Human-readable name
- */
- name: string;
- /**
- * Description
- *
- * What this model tracks
- */
- description: string;
+ operation_id: string;
};
/**
@@ -419,6 +479,62 @@ export type DeleteResponse = {
deleted_count?: number | null;
};
+/**
+ * DirectiveListResponse
+ *
+ * Response model for listing directives.
+ */
+export type DirectiveListResponse = {
+ /**
+ * Items
+ */
+ items: Array;
+};
+
+/**
+ * DirectiveResponse
+ *
+ * Response model for a directive.
+ */
+export type DirectiveResponse = {
+ /**
+ * Id
+ */
+ id: string;
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ /**
+ * Name
+ */
+ name: string;
+ /**
+ * Content
+ */
+ content: string;
+ /**
+ * Priority
+ */
+ priority?: number;
+ /**
+ * Is Active
+ */
+ is_active?: boolean;
+ /**
+ * Tags
+ */
+ tags?: Array;
+ /**
+ * Created At
+ */
+ created_at?: string | null;
+ /**
+ * Updated At
+ */
+ updated_at?: string | null;
+};
+
/**
* DispositionTraits
*
@@ -662,6 +778,32 @@ export type FactsIncludeOptions = {
[key: string]: unknown;
};
+/**
+ * FeaturesInfo
+ *
+ * Feature flags indicating which capabilities are enabled.
+ */
+export type FeaturesInfo = {
+ /**
+ * Mental Models
+ *
+ * Whether mental models (auto-consolidation) are enabled
+ */
+ mental_models: boolean;
+ /**
+ * Mcp
+ *
+ * Whether MCP (Model Context Protocol) server is enabled
+ */
+ mcp: boolean;
+ /**
+ * Worker
+ *
+ * Whether the background worker is enabled
+ */
+ worker: boolean;
+};
+
/**
* GraphDataResponse
*
@@ -842,224 +984,6 @@ export type MemoryItem = {
tags?: Array | null;
};
-/**
- * MentalModelFreshnessResponse
- *
- * Freshness information for a mental model.
- */
-export type MentalModelFreshnessResponse = {
- /**
- * Is Up To Date
- *
- * Whether the model has been refreshed since the last memory was added
- */
- is_up_to_date: boolean;
- /**
- * Last Refresh At
- *
- * When the model was last refreshed (ISO format)
- */
- last_refresh_at: string | null;
- /**
- * Memories Since Refresh
- *
- * Number of memories added since last refresh
- */
- memories_since_refresh: number;
- /**
- * Reasons
- *
- * Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed
- */
- reasons?: Array;
-};
-
-/**
- * MentalModelListResponse
- *
- * Response model for listing mental models.
- */
-export type MentalModelListResponse = {
- /**
- * Items
- */
- items: Array;
-};
-
-/**
- * MentalModelObservationResponse
- *
- * An observation within a mental model with its supporting evidence.
- */
-export type MentalModelObservationResponse = {
- /**
- * Title
- *
- * Short summary title for the observation
- */
- title: string;
- /**
- * Content
- *
- * The observation content - detailed explanation
- */
- content: string;
- /**
- * Evidence
- *
- * Supporting evidence with quotes
- */
- evidence?: Array;
- /**
- * Created At
- *
- * When this observation was first created (ISO format)
- */
- created_at: string;
- /**
- * Trend
- *
- * Computed trend: stable, strengthening, weakening, new, stale
- */
- trend: string;
- /**
- * Evidence Count
- *
- * Number of evidence items supporting this observation
- */
- evidence_count: number;
- /**
- * Evidence Span
- *
- * Time span of evidence: {from: iso_date, to: iso_date}
- */
- evidence_span: {
- [key: string]: unknown;
- };
-};
-
-/**
- * MentalModelResponse
- *
- * Response model for a mental model.
- */
-export type MentalModelResponse = {
- /**
- * Id
- */
- id: string;
- /**
- * Bank Id
- */
- bank_id: string;
- /**
- * Subtype
- */
- subtype: string;
- /**
- * Name
- */
- name: string;
- /**
- * Description
- */
- description: string;
- /**
- * Observations
- *
- * Structured observations with per-observation fact attribution
- */
- observations?: Array;
- /**
- * Version
- *
- * Version number of the mental model observations
- */
- version?: number;
- /**
- * Entity Id
- */
- entity_id?: string | null;
- /**
- * Links
- */
- links?: Array;
- /**
- * Tags
- */
- tags?: Array;
- /**
- * Last Updated
- */
- last_updated?: string | null;
- /**
- * Last Refresh At
- *
- * When observations were last refreshed (ISO format)
- */
- last_refresh_at?: string | null;
- /**
- * Freshness info (null for directive subtypes which don't need refresh)
- */
- freshness?: MentalModelFreshnessResponse | null;
- /**
- * Created At
- */
- created_at: string;
-};
-
-/**
- * ObservationEvidenceResponse
- *
- * A single piece of evidence supporting an observation.
- */
-export type ObservationEvidenceResponse = {
- /**
- * Memory Id
- *
- * ID of the memory unit this evidence comes from
- */
- memory_id: string;
- /**
- * Quote
- *
- * Exact quote from the memory supporting the observation
- */
- quote: string;
- /**
- * Relevance
- *
- * Brief explanation of how this quote supports the observation
- */
- relevance: string;
- /**
- * Timestamp
- *
- * When the source memory was created (ISO format)
- */
- timestamp: string;
-};
-
-/**
- * ObservationInput
- *
- * Input model for a single observation.
- */
-export type ObservationInput = {
- /**
- * Title
- *
- * Short title/header for the observation
- */
- title: string;
- /**
- * Content
- *
- * Content of the observation
- */
- content: string;
-};
-
/**
* OperationResponse
*
@@ -1146,6 +1070,14 @@ export type OperationsListResponse = {
* Total
*/
total: number;
+ /**
+ * Limit
+ */
+ limit: number;
+ /**
+ * Offset
+ */
+ offset: number;
/**
* Operations
*/
@@ -1165,7 +1097,7 @@ export type RecallRequest = {
/**
* Types
*
- * List of fact types to recall: 'world', 'experience'. Defaults to both if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall).
+ * List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall).
*/
types?: Array | null;
budget?: Budget;
@@ -1305,12 +1237,6 @@ export type ReflectBasedOn = {
* Memory facts used to generate the response
*/
memories?: Array;
- /**
- * Mental Models
- *
- * Mental models accessed during reflection
- */
- mental_models?: Array;
};
/**
@@ -1500,12 +1426,6 @@ export type ReflectResponse = {
* Execution trace of tool and LLM calls. Only present when include.tool_calls is set.
*/
trace?: ReflectTrace | null;
- /**
- * Mental Models Created
- *
- * Mental models created during this reflection (via the learn tool).
- */
- mental_models_created?: Array;
};
/**
@@ -1577,23 +1497,63 @@ export type ReflectTrace = {
};
/**
- * RefreshMentalModelsRequest
+ * ReflectionListResponse
*
- * Request model for refresh mental models endpoint.
+ * Response model for listing reflections.
*/
-export type RefreshMentalModelsRequest = {
+export type ReflectionListResponse = {
+ /**
+ * Items
+ */
+ items: Array;
+};
+
+/**
+ * ReflectionResponse
+ *
+ * Response model for a reflection.
+ */
+export type ReflectionResponse = {
+ /**
+ * Id
+ */
+ id: string;
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ /**
+ * Name
+ */
+ name: string;
+ /**
+ * Source Query
+ */
+ source_query: string;
+ /**
+ * Content
+ */
+ content: string;
/**
* Tags
- *
- * Tags to apply to newly created mental models
*/
- tags?: Array | null;
+ tags?: Array;
/**
- * Subtype
- *
- * Only refresh models of this subtype. If not specified, refreshes all subtypes.
+ * Last Refreshed At
*/
- subtype?: "structural" | "emergent" | "pinned" | "learned" | null;
+ last_refreshed_at?: string | null;
+ /**
+ * Created At
+ */
+ created_at?: string | null;
+ /**
+ * Reflect Response
+ *
+ * Full reflect API response payload including based_on facts and mental_models
+ */
+ reflect_response?: {
+ [key: string]: unknown;
+ } | null;
};
/**
@@ -1719,6 +1679,44 @@ export type ToolCallsIncludeOptions = {
output?: boolean;
};
+/**
+ * UpdateDirectiveRequest
+ *
+ * Request model for updating a directive.
+ */
+export type UpdateDirectiveRequest = {
+ /**
+ * Name
+ *
+ * New name
+ */
+ name?: string | null;
+ /**
+ * Content
+ *
+ * New content
+ */
+ content?: string | null;
+ /**
+ * Priority
+ *
+ * New priority
+ */
+ priority?: number | null;
+ /**
+ * Is Active
+ *
+ * New active status
+ */
+ is_active?: boolean | null;
+ /**
+ * Tags
+ *
+ * New tags
+ */
+ tags?: Array | null;
+};
+
/**
* UpdateDispositionRequest
*
@@ -1729,23 +1727,17 @@ export type UpdateDispositionRequest = {
};
/**
- * UpdateMentalModelRequest
+ * UpdateReflectionRequest
*
- * Request model for updating a mental model.
+ * Request model for updating a reflection.
*/
-export type UpdateMentalModelRequest = {
+export type UpdateReflectionRequest = {
/**
* Name
*
- * New name for the mental model
+ * New name for the reflection
*/
name?: string | null;
- /**
- * Description
- *
- * New description/rule text
- */
- description?: string | null;
};
/**
@@ -1766,6 +1758,24 @@ export type ValidationError = {
type: string;
};
+/**
+ * VersionResponse
+ *
+ * Response model for the version/info endpoint.
+ */
+export type VersionResponse = {
+ /**
+ * Api Version
+ *
+ * API version string
+ */
+ api_version: string;
+ /**
+ * Enabled feature flags
+ */
+ features: FeaturesInfo;
+};
+
export type HealthEndpointHealthGetData = {
body?: never;
path?: never;
@@ -1780,6 +1790,22 @@ export type HealthEndpointHealthGetResponses = {
200: unknown;
};
+export type GetVersionData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: "/version";
+};
+
+export type GetVersionResponses = {
+ /**
+ * Successful Response
+ */
+ 200: VersionResponse;
+};
+
+export type GetVersionResponse = GetVersionResponses[keyof GetVersionResponses];
+
export type MetricsEndpointMetricsGetData = {
body?: never;
path?: never;
@@ -2205,7 +2231,7 @@ export type RegenerateEntityObservationsResponses = {
export type RegenerateEntityObservationsResponse =
RegenerateEntityObservationsResponses[keyof RegenerateEntityObservationsResponses];
-export type ListMentalModelsData = {
+export type ListReflectionsData = {
body?: never;
headers?: {
/**
@@ -2220,50 +2246,52 @@ export type ListMentalModelsData = {
bank_id: string;
};
query?: {
- /**
- * Subtype
- *
- * Filter by subtype: structural, emergent, or pinned
- */
- subtype?: string | null;
/**
* Tags
*
- * Filter by tags (includes untagged models)
+ * Filter by tags
*/
tags?: Array | null;
/**
* Tags Match
*
- * How to match tags: 'any' (OR), 'all' (AND), or 'exact'
+ * How to match tags
*/
tags_match?: "any" | "all" | "exact";
+ /**
+ * Limit
+ */
+ limit?: number;
+ /**
+ * Offset
+ */
+ offset?: number;
};
- url: "/v1/default/banks/{bank_id}/mental-models";
+ url: "/v1/default/banks/{bank_id}/reflections";
};
-export type ListMentalModelsErrors = {
+export type ListReflectionsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
-export type ListMentalModelsError =
- ListMentalModelsErrors[keyof ListMentalModelsErrors];
+export type ListReflectionsError =
+ ListReflectionsErrors[keyof ListReflectionsErrors];
-export type ListMentalModelsResponses = {
+export type ListReflectionsResponses = {
/**
* Successful Response
*/
- 200: MentalModelListResponse;
+ 200: ReflectionListResponse;
};
-export type ListMentalModelsResponse =
- ListMentalModelsResponses[keyof ListMentalModelsResponses];
+export type ListReflectionsResponse =
+ ListReflectionsResponses[keyof ListReflectionsResponses];
-export type CreateMentalModelData = {
- body: CreateMentalModelRequest;
+export type CreateReflectionData = {
+ body: CreateReflectionRequest;
headers?: {
/**
* Authorization
@@ -2277,30 +2305,30 @@ export type CreateMentalModelData = {
bank_id: string;
};
query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models";
+ url: "/v1/default/banks/{bank_id}/reflections";
};
-export type CreateMentalModelErrors = {
+export type CreateReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
-export type CreateMentalModelError =
- CreateMentalModelErrors[keyof CreateMentalModelErrors];
+export type CreateReflectionError =
+ CreateReflectionErrors[keyof CreateReflectionErrors];
-export type CreateMentalModelResponses = {
+export type CreateReflectionResponses = {
/**
* Successful Response
*/
- 200: MentalModelResponse;
+ 200: CreateReflectionResponse;
};
-export type CreateMentalModelResponse =
- CreateMentalModelResponses[keyof CreateMentalModelResponses];
+export type CreateReflectionResponse2 =
+ CreateReflectionResponses[keyof CreateReflectionResponses];
-export type DeleteMentalModelData = {
+export type DeleteReflectionData = {
body?: never;
headers?: {
/**
@@ -2314,241 +2342,32 @@ export type DeleteMentalModelData = {
*/
bank_id: string;
/**
- * Model Id
+ * Reflection Id
*/
- model_id: string;
+ reflection_id: string;
};
query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models/{model_id}";
+ url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}";
};
-export type DeleteMentalModelErrors = {
+export type DeleteReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
-export type DeleteMentalModelError =
- DeleteMentalModelErrors[keyof DeleteMentalModelErrors];
+export type DeleteReflectionError =
+ DeleteReflectionErrors[keyof DeleteReflectionErrors];
-export type DeleteMentalModelResponses = {
- /**
- * Successful Response
- */
- 200: DeleteResponse;
-};
-
-export type DeleteMentalModelResponse =
- DeleteMentalModelResponses[keyof DeleteMentalModelResponses];
-
-export type GetMentalModelData = {
- body?: never;
- headers?: {
- /**
- * Authorization
- */
- authorization?: string | null;
- };
- path: {
- /**
- * Bank Id
- */
- bank_id: string;
- /**
- * Model Id
- */
- model_id: string;
- };
- query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models/{model_id}";
-};
-
-export type GetMentalModelErrors = {
- /**
- * Validation Error
- */
- 422: HttpValidationError;
-};
-
-export type GetMentalModelError =
- GetMentalModelErrors[keyof GetMentalModelErrors];
-
-export type GetMentalModelResponses = {
- /**
- * Successful Response
- */
- 200: MentalModelResponse;
-};
-
-export type GetMentalModelResponse =
- GetMentalModelResponses[keyof GetMentalModelResponses];
-
-export type UpdateMentalModelData = {
- body: UpdateMentalModelRequest;
- headers?: {
- /**
- * Authorization
- */
- authorization?: string | null;
- };
- path: {
- /**
- * Bank Id
- */
- bank_id: string;
- /**
- * Model Id
- */
- model_id: string;
- };
- query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models/{model_id}";
-};
-
-export type UpdateMentalModelErrors = {
- /**
- * Validation Error
- */
- 422: HttpValidationError;
-};
-
-export type UpdateMentalModelError =
- UpdateMentalModelErrors[keyof UpdateMentalModelErrors];
-
-export type UpdateMentalModelResponses = {
- /**
- * Successful Response
- */
- 200: MentalModelResponse;
-};
-
-export type UpdateMentalModelResponse =
- UpdateMentalModelResponses[keyof UpdateMentalModelResponses];
-
-export type RefreshMentalModelsData = {
- /**
- * Body
- */
- body?: RefreshMentalModelsRequest | null;
- headers?: {
- /**
- * Authorization
- */
- authorization?: string | null;
- };
- path: {
- /**
- * Bank Id
- */
- bank_id: string;
- };
- query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models/refresh";
-};
-
-export type RefreshMentalModelsErrors = {
- /**
- * Validation Error
- */
- 422: HttpValidationError;
-};
-
-export type RefreshMentalModelsError =
- RefreshMentalModelsErrors[keyof RefreshMentalModelsErrors];
-
-export type RefreshMentalModelsResponses = {
- /**
- * Successful Response
- */
- 200: AsyncOperationSubmitResponse;
-};
-
-export type RefreshMentalModelsResponse =
- RefreshMentalModelsResponses[keyof RefreshMentalModelsResponses];
-
-export type RefreshMentalModelData = {
- body?: never;
- headers?: {
- /**
- * Authorization
- */
- authorization?: string | null;
- };
- path: {
- /**
- * Bank Id
- */
- bank_id: string;
- /**
- * Model Id
- */
- model_id: string;
- };
- query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh";
-};
-
-export type RefreshMentalModelErrors = {
- /**
- * Validation Error
- */
- 422: HttpValidationError;
-};
-
-export type RefreshMentalModelError =
- RefreshMentalModelErrors[keyof RefreshMentalModelErrors];
-
-export type RefreshMentalModelResponses = {
- /**
- * Successful Response
- */
- 200: AsyncOperationSubmitResponse;
-};
-
-export type RefreshMentalModelResponse =
- RefreshMentalModelResponses[keyof RefreshMentalModelResponses];
-
-export type ListMentalModelVersionsData = {
- body?: never;
- headers?: {
- /**
- * Authorization
- */
- authorization?: string | null;
- };
- path: {
- /**
- * Bank Id
- */
- bank_id: string;
- /**
- * Model Id
- */
- model_id: string;
- };
- query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions";
-};
-
-export type ListMentalModelVersionsErrors = {
- /**
- * Validation Error
- */
- 422: HttpValidationError;
-};
-
-export type ListMentalModelVersionsError =
- ListMentalModelVersionsErrors[keyof ListMentalModelVersionsErrors];
-
-export type ListMentalModelVersionsResponses = {
+export type DeleteReflectionResponses = {
/**
* Successful Response
*/
200: unknown;
};
-export type GetMentalModelVersionData = {
+export type GetReflectionData = {
body?: never;
headers?: {
/**
@@ -2562,35 +2381,342 @@ export type GetMentalModelVersionData = {
*/
bank_id: string;
/**
- * Model Id
+ * Reflection Id
*/
- model_id: string;
- /**
- * Version
- */
- version: number;
+ reflection_id: string;
};
query?: never;
- url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}";
+ url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}";
};
-export type GetMentalModelVersionErrors = {
+export type GetReflectionErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
-export type GetMentalModelVersionError =
- GetMentalModelVersionErrors[keyof GetMentalModelVersionErrors];
+export type GetReflectionError = GetReflectionErrors[keyof GetReflectionErrors];
-export type GetMentalModelVersionResponses = {
+export type GetReflectionResponses = {
+ /**
+ * Successful Response
+ */
+ 200: ReflectionResponse;
+};
+
+export type GetReflectionResponse =
+ GetReflectionResponses[keyof GetReflectionResponses];
+
+export type UpdateReflectionData = {
+ body: UpdateReflectionRequest;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ /**
+ * Reflection Id
+ */
+ reflection_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}";
+};
+
+export type UpdateReflectionErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type UpdateReflectionError =
+ UpdateReflectionErrors[keyof UpdateReflectionErrors];
+
+export type UpdateReflectionResponses = {
+ /**
+ * Successful Response
+ */
+ 200: ReflectionResponse;
+};
+
+export type UpdateReflectionResponse =
+ UpdateReflectionResponses[keyof UpdateReflectionResponses];
+
+export type RefreshReflectionData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ /**
+ * Reflection Id
+ */
+ reflection_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh";
+};
+
+export type RefreshReflectionErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type RefreshReflectionError =
+ RefreshReflectionErrors[keyof RefreshReflectionErrors];
+
+export type RefreshReflectionResponses = {
+ /**
+ * Successful Response
+ */
+ 200: ReflectionResponse;
+};
+
+export type RefreshReflectionResponse =
+ RefreshReflectionResponses[keyof RefreshReflectionResponses];
+
+export type ListDirectivesData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ };
+ query?: {
+ /**
+ * Tags
+ *
+ * Filter by tags
+ */
+ tags?: Array | null;
+ /**
+ * Tags Match
+ *
+ * How to match tags
+ */
+ tags_match?: "any" | "all" | "exact";
+ /**
+ * Active Only
+ *
+ * Only return active directives
+ */
+ active_only?: boolean;
+ /**
+ * Limit
+ */
+ limit?: number;
+ /**
+ * Offset
+ */
+ offset?: number;
+ };
+ url: "/v1/default/banks/{bank_id}/directives";
+};
+
+export type ListDirectivesErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type ListDirectivesError =
+ ListDirectivesErrors[keyof ListDirectivesErrors];
+
+export type ListDirectivesResponses = {
+ /**
+ * Successful Response
+ */
+ 200: DirectiveListResponse;
+};
+
+export type ListDirectivesResponse =
+ ListDirectivesResponses[keyof ListDirectivesResponses];
+
+export type CreateDirectiveData = {
+ body: CreateDirectiveRequest;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/directives";
+};
+
+export type CreateDirectiveErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type CreateDirectiveError =
+ CreateDirectiveErrors[keyof CreateDirectiveErrors];
+
+export type CreateDirectiveResponses = {
+ /**
+ * Successful Response
+ */
+ 200: DirectiveResponse;
+};
+
+export type CreateDirectiveResponse =
+ CreateDirectiveResponses[keyof CreateDirectiveResponses];
+
+export type DeleteDirectiveData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ /**
+ * Directive Id
+ */
+ directive_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/directives/{directive_id}";
+};
+
+export type DeleteDirectiveErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type DeleteDirectiveError =
+ DeleteDirectiveErrors[keyof DeleteDirectiveErrors];
+
+export type DeleteDirectiveResponses = {
/**
* Successful Response
*/
200: unknown;
};
+export type GetDirectiveData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ /**
+ * Directive Id
+ */
+ directive_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/directives/{directive_id}";
+};
+
+export type GetDirectiveErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type GetDirectiveError = GetDirectiveErrors[keyof GetDirectiveErrors];
+
+export type GetDirectiveResponses = {
+ /**
+ * Successful Response
+ */
+ 200: DirectiveResponse;
+};
+
+export type GetDirectiveResponse =
+ GetDirectiveResponses[keyof GetDirectiveResponses];
+
+export type UpdateDirectiveData = {
+ body: UpdateDirectiveRequest;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ /**
+ * Directive Id
+ */
+ directive_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/directives/{directive_id}";
+};
+
+export type UpdateDirectiveErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type UpdateDirectiveError =
+ UpdateDirectiveErrors[keyof UpdateDirectiveErrors];
+
+export type UpdateDirectiveResponses = {
+ /**
+ * Successful Response
+ */
+ 200: DirectiveResponse;
+};
+
+export type UpdateDirectiveResponse =
+ UpdateDirectiveResponses[keyof UpdateDirectiveResponses];
+
export type ListDocumentsData = {
body?: never;
headers?: {
@@ -2829,7 +2955,26 @@ export type ListOperationsData = {
*/
bank_id: string;
};
- query?: never;
+ query?: {
+ /**
+ * Status
+ *
+ * Filter by status: pending, completed, or failed
+ */
+ status?: string | null;
+ /**
+ * Limit
+ *
+ * Maximum number of operations to return
+ */
+ limit?: number;
+ /**
+ * Offset
+ *
+ * Number of operations to skip
+ */
+ offset?: number;
+ };
url: "/v1/default/banks/{bank_id}/operations";
};
@@ -3161,6 +3306,82 @@ export type CreateOrUpdateBankResponses = {
export type CreateOrUpdateBankResponse =
CreateOrUpdateBankResponses[keyof CreateOrUpdateBankResponses];
+export type ClearMentalModelsData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/mental-models";
+};
+
+export type ClearMentalModelsErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type ClearMentalModelsError =
+ ClearMentalModelsErrors[keyof ClearMentalModelsErrors];
+
+export type ClearMentalModelsResponses = {
+ /**
+ * Successful Response
+ */
+ 200: DeleteResponse;
+};
+
+export type ClearMentalModelsResponse =
+ ClearMentalModelsResponses[keyof ClearMentalModelsResponses];
+
+export type TriggerConsolidationData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ };
+ path: {
+ /**
+ * Bank Id
+ */
+ bank_id: string;
+ };
+ query?: never;
+ url: "/v1/default/banks/{bank_id}/consolidate";
+};
+
+export type TriggerConsolidationErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type TriggerConsolidationError =
+ TriggerConsolidationErrors[keyof TriggerConsolidationErrors];
+
+export type TriggerConsolidationResponses = {
+ /**
+ * Successful Response
+ */
+ 200: ConsolidationResponse;
+};
+
+export type TriggerConsolidationResponse =
+ TriggerConsolidationResponses[keyof TriggerConsolidationResponses];
+
export type ClearBankMemoriesData = {
body?: never;
headers?: {
diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts
index 9a0ef223..64473aed 100644
--- a/hindsight-clients/typescript/src/index.ts
+++ b/hindsight-clients/typescript/src/index.ts
@@ -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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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
diff --git a/hindsight-clients/typescript/tests/main_operations.test.ts b/hindsight-clients/typescript/tests/main_operations.test.ts
index 4d7b2255..d88ea51b 100644
--- a/hindsight-clients/typescript/tests/main_operations.test.ts
+++ b/hindsight-clients/typescript/tests/main_operations.test.ts
@@ -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();
- });
});
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts
new file mode 100644
index 00000000..2d6c9e33
--- /dev/null
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts
@@ -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 });
+ }
+}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts
new file mode 100644
index 00000000..ece22087
--- /dev/null
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts
@@ -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 });
+ }
+}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts
new file mode 100644
index 00000000..aa884c94
--- /dev/null
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts
@@ -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 });
+ }
+}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts
deleted file mode 100644
index 8e63aa87..00000000
--- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/refresh/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts
index d0ca5b40..97333009 100644
--- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/route.ts
@@ -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 });
}
}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts
deleted file mode 100644
index 5587f4b8..00000000
--- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/[version]/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts
deleted file mode 100644
index 29f2ea7d..00000000
--- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[modelId]/versions/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/refresh/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/refresh/route.ts
deleted file mode 100644
index 26c2bf44..00000000
--- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/refresh/route.ts
+++ /dev/null
@@ -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 });
- }
-}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts
index 39fd3211..d98b6c78 100644
--- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts
@@ -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 });
}
}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/refresh/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/refresh/route.ts
new file mode 100644
index 00000000..2a6a2d43
--- /dev/null
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/refresh/route.ts
@@ -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 });
+ }
+}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/route.ts
new file mode 100644
index 00000000..e146788a
--- /dev/null
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/[reflectionId]/route.ts
@@ -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 });
+ }
+}
diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/route.ts
new file mode 100644
index 00000000..89869976
--- /dev/null
+++ b/hindsight-control-plane/src/app/api/banks/[bankId]/reflections/route.ts
@@ -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 });
+ }
+}
diff --git a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts
index 8ca88ffe..c01dc36a 100644
--- a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts
+++ b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts
@@ -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) {
diff --git a/hindsight-control-plane/src/app/api/version/route.ts b/hindsight-control-plane/src/app/api/version/route.ts
new file mode 100644
index 00000000..06e32f8b
--- /dev/null
+++ b/hindsight-control-plane/src/app/api/version/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+import { sdk, lowLevelClient } from "@/lib/hindsight-client";
+
+export async function GET() {
+ try {
+ const response = await sdk.getVersion({
+ client: lowLevelClient,
+ });
+
+ if (response.error) {
+ console.error("API error getting version:", response.error);
+ return NextResponse.json({ error: "Failed to get version" }, { status: 500 });
+ }
+
+ return NextResponse.json(response.data, { status: 200 });
+ } catch (error) {
+ console.error("Error getting version:", error);
+ return NextResponse.json({ error: "Failed to get version" }, { status: 500 });
+ }
+}
diff --git a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx
index c2055f9a..879e6f0d 100644
--- a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx
+++ b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx
@@ -9,10 +9,10 @@ import { EntitiesView } from "@/components/entities-view";
import { ThinkView } from "@/components/think-view";
import { SearchDebugView } from "@/components/search-debug-view";
import { BankProfileView } from "@/components/bank-profile-view";
-import { MentalModelsView } from "@/components/mental-models-view";
+import { ReflectionsView } from "@/components/reflections-view";
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
-type DataSubTab = "world" | "experience" | "models";
+type DataSubTab = "world" | "experience" | "models" | "reflections";
export default function BankPage() {
const params = useParams();
@@ -124,13 +124,27 @@ export default function BankPage() {
)}
+ handleDataSubTabChange("reflections")}
+ className={`px-6 py-3 font-semibold text-sm transition-all relative ${
+ subTab === "reflections"
+ ? "text-primary"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ Reflections
+ {subTab === "reflections" && (
+
+ )}
+
{subTab === "world" && }
{subTab === "experience" && }
- {subTab === "models" && }
+ {subTab === "models" && }
+ {subTab === "reflections" && }
)}
diff --git a/hindsight-control-plane/src/components/bank-profile-view.tsx b/hindsight-control-plane/src/components/bank-profile-view.tsx
index 2438ab4f..9700190f 100644
--- a/hindsight-control-plane/src/components/bank-profile-view.tsx
+++ b/hindsight-control-plane/src/components/bank-profile-view.tsx
@@ -1,10 +1,12 @@
"use client";
import { useState, useEffect, useRef } from "react";
+import ReactMarkdown from "react-markdown";
import { useRouter } from "next/navigation";
import { client } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -15,6 +17,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -25,6 +35,13 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import {
RefreshCw,
Save,
@@ -38,6 +55,13 @@ import {
Activity,
Trash2,
Target,
+ AlertTriangle,
+ Plus,
+ Tag,
+ Loader2,
+ X,
+ MoreVertical,
+ Pencil,
} from "lucide-react";
interface DispositionTraits {
@@ -70,14 +94,32 @@ interface BankStats {
};
pending_operations: number;
failed_operations: number;
+ // Consolidation stats
+ last_consolidated_at: string | null;
+ pending_consolidation: number;
+ total_mental_models: number;
}
interface Operation {
id: string;
task_type: string;
+ items_count: number;
+ document_id: string | null;
created_at: string;
status: string;
- error_message?: string;
+ error_message: string | null;
+}
+
+interface Directive {
+ id: string;
+ bank_id: string;
+ name: string;
+ content: string;
+ priority: number;
+ is_active: boolean;
+ tags: string[];
+ created_at: string;
+ updated_at: string;
}
const TRAIT_LABELS: Record<
@@ -174,11 +216,20 @@ export function BankProfileView() {
const [stats, setStats] = useState(null);
const [operations, setOperations] = useState([]);
const [totalOperations, setTotalOperations] = useState(0);
- const [mentalModelsCount, setMentalModelsCount] = useState(0);
+ const [directives, setDirectives] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editMode, setEditMode] = useState(false);
+ // Directive state
+ const [showCreateDirective, setShowCreateDirective] = useState(false);
+ const [selectedDirective, setSelectedDirective] = useState(null);
+ const [directiveDeleteTarget, setDirectiveDeleteTarget] = useState<{
+ id: string;
+ name: string;
+ } | null>(null);
+ const [deletingDirective, setDeletingDirective] = useState(false);
+
// Ref to track editMode for polling (avoids stale closure)
const editModeRef = useRef(editMode);
useEffect(() => {
@@ -189,6 +240,19 @@ export function BankProfileView() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
+ // Clear mental models state
+ const [showClearMentalModelsDialog, setShowClearMentalModelsDialog] = useState(false);
+ const [isClearingMentalModels, setIsClearingMentalModels] = useState(false);
+
+ // Consolidation state
+ const [isConsolidating, setIsConsolidating] = useState(false);
+
+ // Operations filter/pagination state
+ const [opsStatusFilter, setOpsStatusFilter] = useState(null);
+ const [opsLimit] = useState(10);
+ const [opsOffset, setOpsOffset] = useState(0);
+ const [cancellingOpId, setCancellingOpId] = useState(null);
+
// Edit state
const [editMission, setEditMission] = useState("");
const [editDisposition, setEditDisposition] = useState({
@@ -197,23 +261,38 @@ export function BankProfileView() {
empathy: 3,
});
+ const loadOperations = async (
+ statusFilter: string | null = opsStatusFilter,
+ offset: number = opsOffset
+ ) => {
+ if (!currentBank) return;
+ try {
+ const opsData = await client.listOperations(currentBank, {
+ status: statusFilter || undefined,
+ limit: opsLimit,
+ offset,
+ });
+ setOperations(opsData.operations || []);
+ setTotalOperations(opsData.total || 0);
+ } catch (error) {
+ console.error("Error loading operations:", error);
+ }
+ };
+
const loadData = async (isPolling = false) => {
if (!currentBank) return;
- // Don't overwrite form state during polling when in edit mode
+ // During polling, only refresh stats (not operations to avoid interfering with filters)
// Use ref to get current value (avoids stale closure in setInterval)
- if (isPolling && editModeRef.current) {
- // Only refresh stats and operations during edit mode
+ if (isPolling) {
try {
- const [statsData, opsData, modelsData] = await Promise.all([
+ const [statsData, directivesData] = await Promise.all([
client.getBankStats(currentBank),
- client.listOperations(currentBank),
- client.listMentalModels(currentBank),
+ client.listDirectives(currentBank),
]);
setStats(statsData as BankStats);
- setOperations((opsData as any)?.operations || []);
- setTotalOperations((opsData as any)?.total || 0);
- setMentalModelsCount(modelsData.items?.length || 0);
+ setDirectives(directivesData.items || []);
+ // Skip operations refresh during polling to not interfere with filter/pagination state
} catch (error) {
console.error("Error refreshing stats:", error);
}
@@ -222,17 +301,15 @@ export function BankProfileView() {
setLoading(true);
try {
- const [profileData, statsData, opsData, modelsData] = await Promise.all([
+ const [profileData, statsData, directivesData] = await Promise.all([
client.getBankProfile(currentBank),
client.getBankStats(currentBank),
- client.listOperations(currentBank),
- client.listMentalModels(currentBank),
+ client.listDirectives(currentBank),
]);
setProfile(profileData);
setStats(statsData as BankStats);
- setOperations((opsData as any)?.operations || []);
- setTotalOperations((opsData as any)?.total || 0);
- setMentalModelsCount(modelsData.items?.length || 0);
+ setDirectives(directivesData.items || []);
+ await loadOperations();
// Only initialize edit state when not in edit mode
if (!editModeRef.current) {
@@ -292,6 +369,85 @@ export function BankProfileView() {
}
};
+ const handleClearMentalModels = async () => {
+ if (!currentBank) return;
+
+ setIsClearingMentalModels(true);
+ try {
+ const result = await client.clearMentalModels(currentBank);
+ setShowClearMentalModelsDialog(false);
+ await loadData();
+ alert(result.message || "Mental models cleared successfully");
+ } catch (error) {
+ console.error("Error clearing mental models:", error);
+ alert("Error clearing mental models: " + (error as Error).message);
+ } finally {
+ setIsClearingMentalModels(false);
+ }
+ };
+
+ const handleTriggerConsolidation = async () => {
+ if (!currentBank) return;
+
+ setIsConsolidating(true);
+ try {
+ const result = await client.triggerConsolidation(currentBank);
+ await loadData();
+ alert(
+ result.message ||
+ `Consolidation completed: ${result.created} created, ${result.updated} updated`
+ );
+ } catch (error) {
+ console.error("Error triggering consolidation:", error);
+ alert("Error triggering consolidation: " + (error as Error).message);
+ } finally {
+ setIsConsolidating(false);
+ }
+ };
+
+ const handleOpsFilterChange = (newFilter: string | null) => {
+ setOpsStatusFilter(newFilter);
+ setOpsOffset(0); // Reset to first page when filter changes
+ loadOperations(newFilter, 0);
+ };
+
+ const handleOpsPageChange = (newOffset: number) => {
+ setOpsOffset(newOffset);
+ loadOperations(opsStatusFilter, newOffset);
+ };
+
+ const handleCancelOperation = async (operationId: string) => {
+ if (!currentBank) return;
+
+ setCancellingOpId(operationId);
+ try {
+ await client.cancelOperation(currentBank, operationId);
+ await loadOperations();
+ } catch (error) {
+ console.error("Error cancelling operation:", error);
+ alert("Error cancelling operation: " + (error as Error).message);
+ } finally {
+ setCancellingOpId(null);
+ }
+ };
+
+ const handleDeleteDirective = async () => {
+ if (!currentBank || !directiveDeleteTarget) return;
+
+ setDeletingDirective(true);
+ try {
+ await client.deleteDirective(currentBank, directiveDeleteTarget.id);
+ setDirectives((prev) => prev.filter((d) => d.id !== directiveDeleteTarget.id));
+ if (selectedDirective?.id === directiveDeleteTarget.id) setSelectedDirective(null);
+ setDirectiveDeleteTarget(null);
+ } catch (error) {
+ console.error("Error deleting directive:", error);
+ alert("Error deleting: " + (error as Error).message);
+ } finally {
+ setDeletingDirective(false);
+ }
+ };
+
useEffect(() => {
if (currentBank) {
loadData();
@@ -301,6 +457,17 @@ export function BankProfileView() {
}
}, [currentBank]);
+ // Close directive detail panel on Escape
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setSelectedDirective(null);
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
if (!currentBank) {
return (
@@ -354,19 +521,44 @@ export function BankProfileView() {
>
) : (
- <>
- loadData()} variant="secondary" size="sm">
-
- Refresh
-
- setEditMode(true)} size="sm">
- Edit Profile
-
- setShowDeleteDialog(true)} variant="destructive" size="sm">
-
- Delete Bank
-
- >
+
+
+
+ Actions
+
+
+
+
+ setEditMode(true)}>
+
+ Edit Profile
+
+
+
+ {isConsolidating ? (
+
+ ) : (
+
+ )}
+ {isConsolidating ? "Consolidating..." : "Run Consolidation"}
+
+ setShowClearMentalModelsDialog(true)}
+ className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
+ >
+
+ Clear Mental Models
+
+
+ setShowDeleteDialog(true)}
+ className="text-red-600 dark:text-red-400 focus:text-red-700 dark:focus:text-red-300"
+ >
+
+ Delete Bank
+
+
+
)}
@@ -440,7 +632,7 @@ export function BankProfileView() {
{/* Memory Type Breakdown */}
{stats && (
-
+
World Facts
@@ -457,11 +649,21 @@ export function BankProfileView() {
{stats.nodes_by_fact_type?.experience || 0}
-
-
+
+
Mental Models
-
{mentalModelsCount}
+
+ {stats.total_mental_models || 0}
+
+
+
+
+ Directives
+
+
+ {directives.length}
+
)}
@@ -521,6 +723,79 @@ export function BankProfileView() {
+ {/* Directives Section */}
+
+
+
+
+
+
+ Directives
+
+
Hard rules that must be followed during reflect
+
+
setShowCreateDirective(true)}
+ variant="outline"
+ size="sm"
+ className="h-8"
+ >
+
+ Add
+
+
+
+
+ {directives.length > 0 ? (
+
+ {directives.map((d) => (
+
setSelectedDirective(d)}
+ >
+
+
+
+
+
{d.name}
+
+ {d.content}
+
+ {d.tags && d.tags.length > 0 && (
+
+
+ {d.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ No directives yet. Directives are hard rules that must be followed during reflect.
+
+
+ )}
+
+
+
{/* Operations Section */}
@@ -529,87 +804,145 @@ export function BankProfileView() {
Background Operations
+ loadOperations()}
+ className="p-1 rounded hover:bg-muted transition-colors"
+ title="Refresh operations"
+ >
+
+
- {totalOperations} total operation{totalOperations !== 1 ? "s" : ""}
- {operations.length < totalOperations ? ` (showing last ${operations.length})` : ""}
+ {totalOperations} operation{totalOperations !== 1 ? "s" : ""}
+ {opsStatusFilter ? ` (${opsStatusFilter})` : ""}
- {stats && (stats.pending_operations > 0 || stats.failed_operations > 0) && (
-
- {stats.pending_operations > 0 && (
-
-
-
- {stats.pending_operations} pending
-
-
- )}
- {stats.failed_operations > 0 && (
-
-
-
- {stats.failed_operations} failed
-
-
- )}
-
- )}
+
+ {[
+ { value: null, label: "All" },
+ { value: "pending", label: "Pending" },
+ { value: "completed", label: "Completed" },
+ { value: "failed", label: "Failed" },
+ ].map((filter) => (
+ handleOpsFilterChange(filter.value)}
+ className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
+ opsStatusFilter === filter.value
+ ? "bg-background shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {filter.label}
+
+ ))}
+
{operations.length > 0 ? (
-
-
-
-
- ID
- Type
- Created
- Status
-
-
-
- {operations.map((op) => (
-
-
- {op.id.substring(0, 8)}
-
- {op.task_type}
-
- {new Date(op.created_at).toLocaleString()}
-
-
- {op.status === "pending" && (
-
-
- pending
-
- )}
- {op.status === "failed" && (
-
-
- failed
-
- )}
- {op.status === "completed" && (
-
-
- done
-
- )}
-
+ <>
+
+
+
+
+ ID
+ Type
+ Created
+ Status
+
- ))}
-
-
-
+
+
+ {operations.map((op) => (
+
+
+ {op.id.substring(0, 8)}
+
+ {op.task_type}
+
+ {new Date(op.created_at).toLocaleString()}
+
+
+ {op.status === "pending" && (
+
+
+ pending
+
+ )}
+ {op.status === "failed" && (
+
+
+ failed
+
+ )}
+ {op.status === "completed" && (
+
+
+ completed
+
+ )}
+
+
+ {op.status === "pending" && (
+ handleCancelOperation(op.id)}
+ disabled={cancellingOpId === op.id}
+ >
+ {cancellingOpId === op.id ? (
+
+ ) : (
+
+ )}
+ {cancellingOpId === op.id ? "" : "Cancel"}
+
+ )}
+
+
+ ))}
+
+
+
+ {/* Pagination */}
+ {totalOperations > opsLimit && (
+
+
+ Showing {opsOffset + 1}-{Math.min(opsOffset + opsLimit, totalOperations)} of{" "}
+ {totalOperations}
+
+
+ handleOpsPageChange(Math.max(0, opsOffset - opsLimit))}
+ disabled={opsOffset === 0}
+ >
+ Previous
+
+ handleOpsPageChange(opsOffset + opsLimit)}
+ disabled={opsOffset + opsLimit >= totalOperations}
+ >
+ Next
+
+
+
+ )}
+ >
) : (
- No background operations
+ No {opsStatusFilter ? `${opsStatusFilter} ` : ""}operations
)}
@@ -661,6 +994,302 @@ export function BankProfileView() {
+
+ {/* Clear Mental Models Confirmation Dialog */}
+
+
+
+ Clear Mental Models
+
+
+
+ Are you sure you want to clear all mental models for{" "}
+ {currentBank} ?
+
+
+ This will delete all consolidated knowledge. Mental models will be regenerated the
+ next time consolidation runs.
+
+ {stats && stats.total_mental_models > 0 && (
+
This will delete {stats.total_mental_models} mental models.
+ )}
+
+
+
+
+ Cancel
+
+ {isClearingMentalModels ? (
+ <>
+
+ Clearing...
+ >
+ ) : (
+ <>
+
+ Clear Mental Models
+ >
+ )}
+
+
+
+
+
+ {/* Create Directive Dialog */}
+ setShowCreateDirective(false)}
+ onCreated={(d) => {
+ setDirectives((prev) => [d, ...prev]);
+ setShowCreateDirective(false);
+ }}
+ />
+
+ {/* Delete Directive Confirmation Dialog */}
+ !open && setDirectiveDeleteTarget(null)}
+ >
+
+
+ Delete Directive
+
+ Are you sure you want to delete{" "}
+ "{directiveDeleteTarget?.name}" ?
+
+
+ This action cannot be undone.
+
+
+
+ Cancel
+
+ {deletingDirective ? : null}
+ Delete
+
+
+
+
+
+ {/* Directive Detail Panel */}
+ {selectedDirective && (
+ setSelectedDirective(null)}
+ onDelete={() =>
+ setDirectiveDeleteTarget({
+ id: selectedDirective.id,
+ name: selectedDirective.name,
+ })
+ }
+ />
+ )}
+
+ );
+}
+
+// ============= CREATE DIRECTIVE DIALOG =============
+
+function CreateDirectiveDialog({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: (d: Directive) => void;
+}) {
+ const { currentBank } = useBank();
+ const [creating, setCreating] = useState(false);
+ const [form, setForm] = useState({ name: "", description: "", tags: "" });
+
+ const handleCreate = async () => {
+ if (!currentBank || !form.name.trim() || !form.description.trim()) return;
+
+ setCreating(true);
+ try {
+ const tags = form.tags
+ .split(",")
+ .map((t) => t.trim())
+ .filter((t) => t.length > 0);
+
+ const result = await client.createDirective(currentBank, {
+ name: form.name.trim(),
+ content: form.description.trim(),
+ tags: tags.length > 0 ? tags : undefined,
+ });
+
+ setForm({ name: "", description: "", tags: "" });
+ onCreated(result);
+ } catch (error) {
+ console.error("Error creating directive:", error);
+ alert("Error creating directive: " + (error as Error).message);
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ return (
+ {
+ if (!o) {
+ setForm({ name: "", description: "", tags: "" });
+ onClose();
+ }
+ }}
+ >
+
+
+
+
+ Create Directive
+
+
+ Directives are hard rules that must be followed during reflect.
+
+
+
+
+
+ Name *
+ setForm({ ...form, name: e.target.value })}
+ placeholder="e.g., Competitor Policy"
+ />
+
+
+ Rule *
+
+
+
+ Tags (optional)
+
+ setForm({ ...form, tags: e.target.value })}
+ placeholder="e.g., project-x, team-alpha (comma-separated)"
+ />
+
+
+
+
+
+ Cancel
+
+
+ {creating ? : null}
+ Create
+
+
+
+
+ );
+}
+
+// ============= DIRECTIVE DETAIL PANEL =============
+
+function DirectiveDetailPanel({
+ directive,
+ onClose,
+ onDelete,
+}: {
+ directive: Directive;
+ onClose: () => void;
+ onDelete: () => void;
+}) {
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
{directive.name}
+
+ directive
+
+
+
+
+
+
+
+
+
+ {/* Description */}
+
+
+ Rule
+
+
+ {directive.content}
+
+
+
+ {/* Tags */}
+ {directive.tags && directive.tags.length > 0 && (
+
+
+ Tags
+
+
+ {directive.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+ {/* ID */}
+
+
+ ID
+
+
+ {directive.id}
+
+
+
+ {/* Actions */}
+
+
+
+ Delete
+
+
+
+
);
}
diff --git a/hindsight-control-plane/src/components/bank-selector.tsx b/hindsight-control-plane/src/components/bank-selector.tsx
index 0a9fb712..08e54efa 100644
--- a/hindsight-control-plane/src/components/bank-selector.tsx
+++ b/hindsight-control-plane/src/components/bank-selector.tsx
@@ -23,13 +23,41 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
-import { Check, ChevronsUpDown, Plus, FileText, Moon, Sun, Github, Tag } from "lucide-react";
+import {
+ Check,
+ ChevronsUpDown,
+ Plus,
+ FileText,
+ Moon,
+ Sun,
+ Github,
+ Tag,
+ Upload,
+ X,
+} from "lucide-react";
import { useTheme } from "@/lib/theme-context";
import Image from "next/image";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
+// Supported text file extensions (matching CLI)
+const TEXT_EXTENSIONS = [
+ "txt",
+ "md",
+ "json",
+ "yaml",
+ "yml",
+ "toml",
+ "xml",
+ "csv",
+ "log",
+ "rst",
+ "adoc",
+];
+const ACCEPT_FILES = TEXT_EXTENSIONS.map((ext) => `.${ext}`).join(",");
+
function BankSelectorInner() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -43,6 +71,7 @@ function BankSelectorInner() {
// Document creation state
const [docDialogOpen, setDocDialogOpen] = React.useState(false);
+ const [docTab, setDocTab] = React.useState<"text" | "upload">("text");
const [docContent, setDocContent] = React.useState("");
const [docContext, setDocContext] = React.useState("");
const [docEventDate, setDocEventDate] = React.useState("");
@@ -52,6 +81,11 @@ function BankSelectorInner() {
const [isCreatingDoc, setIsCreatingDoc] = React.useState(false);
const [docError, setDocError] = React.useState(null);
+ // File upload state
+ const [selectedFiles, setSelectedFiles] = React.useState([]);
+ const [uploadProgress, setUploadProgress] = React.useState("");
+ const fileInputRef = React.useRef(null);
+
const sortedBanks = React.useMemo(() => {
return [...banks].sort((a, b) => a.localeCompare(b));
}, [banks]);
@@ -77,6 +111,91 @@ function BankSelectorInner() {
}
};
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files || []);
+ // Filter to only supported extensions
+ const validFiles = files.filter((file) => {
+ const ext = file.name.split(".").pop()?.toLowerCase();
+ return ext && TEXT_EXTENSIONS.includes(ext);
+ });
+ setSelectedFiles((prev) => [...prev, ...validFiles]);
+ // Reset input to allow selecting same file again
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ const removeFile = (index: number) => {
+ setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const readFileContent = (file: File): Promise => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = (e) => resolve(e.target?.result as string);
+ reader.onerror = (e) => reject(e);
+ reader.readAsText(file);
+ });
+ };
+
+ const handleUploadFiles = async () => {
+ if (!currentBank || selectedFiles.length === 0) return;
+
+ setIsCreatingDoc(true);
+ setDocError(null);
+ setUploadProgress("");
+
+ try {
+ const parsedTags = docTags
+ .split(",")
+ .map((t) => t.trim())
+ .filter((t) => t.length > 0);
+
+ // Read all files and create items
+ const items: any[] = [];
+ for (let i = 0; i < selectedFiles.length; i++) {
+ const file = selectedFiles[i];
+ setUploadProgress(`Reading file ${i + 1}/${selectedFiles.length}: ${file.name}`);
+ const content = await readFileContent(file);
+ const item: any = {
+ content,
+ context: `File: ${file.name}`,
+ };
+ if (parsedTags.length > 0) item.tags = parsedTags;
+ items.push(item);
+ }
+
+ setUploadProgress(`Uploading ${items.length} file(s)...`);
+
+ const params: any = {
+ bank_id: currentBank,
+ items,
+ };
+ if (parsedTags.length > 0) params.document_tags = parsedTags;
+
+ if (docAsync) {
+ await client.retain({ ...params, async: true });
+ } else {
+ await client.retain(params);
+ }
+
+ // Reset form and close dialog
+ setDocDialogOpen(false);
+ setSelectedFiles([]);
+ setDocTags("");
+ setDocAsync(false);
+ setUploadProgress("");
+
+ // Navigate to documents view
+ router.push(`/banks/${currentBank}?view=documents`);
+ } catch (error) {
+ setDocError(error instanceof Error ? error.message : "Failed to upload files");
+ } finally {
+ setIsCreatingDoc(false);
+ setUploadProgress("");
+ }
+ };
+
const handleCreateDocument = async () => {
if (!currentBank || !docContent.trim()) return;
@@ -299,81 +418,187 @@ function BankSelectorInner() {
{currentBank}
-
-
- Content *
-
-
- Context
- setDocContext(e.target.value)}
- placeholder="Optional context about this document..."
- />
-
+
setDocTab(v as "text" | "upload")}>
+
+
+
+ Text
+
+
+
+ Upload Files
+
+
-
-
-
-
- Tags
-
-
setDocTags(e.target.value)}
- placeholder="user_alice, session_123, project_x"
- />
-
- Comma-separated tags for filtering during recall/reflect
-
-
+
+ setDocAsync(checked as boolean)}
+ />
+
+ Process in background (async)
+
+
+
-
- setDocAsync(checked as boolean)}
- />
-
- Process in background (async)
-
-
+
+
+
+
+
+
+ Click to select files or drag and drop
+
+
+ Supported: {TEXT_EXTENSIONS.join(", ")}
+
+
+
+
+ {selectedFiles.length > 0 && (
+
+
+ Selected Files ({selectedFiles.length})
+
+
+ {selectedFiles.map((file, index) => (
+
+
+
+ {file.name}
+
+ ({(file.size / 1024).toFixed(1)} KB)
+
+
+
removeFile(index)}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ Tags (applied to all files)
+
+
setDocTags(e.target.value)}
+ placeholder="user_alice, session_123, project_x"
+ />
+
+ Comma-separated tags for filtering during recall/reflect
+
+
+
+
+ setDocAsync(checked as boolean)}
+ />
+
+ Process in background (async)
+
+
+
+ {uploadProgress && (
+ {uploadProgress}
+ )}
+
+
+
+ {docError &&
{docError}
}
- {docError &&
{docError}
}
-
Cancel
-
- {isCreatingDoc ? "Adding..." : "Add Document"}
-
+ {docTab === "text" ? (
+
+ {isCreatingDoc ? "Adding..." : "Add Document"}
+
+ ) : (
+
+ {isCreatingDoc
+ ? uploadProgress || "Uploading..."
+ : `Upload ${selectedFiles.length} File${selectedFiles.length !== 1 ? "s" : ""}`}
+
+ )}
diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx
index a3af4d44..f96e8ee7 100644
--- a/hindsight-control-plane/src/components/data-view.tsx
+++ b/hindsight-control-plane/src/components/data-view.tsx
@@ -19,6 +19,8 @@ import {
Eye,
EyeOff,
RefreshCw,
+ CheckCircle,
+ Clock,
} from "lucide-react";
import {
Table,
@@ -34,7 +36,7 @@ import { Switch } from "@/components/ui/switch";
import { MemoryDetailPanel } from "./memory-detail-panel";
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
-type FactType = "world" | "experience";
+type FactType = "world" | "experience" | "mental_model";
type ViewMode = "graph" | "table" | "timeline";
interface DataViewProps {
@@ -56,6 +58,12 @@ export function DataView({ factType }: DataViewProps) {
// Fetch limit state - how many memories to load from the API
const [fetchLimit, setFetchLimit] = useState(1000);
+ // Consolidation status for mental models
+ const [consolidationStatus, setConsolidationStatus] = useState<{
+ pending_consolidation: number;
+ last_consolidated_at: string | null;
+ } | null>(null);
+
// Graph controls state
const [showLabels, setShowLabels] = useState(true);
const [maxNodes, setMaxNodes] = useState(undefined);
@@ -108,6 +116,15 @@ export function DataView({ factType }: DataViewProps) {
limit: limit ?? fetchLimit,
});
setData(graphData);
+
+ // Fetch consolidation status for mental models
+ if (factType === "mental_model") {
+ const stats: any = await client.getBankStats(currentBank);
+ setConsolidationStatus({
+ pending_consolidation: stats.pending_consolidation || 0,
+ last_consolidated_at: stats.last_consolidated_at || null,
+ });
+ }
} catch (error) {
console.error("Error loading data:", error);
alert(`Error loading ${factType} data: ` + (error as Error).message);
@@ -267,25 +284,55 @@ export function DataView({ factType }: DataViewProps) {
-
- {searchQuery ? (
- `${filteredTableRows.length} of ${data.table_rows?.length ?? 0} loaded memories`
- ) : data.table_rows?.length < data.total_units ? (
-
- Showing {data.table_rows?.length ?? 0} of {data.total_units} total memories
- {
- const newLimit = Math.min(data.total_units, fetchLimit + 1000);
- setFetchLimit(newLimit);
- loadData(newLimit);
- }}
- className="ml-2 text-primary hover:underline"
- >
- Load more
-
-
- ) : (
- `${data.total_units} total memories`
+
+
+ {searchQuery ? (
+ `${filteredTableRows.length} of ${data.table_rows?.length ?? 0} loaded memories`
+ ) : data.table_rows?.length < data.total_units ? (
+
+ Showing {data.table_rows?.length ?? 0} of {data.total_units} total memories
+ {
+ const newLimit = Math.min(data.total_units, fetchLimit + 1000);
+ setFetchLimit(newLimit);
+ loadData(newLimit);
+ }}
+ className="ml-2 text-primary hover:underline"
+ >
+ Load more
+
+
+ ) : (
+ `${data.total_units} total memories`
+ )}
+
+
+ {/* Consolidation status for mental models */}
+ {factType === "mental_model" && consolidationStatus && (
+
+ {consolidationStatus.pending_consolidation === 0 ? (
+ <>
+
+ In Sync
+ >
+ ) : (
+ <>
+
+ {consolidationStatus.pending_consolidation} Pending
+ >
+ )}
+
)}
diff --git a/hindsight-control-plane/src/components/memory-detail-panel.tsx b/hindsight-control-plane/src/components/memory-detail-panel.tsx
index e49e3682..0c28b9b1 100644
--- a/hindsight-control-plane/src/components/memory-detail-panel.tsx
+++ b/hindsight-control-plane/src/components/memory-detail-panel.tsx
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check, X, Loader2 } from "lucide-react";
import { DocumentChunkModal } from "./document-chunk-modal";
+import { MemoryDetailModal } from "./memory-detail-modal";
import { client } from "@/lib/api";
interface MemoryDetailPanelProps {
@@ -26,8 +27,10 @@ export function MemoryDetailPanel({
const [modalId, setModalId] = useState
(null);
const [fullMemory, setFullMemory] = useState(null);
const [loading, setLoading] = useState(false);
+ const [sourceMemoryModalId, setSourceMemoryModalId] = useState(null);
// Fetch full memory data when panel opens
+ // For mental models, use getMentalModel to get source memories
useEffect(() => {
const memoryId = memory?.id || memory?.node_id;
if (!memoryId || !bankId) {
@@ -35,9 +38,19 @@ export function MemoryDetailPanel({
return;
}
+ const isMentalModel = memory?.fact_type === "mental_model" || memory?.type === "mental_model";
+
setLoading(true);
- client
- .getMemory(memoryId, bankId)
+
+ const fetchPromise = isMentalModel
+ ? client.getMentalModel(bankId, memoryId).then((data) => ({
+ ...data,
+ fact_type: "mental_model",
+ type: "mental_model",
+ }))
+ : client.getMemory(memoryId, bankId);
+
+ fetchPromise
.then((data) => {
setFullMemory(data);
})
@@ -49,7 +62,7 @@ export function MemoryDetailPanel({
.finally(() => {
setLoading(false);
});
- }, [memory?.id, memory?.node_id, bankId]);
+ }, [memory?.id, memory?.node_id, memory?.fact_type, memory?.type, bankId]);
// Use full memory data if available, otherwise fall back to the partial data passed in
const displayMemory = fullMemory || memory;
@@ -202,6 +215,67 @@ export function MemoryDetailPanel({
)}
+ {/* Source Memories (for mental models) */}
+ {displayMemory.source_memories && displayMemory.source_memories.length > 0 && (
+
+
+ Source Memories ({displayMemory.source_memories.length})
+
+
+ {displayMemory.source_memories.map((source: any, i: number) => (
+
+
+
+ {source.type}
+
+ setSourceMemoryModalId(source.id)}
+ >
+ View
+
+
+
{source.text}
+ {source.context && (
+
+ Context: {source.context}
+
+ )}
+
+
+
Occurred
+
+ {source.occurred_start
+ ? new Date(source.occurred_start).toLocaleString()
+ : "N/A"}
+
+
+
+
Mentioned
+
+ {source.mentioned_at
+ ? new Date(source.mentioned_at).toLocaleString()
+ : "N/A"}
+
+
+
+
+ ))}
+
+
+ )}
+
{/* ID */}
{memoryId && (
@@ -259,6 +333,12 @@ export function MemoryDetailPanel({
{modalType && modalId && (
)}
+
+ {/* Source Memory Modal */}
+
setSourceMemoryModalId(null)}
+ />
>
);
}
diff --git a/hindsight-control-plane/src/components/mental-models-view.tsx b/hindsight-control-plane/src/components/mental-models-view.tsx
deleted file mode 100644
index 856346da..00000000
--- a/hindsight-control-plane/src/components/mental-models-view.tsx
+++ /dev/null
@@ -1,2123 +0,0 @@
-"use client";
-
-import { useState, useEffect, useRef } from "react";
-import ReactMarkdown from "react-markdown";
-import { client } from "@/lib/api";
-import { useBank } from "@/lib/bank-context";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Card, CardContent } from "@/components/ui/card";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import {
- RefreshCw,
- Lightbulb,
- Sparkles,
- Target,
- Pencil,
- Check,
- X,
- Plus,
- Pin,
- ChevronDown,
- FileText,
- Calendar,
- Tag,
- Users,
- ChevronRight,
- Loader2,
- ExternalLink,
- AlertTriangle,
- Trash2,
- History,
- ArrowLeft,
- ArrowRight,
-} from "lucide-react";
-import { MemoryDetailModal } from "./memory-detail-modal";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-
-type ViewMode = "dashboard" | "table";
-
-interface ObservationEvidence {
- memory_id: string;
- quote: string;
- relevance: string;
- timestamp: string;
-}
-
-interface MentalModelObservation {
- title: string;
- // 'content' is used for generated observations, 'text' for directives
- content?: string;
- text?: string;
- evidence?: ObservationEvidence[];
- based_on?: string[]; // For directives
- created_at?: string;
- trend?: "stable" | "strengthening" | "weakening" | "new" | "stale";
- evidence_count?: number;
- evidence_span?: { from: string | null; to: string | null };
-}
-
-interface MentalModelFreshness {
- is_up_to_date: boolean;
- last_refresh_at: string | null;
- memories_since_refresh: number;
- reasons: string[];
-}
-
-interface MentalModelVersion {
- version: number;
- created_at: string | null;
- observation_count: number;
-}
-
-interface VersionObservation {
- title: string;
- content: string;
- evidence: ObservationEvidence[];
- created_at: string;
- trend: string;
- evidence_count: number;
- evidence_span: { from: string | null; to: string | null };
-}
-
-interface MentalModel {
- id: string;
- bank_id: string;
- subtype: string;
- name: string;
- description: string;
- observations?: MentalModelObservation[];
- entity_id: string | null;
- links: string[];
- tags?: string[];
- last_updated: string | null;
- last_refresh_at?: string | null;
- freshness?: MentalModelFreshness | null;
- created_at: string;
-}
-
-// Helper to count total source memories across all observations
-function getTotalMemoryCount(model: MentalModel): number {
- return model.observations?.reduce((sum, obs) => sum + (obs.evidence?.length || 0), 0) || 0;
-}
-
-// Helper to format freshness reasons for display
-function formatFreshnessReason(freshness: MentalModelFreshness): string {
- if (freshness.is_up_to_date) return "Up to date";
- if (!freshness.reasons || freshness.reasons.length === 0) {
- // Fallback to memory count if no reasons
- return freshness.memories_since_refresh > 0
- ? `${freshness.memories_since_refresh} new`
- : "Stale";
- }
-
- // Map reason codes to human-readable labels
- const reasonLabels: Record = {
- never_refreshed: "Never refreshed",
- new_memories: `${freshness.memories_since_refresh} new`,
- mission_changed: "Mission changed",
- disposition_changed: "Disposition changed",
- directives_changed: "Directives changed",
- };
-
- // Return the first reason (most important)
- const primaryReason = freshness.reasons[0];
- return reasonLabels[primaryReason] || primaryReason;
-}
-
-export function MentalModelsView() {
- const { currentBank } = useBank();
- const [mentalModels, setMentalModels] = useState([]);
- const [loading, setLoading] = useState(false);
- const [refreshing, setRefreshing] = useState<
- "all" | "pinned" | "structural" | "emergent" | "learned" | null
- >(null);
- const [operationStatus, setOperationStatus] = useState<{
- operationId: string;
- type: "all" | "pinned" | "structural" | "emergent" | "learned";
- status: "pending" | "completed" | "failed" | "not_found";
- errorMessage?: string | null;
- } | null>(null);
- const [mission, setMission] = useState(null);
- const [viewMode, setViewMode] = useState("dashboard");
- const [selectedModel, setSelectedModel] = useState(null);
-
- // Mission editing state
- const [editingMission, setEditingMission] = useState(false);
- const [editMissionValue, setEditMissionValue] = useState("");
- const [savingMission, setSavingMission] = useState(false);
-
- // Create mental model state
- const [showCreateForm, setShowCreateForm] = useState(false);
- const [createType, setCreateType] = useState<"pinned" | "directive">("pinned");
- const [creating, setCreating] = useState(false);
- const [newModel, setNewModel] = useState({ name: "", description: "", tags: "" });
-
- // Delete state
- const [deletingModel, setDeletingModel] = useState(null);
-
- // Edit state
- const [editingModel, setEditingModel] = useState(null);
- const [editForm, setEditForm] = useState({ name: "", description: "" });
- const [saving, setSaving] = useState(false);
-
- // Auto-refresh interval (5 seconds)
- const AUTO_REFRESH_INTERVAL = 5000;
- const currentBankRef = useRef(currentBank);
- currentBankRef.current = currentBank;
- const selectedModelRef = useRef(selectedModel);
- selectedModelRef.current = selectedModel;
-
- const loadMentalModels = async () => {
- if (!currentBank) return;
-
- setLoading(true);
- try {
- const [modelsData, profileData] = await Promise.all([
- client.listMentalModels(currentBank),
- client.getBankProfile(currentBank),
- ]);
- setMentalModels(modelsData.items || []);
- setMission(profileData.mission);
- setEditMissionValue(profileData.mission || "");
- } catch (error) {
- console.error("Error loading mental models:", error);
- } finally {
- setLoading(false);
- }
- };
-
- // Silent refresh for auto-refresh (no loading spinner)
- const silentRefreshModels = async () => {
- const bank = currentBankRef.current;
- if (!bank) return;
-
- try {
- const modelsData = await client.listMentalModels(bank);
- const items = modelsData.items || [];
- setMentalModels(items);
-
- // Update selected model if it exists in the refreshed list
- const currentSelected = selectedModelRef.current;
- if (currentSelected) {
- const updatedModel = items.find((m) => m.id === currentSelected.id);
- if (updatedModel) {
- setSelectedModel(updatedModel);
- }
- }
- } catch (error) {
- console.error("Error auto-refreshing mental models:", error);
- }
- };
-
- const handleSaveMission = async () => {
- if (!currentBank) return;
-
- setSavingMission(true);
- try {
- await client.setBankMission(currentBank, editMissionValue);
- setMission(editMissionValue || null);
- setEditingMission(false);
- } catch (error) {
- console.error("Error saving mission:", error);
- alert("Error saving mission: " + (error as Error).message);
- } finally {
- setSavingMission(false);
- }
- };
-
- const handleCancelEditMission = () => {
- setEditMissionValue(mission || "");
- setEditingMission(false);
- };
-
- const handleRefresh = async (subtype?: "structural" | "emergent" | "pinned" | "learned") => {
- if (!currentBank) return;
-
- const refreshType = subtype || "all";
- setRefreshing(refreshType);
- setOperationStatus(null);
- try {
- const result = await client.refreshMentalModels(currentBank, subtype);
- // Start polling for operation status
- if (result.operation_id) {
- setOperationStatus({
- operationId: result.operation_id,
- type: refreshType,
- status: "pending",
- });
- pollOperationStatus(result.operation_id, refreshType);
- }
- } catch (error) {
- console.error("Error refreshing mental models:", error);
- alert("Error refreshing mental models: " + (error as Error).message);
- } finally {
- setRefreshing(null);
- }
- };
-
- const pollOperationStatus = async (
- operationId: string,
- type: "all" | "pinned" | "structural" | "emergent" | "learned"
- ) => {
- const bank = currentBankRef.current;
- if (!bank) return;
-
- const poll = async () => {
- try {
- const status = await client.getOperationStatus(bank, operationId);
- setOperationStatus({
- operationId,
- type,
- status: status.status,
- errorMessage: status.error_message,
- });
-
- if (status.status === "pending") {
- // Continue polling every 2 seconds
- setTimeout(poll, 2000);
- } else {
- // Operation completed or failed - refresh models and clear after delay
- if (status.status === "completed") {
- await silentRefreshModels();
- }
- setTimeout(() => setOperationStatus(null), 5000);
- }
- } catch (error) {
- console.error("Error polling operation status:", error);
- // On error, assume completed and clear
- setOperationStatus(null);
- }
- };
-
- poll();
- };
-
- const handleCreateModel = async () => {
- if (!currentBank || !newModel.name.trim() || !newModel.description.trim()) return;
-
- setCreating(true);
- try {
- // Parse comma-separated tags
- const tags = newModel.tags
- .split(",")
- .map((t) => t.trim())
- .filter((t) => t.length > 0);
-
- await client.createMentalModel(currentBank, {
- name: newModel.name.trim(),
- description: newModel.description.trim(),
- subtype: createType === "directive" ? "directive" : undefined,
- tags: tags.length > 0 ? tags : undefined,
- observations:
- createType === "directive"
- ? [{ title: newModel.name.trim(), content: newModel.description.trim() }]
- : undefined,
- });
-
- // Reset form and reload
- setNewModel({ name: "", description: "", tags: "" });
- setCreateType("pinned");
- setShowCreateForm(false);
- await loadMentalModels();
- } catch (error) {
- console.error("Error creating mental model:", error);
- alert("Error creating mental model: " + (error as Error).message);
- } finally {
- setCreating(false);
- }
- };
-
- const handleDeleteModel = async (modelId: string) => {
- if (!currentBank) return;
-
- setDeletingModel(modelId);
- try {
- await client.deleteMentalModel(currentBank, modelId);
- await loadMentalModels();
- if (selectedModel?.id === modelId) {
- setSelectedModel(null);
- }
- } catch (error) {
- console.error("Error deleting mental model:", error);
- alert("Error deleting mental model: " + (error as Error).message);
- } finally {
- setDeletingModel(null);
- }
- };
-
- const handleStartEdit = (model: MentalModel) => {
- setEditingModel(model);
- setEditForm({ name: model.name, description: model.description });
- };
-
- const handleSaveEdit = async () => {
- if (!currentBank || !editingModel) return;
-
- setSaving(true);
- try {
- await client.updateMentalModel(currentBank, editingModel.id, {
- name: editForm.name.trim(),
- description: editForm.description.trim(),
- });
- setEditingModel(null);
- await loadMentalModels();
- } catch (error) {
- console.error("Error updating mental model:", error);
- alert("Error updating mental model: " + (error as Error).message);
- } finally {
- setSaving(false);
- }
- };
-
- useEffect(() => {
- if (currentBank) {
- loadMentalModels();
-
- // Set up auto-refresh interval for mental models only
- const intervalId = setInterval(silentRefreshModels, AUTO_REFRESH_INTERVAL);
-
- return () => {
- clearInterval(intervalId);
- };
- }
- }, [currentBank]);
-
- // Close detail panel on Escape
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape" && selectedModel) {
- setSelectedModel(null);
- }
- };
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [selectedModel]);
-
- if (!currentBank) {
- return (
-
-
- Select a memory bank to view mental models.
-
-
- );
- }
-
- // Group mental models by subtype
- const pinnedModels = mentalModels.filter((m) => m.subtype === "pinned");
- const structuralModels = mentalModels.filter((m) => m.subtype === "structural");
- const emergentModels = mentalModels.filter((m) => m.subtype === "emergent");
- const learnedModels = mentalModels.filter((m) => m.subtype === "learned");
- const directiveModels = mentalModels.filter((m) => m.subtype === "directive");
-
- const getSubtypeIcon = (subtype: string) => {
- switch (subtype) {
- case "pinned":
- return ;
- case "structural":
- return ;
- case "emergent":
- return ;
- case "learned":
- return ;
- case "directive":
- return ;
- default:
- return ;
- }
- };
-
- return (
-
- {/* Mission section - compact inline style */}
-
-
-
- Mission:
-
- {editingMission ? (
-
- setEditMissionValue(e.target.value)}
- placeholder="e.g., Be a PM for the engineering team..."
- className="flex-1 h-7 text-sm"
- autoFocus
- onKeyDown={(e) => {
- if (e.key === "Enter") handleSaveMission();
- if (e.key === "Escape") handleCancelEditMission();
- }}
- />
-
-
-
-
-
-
-
- ) : (
- <>
-
- {mission || "No mission set"}
-
-
setEditingMission(true)}
- className="h-6 px-2 text-xs"
- >
-
- {mission ? "Edit" : "Set"}
-
- >
- )}
-
-
- {/* Header with count, actions, and view toggle */}
-
-
-
- {mentalModels.length} mental model{mentalModels.length !== 1 ? "s" : ""}
-
-
-
-
-
- {refreshing ? (
-
- ) : (
-
- )}
- {refreshing ? "Refreshing..." : "Refresh"}
-
-
-
-
- handleRefresh()}>
-
- All
-
- handleRefresh("pinned")}>
-
- Pinned
-
- handleRefresh("structural")}>
-
- Structural
-
- handleRefresh("emergent")}>
-
- Emergent
-
- handleRefresh("learned")}>
-
- Learned
-
-
-
-
-
-
- {/* View mode toggle - top right like data-view */}
-
- setViewMode("dashboard")}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
- viewMode === "dashboard"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- Dashboard
-
- setViewMode("table")}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
- viewMode === "table"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- Table
-
-
-
-
- {/* Regeneration Progress Banner */}
- {refreshing && (
-
-
-
- Scheduling regeneration for{" "}
- {refreshing === "all" ? "all mental models" : `${refreshing} models`}...
-
-
- )}
-
- {/* Operation Status Banner - shows operation progress */}
- {operationStatus && !refreshing && (
-
- {operationStatus.status === "pending" ? (
-
- ) : operationStatus.status === "completed" ? (
-
- ) : (
-
- )}
-
- {operationStatus.status === "pending"
- ? `Refreshing ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}...`
- : operationStatus.status === "completed"
- ? `Successfully refreshed ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}`
- : `Failed to refresh ${operationStatus.type === "all" ? "all mental models" : `${operationStatus.type} models`}`}
-
- {operationStatus.status === "pending" && (
- Running in background...
- )}
- {operationStatus.errorMessage && (
- {operationStatus.errorMessage}
- )}
- setOperationStatus(null)}
- >
-
-
-
- )}
-
- {/* Create Mental Model Dialog */}
-
{
- setShowCreateForm(open);
- if (!open) {
- setNewModel({ name: "", description: "", tags: "" });
- setCreateType("pinned");
- }
- }}
- >
-
-
-
- {createType === "directive" ? (
- <>
-
- Create Directive
- >
- ) : (
- <>
-
- Create Pinned Mental Model
- >
- )}
-
-
- {createType === "directive"
- ? "Directives are hard rules that the agent MUST follow in all responses."
- : "Pinned models persist across regeneration and help organize your memory bank."}
-
-
-
-
- {/* Type selector */}
-
-
setCreateType("pinned")}
- className={`flex-1 ${createType === "pinned" ? "bg-amber-500 hover:bg-amber-600" : ""}`}
- >
-
- Pinned Model
-
-
setCreateType("directive")}
- className={`flex-1 ${createType === "directive" ? "bg-rose-500 hover:bg-rose-600" : ""}`}
- >
-
- Directive
-
-
-
-
- Name *
- setNewModel({ ...newModel, name: e.target.value })}
- placeholder={
- createType === "directive"
- ? "e.g., Competitor Policy, Response Guidelines"
- : "e.g., Product Roadmap, Team Structure, Q1 Goals"
- }
- />
-
-
-
- {createType === "directive" ? "Rule *" : "Description *"}
-
-
-
-
- Tags (optional)
-
-
setNewModel({ ...newModel, tags: e.target.value })}
- placeholder="e.g., project-x, team-alpha (comma-separated)"
- />
-
- This model is used during reflect only when the request includes matching tags.
-
-
-
-
-
- {
- setShowCreateForm(false);
- setNewModel({ name: "", description: "", tags: "" });
- setCreateType("pinned");
- }}
- >
- Cancel
-
-
- {creating ? "Creating..." : "Create"}
-
-
-
-
-
- {/* Edit Mental Model Dialog */}
-
!open && setEditingModel(null)}>
-
-
-
- {editingModel?.subtype === "directive" ? (
- <>
-
- Edit Directive
- >
- ) : (
- <>
-
- Edit Pinned Model
- >
- )}
-
-
- {editingModel?.subtype === "directive"
- ? "Update the directive name and rule text."
- : "Update the pinned model name and description."}
-
-
-
-
-
- Name
- setEditForm({ ...editForm, name: e.target.value })}
- />
-
-
-
- {editingModel?.subtype === "directive" ? "Rule" : "Description"}
-
-
-
-
-
- setEditingModel(null)}>
- Cancel
-
-
- {saving ? "Saving..." : "Save"}
-
-
-
-
-
- {/* Loading state */}
- {loading ? (
-
-
-
Loading mental models...
-
- ) : mentalModels.length > 0 ? (
- <>
- {/* Table View */}
- {viewMode === "table" && (
-
-
-
-
- Name
- Source
- Description
- Obs
- Memories
- Updated
-
-
-
- {mentalModels.map((model) => (
- setSelectedModel(model)}
- className={`cursor-pointer hover:bg-muted/50 ${
- selectedModel?.id === model.id ? "bg-primary/10" : ""
- }`}
- >
-
-
- {getSubtypeIcon(model.subtype)}
- {model.name}
-
-
-
-
- {model.subtype}
-
-
-
-
- {model.description}
-
-
-
- {model.observations?.length || 0}
-
-
- {getTotalMemoryCount(model)}
-
-
-
- {model.freshness && !model.freshness.is_up_to_date && (
-
-
- {formatFreshnessReason(model.freshness)}
-
- )}
-
- {model.last_refresh_at
- ? new Date(model.last_refresh_at).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- }) +
- " " +
- new Date(model.last_refresh_at).toLocaleTimeString("en-US", {
- hour: "numeric",
- minute: "2-digit",
- })
- : "-"}
-
-
-
-
- ))}
-
-
-
- )}
-
- {/* Dashboard View - Grouped by subtype, 2 columns */}
- {/* Order: Structural, Emergent, Pinned, Learned */}
- {viewMode === "dashboard" && (
-
- {/* Structural Models Section */}
- {structuralModels.length > 0 && (
-
-
-
- Structural Models
-
- (Mission-derived)
-
-
-
- {structuralModels.map((model) => (
- setSelectedModel(model)}
- />
- ))}
-
-
- )}
-
- {/* Emergent Models Section - Always show */}
-
-
-
- Emergent Models
-
- (Pattern-derived)
-
-
- {emergentModels.length > 0 ? (
-
- {emergentModels.map((model) => (
- setSelectedModel(model)}
- />
- ))}
-
- ) : (
-
-
-
- No emergent models yet. Models are discovered from patterns in your data.
-
-
- )}
-
-
- {/* Pinned Models Section - Always show */}
-
-
-
- Pinned Models
- (User-defined)
- {
- setCreateType("pinned");
- setShowCreateForm(true);
- }}
- variant="outline"
- size="sm"
- className="ml-auto h-7 text-xs"
- >
-
- Add
-
-
- {pinnedModels.length > 0 ? (
-
- {pinnedModels.map((model) => (
- setSelectedModel(model)}
- onEdit={() => handleStartEdit(model)}
- />
- ))}
-
- ) : (
-
-
-
- No pinned models yet. Create custom models to track specific topics.
-
-
- )}
-
-
- {/* Learned Models Section - Always show */}
-
-
-
- Learned Models
-
- (Reflection-derived)
-
-
- {learnedModels.length > 0 ? (
-
- {learnedModels.map((model) => (
- setSelectedModel(model)}
- />
- ))}
-
- ) : (
-
-
-
- No learned models yet. Models are created automatically during reflection.
-
-
- )}
-
-
- {/* Directives Section - Always show */}
-
-
-
- Directives
- (Hard rules)
- {
- setCreateType("directive");
- setShowCreateForm(true);
- }}
- variant="outline"
- size="sm"
- className="ml-auto h-7 text-xs"
- >
-
- Add
-
-
- {directiveModels.length > 0 ? (
-
- {directiveModels.map((model) => (
- setSelectedModel(model)}
- />
- ))}
-
- ) : (
-
-
-
- No directives yet. Directives are hard rules that the agent must follow.
-
-
- )}
-
-
- )}
- >
- ) : (
-
-
-
- No Mental Models Yet
-
- {!mission
- ? "Set a mission above, then use the refresh buttons to create mental models."
- : "Use the refresh buttons to create mental models. Click 'All' for both types, or refresh 'Structural' (from mission) and 'Emergent' (from data) separately."}
-
-
-
- )}
-
- {/* Detail Panel - Fixed on Right */}
- {selectedModel && (
-
- setSelectedModel(null)}
- onRegenerated={silentRefreshModels}
- onEdit={() => handleStartEdit(selectedModel)}
- onDelete={() => handleDeleteModel(selectedModel.id)}
- deleting={deletingModel === selectedModel.id}
- />
-
- )}
-
- );
-}
-
-// Unified card component for all mental model types
-function MentalModelCard({
- model,
- selected,
- onClick,
- onEdit,
-}: {
- model: MentalModel;
- selected: boolean;
- onClick: () => void;
- onEdit?: () => void;
-}) {
- const isDirective = model.subtype === "directive";
- const isPinned = model.subtype === "pinned";
-
- // Get icon based on subtype
- const getIcon = () => {
- switch (model.subtype) {
- case "pinned":
- return ;
- case "directive":
- return ;
- case "structural":
- return ;
- case "emergent":
- return ;
- case "learned":
- return ;
- default:
- return null;
- }
- };
-
- // Card styling based on subtype
- const cardClassName = isDirective
- ? `cursor-pointer transition-colors border-rose-500/30 ${
- selected ? "bg-rose-500/10 border-rose-500" : "hover:bg-rose-500/5"
- }`
- : `cursor-pointer transition-colors ${
- selected ? "bg-primary/10 border-primary" : "hover:bg-muted/50"
- }`;
-
- return (
-
-
-
-
- {/* Header with icon and name */}
-
- {getIcon()}
- {model.name}
-
-
- {/* Description */}
-
{model.description}
-
- {/* Stats row - not shown for directives */}
- {!isDirective && (
-
-
-
- {model.observations?.length || 0}
- {" "}
- obs
-
-
- {getTotalMemoryCount(model)} {" "}
- memories
-
- {model.freshness && !model.freshness.is_up_to_date && (
-
-
- {formatFreshnessReason(model.freshness)}
-
- )}
- {model.last_refresh_at && (
-
- {new Date(model.last_refresh_at).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- })}
-
- )}
-
- )}
-
- {/* Tags */}
- {model.tags && model.tags.length > 0 && (
-
-
- {model.tags.map((tag) => (
-
- {tag}
-
- ))}
-
- )}
-
-
- {/* Edit button - only for pinned models */}
- {isPinned && onEdit && (
-
{
- e.stopPropagation();
- onEdit();
- }}
- title="Edit"
- >
-
-
- )}
-
-
-
- );
-}
-
-// Detail panel for mental model (like MemoryDetailPanel)
-function MentalModelDetailPanel({
- model,
- onClose,
- onRegenerated,
- onEdit,
- onDelete,
- deleting,
-}: {
- model: MentalModel;
- onClose: () => void;
- onRegenerated?: () => void;
- onEdit?: () => void;
- onDelete?: () => void;
- deleting?: boolean;
-}) {
- const { currentBank } = useBank();
- const [expandedObservation, setExpandedObservation] = useState(null);
- const [regenerateStatus, setRegenerateStatus] = useState<{
- status: "scheduling" | "pending" | "completed" | "failed";
- errorMessage?: string | null;
- } | null>(null);
-
- // Memory detail modal state
- const [selectedMemoryId, setSelectedMemoryId] = useState(null);
-
- // Version history state
- const [showVersionHistory, setShowVersionHistory] = useState(false);
- const [versions, setVersions] = useState([]);
- const [loadingVersions, setLoadingVersions] = useState(false);
- const [selectedVersion, setSelectedVersion] = useState(null);
- const [versionObservations, setVersionObservations] = useState([]);
- const [loadingVersionData, setLoadingVersionData] = useState(false);
-
- const loadVersionHistory = async () => {
- if (!currentBank) return;
- setLoadingVersions(true);
- try {
- const data = await client.listMentalModelVersions(currentBank, model.id);
- setVersions(data.versions || []);
- } catch (error) {
- console.error("Error loading version history:", error);
- } finally {
- setLoadingVersions(false);
- }
- };
-
- const loadVersionData = async (version: number) => {
- if (!currentBank) return;
- setLoadingVersionData(true);
- try {
- const data = await client.getMentalModelVersion(currentBank, model.id, version);
- setVersionObservations(data.observations || []);
- setSelectedVersion(version);
- } catch (error) {
- console.error("Error loading version data:", error);
- } finally {
- setLoadingVersionData(false);
- }
- };
-
- const handleShowHistory = () => {
- setShowVersionHistory(true);
- loadVersionHistory();
- };
-
- const handleCloseHistory = () => {
- setShowVersionHistory(false);
- setSelectedVersion(null);
- setVersionObservations([]);
- };
-
- const handleRegenerate = async () => {
- if (!currentBank) return;
-
- setRegenerateStatus({ status: "scheduling" });
- try {
- const result = await client.refreshMentalModel(currentBank, model.id);
- if (result.operation_id) {
- setRegenerateStatus({ status: "pending" });
- pollRegenerateStatus(result.operation_id);
- }
- } catch (error) {
- console.error("Error refreshing mental model:", error);
- setRegenerateStatus({ status: "failed", errorMessage: (error as Error).message });
- setTimeout(() => setRegenerateStatus(null), 5000);
- }
- };
-
- const pollRegenerateStatus = async (operationId: string) => {
- if (!currentBank) return;
-
- const poll = async () => {
- try {
- const status = await client.getOperationStatus(currentBank, operationId);
- if (status.status === "pending") {
- setTimeout(poll, 2000);
- } else if (status.status === "completed") {
- setRegenerateStatus({ status: "completed" });
- onRegenerated?.();
- setTimeout(() => setRegenerateStatus(null), 3000);
- } else {
- setRegenerateStatus({ status: "failed", errorMessage: status.error_message });
- setTimeout(() => setRegenerateStatus(null), 5000);
- }
- } catch (error) {
- console.error("Error polling refresh status:", error);
- setRegenerateStatus(null);
- }
- };
-
- poll();
- };
-
- const getSubtypeIcon = (subtype: string) => {
- switch (subtype) {
- case "pinned":
- return ;
- case "structural":
- return ;
- case "emergent":
- return ;
- case "learned":
- return ;
- case "directive":
- return ;
- default:
- return ;
- }
- };
-
- const getTypeColor = (type: string) => {
- switch (type) {
- case "world":
- return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
- case "experience":
- return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
- case "opinion":
- return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
- default:
- return "bg-slate-500/10 text-slate-600 dark:text-slate-400";
- }
- };
-
- const getTrendColor = (trend: string) => {
- switch (trend) {
- case "stable":
- return "bg-blue-500/10 text-blue-600 dark:text-blue-400";
- case "strengthening":
- return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
- case "weakening":
- return "bg-amber-500/10 text-amber-600 dark:text-amber-400";
- case "new":
- return "bg-violet-500/10 text-violet-600 dark:text-violet-400";
- case "stale":
- return "bg-slate-500/10 text-slate-600 dark:text-slate-400";
- default:
- return "bg-slate-500/10 text-slate-600 dark:text-slate-400";
- }
- };
-
- const getTrendDescription = (trend: string) => {
- switch (trend) {
- case "stable":
- return "Consistently mentioned over time";
- case "strengthening":
- return "Mentioned more frequently recently";
- case "weakening":
- return "Mentioned less frequently recently";
- case "new":
- return "Recently discovered";
- case "stale":
- return "Not mentioned recently";
- default:
- return "";
- }
- };
-
- return (
-
- {/* Header */}
-
-
- {getSubtypeIcon(model.subtype)}
-
-
{model.name}
-
-
- {model.subtype}
-
- {model.tags && model.tags.length > 0 && (
- <>
- {model.tags.map((tag) => (
-
-
- {tag}
-
- ))}
- >
- )}
-
-
-
-
- {model.subtype !== "directive" && (
- <>
-
-
- History
-
-
- {regenerateStatus?.status === "scheduling" ||
- regenerateStatus?.status === "pending" ? (
-
- ) : regenerateStatus?.status === "completed" ? (
-
- ) : regenerateStatus?.status === "failed" ? (
-
- ) : (
-
- )}
- {regenerateStatus?.status === "scheduling"
- ? "Scheduling..."
- : regenerateStatus?.status === "pending"
- ? "Refreshing..."
- : regenerateStatus?.status === "completed"
- ? "Done!"
- : regenerateStatus?.status === "failed"
- ? "Failed"
- : "Refresh"}
-
- >
- )}
-
-
-
-
-
-
-
- {/* Stats - compact row with status badge (same format as list view) */}
-
-
- {model.observations?.length || 0} {" "}
- observations
-
- {model.subtype !== "directive" && (
- <>
-
- {getTotalMemoryCount(model)} {" "}
- source memories
-
-
- Last refreshed:{" "}
-
- {model.last_refresh_at
- ? new Date(model.last_refresh_at).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- }) +
- " " +
- new Date(model.last_refresh_at).toLocaleTimeString("en-US", {
- hour: "numeric",
- minute: "2-digit",
- })
- : "Never"}
-
-
- {model.freshness && !model.freshness.is_up_to_date && (
-
-
- {formatFreshnessReason(model.freshness)}
-
- )}
- >
- )}
-
-
- {/* Description - right before observations */}
-
-
- Description
-
-
- {model.description}
-
-
-
- {/* Observations */}
- {model.observations && model.observations.length > 0 && (
-
-
- Observations ({model.observations.length})
-
-
- {model.observations.map((observation, idx) => {
- const hasEvidence = observation.evidence && observation.evidence.length > 0;
-
- return (
-
- {/* Title */}
-
- {observation.title}
-
-
- {/* Trend and date badges (not for directives) */}
- {model.subtype !== "directive" && (
-
- {observation.trend && (
-
-
-
-
- {observation.trend}
-
-
-
-
-
- Trend Legend
-
- {(
- [
- "new",
- "strengthening",
- "stable",
- "weakening",
- "stale",
- ] as const
- ).map((trend) => (
-
-
- {trend}
-
-
- {getTrendDescription(trend)}
-
-
- ))}
-
-
-
-
- )}
- {observation.evidence_span?.from && observation.evidence_span?.to && (
-
-
- {new Date(observation.evidence_span.from).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- })}{" "}
- -{" "}
- {new Date(observation.evidence_span.to).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- })}
-
- )}
-
- )}
-
- {/* Content */}
-
-
(
-
- {children}
-
- ),
- ul: ({ children }) => ,
- ol: ({ children }) => (
- {children}
- ),
- li: ({ children }) => (
- {children}
- ),
- code: ({ children }) => (
-
- {children}
-
- ),
- strong: ({ children }) => (
- {children}
- ),
- }}
- >
- {observation.text || observation.content}
-
-
-
- {/* Evidence section */}
- {hasEvidence && (
-
-
- setExpandedObservation(expandedObservation === idx ? null : idx)
- }
- className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors group"
- >
-
-
-
- {observation.evidence!.length} evidence quote
- {observation.evidence!.length !== 1 ? "s" : ""}
-
-
- {expandedObservation === idx ? "Hide" : "Show"}
-
-
-
- {expandedObservation === idx && (
-
- {observation.evidence!.map((ev, evIdx) => (
-
-
-
- “{ev.quote}”
-
- {ev.timestamp && (
-
-
- {new Date(ev.timestamp).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- })}
-
- )}
-
-
{
- e.stopPropagation();
- setSelectedMemoryId(ev.memory_id);
- }}
- >
-
- View
-
-
- ))}
-
- )}
-
- )}
-
- );
- })}
-
-
- )}
-
- {/* ID */}
-
-
- Model ID
-
-
{model.id}
-
-
- {/* Action buttons for user-managed models */}
- {(model.subtype === "pinned" || model.subtype === "directive") && (
-
- {onEdit && (
-
-
- Edit this {model.subtype === "directive" ? "directive" : "mental model"}
-
- )}
- {onDelete && (
-
- {deleting ? (
-
- ) : (
-
- )}
- Delete this {model.subtype === "directive" ? "directive" : "mental model"}
-
- )}
-
- )}
-
-
- {/* Version History Dialog */}
-
!open && handleCloseHistory()}>
-
-
-
-
- Version History - {model.name}
-
-
- {selectedVersion !== null
- ? "Side-by-side comparison of observations"
- : "Select a version to compare with current"}
-
-
-
-
- {loadingVersions ? (
-
-
-
- ) : versions.length === 0 ? (
-
-
-
No version history available yet.
-
Versions are created each time the model is refreshed.
-
- ) : selectedVersion !== null ? (
- // Side-by-side diff view
-
- {/* Header with back button and stats */}
-
-
{
- setSelectedVersion(null);
- setVersionObservations([]);
- }}
- className="h-8"
- >
-
- Back
-
-
- {(() => {
- const currentObs = model.observations || [];
- const added = currentObs.filter(
- (c) => !versionObservations.some((o) => o.title === c.title)
- ).length;
- const removed = versionObservations.filter(
- (o) => !currentObs.some((c) => c.title === o.title)
- ).length;
- // Modified: same title but different content or evidence count
- const modified = versionObservations.filter((old) => {
- const current = currentObs.find((c) => c.title === old.title);
- if (!current) return false;
- const oldContent = old.content || "";
- const currentContent = current.content || current.text || "";
- const oldEvCount = old.evidence_count || 0;
- const currentEvCount = current.evidence?.length || 0;
- return oldContent !== currentContent || oldEvCount !== currentEvCount;
- }).length;
- const unchanged = versionObservations.filter((old) => {
- const current = currentObs.find((c) => c.title === old.title);
- if (!current) return false;
- const oldContent = old.content || "";
- const currentContent = current.content || current.text || "";
- const oldEvCount = old.evidence_count || 0;
- const currentEvCount = current.evidence?.length || 0;
- return oldContent === currentContent && oldEvCount === currentEvCount;
- }).length;
- return (
- <>
- {added > 0 && (
-
- +{added} added
-
- )}
- {removed > 0 && (
-
- -{removed} removed
-
- )}
- {modified > 0 && (
-
- ~{modified} modified
-
- )}
- {unchanged > 0 && (
- {unchanged} unchanged
- )}
- >
- );
- })()}
-
-
-
- {loadingVersionData ? (
-
-
-
- ) : (
- // Two-column layout
-
- {/* Left column - Old version */}
-
-
-
-
Version {selectedVersion}
-
- {versions.find((v) => v.version === selectedVersion)?.created_at
- ? new Date(
- versions.find((v) => v.version === selectedVersion)!.created_at!
- ).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- })
- : ""}
-
-
- {versionObservations.length} observations
-
-
-
- {versionObservations.length === 0 ? (
-
- No observations
-
- ) : (
- versionObservations.map((obs, idx) => {
- const currentMatch = model.observations?.find(
- (c) => c.title === obs.title
- );
- const existsInCurrent = !!currentMatch;
- // Check if modified (same title but different content/evidence)
- const isModified =
- existsInCurrent &&
- (() => {
- const oldContent = obs.content || "";
- const currentContent =
- currentMatch.content || currentMatch.text || "";
- const oldEvCount = obs.evidence_count || 0;
- const currentEvCount = currentMatch.evidence?.length || 0;
- return (
- oldContent !== currentContent || oldEvCount !== currentEvCount
- );
- })();
-
- // Determine styling: removed (rose), modified (amber), unchanged (muted)
- const cardStyle = !existsInCurrent
- ? "bg-rose-500/10 border-rose-500/30"
- : isModified
- ? "bg-amber-500/10 border-amber-500/30"
- : "bg-muted/30 border-border";
-
- return (
-
-
- {!existsInCurrent && (
-
−
- )}
- {isModified && (
-
~
- )}
-
-
{obs.title}
-
- {obs.content}
-
-
- {obs.evidence_count} evidence
- {obs.trend && (
-
- {obs.trend}
-
- )}
-
-
-
-
- );
- })
- )}
-
-
-
- {/* Right column - Current version */}
-
-
-
-
Current
-
- {model.last_refresh_at
- ? new Date(model.last_refresh_at).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- })
- : ""}
-
-
- {model.observations?.length || 0} observations
-
-
-
- {!model.observations || model.observations.length === 0 ? (
-
- No observations
-
- ) : (
- model.observations.map((obs, idx) => {
- const oldMatch = versionObservations.find((o) => o.title === obs.title);
- const existsInOld = !!oldMatch;
- // Check if modified (same title but different content/evidence)
- const isModified =
- existsInOld &&
- (() => {
- const oldContent = oldMatch.content || "";
- const currentContent = obs.content || obs.text || "";
- const oldEvCount = oldMatch.evidence_count || 0;
- const currentEvCount = obs.evidence?.length || 0;
- return (
- oldContent !== currentContent || oldEvCount !== currentEvCount
- );
- })();
-
- // Determine styling: added (emerald), modified (amber), unchanged (muted)
- const cardStyle = !existsInOld
- ? "bg-emerald-500/10 border-emerald-500/30"
- : isModified
- ? "bg-amber-500/10 border-amber-500/30"
- : "bg-muted/30 border-border";
-
- return (
-
-
- {!existsInOld && (
-
+
- )}
- {isModified && (
-
~
- )}
-
-
{obs.title}
-
- {obs.content || obs.text}
-
-
- {obs.evidence?.length || 0} evidence
- {obs.trend && (
-
- {obs.trend}
-
- )}
-
-
-
-
- );
- })
- )}
-
-
-
- )}
-
- ) : (
- // Show version list
-
- {versions.map((version) => (
-
loadVersionData(version.version)}
- className="w-full p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left flex items-center justify-between group"
- >
-
-
Version {version.version}
-
- {version.created_at
- ? new Date(version.created_at).toLocaleString()
- : "Unknown date"}
-
-
-
-
- {version.observation_count} observation
- {version.observation_count !== 1 ? "s" : ""}
-
-
-
-
- ))}
-
- )}
-
-
-
-
- {/* Memory Detail Modal */}
-
setSelectedMemoryId(null)} />
-
- );
-}
diff --git a/hindsight-control-plane/src/components/reflections-view.tsx b/hindsight-control-plane/src/components/reflections-view.tsx
new file mode 100644
index 00000000..979b91c2
--- /dev/null
+++ b/hindsight-control-plane/src/components/reflections-view.tsx
@@ -0,0 +1,808 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import ReactMarkdown from "react-markdown";
+import { client } from "@/lib/api";
+import { useBank } from "@/lib/bank-context";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Plus,
+ Sparkles,
+ Loader2,
+ Trash2,
+ RefreshCw,
+ X,
+ ChevronLeft,
+ ChevronRight,
+ ChevronsLeft,
+ ChevronsRight,
+ Pencil,
+} from "lucide-react";
+import { MemoryDetailModal } from "./memory-detail-modal";
+
+interface ReflectResponseBasedOnFact {
+ id: string;
+ text: string;
+ type: string;
+}
+
+interface ReflectResponse {
+ text: string;
+ based_on: Record;
+ mental_models?: Array<{ id: string; text: string }>;
+}
+
+interface Reflection {
+ id: string;
+ bank_id: string;
+ name: string;
+ source_query: string;
+ content: string;
+ tags: string[];
+ last_refreshed_at: string;
+ created_at: string;
+ reflect_response?: ReflectResponse;
+}
+
+export function ReflectionsView() {
+ const { currentBank } = useBank();
+ const [reflections, setReflections] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [currentPage, setCurrentPage] = useState(1);
+ const itemsPerPage = 100;
+
+ const [showCreateReflection, setShowCreateReflection] = useState(false);
+ const [selectedReflection, setSelectedReflection] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState<{
+ id: string;
+ name: string;
+ } | null>(null);
+ const [deleting, setDeleting] = useState(false);
+
+ // Filter reflections based on search query
+ const filteredReflections = reflections.filter((r) => {
+ if (!searchQuery) return true;
+ const query = searchQuery.toLowerCase();
+ return (
+ r.name.toLowerCase().includes(query) ||
+ r.source_query.toLowerCase().includes(query) ||
+ r.content.toLowerCase().includes(query)
+ );
+ });
+
+ const loadData = async () => {
+ if (!currentBank) return;
+
+ setLoading(true);
+ try {
+ const reflectionsData = await client.listReflections(currentBank);
+ setReflections(reflectionsData.items || []);
+ } catch (error) {
+ console.error("Error loading reflections:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!currentBank || !deleteTarget) return;
+
+ setDeleting(true);
+ try {
+ await client.deleteReflection(currentBank, deleteTarget.id);
+ setReflections((prev) => prev.filter((r) => r.id !== deleteTarget.id));
+ if (selectedReflection?.id === deleteTarget.id) setSelectedReflection(null);
+ setDeleteTarget(null);
+ } catch (error) {
+ console.error("Error deleting reflection:", error);
+ alert("Error deleting: " + (error as Error).message);
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ useEffect(() => {
+ if (currentBank) {
+ loadData();
+ }
+ }, [currentBank]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setSelectedReflection(null);
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ // Reset to first page when search query changes
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [searchQuery]);
+
+ if (!currentBank) {
+ return (
+
+
+ Select a memory bank to view reflections.
+
+
+ );
+ }
+
+ // Pagination calculations
+ const totalPages = Math.ceil(filteredReflections.length / itemsPerPage);
+ const startIndex = (currentPage - 1) * itemsPerPage;
+ const endIndex = startIndex + itemsPerPage;
+ const paginatedReflections = filteredReflections.slice(startIndex, endIndex);
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ <>
+ {/* Search filter */}
+
+ setSearchQuery(e.target.value)}
+ placeholder="Filter reflections by name, query, or content..."
+ className="max-w-md"
+ />
+
+
+
+
+ {searchQuery
+ ? `${filteredReflections.length} of ${reflections.length} reflections`
+ : `${reflections.length} reflection${reflections.length !== 1 ? "s" : ""}`}
+
+
setShowCreateReflection(true)} variant="outline" size="sm">
+
+ Add Reflection
+
+
+
+ {filteredReflections.length > 0 ? (
+ <>
+
+
+
+
+ Name
+ Source Query
+ Last Refreshed
+
+
+
+
+ {paginatedReflections.map((r) => {
+ const refreshedDate = new Date(r.last_refreshed_at);
+ const dateDisplay = refreshedDate.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ const timeDisplay = refreshedDate.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+
+ return (
+ setSelectedReflection(r)}
+ >
+
+ {r.name}
+
+
+
+ {r.source_query}
+
+
+
+ {dateDisplay}
+ {timeDisplay}
+
+
+ {
+ e.stopPropagation();
+ setDeleteTarget({ id: r.id, name: r.name });
+ }}
+ >
+
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Pagination Controls */}
+ {totalPages > 1 && (
+
+
+ {startIndex + 1}-{Math.min(endIndex, filteredReflections.length)} of{" "}
+ {filteredReflections.length}
+
+
+ setCurrentPage(1)}
+ disabled={currentPage === 1}
+ className="h-7 w-7 p-0"
+ >
+
+
+ setCurrentPage((p) => Math.max(1, p - 1))}
+ disabled={currentPage === 1}
+ className="h-7 w-7 p-0"
+ >
+
+
+
+ {currentPage} / {totalPages}
+
+ setCurrentPage((p) => Math.min(totalPages, p + 1))}
+ disabled={currentPage === totalPages}
+ className="h-7 w-7 p-0"
+ >
+
+
+ setCurrentPage(totalPages)}
+ disabled={currentPage === totalPages}
+ className="h-7 w-7 p-0"
+ >
+
+
+
+
+ )}
+ >
+ ) : (
+
+
+
+ {searchQuery
+ ? "No reflections match your filter"
+ : "No reflections yet. Create a reflection to generate and save a summary from your memories."}
+
+
+ )}
+ >
+ )}
+
+
setShowCreateReflection(false)}
+ onCreated={() => {
+ setShowCreateReflection(false);
+ // Reload the list immediately to show the new reflection
+ loadData();
+ }}
+ />
+
+ !open && setDeleteTarget(null)}>
+
+
+ Delete Reflection
+
+ Are you sure you want to delete{" "}
+ "{deleteTarget?.name}" ?
+
+
+ This action cannot be undone.
+
+
+
+ Cancel
+
+ {deleting ? : null}
+ Delete
+
+
+
+
+
+ {selectedReflection && (
+ setSelectedReflection(null)}
+ onDelete={() =>
+ setDeleteTarget({ id: selectedReflection.id, name: selectedReflection.name })
+ }
+ onRefreshed={(updated) => {
+ setReflections((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
+ setSelectedReflection(updated);
+ }}
+ />
+ )}
+
+ );
+}
+
+function CreateReflectionDialog({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const { currentBank } = useBank();
+ const [creating, setCreating] = useState(false);
+ const [form, setForm] = useState({ name: "", sourceQuery: "", maxTokens: "2048", tags: "" });
+
+ const handleCreate = async () => {
+ if (!currentBank || !form.name.trim() || !form.sourceQuery.trim()) return;
+
+ setCreating(true);
+ try {
+ const tags = form.tags
+ .split(",")
+ .map((t) => t.trim())
+ .filter((t) => t.length > 0);
+
+ const maxTokens = parseInt(form.maxTokens) || 2048;
+
+ // Submit reflection creation - content will be generated in background
+ await client.createReflection(currentBank, {
+ name: form.name.trim(),
+ source_query: form.sourceQuery.trim(),
+ tags: tags.length > 0 ? tags : undefined,
+ max_tokens: maxTokens,
+ });
+
+ setForm({ name: "", sourceQuery: "", maxTokens: "2048", tags: "" });
+ onCreated();
+ } catch (error) {
+ console.error("Error creating reflection:", error);
+ alert("Error creating reflection: " + (error as Error).message);
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ return (
+ {
+ if (!o) {
+ setForm({ name: "", sourceQuery: "", maxTokens: "2048", tags: "" });
+ onClose();
+ }
+ }}
+ >
+
+
+ Create Reflection
+
+ Create a reflection by running a query. The content will be auto-generated and can be
+ refreshed later.
+
+
+
+
+
+ Name *
+ setForm({ ...form, name: e.target.value })}
+ placeholder="e.g., Team Communication Preferences"
+ />
+
+
+
Source Query *
+
setForm({ ...form, sourceQuery: e.target.value })}
+ placeholder="e.g., How does the team prefer to communicate?"
+ />
+
+ This query will be run to generate the initial content, and re-run when you refresh.
+
+
+
+
Max Tokens
+
setForm({ ...form, maxTokens: e.target.value })}
+ placeholder="2048"
+ min="256"
+ max="8192"
+ />
+
+ Maximum tokens for the generated response (256-8192).
+
+
+
+
+ Tags (optional)
+
+ setForm({ ...form, tags: e.target.value })}
+ placeholder="e.g., project-x, team-alpha (comma-separated)"
+ />
+
+
+
+
+
+ Cancel
+
+
+ {creating ? (
+ <>
+
+ Generating...
+ >
+ ) : (
+ "Create"
+ )}
+
+
+
+
+ );
+}
+
+function ReflectionDetailPanel({
+ reflection,
+ onClose,
+ onDelete,
+ onRefreshed,
+}: {
+ reflection: Reflection;
+ onClose: () => void;
+ onDelete: () => void;
+ onRefreshed: (r: Reflection) => void;
+}) {
+ const { currentBank } = useBank();
+ const [refreshing, setRefreshing] = useState(false);
+ const [viewMemoryId, setViewMemoryId] = useState(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState(reflection.name);
+ const [saving, setSaving] = useState(false);
+
+ // Reset edit form when reflection changes
+ useEffect(() => {
+ setEditName(reflection.name);
+ setIsEditing(false);
+ }, [reflection.id, reflection.name]);
+
+ const handleRefresh = async () => {
+ if (!currentBank) return;
+
+ setRefreshing(true);
+ try {
+ const updated = await client.refreshReflection(currentBank, reflection.id);
+ onRefreshed(updated);
+ } catch (error) {
+ console.error("Error refreshing reflection:", error);
+ alert("Error refreshing: " + (error as Error).message);
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ const handleSave = async () => {
+ if (!currentBank || !editName.trim()) return;
+
+ setSaving(true);
+ try {
+ const updated = await client.updateReflection(currentBank, reflection.id, {
+ name: editName.trim(),
+ });
+ onRefreshed(updated);
+ setIsEditing(false);
+ } catch (error) {
+ console.error("Error updating reflection:", error);
+ alert("Error updating: " + (error as Error).message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const formatDateTime = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return `${date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })} at ${date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ })}`;
+ };
+
+ // Extract all memories from based_on
+ const basedOnFacts = reflection.reflect_response?.based_on
+ ? Object.entries(reflection.reflect_response.based_on).flatMap(([factType, facts]) =>
+ facts.map((fact) => ({ ...fact, factType }))
+ )
+ : [];
+
+ const mentalModels = reflection.reflect_response?.mental_models || [];
+
+ return (
+
+
+
+
+ {isEditing ? (
+
+
setEditName(e.target.value)}
+ className="text-lg font-bold"
+ autoFocus
+ />
+
+
+ {saving ? : null}
+ Save
+
+ {
+ setEditName(reflection.name);
+ setIsEditing(false);
+ }}
+ >
+ Cancel
+
+
+
+ ) : (
+ <>
+
+
{reflection.name}
+
setIsEditing(true)}
+ className="h-7 w-7 p-0"
+ >
+
+
+
+
{reflection.source_query}
+ >
+ )}
+
+
+
+ {refreshing ? (
+
+ ) : (
+
+ )}
+ Refresh
+
+
+
+
+
+
+
+
+
+
+ Content
+
+
+ {reflection.content}
+
+
+
+ {/* Based On Facts Section */}
+ {basedOnFacts.length > 0 && (
+
+
+ Based On ({basedOnFacts.length} {basedOnFacts.length === 1 ? "fact" : "facts"})
+
+
+ {basedOnFacts.map((fact, i) => (
+
+
+
+ {fact.factType}
+
+ setViewMemoryId(fact.id)}
+ >
+ View
+
+
+
{fact.text}
+
+ ))}
+
+
+ )}
+
+ {/* Mental Models Used Section */}
+ {mentalModels.length > 0 && (
+
+
+ Mental Models Used ({mentalModels.length})
+
+
+ {mentalModels.map((model, i) => (
+
+
+
+ mental_model
+
+ setViewMemoryId(model.id)}
+ >
+ View
+
+
+
{model.text}
+
+ ))}
+
+
+ )}
+
+ {/* No based_on data yet */}
+ {!reflection.reflect_response && (
+
+
Based On
+
+ No source data available. Click "Refresh" to regenerate with source
+ tracking.
+
+
+ )}
+
+ {reflection.tags && reflection.tags.length > 0 && (
+
+
+ Tags
+
+
+ {reflection.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+
+ Created: {formatDateTime(reflection.created_at)}
+ Refreshed: {formatDateTime(reflection.last_refreshed_at)}
+
+
+
+
+ ID
+
+
+ {reflection.id}
+
+
+
+
+
+
+ Delete
+
+
+
+
+
+ {/* Memory Detail Modal */}
+ {viewMemoryId && currentBank && (
+
setViewMemoryId(null)} />
+ )}
+
+ );
+}
diff --git a/hindsight-control-plane/src/components/search-debug-view.tsx b/hindsight-control-plane/src/components/search-debug-view.tsx
index 23f8e0a5..316e5c75 100644
--- a/hindsight-control-plane/src/components/search-debug-view.tsx
+++ b/hindsight-control-plane/src/components/search-debug-view.tsx
@@ -32,7 +32,7 @@ import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import { MemoryDetailPanel } from "./memory-detail-panel";
-type FactType = "world" | "experience";
+type FactType = "world" | "experience" | "mental_model";
type Budget = "low" | "mid" | "high";
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
type ViewMode = "results" | "trace" | "json";
@@ -55,6 +55,7 @@ export function SearchDebugView() {
const [results, setResults] = useState(null);
const [entities, setEntities] = useState(null);
const [chunks, setChunks] = useState(null);
+ const [mentalModels, setMentalModels] = useState(null);
const [trace, setTrace] = useState(null);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState("results");
@@ -102,10 +103,13 @@ export function SearchDebugView() {
return;
}
- if (!query || factTypes.length === 0) {
- if (factTypes.length === 0) {
- alert("Please select at least one fact type");
- }
+ if (!query) {
+ return;
+ }
+
+ // Must select at least one type
+ if (factTypes.length === 0) {
+ alert("Please select at least one type (World, Experience, or Mental Models)");
return;
}
@@ -138,6 +142,7 @@ export function SearchDebugView() {
setResults(data.results || []);
setEntities(data.entities || null);
setChunks(data.chunks || null);
+ setMentalModels(data.mental_models || null);
setTrace(data.trace || null);
setViewMode("results");
} catch (error) {
@@ -201,6 +206,13 @@ export function SearchDebugView() {
{ft}
))}
+
+ toggleFactType("mental_model")}
+ />
+ Mental Models
+
@@ -347,55 +359,90 @@ export function SearchDebugView() {
{/* Results View */}
{viewMode === "results" && (
-
- {results.length === 0 ? (
-
-
-
- No memories found for this query.
+
+ {/* Mental Models Section */}
+ {mentalModels && mentalModels.length > 0 && (
+
+
+
+
+ Mental Models
+ ({mentalModels.length})
+
+
+
+ {mentalModels.map((mm: any, idx: number) => (
+
+
{mm.text}
+
+
+ Mental Model
+
+ Proof count: {mm.proof_count || 1}
+ Relevance: {(mm.relevance || 0).toFixed(3)}
+
+
+ ))}
- ) : (
- results.map((result: any, idx: number) => {
- const visit = trace?.visits?.find((v: any) => v.node_id === result.id);
- const score = visit ? visit.weights.final_weight : result.score || 0;
-
- return (
-
setSelectedMemory(result)}
- >
-
-
-
- {idx + 1}
-
-
-
{result.text}
-
-
- {result.type || "world"}
-
- {result.context && (
- {result.context}
- )}
- {result.occurred_start && (
- {new Date(result.occurred_start).toLocaleDateString()}
- )}
-
-
-
-
{score.toFixed(3)}
-
score
-
-
-
-
-
- );
- })
)}
+
+ {/* Memories Section */}
+
+ {results.length === 0 && (!mentalModels || mentalModels.length === 0) ? (
+
+
+
+ No memories found for this query.
+
+
+ ) : (
+ results.map((result: any, idx: number) => {
+ const visit = trace?.visits?.find((v: any) => v.node_id === result.id);
+ const score = visit ? visit.weights.final_weight : result.score || 0;
+
+ return (
+
setSelectedMemory(result)}
+ >
+
+
+
+ {idx + 1}
+
+
+
{result.text}
+
+
+ {result.type || "world"}
+
+ {result.context && (
+ {result.context}
+ )}
+ {result.occurred_start && (
+
+ {new Date(result.occurred_start).toLocaleDateString()}
+
+ )}
+
+
+
+
{score.toFixed(3)}
+
score
+
+
+
+
+
+ );
+ })
+ )}
+
)}
@@ -963,6 +1010,7 @@ export function SearchDebugView() {
results,
...(entities && { entities }),
...(chunks && { chunks }),
+ ...(mentalModels && { mental_models: mentalModels }),
trace,
}}
collapsed={2}
diff --git a/hindsight-control-plane/src/components/think-view.tsx b/hindsight-control-plane/src/components/think-view.tsx
index 371dd325..5d9d523f 100644
--- a/hindsight-control-plane/src/components/think-view.tsx
+++ b/hindsight-control-plane/src/components/think-view.tsx
@@ -106,20 +106,18 @@ export function ThinkView() {
const existingDirective = directives.items?.find((d) => d.name === FEEDBACK_DIRECTIVE_NAME);
if (existingDirective) {
- // Append to existing directive description
- const newDescription = existingDirective.description
- ? `${existingDirective.description}\n${feedback.trim()}`
+ // Append to existing directive content
+ const newContent = existingDirective.content
+ ? `${existingDirective.content}\n${feedback.trim()}`
: feedback.trim();
- await client.updateMentalModel(currentBank, existingDirective.id, {
- description: newDescription,
+ await client.updateDirective(currentBank, existingDirective.id, {
+ content: newContent,
});
} else {
- // Create new directive with observation
- await client.createMentalModel(currentBank, {
+ // Create new directive
+ await client.createDirective(currentBank, {
name: FEEDBACK_DIRECTIVE_NAME,
- description: "User feedback for improving responses",
- subtype: "directive",
- observations: [{ title: "Feedback", content: feedback.trim() }],
+ content: feedback.trim(),
});
}
diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts
index 9f4da270..d718a675 100644
--- a/hindsight-control-plane/src/lib/api.ts
+++ b/hindsight-control-plane/src/lib/api.ts
@@ -51,6 +51,7 @@ export class ControlPlaneClient {
include?: {
entities?: { max_tokens: number } | null;
chunks?: { max_tokens: number } | null;
+ mental_models?: { max_results?: number } | null;
};
query_timestamp?: string;
tags?: string[];
@@ -123,10 +124,45 @@ export class ControlPlaneClient {
}
/**
- * List operations
+ * List operations with optional filtering and pagination
*/
- async listOperations(bankId: string) {
- return this.fetchApi(`/api/operations/${bankId}`);
+ async listOperations(
+ bankId: string,
+ options?: { status?: string; limit?: number; offset?: number }
+ ) {
+ const params = new URLSearchParams();
+ if (options?.status) params.append("status", options.status);
+ if (options?.limit) params.append("limit", options.limit.toString());
+ if (options?.offset) params.append("offset", options.offset.toString());
+ const query = params.toString();
+ return this.fetchApi<{
+ bank_id: string;
+ total: number;
+ limit: number;
+ offset: number;
+ operations: Array<{
+ id: string;
+ task_type: string;
+ items_count: number;
+ document_id: string | null;
+ created_at: string;
+ status: string;
+ error_message: string | null;
+ }>;
+ }>(`/api/operations/${bankId}${query ? `?${query}` : ""}`);
+ }
+
+ /**
+ * Cancel a pending operation
+ */
+ async cancelOperation(bankId: string, operationId: string) {
+ return this.fetchApi<{
+ success: boolean;
+ message: string;
+ operation_id: string;
+ }>(`/api/operations/${bankId}?operation_id=${operationId}`, {
+ method: "DELETE",
+ });
}
/**
@@ -207,6 +243,34 @@ export class ControlPlaneClient {
});
}
+ /**
+ * Clear all mental models for a bank
+ */
+ async clearMentalModels(bankId: string) {
+ return this.fetchApi<{
+ success: boolean;
+ message: string;
+ deleted_count: number;
+ }>(`/api/banks/${bankId}/mental-models`, {
+ method: "DELETE",
+ });
+ }
+
+ /**
+ * Trigger consolidation for a bank
+ */
+ async triggerConsolidation(bankId: string) {
+ return this.fetchApi<{
+ status: string;
+ processed: number;
+ created: number;
+ updated: number;
+ message: string;
+ }>(`/api/banks/${bankId}/consolidate`, {
+ method: "POST",
+ });
+ }
+
/**
* Get chunk
*/
@@ -262,73 +326,112 @@ export class ControlPlaneClient {
}
/**
- * List mental models for a bank
+ * List directives for a bank
*/
- async listMentalModels(bankId: string) {
+ async listDirectives(bankId: string, tags?: string[], tagsMatch?: string) {
+ const params = new URLSearchParams();
+ if (tags && tags.length > 0) {
+ tags.forEach((t) => params.append("tags", t));
+ }
+ if (tagsMatch) {
+ params.append("tags_match", tagsMatch);
+ }
+ const query = params.toString();
return this.fetchApi<{
items: Array<{
id: string;
bank_id: string;
- subtype: string;
name: string;
- description: string;
- observations?: Array<{ title: string; content: string; based_on: string[] }>;
- entity_id: string | null;
- links: string[];
- tags?: string[];
- last_updated: string | null;
+ content: string;
+ priority: number;
+ is_active: boolean;
+ tags: string[];
created_at: string;
+ updated_at: string;
}>;
- }>(`/api/banks/${bankId}/mental-models`);
+ }>(`/api/banks/${bankId}/directives${query ? `?${query}` : ""}`);
}
/**
- * Refresh mental models for a bank (async)
- * @param subtype - Optional subtype to refresh. If not specified, refreshes all.
+ * Create a directive
*/
- async refreshMentalModels(
+ async createDirective(
bankId: string,
- subtype?: "structural" | "emergent" | "pinned" | "learned"
+ params: {
+ name: string;
+ content: string;
+ priority?: number;
+ is_active?: boolean;
+ tags?: string[];
+ }
) {
- return this.fetchApi<{ operation_id: string; message: string }>(
- `/api/banks/${bankId}/mental-models/refresh`,
- {
- method: "POST",
- body: JSON.stringify(subtype ? { subtype } : {}),
- }
- );
+ return this.fetchApi<{
+ id: string;
+ bank_id: string;
+ name: string;
+ content: string;
+ priority: number;
+ is_active: boolean;
+ tags: string[];
+ created_at: string;
+ updated_at: string;
+ }>(`/api/banks/${bankId}/directives`, {
+ method: "POST",
+ body: JSON.stringify(params),
+ });
}
/**
- * Delete a mental model
+ * Get a directive
*/
- async deleteMentalModel(bankId: string, modelId: string) {
- return this.fetchApi(`/api/banks/${bankId}/mental-models/${modelId}`, {
+ async getDirective(bankId: string, directiveId: string) {
+ return this.fetchApi<{
+ id: string;
+ bank_id: string;
+ name: string;
+ content: string;
+ priority: number;
+ is_active: boolean;
+ tags: string[];
+ created_at: string;
+ updated_at: string;
+ }>(`/api/banks/${bankId}/directives/${directiveId}`);
+ }
+
+ /**
+ * Delete a directive
+ */
+ async deleteDirective(bankId: string, directiveId: string) {
+ return this.fetchApi(`/api/banks/${bankId}/directives/${directiveId}`, {
method: "DELETE",
});
}
/**
- * Update a mental model (name and/or description)
+ * Update a directive
*/
- async updateMentalModel(
+ async updateDirective(
bankId: string,
- modelId: string,
- params: { name?: string; description?: string }
+ directiveId: string,
+ params: {
+ name?: string;
+ content?: string;
+ priority?: number;
+ is_active?: boolean;
+ tags?: string[];
+ }
) {
return this.fetchApi<{
id: string;
bank_id: string;
- subtype: string;
name: string;
- description: string;
- observations?: Array<{ title: string; content: string; based_on: string[] }>;
- entity_id: string | null;
- links: string[];
- tags?: string[];
- last_updated: string | null;
+ content: string;
+ priority: number;
+ is_active: boolean;
+ tags: string[];
created_at: string;
- }>(`/api/banks/${bankId}/mental-models/${modelId}`, {
+ updated_at: string;
+ }>(`/api/banks/${bankId}/directives/${directiveId}`, {
method: "PATCH",
body: JSON.stringify(params),
});
@@ -349,49 +452,6 @@ export class ControlPlaneClient {
}>(`/api/banks/${bankId}/operations/${operationId}`);
}
- /**
- * Refresh content for a specific mental model (async)
- */
- async refreshMentalModel(bankId: string, modelId: string) {
- return this.fetchApi<{ operation_id: string; message: string }>(
- `/api/banks/${bankId}/mental-models/${modelId}/refresh`,
- {
- method: "POST",
- }
- );
- }
-
- /**
- * Create a mental model (pinned or directive)
- */
- async createMentalModel(
- bankId: string,
- params: {
- name: string;
- description: string;
- subtype?: "pinned" | "directive";
- observations?: Array<{ title: string; content: string }>;
- tags?: string[];
- }
- ) {
- return this.fetchApi<{
- id: string;
- bank_id: string;
- subtype: string;
- name: string;
- description: string;
- observations?: Array<{ title: string; content: string; based_on: string[] }>;
- entity_id: string | null;
- links: string[];
- tags?: string[];
- last_updated: string | null;
- created_at: string;
- }>(`/api/banks/${bankId}/mental-models`, {
- method: "POST",
- body: JSON.stringify(params),
- });
- }
-
/**
* Update bank profile
*/
@@ -413,62 +473,226 @@ export class ControlPlaneClient {
});
}
+ // ============= MENTAL MODELS (auto-consolidated, read-only) =============
+
/**
- * List directives for a bank
+ * List mental models for a bank (auto-consolidated knowledge)
*/
- async listDirectives(bankId: string) {
+ async listMentalModels(bankId: string, tags?: string[], tagsMatch?: string) {
+ const params = new URLSearchParams();
+ if (tags && tags.length > 0) {
+ tags.forEach((t) => params.append("tags", t));
+ }
+ if (tagsMatch) {
+ params.append("tags_match", tagsMatch);
+ }
+ const query = params.toString();
return this.fetchApi<{
items: Array<{
id: string;
bank_id: string;
- subtype: string;
- name: string;
- description: string;
- observations?: Array<{ title: string; content: string; based_on: string[] }>;
- entity_id: string | null;
- links: string[];
- tags?: string[];
- last_updated: string | null;
- created_at: string;
- }>;
- }>(`/api/banks/${bankId}/mental-models?subtype=directive`);
- }
-
- /**
- * List version history for a mental model
- */
- async listMentalModelVersions(bankId: string, modelId: string) {
- return this.fetchApi<{
- versions: Array<{
- version: number;
- created_at: string | null;
- observation_count: number;
- }>;
- }>(`/api/banks/${bankId}/mental-models/${modelId}/versions`);
- }
-
- /**
- * Get a specific version of a mental model
- */
- async getMentalModelVersion(bankId: string, modelId: string, version: number) {
- return this.fetchApi<{
- version: number;
- observations: Array<{
- title: string;
- content: string;
- evidence: Array<{
- memory_id: string;
- quote: string;
- relevance: string;
- timestamp: string;
+ text: string;
+ proof_count: number;
+ history: Array<{
+ previous_text: string;
+ changed_at: string;
+ reason: string;
+ }>;
+ tags: string[];
+ source_memory_ids: string[];
+ source_memories: Array<{
+ id: string;
+ text: string;
+ type: string;
+ context?: string;
+ occurred_start?: string;
+ mentioned_at?: string;
}>;
created_at: string;
- trend: string;
- evidence_count: number;
- evidence_span: { from: string | null; to: string | null };
+ updated_at: string;
}>;
- created_at: string | null;
- }>(`/api/banks/${bankId}/mental-models/${modelId}/versions/${version}`);
+ }>(`/api/banks/${bankId}/mental-models${query ? `?${query}` : ""}`);
+ }
+
+ /**
+ * Get a mental model with source memories
+ */
+ async getMentalModel(bankId: string, modelId: string) {
+ return this.fetchApi<{
+ id: string;
+ bank_id: string;
+ text: string;
+ proof_count: number;
+ history: Array<{
+ previous_text: string;
+ changed_at: string;
+ reason: string;
+ }>;
+ tags: string[];
+ source_memory_ids: string[];
+ source_memories: Array<{
+ id: string;
+ text: string;
+ type: string;
+ context?: string;
+ occurred_start?: string;
+ mentioned_at?: string;
+ }>;
+ created_at: string;
+ updated_at: string;
+ }>(`/api/banks/${bankId}/mental-models/${modelId}`);
+ }
+
+ // ============= REFLECTIONS =============
+
+ /**
+ * List reflections for a bank
+ */
+ async listReflections(bankId: string, tags?: string[], tagsMatch?: string) {
+ const params = new URLSearchParams();
+ if (tags && tags.length > 0) {
+ tags.forEach((t) => params.append("tags", t));
+ }
+ if (tagsMatch) {
+ params.append("tags_match", tagsMatch);
+ }
+ const query = params.toString();
+ return this.fetchApi<{
+ items: Array<{
+ id: string;
+ bank_id: string;
+ name: string;
+ source_query: string;
+ content: string;
+ tags: string[];
+ last_refreshed_at: string;
+ created_at: string;
+ reflect_response?: {
+ text: string;
+ based_on: Record>;
+ mental_models?: Array<{ id: string; text: string }>;
+ };
+ }>;
+ }>(`/api/banks/${bankId}/reflections${query ? `?${query}` : ""}`);
+ }
+
+ /**
+ * Create a reflection (async - content auto-generated in background)
+ * Returns operation_id to track progress
+ */
+ async createReflection(
+ bankId: string,
+ params: {
+ name: string;
+ source_query: string;
+ tags?: string[];
+ max_tokens?: number;
+ }
+ ) {
+ return this.fetchApi<{
+ operation_id: string;
+ }>(`/api/banks/${bankId}/reflections`, {
+ method: "POST",
+ body: JSON.stringify(params),
+ });
+ }
+
+ /**
+ * Get a reflection
+ */
+ async getReflection(bankId: string, reflectionId: string) {
+ return this.fetchApi<{
+ id: string;
+ bank_id: string;
+ name: string;
+ source_query: string;
+ content: string;
+ tags: string[];
+ last_refreshed_at: string;
+ created_at: string;
+ reflect_response?: {
+ text: string;
+ based_on: Record>;
+ mental_models?: Array<{ id: string; text: string }>;
+ };
+ }>(`/api/banks/${bankId}/reflections/${reflectionId}`);
+ }
+
+ /**
+ * Update a reflection
+ */
+ async updateReflection(
+ bankId: string,
+ reflectionId: string,
+ params: {
+ name?: string;
+ }
+ ) {
+ return this.fetchApi<{
+ id: string;
+ bank_id: string;
+ name: string;
+ source_query: string;
+ content: string;
+ tags: string[];
+ last_refreshed_at: string;
+ created_at: string;
+ reflect_response?: {
+ text: string;
+ based_on: Record>;
+ mental_models?: Array<{ id: string; text: string }>;
+ };
+ }>(`/api/banks/${bankId}/reflections/${reflectionId}`, {
+ method: "PATCH",
+ body: JSON.stringify(params),
+ });
+ }
+
+ /**
+ * Delete a reflection
+ */
+ async deleteReflection(bankId: string, reflectionId: string) {
+ return this.fetchApi(`/api/banks/${bankId}/reflections/${reflectionId}`, {
+ method: "DELETE",
+ });
+ }
+
+ /**
+ * Refresh a reflection (re-run source query)
+ */
+ async refreshReflection(bankId: string, reflectionId: string) {
+ return this.fetchApi<{
+ id: string;
+ bank_id: string;
+ name: string;
+ source_query: string;
+ content: string;
+ tags: string[];
+ last_refreshed_at: string;
+ created_at: string;
+ reflect_response?: {
+ text: string;
+ based_on: Record>;
+ mental_models?: Array<{ id: string; text: string }>;
+ };
+ }>(`/api/banks/${bankId}/reflections/${reflectionId}/refresh`, {
+ method: "POST",
+ });
+ }
+
+ /**
+ * Get API version and feature flags
+ * Use this to check which capabilities are available in the dataplane
+ */
+ async getVersion() {
+ return this.fetchApi<{
+ api_version: string;
+ features: {
+ mental_models: boolean;
+ mcp: boolean;
+ worker: boolean;
+ };
+ }>("/api/version");
}
}
diff --git a/hindsight-dev/benchmarks/common/benchmark_runner.py b/hindsight-dev/benchmarks/common/benchmark_runner.py
index fbbf16bd..1b1841f7 100644
--- a/hindsight-dev/benchmarks/common/benchmark_runner.py
+++ b/hindsight-dev/benchmarks/common/benchmark_runner.py
@@ -15,6 +15,8 @@ The framework supports two answer generation patterns:
2. Integrated: Answer generator performs its own retrieval (e.g., think API)
- Indicated by needs_external_search() returning False
- Skips the search step for efficiency
+
+Optional --include-mental-models flag enables returning mental models in recall results.
"""
import asyncio
@@ -178,6 +180,7 @@ class LLMAnswerGenerator(ABC):
recall_result: Dict[str, Any],
question_date: Optional[datetime] = None,
question_type: Optional[str] = None,
+ bank_id: Optional[str] = None,
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer from retrieved memories.
@@ -187,6 +190,7 @@ class LLMAnswerGenerator(ABC):
recall_result: Full RecallResult dict containing results, entities, chunks, and trace
question_date: Optional date when the question was asked (for temporal context)
question_type: Optional question category/type (e.g., 'multi-session', 'temporal-reasoning')
+ bank_id: Optional bank ID for generators that need it (e.g., ReflectAnswerGenerator)
Returns:
Tuple of (answer, reasoning, retrieved_memories_override)
@@ -418,12 +422,19 @@ class BenchmarkRunner:
"max_session_length": max(session_lengths) if session_lengths else 0,
}
- async def ingest_conversation(self, item: Dict[str, Any], agent_id: str) -> int:
+ async def ingest_conversation(
+ self, item: Dict[str, Any], agent_id: str, wait_for_consolidation: bool = False
+ ) -> int:
"""
Ingest conversation into memory using batch ingestion.
Uses put_batch_async for maximum efficiency.
+ Args:
+ item: Dataset item to ingest
+ agent_id: Agent/bank ID to ingest into
+ wait_for_consolidation: If True, wait for consolidation to complete after ingestion
+
Returns:
Number of sessions ingested
"""
@@ -436,8 +447,87 @@ class BenchmarkRunner:
request_context=RequestContext(),
)
+ if wait_for_consolidation and batch_contents:
+ await self._wait_for_consolidation(agent_id)
+
return len(batch_contents)
+ async def _get_pending_consolidation_count(self, bank_id: str) -> int:
+ """
+ Get the count of memories pending consolidation.
+
+ Returns:
+ Number of memories not yet consolidated into mental models
+ """
+ pool = await self.memory._get_pool()
+ from hindsight_api.engine.memory_engine import fq_table
+
+ async with pool.acquire() as conn:
+ # Check when consolidation last ran
+ last_consolidated_row = await conn.fetchrow(
+ f"""
+ SELECT MAX(created_at) as last_consolidated_at
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND fact_type = 'mental_model'
+ """,
+ bank_id,
+ )
+ last_consolidated_at = last_consolidated_row["last_consolidated_at"] if last_consolidated_row else None
+
+ if last_consolidated_at:
+ # Count memories created after last consolidation
+ result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND fact_type IN ('experience', 'world')
+ AND created_at > $2
+ """,
+ bank_id,
+ last_consolidated_at,
+ )
+ else:
+ # If never consolidated, count all experience/world memories
+ result = await conn.fetchrow(
+ f"""
+ SELECT COUNT(*) as count
+ FROM {fq_table("memory_units")}
+ WHERE bank_id = $1 AND fact_type IN ('experience', 'world')
+ """,
+ bank_id,
+ )
+ return result["count"] if result else 0
+
+ async def _wait_for_consolidation(self, bank_id: str, poll_interval: float = 2.0, timeout: float = 300.0) -> None:
+ """
+ Wait for consolidation to complete (pending_consolidation reaches 0).
+
+ Args:
+ bank_id: Bank ID to check
+ poll_interval: Seconds between polls
+ timeout: Maximum seconds to wait
+
+ Raises:
+ TimeoutError: If consolidation doesn't complete within timeout
+ """
+ import time
+
+ start_time = time.time()
+ console.print(" [yellow]Waiting for consolidation to complete...[/yellow]")
+
+ while True:
+ elapsed = time.time() - start_time
+ if elapsed > timeout:
+ raise TimeoutError(f"Consolidation did not complete within {timeout}s")
+
+ pending = await self._get_pending_consolidation_count(bank_id)
+ if pending == 0:
+ console.print(" [green]✓[/green] Consolidation complete")
+ return
+
+ # Still pending, wait and poll again
+ await asyncio.sleep(poll_interval)
+
async def answer_question(
self,
agent_id: str,
@@ -446,6 +536,8 @@ class BenchmarkRunner:
max_tokens: int = 4096,
question_date: Optional[datetime] = None,
question_type: Optional[str] = None,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
) -> Tuple[str, str, List[Dict], Dict[str, Dict]]:
"""
Answer a question using memory retrieval.
@@ -457,6 +549,8 @@ class BenchmarkRunner:
max_tokens: Maximum tokens to retrieve
question_date: Date when the question was asked (for temporal filtering)
question_type: Question category/type (e.g., 'multi-session', 'temporal-reasoning')
+ include_mental_models: If True, include mental models in recall results
+ only_mental_models: If True, only retrieve mental models (no facts)
Returns:
Tuple of (answer, reasoning, retrieved_memories, chunks)
@@ -471,16 +565,26 @@ class BenchmarkRunner:
import time
recall_start_time = time.time()
+ # Build fact_types based on what's requested
+ if only_mental_models:
+ # Only retrieve mental models
+ fact_types = ["mental_model"]
+ elif include_mental_models:
+ # Retrieve facts AND mental models
+ fact_types = ["world", "experience", "mental_model"]
+ else:
+ # Only retrieve facts
+ fact_types = ["world", "experience"]
search_result = await self.memory.recall_async(
bank_id=agent_id,
query=question,
budget=budget,
max_tokens=max_tokens,
- fact_type=["world", "experience"],
+ fact_type=fact_types,
question_date=question_date,
- include_entities=True,
+ include_entities=not only_mental_models, # Skip entities when only mental models
max_entity_tokens=2048,
- include_chunks=True,
+ include_chunks=True, # Always include chunks (mental models fetch from source memories)
request_context=RequestContext(),
)
recall_time = time.time() - recall_start_time
@@ -505,7 +609,7 @@ class BenchmarkRunner:
# Generate answer using LLM - pass entire recall result
answer, reasoning, memories_override = await self.answer_generator.generate_answer(
- question, recall_result_dict, question_date, question_type
+ question, recall_result_dict, question_date, question_type, bank_id=agent_id
)
# Use override if provided, otherwise use the results from recall
@@ -517,10 +621,10 @@ class BenchmarkRunner:
return answer, reasoning, final_memories, chunks
else:
- # Integrated flow: generator does its own search (e.g., think API)
+ # Integrated flow: generator does its own search (e.g., reflect API)
# Pass empty recall result since generator doesn't need them
answer, reasoning, memories_override = await self.answer_generator.generate_answer(
- question, {"results": []}, question_date, question_type
+ question, {"results": []}, question_date, question_type, bank_id=agent_id
)
# Use memories from generator (should not be None for integrated mode)
@@ -537,12 +641,16 @@ class BenchmarkRunner:
max_tokens: int,
max_questions: Optional[int] = None,
semaphore: asyncio.Semaphore = None,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
) -> List[Dict]:
"""
Evaluate QA task with parallel question processing.
Args:
semaphore: Semaphore to limit concurrent question processing
+ include_mental_models: If True, include mental models in recall results
+ only_mental_models: If True, only retrieve mental models (no facts)
Returns:
List of QA results
@@ -581,7 +689,14 @@ class BenchmarkRunner:
try:
# Get predicted answer, reasoning, retrieved memories, and chunks
predicted_answer, reasoning, retrieved_memories, chunks = await self.answer_question(
- agent_id, question, thinking_budget, max_tokens, question_date, category
+ agent_id,
+ question,
+ thinking_budget,
+ max_tokens,
+ question_date,
+ category,
+ include_mental_models,
+ only_mental_models,
)
# Remove embeddings from retrieved memories to reduce file size
@@ -760,6 +875,8 @@ class BenchmarkRunner:
question_semaphore: asyncio.Semaphore,
eval_semaphore_size: int = 8,
clear_this_agent: bool = True,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
) -> Dict:
"""
Process a single item (ingest + evaluate).
@@ -767,6 +884,8 @@ class BenchmarkRunner:
Args:
clear_this_agent: Whether to clear this agent's data before ingesting.
Set to False to skip clearing (e.g., when agent_id is shared and already cleared)
+ include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion
+ only_mental_models: If True, only retrieve mental models (no facts)
Returns:
Result dict with metrics
@@ -775,23 +894,27 @@ class BenchmarkRunner:
console.print(f"\n[bold blue]Item {i}/{total_items}[/bold blue] (ID: {item_id})")
+ step = 1
if not skip_ingestion:
# Clear agent data before ingesting
if clear_this_agent:
- console.print(" [1] Clearing previous agent data...")
+ console.print(f" [{step}] Clearing previous agent data...")
await self.memory.delete_bank(agent_id, request_context=RequestContext())
console.print(f" [green]✓[/green] Cleared '{agent_id}' agent data")
- # Ingest conversation
- console.print(" [2] Ingesting conversation (batch mode)...")
- num_sessions = await self.ingest_conversation(item, agent_id)
+ # Ingest conversation (wait for consolidation if mental models are requested)
+ step += 1
+ console.print(f" [{step}] Ingesting conversation (batch mode)...")
+ wait_for_consolidation = include_mental_models or only_mental_models
+ num_sessions = await self.ingest_conversation(item, agent_id, wait_for_consolidation=wait_for_consolidation)
console.print(f" [green]✓[/green] Ingested {num_sessions} sessions")
else:
num_sessions = -1
# Evaluate QA
+ step += 1
qa_pairs = self.dataset.get_qa_pairs(item)
- console.print(f" [3] Evaluating {len(qa_pairs)} QA pairs (parallel)...")
+ console.print(f" [{step}] Evaluating {len(qa_pairs)} QA pairs (parallel)...")
qa_results = await self.evaluate_qa_task(
agent_id,
qa_pairs,
@@ -800,10 +923,13 @@ class BenchmarkRunner:
max_tokens,
max_questions_per_item,
question_semaphore,
+ include_mental_models,
+ only_mental_models,
)
# Calculate metrics
- console.print(" [4] Calculating metrics...")
+ step += 1
+ console.print(f" [{step}] Calculating metrics...")
metrics = await self.calculate_metrics(qa_results, eval_semaphore_size)
console.print(
@@ -830,6 +956,8 @@ class BenchmarkRunner:
max_concurrent_items: int = 1, # Max concurrent items (conversations) to process in parallel
output_path: Optional[Path] = None, # Path to save results incrementally
merge_with_existing: bool = False, # Whether to merge with existing results
+ include_mental_models: bool = False, # If True, include mental models in recall results
+ only_mental_models: bool = False, # If True, only retrieve mental models (no facts)
) -> Dict[str, Any]:
"""
Run the full benchmark evaluation.
@@ -849,6 +977,8 @@ class BenchmarkRunner:
separate_ingestion_phase: If True, ingest all data first, then evaluate all questions (single agent)
filln: If True, only process items where the agent has no indexed data yet
max_concurrent_items: Max concurrent items to process in parallel (requires clear_agent_per_item=True)
+ include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion.
+ only_mental_models: If True, only retrieve mental models (no facts). Implies waiting for consolidation.
Returns:
Dict with complete benchmark results
@@ -890,6 +1020,8 @@ class BenchmarkRunner:
eval_semaphore_size,
output_path,
merge_with_existing,
+ include_mental_models,
+ only_mental_models,
)
else:
# Original approach: process each item independently
@@ -907,6 +1039,8 @@ class BenchmarkRunner:
max_concurrent_items,
output_path,
merge_with_existing,
+ include_mental_models,
+ only_mental_models,
)
async def _run_single_phase(
@@ -924,6 +1058,8 @@ class BenchmarkRunner:
max_concurrent_items: int = 1,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
) -> Dict[str, Any]:
"""Original single-phase approach: process each item independently."""
# Create semaphore for question processing
@@ -945,6 +1081,8 @@ class BenchmarkRunner:
max_concurrent_items,
output_path,
merge_with_existing,
+ include_mental_models,
+ only_mental_models,
)
else:
# Sequential item processing (original behavior)
@@ -961,6 +1099,8 @@ class BenchmarkRunner:
filln,
output_path,
merge_with_existing,
+ include_mental_models,
+ only_mental_models,
)
# Calculate overall metrics
@@ -996,6 +1136,8 @@ class BenchmarkRunner:
filln: bool,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
) -> List[Dict]:
"""Process items sequentially (original behavior)."""
all_results = []
@@ -1043,6 +1185,8 @@ class BenchmarkRunner:
question_semaphore,
eval_semaphore_size,
clear_this_agent,
+ include_mental_models,
+ only_mental_models,
)
# Replace existing result or append new one
@@ -1074,6 +1218,8 @@ class BenchmarkRunner:
max_concurrent_items: int,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
) -> List[Dict]:
"""Process items in parallel (requires unique agent IDs per item)."""
# Load existing results if merge_with_existing is True
@@ -1118,6 +1264,8 @@ class BenchmarkRunner:
question_semaphore,
eval_semaphore_size,
clear_this_agent=True, # Always clear for parallel processing
+ include_mental_models=include_mental_models,
+ only_mental_models=only_mental_models,
)
return result
@@ -1156,11 +1304,17 @@ class BenchmarkRunner:
eval_semaphore_size: int,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
) -> Dict[str, Any]:
"""
Two-phase approach: ingest all data into single agent, then evaluate all questions.
More realistic scenario where agent accumulates memories over time.
+
+ Args:
+ include_mental_models: If True, include mental models in recall results and wait for consolidation
+ only_mental_models: If True, only retrieve mental models (no facts)
"""
# Phase 1: Ingestion
if not skip_ingestion:
@@ -1196,6 +1350,10 @@ class BenchmarkRunner:
)
console.print(f" [green]✓[/green] Ingested {len(all_sessions)} sessions from {len(items)} items")
+
+ # Wait for consolidation if mental models are requested
+ if include_mental_models or only_mental_models:
+ await self._wait_for_consolidation(agent_id)
else:
console.print("\n[3] Skipping ingestion (using existing data)")
@@ -1222,6 +1380,8 @@ class BenchmarkRunner:
max_tokens,
max_questions_per_item,
question_semaphore,
+ include_mental_models,
+ only_mental_models,
)
# Calculate metrics
diff --git a/hindsight-dev/benchmarks/locomo/locomo_benchmark.py b/hindsight-dev/benchmarks/locomo/locomo_benchmark.py
index e58999d4..df28ac9d 100644
--- a/hindsight-dev/benchmarks/locomo/locomo_benchmark.py
+++ b/hindsight-dev/benchmarks/locomo/locomo_benchmark.py
@@ -16,7 +16,12 @@ import pydantic
from hindsight_api.engine.llm_wrapper import LLMConfig
from openai import AsyncOpenAI
-from benchmarks.common.benchmark_runner import BenchmarkDataset, BenchmarkRunner, LLMAnswerEvaluator, LLMAnswerGenerator
+from benchmarks.common.benchmark_runner import (
+ BenchmarkDataset,
+ BenchmarkRunner,
+ LLMAnswerEvaluator,
+ LLMAnswerGenerator,
+)
class LoComoDataset(BenchmarkDataset):
@@ -117,6 +122,7 @@ class LoComoAnswerGenerator(LLMAnswerGenerator):
recall_result: Dict[str, Any],
question_date: Optional[datetime] = None,
question_type: Optional[str] = None,
+ bank_id: Optional[str] = None,
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer from retrieved memories using Groq.
@@ -208,6 +214,7 @@ class LoComoThinkAnswerGenerator(LLMAnswerGenerator):
recall_result: Dict[str, Any],
question_date: Optional[datetime] = None,
question_type: Optional[str] = None,
+ bank_id: Optional[str] = None,
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer using the integrated think API.
@@ -220,6 +227,7 @@ class LoComoThinkAnswerGenerator(LLMAnswerGenerator):
recall_result: Not used (empty dict), as think does its own retrieval
question_date: Date when the question was asked (currently not used by think API)
question_type: Question category (unused in think API)
+ bank_id: Not used - think API uses self.agent_id from constructor
Returns:
Tuple of (answer, reasoning, retrieved_memories)
@@ -270,6 +278,8 @@ async def run_benchmark(
max_questions_per_conv: int = None,
skip_ingestion: bool = False,
use_think: bool = False,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
conversation: str = None,
api_url: str = None,
max_concurrent_questions_override: int = None,
@@ -284,6 +294,8 @@ async def run_benchmark(
max_questions_per_conv: Maximum questions per conversation (None for all)
skip_ingestion: Whether to skip ingestion and use existing data
use_think: Whether to use the think API instead of search + LLM
+ include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion.
+ only_mental_models: If True, only retrieve mental models (no facts). Implies waiting for consolidation.
conversation: Specific conversation ID to run (e.g., "conv-26")
api_url: Optional API URL to connect to (default: use local memory)
only_failed: If True, only run conversations that have failed questions (is_correct=False)
@@ -349,11 +361,16 @@ async def run_benchmark(
memory = await create_memory_engine()
+ # Select answer generator based on mode
+ from hindsight_api.engine.memory_engine import Budget
+
if use_think:
+ console.print("[blue]Mode: think (using think API)[/blue]")
answer_generator = LoComoThinkAnswerGenerator(memory=memory, agent_id="locomo", thinking_budget=500)
max_concurrent_questions = max_concurrent_questions_override or 4
eval_semaphore_size = 4
else:
+ console.print("[blue]Mode: recall+LLM (traditional)[/blue]")
answer_generator = LoComoAnswerGenerator()
# Reduced from 32 to 10 to match search semaphore limit
# Prevents "too many connections" errors
@@ -386,7 +403,14 @@ async def run_benchmark(
dataset.load = filtered_load
# Determine output filename based on mode
- suffix = "_think" if use_think else ""
+ if use_think:
+ suffix = "_think"
+ elif only_mental_models:
+ suffix = "_only_mental_models"
+ elif include_mental_models:
+ suffix = "_mental_models"
+ else:
+ suffix = ""
results_filename = f"benchmark_results{suffix}.json"
output_path = Path(__file__).parent / "results" / results_filename
@@ -396,6 +420,15 @@ async def run_benchmark(
# Merge with existing results if running a specific conversation or using filters
merge_with_existing = conversation is not None or only_failed or only_invalid
+ # Each conversation gets its own isolated bank
+ separate_ingestion = False
+ clear_per_item = True # Use unique agent ID per conversation
+ if include_mental_models or only_mental_models:
+ # Mental models requires more time due to consolidation, limit parallelism
+ concurrent_items = 2
+ else:
+ concurrent_items = 3 # Process up to 3 conversations in parallel
+
# Run benchmark with parallel conversation processing
# Each conversation gets its own agent ID (locomo_conv-26, locomo_conv-30, etc.)
# This allows conversations to run in parallel (up to max_concurrent_items at a time)
@@ -410,10 +443,13 @@ async def run_benchmark(
max_concurrent_questions=max_concurrent_questions,
eval_semaphore_size=eval_semaphore_size,
specific_item=conversation,
- clear_agent_per_item=True, # Use unique agent ID per conversation
- max_concurrent_items=3, # Process up to 3 conversations in parallel
+ separate_ingestion_phase=separate_ingestion,
+ clear_agent_per_item=clear_per_item,
+ max_concurrent_items=concurrent_items,
output_path=output_path, # Save results incrementally
merge_with_existing=merge_with_existing,
+ include_mental_models=include_mental_models, # Include mental models in recall results
+ only_mental_models=only_mental_models, # Only retrieve mental models (no facts)
)
# Display results (final save already happened incrementally)
@@ -421,12 +457,16 @@ async def run_benchmark(
console.print(f"\n[green]✓[/green] Results saved incrementally to {output_path}")
# Generate markdown table
- generate_markdown_table(results, use_think)
+ generate_markdown_table(
+ results, use_think=use_think, include_mental_models=include_mental_models, only_mental_models=only_mental_models
+ )
return results
-def generate_markdown_table(results: dict, use_think: bool = False):
+def generate_markdown_table(
+ results: dict, use_think: bool = False, include_mental_models: bool = False, only_mental_models: bool = False
+):
"""
Generate a markdown table with benchmark results.
@@ -444,7 +484,14 @@ def generate_markdown_table(results: dict, use_think: bool = False):
# Build markdown content
lines = []
- mode_str = " (Think Mode)" if use_think else ""
+ if use_think:
+ mode_str = " (Think Mode)"
+ elif only_mental_models:
+ mode_str = " (Only Mental Models Mode)"
+ elif include_mental_models:
+ mode_str = " (Mental Models Mode)"
+ else:
+ mode_str = ""
lines.append(f"# LoComo Benchmark Results{mode_str}")
lines.append("")
@@ -495,7 +542,14 @@ def generate_markdown_table(results: dict, use_think: bool = False):
)
# Write to file with suffix
- suffix = "_think" if use_think else ""
+ if use_think:
+ suffix = "_think"
+ elif only_mental_models:
+ suffix = "_only_mental_models"
+ elif include_mental_models:
+ suffix = "_mental_models"
+ else:
+ suffix = ""
output_file = Path(__file__).parent / "results" / f"results_table{suffix}.md"
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text("\n".join(lines))
@@ -538,6 +592,16 @@ if __name__ == "__main__":
action="store_true",
help="Only run conversations that have invalid questions (is_invalid=True). Requires existing results file.",
)
+ parser.add_argument(
+ "--include-mental-models",
+ action="store_true",
+ help="Include mental models in recall results. This waits for consolidation to complete after ingestion and includes mental models in the recall response.",
+ )
+ parser.add_argument(
+ "--only-mental-models",
+ action="store_true",
+ help="Only retrieve mental models (no facts). This waits for consolidation to complete after ingestion and only returns mental models.",
+ )
args = parser.parse_args()
@@ -551,6 +615,8 @@ if __name__ == "__main__":
max_questions_per_conv=args.max_questions,
skip_ingestion=args.skip_ingestion,
use_think=args.use_think,
+ include_mental_models=args.include_mental_models,
+ only_mental_models=args.only_mental_models,
conversation=args.conversation,
api_url=args.api_url,
max_concurrent_questions_override=args.max_concurrent_questions,
diff --git a/hindsight-dev/benchmarks/longmemeval/longmemeval_benchmark.py b/hindsight-dev/benchmarks/longmemeval/longmemeval_benchmark.py
index 549b49e0..2f2a6ce5 100644
--- a/hindsight-dev/benchmarks/longmemeval/longmemeval_benchmark.py
+++ b/hindsight-dev/benchmarks/longmemeval/longmemeval_benchmark.py
@@ -16,7 +16,12 @@ import pydantic
from hindsight_api.engine.llm_wrapper import LLMConfig
from openai import AsyncOpenAI
-from benchmarks.common.benchmark_runner import BenchmarkDataset, BenchmarkRunner, LLMAnswerEvaluator, LLMAnswerGenerator
+from benchmarks.common.benchmark_runner import (
+ BenchmarkDataset,
+ BenchmarkRunner,
+ LLMAnswerEvaluator,
+ LLMAnswerGenerator,
+)
class LongMemEvalDataset(BenchmarkDataset):
@@ -332,6 +337,7 @@ The context contains memory facts extracted from previous conversations, each wi
recall_result: Dict[str, Any],
question_date: Optional[datetime] = None,
question_type: Optional[str] = None,
+ bank_id: Optional[str] = None,
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer from retrieved memories using Groq.
@@ -427,6 +433,8 @@ async def run_benchmark(
results_filename: str = "benchmark_results.json",
context_format: str = "json",
source_results: str = None,
+ include_mental_models: bool = False,
+ only_mental_models: bool = False,
):
"""
Run the LongMemEval benchmark.
@@ -448,6 +456,8 @@ async def run_benchmark(
results_filename: Filename for results (default: benchmark_results.json). Directory is fixed to results/.
context_format: How to format context for answer generation. "json" (raw JSON) or "structured" (human-readable with facts+chunks).
source_results: Source results file to read failed/invalid questions from (for --only-failed/--only-invalid). Defaults to benchmark_results.json.
+ include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion.
+ only_mental_models: If True, only retrieve mental models (no facts). Implies waiting for consolidation.
"""
from rich.console import Console
@@ -607,17 +617,25 @@ async def run_benchmark(
else:
console.print(f"[green]Found {total_found} {filter_type} items to re-evaluate[/green]")
- answer_generator = LongMemEvalAnswerGenerator(context_format=context_format)
- answer_evaluator = LLMAnswerEvaluator()
-
- # Log context format being used
- console.print(f"[blue]Context format: {context_format}[/blue]")
-
# Create local memory engine
+ from hindsight_api.engine.memory_engine import Budget
+ from hindsight_api.models import RequestContext
+
from benchmarks.common.benchmark_runner import create_memory_engine
memory = await create_memory_engine()
+ # Create answer generator
+ answer_generator = LongMemEvalAnswerGenerator(context_format=context_format)
+ # Log context format being used
+ console.print(f"[blue]Context format: {context_format}[/blue]")
+ if only_mental_models:
+ console.print("[blue]Mental models: ONLY (no facts)[/blue]")
+ elif include_mental_models:
+ console.print("[blue]Mental models: included in recall[/blue]")
+
+ answer_evaluator = LLMAnswerEvaluator()
+
# Filter by only_ingested: only run items whose memory bank already exists
if only_ingested:
console.print("[cyan]Filtering to only items with existing memory banks...[/cyan]")
@@ -684,6 +702,11 @@ async def run_benchmark(
or max_instances_per_category is not None
)
+ # Configuration for single-phase benchmark
+ separate_ingestion = False
+ clear_per_item = True # Use unique agent_id per question
+ concurrent_questions = 4 if (include_mental_models or only_mental_models) else 8
+
results = await runner.run(
dataset_path=dataset_path,
agent_id="longmemeval", # Will be suffixed with question_id per item
@@ -694,15 +717,17 @@ async def run_benchmark(
thinking_budget=thinking_budget,
max_tokens=max_tokens,
skip_ingestion=skip_ingestion or only_ingested, # Auto-skip ingestion when using --only-ingested
- max_concurrent_questions=8,
+ max_concurrent_questions=concurrent_questions,
eval_semaphore_size=8,
- separate_ingestion_phase=False, # Process each question independently
- clear_agent_per_item=True, # Use unique agent_id per question
+ separate_ingestion_phase=separate_ingestion,
+ clear_agent_per_item=clear_per_item,
filln=filln, # Only process questions without indexed data
specific_item=question_id, # Optional filter for specific question ID
max_concurrent_items=max_concurrent_items, # Parallel instance processing
output_path=output_path, # Save results incrementally
merge_with_existing=merge_with_existing, # Merge when using --fill, --category, --only-failed, --only-invalid flags or specific question
+ include_mental_models=include_mental_models, # Include mental models in recall results
+ only_mental_models=only_mental_models, # Only retrieve mental models (no facts)
)
# Display results (final save already happened incrementally)
@@ -953,6 +978,16 @@ if __name__ == "__main__":
default=None,
help="Source results file to read failed/invalid questions from (for --only-failed/--only-invalid). Defaults to benchmark_results.json if not specified.",
)
+ parser.add_argument(
+ "--include-mental-models",
+ action="store_true",
+ help="Include mental models in recall results. This waits for consolidation to complete after ingestion and includes mental models in the recall response.",
+ )
+ parser.add_argument(
+ "--only-mental-models",
+ action="store_true",
+ help="Only retrieve mental models (no facts). This waits for consolidation to complete after ingestion and only returns mental models.",
+ )
args = parser.parse_args()
@@ -983,5 +1018,7 @@ if __name__ == "__main__":
results_filename=args.results_filename,
context_format=args.context_format,
source_results=args.source_results,
+ include_mental_models=args.include_mental_models,
+ only_mental_models=args.only_mental_models,
)
)
diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json
index 60ab5256..c4cc88d6 100644
--- a/hindsight-docs/static/openapi.json
+++ b/hindsight-docs/static/openapi.json
@@ -33,6 +33,28 @@
}
}
},
+ "/version": {
+ "get": {
+ "tags": [
+ "Monitoring"
+ ],
+ "summary": "Get API version and feature flags",
+ "description": "Returns API version information and enabled feature flags. Use this to check which capabilities are available in this deployment.",
+ "operationId": "get_version",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/VersionResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/metrics": {
"get": {
"tags": [
@@ -782,14 +804,14 @@
}
}
},
- "/v1/default/banks/{bank_id}/mental-models": {
+ "/v1/default/banks/{bank_id}/reflections": {
"get": {
"tags": [
- "Mental Models"
+ "Reflections"
],
- "summary": "List mental models",
- "description": "List all mental models for a bank, optionally filtered by subtype or tags.",
- "operationId": "list_mental_models",
+ "summary": "List reflections",
+ "description": "List user-curated living documents that stay current.",
+ "operationId": "list_reflections",
"parameters": [
{
"name": "bank_id",
@@ -800,24 +822,6 @@
"title": "Bank Id"
}
},
- {
- "name": "subtype",
- "in": "query",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "description": "Filter by subtype: structural, emergent, or pinned",
- "title": "Subtype"
- },
- "description": "Filter by subtype: structural, emergent, or pinned"
- },
{
"name": "tags",
"in": "query",
@@ -834,10 +838,10 @@
"type": "null"
}
],
- "description": "Filter by tags (includes untagged models)",
+ "description": "Filter by tags",
"title": "Tags"
},
- "description": "Filter by tags (includes untagged models)"
+ "description": "Filter by tags"
},
{
"name": "tags_match",
@@ -850,11 +854,34 @@
"exact"
],
"type": "string",
- "description": "How to match tags: 'any' (OR), 'all' (AND), or 'exact'",
+ "description": "How to match tags",
"default": "any",
"title": "Tags Match"
},
- "description": "How to match tags: 'any' (OR), 'all' (AND), or 'exact'"
+ "description": "How to match tags"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 1000,
+ "minimum": 1,
+ "default": 100,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "offset",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0,
+ "title": "Offset"
+ }
},
{
"name": "authorization",
@@ -879,7 +906,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/MentalModelListResponse"
+ "$ref": "#/components/schemas/ReflectionListResponse"
}
}
}
@@ -898,11 +925,11 @@
},
"post": {
"tags": [
- "Mental Models"
+ "Reflections"
],
- "summary": "Create mental model",
- "description": "Create a mental model. Supports two subtypes:\n- 'pinned' (default): User-defined topic, observations are LLM-generated on refresh\n- 'directive': User-defined hard rules, observations are provided at creation and never regenerated",
- "operationId": "create_mental_model",
+ "summary": "Create reflection",
+ "description": "Create a reflection by running reflect with the source query in the background. Returns an operation ID to track progress. The content is auto-generated by the reflect endpoint. Use the operations endpoint to check completion status.",
+ "operationId": "create_reflection",
"parameters": [
{
"name": "bank_id",
@@ -935,7 +962,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CreateMentalModelRequest"
+ "$ref": "#/components/schemas/CreateReflectionRequest"
}
}
}
@@ -946,7 +973,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/MentalModelResponse"
+ "$ref": "#/components/schemas/CreateReflectionResponse"
}
}
}
@@ -964,14 +991,14 @@
}
}
},
- "/v1/default/banks/{bank_id}/mental-models/{model_id}": {
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}": {
"get": {
"tags": [
- "Mental Models"
+ "Reflections"
],
- "summary": "Get mental model",
- "description": "Get a specific mental model by ID.",
- "operationId": "get_mental_model",
+ "summary": "Get reflection",
+ "description": "Get a specific reflection by ID.",
+ "operationId": "get_reflection",
"parameters": [
{
"name": "bank_id",
@@ -983,12 +1010,12 @@
}
},
{
- "name": "model_id",
+ "name": "reflection_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
- "title": "Model Id"
+ "title": "Reflection Id"
}
},
{
@@ -1014,73 +1041,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/MentalModelResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- },
- "delete": {
- "tags": [
- "Mental Models"
- ],
- "summary": "Delete mental model",
- "description": "Delete a mental model.",
- "operationId": "delete_mental_model",
- "parameters": [
- {
- "name": "bank_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Bank Id"
- }
- },
- {
- "name": "model_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Model Id"
- }
- },
- {
- "name": "authorization",
- "in": "header",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Authorization"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/DeleteResponse"
+ "$ref": "#/components/schemas/ReflectionResponse"
}
}
}
@@ -1099,11 +1060,11 @@
},
"patch": {
"tags": [
- "Mental Models"
+ "Reflections"
],
- "summary": "Update mental model",
- "description": "Update a mental model's name and/or description. Useful for editing directives.",
- "operationId": "update_mental_model",
+ "summary": "Update reflection",
+ "description": "Update a reflection's name.",
+ "operationId": "update_reflection",
"parameters": [
{
"name": "bank_id",
@@ -1115,12 +1076,12 @@
}
},
{
- "name": "model_id",
+ "name": "reflection_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
- "title": "Model Id"
+ "title": "Reflection Id"
}
},
{
@@ -1145,7 +1106,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/UpdateMentalModelRequest"
+ "$ref": "#/components/schemas/UpdateReflectionRequest"
}
}
}
@@ -1156,7 +1117,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/MentalModelResponse"
+ "$ref": "#/components/schemas/ReflectionResponse"
}
}
}
@@ -1172,16 +1133,14 @@
}
}
}
- }
- },
- "/v1/default/banks/{bank_id}/mental-models/refresh": {
- "post": {
+ },
+ "delete": {
"tags": [
- "Mental Models"
+ "Reflections"
],
- "summary": "Refresh mental models (async)",
- "description": "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.",
- "operationId": "refresh_mental_models",
+ "summary": "Delete reflection",
+ "description": "Delete a reflection.",
+ "operationId": "delete_reflection",
"parameters": [
{
"name": "bank_id",
@@ -1193,156 +1152,12 @@
}
},
{
- "name": "authorization",
- "in": "header",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Authorization"
- }
- }
- ],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/RefreshMentalModelsRequest"
- },
- {
- "type": "null"
- }
- ],
- "title": "Body"
- }
- }
- }
- },
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/AsyncOperationSubmitResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh": {
- "post": {
- "tags": [
- "Mental Models"
- ],
- "summary": "Refresh mental model content (async)",
- "description": "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.",
- "operationId": "refresh_mental_model",
- "parameters": [
- {
- "name": "bank_id",
+ "name": "reflection_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
- "title": "Bank Id"
- }
- },
- {
- "name": "model_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Model Id"
- }
- },
- {
- "name": "authorization",
- "in": "header",
- "required": false,
- "schema": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Authorization"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/AsyncOperationSubmitResponse"
- }
- }
- }
- },
- "422": {
- "description": "Validation Error",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/HTTPValidationError"
- }
- }
- }
- }
- }
- }
- },
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions": {
- "get": {
- "tags": [
- "Mental Models"
- ],
- "summary": "List mental model version history",
- "description": "List all saved versions of a mental model's observations, ordered by version descending.",
- "operationId": "list_mental_model_versions",
- "parameters": [
- {
- "name": "bank_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Bank Id"
- }
- },
- {
- "name": "model_id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "title": "Model Id"
+ "title": "Reflection Id"
}
},
{
@@ -1384,14 +1199,14 @@
}
}
},
- "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}": {
- "get": {
+ "/v1/default/banks/{bank_id}/reflections/{reflection_id}/refresh": {
+ "post": {
"tags": [
- "Mental Models"
+ "Reflections"
],
- "summary": "Get specific mental model version",
- "description": "Get observations from a specific version of a mental model.",
- "operationId": "get_mental_model_version",
+ "summary": "Refresh reflection",
+ "description": "Re-run the source query through reflect and update the content.",
+ "operationId": "refresh_reflection",
"parameters": [
{
"name": "bank_id",
@@ -1403,21 +1218,421 @@
}
},
{
- "name": "model_id",
+ "name": "reflection_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
- "title": "Model Id"
+ "title": "Reflection Id"
}
},
{
- "name": "version",
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReflectionResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/default/banks/{bank_id}/directives": {
+ "get": {
+ "tags": [
+ "Directives"
+ ],
+ "summary": "List directives",
+ "description": "List hard rules that are injected into prompts.",
+ "operationId": "list_directives",
+ "parameters": [
+ {
+ "name": "bank_id",
"in": "path",
"required": true,
+ "schema": {
+ "type": "string",
+ "title": "Bank Id"
+ }
+ },
+ {
+ "name": "tags",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by tags",
+ "title": "Tags"
+ },
+ "description": "Filter by tags"
+ },
+ {
+ "name": "tags_match",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "enum": [
+ "any",
+ "all",
+ "exact"
+ ],
+ "type": "string",
+ "description": "How to match tags",
+ "default": "any",
+ "title": "Tags Match"
+ },
+ "description": "How to match tags"
+ },
+ {
+ "name": "active_only",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "description": "Only return active directives",
+ "default": true,
+ "title": "Active Only"
+ },
+ "description": "Only return active directives"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
"schema": {
"type": "integer",
- "title": "Version"
+ "maximum": 1000,
+ "minimum": 1,
+ "default": 100,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "offset",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0,
+ "title": "Offset"
+ }
+ },
+ {
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DirectiveListResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "Directives"
+ ],
+ "summary": "Create directive",
+ "description": "Create a hard rule that will be injected into prompts.",
+ "operationId": "create_directive",
+ "parameters": [
+ {
+ "name": "bank_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Bank Id"
+ }
+ },
+ {
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateDirectiveRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DirectiveResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/default/banks/{bank_id}/directives/{directive_id}": {
+ "get": {
+ "tags": [
+ "Directives"
+ ],
+ "summary": "Get directive",
+ "description": "Get a specific directive by ID.",
+ "operationId": "get_directive",
+ "parameters": [
+ {
+ "name": "bank_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Bank Id"
+ }
+ },
+ {
+ "name": "directive_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Directive Id"
+ }
+ },
+ {
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DirectiveResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "tags": [
+ "Directives"
+ ],
+ "summary": "Update directive",
+ "description": "Update a directive's properties.",
+ "operationId": "update_directive",
+ "parameters": [
+ {
+ "name": "bank_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Bank Id"
+ }
+ },
+ {
+ "name": "directive_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Directive Id"
+ }
+ },
+ {
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UpdateDirectiveRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DirectiveResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Directives"
+ ],
+ "summary": "Delete directive",
+ "description": "Delete a directive.",
+ "operationId": "delete_directive",
+ "parameters": [
+ {
+ "name": "bank_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Bank Id"
+ }
+ },
+ {
+ "name": "directive_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Directive Id"
}
},
{
@@ -1854,7 +2069,7 @@
"Operations"
],
"summary": "List async operations",
- "description": "Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
+ "description": "Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
"operationId": "list_operations",
"parameters": [
{
@@ -1866,6 +2081,51 @@
"title": "Bank Id"
}
},
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by status: pending, completed, or failed",
+ "title": "Status"
+ },
+ "description": "Filter by status: pending, completed, or failed"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1,
+ "description": "Maximum number of operations to return",
+ "default": 20,
+ "title": "Limit"
+ },
+ "description": "Maximum number of operations to return"
+ },
+ {
+ "name": "offset",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Number of operations to skip",
+ "default": 0,
+ "title": "Offset"
+ },
+ "description": "Number of operations to skip"
+ },
{
"name": "authorization",
"in": "header",
@@ -2430,6 +2690,124 @@
}
}
},
+ "/v1/default/banks/{bank_id}/mental-models": {
+ "delete": {
+ "tags": [
+ "Banks"
+ ],
+ "summary": "Clear all mental models",
+ "description": "Delete all mental models for a memory bank. This is useful for resetting the consolidated knowledge.",
+ "operationId": "clear_mental_models",
+ "parameters": [
+ {
+ "name": "bank_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Bank Id"
+ }
+ },
+ {
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DeleteResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/default/banks/{bank_id}/consolidate": {
+ "post": {
+ "tags": [
+ "Banks"
+ ],
+ "summary": "Trigger consolidation",
+ "description": "Run memory consolidation to create/update mental models from recent memories.",
+ "operationId": "trigger_consolidation",
+ "parameters": [
+ {
+ "name": "bank_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Bank Id"
+ }
+ },
+ {
+ "name": "authorization",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ConsolidationResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/v1/default/banks/{bank_id}/memories": {
"post": {
"tags": [
@@ -2602,29 +2980,6 @@
"update_disposition": true
}
},
- "AsyncOperationSubmitResponse": {
- "properties": {
- "operation_id": {
- "type": "string",
- "title": "Operation Id"
- },
- "status": {
- "type": "string",
- "title": "Status"
- }
- },
- "type": "object",
- "required": [
- "operation_id",
- "status"
- ],
- "title": "AsyncOperationSubmitResponse",
- "description": "Response model for submitting an async operation.",
- "example": {
- "operation_id": "550e8400-e29b-41d4-a716-446655440000",
- "status": "queued"
- }
- },
"BackgroundResponse": {
"properties": {
"mission": {
@@ -2866,6 +3221,30 @@
"failed_operations": {
"type": "integer",
"title": "Failed Operations"
+ },
+ "last_consolidated_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Consolidated At",
+ "description": "When consolidation last ran (ISO format)"
+ },
+ "pending_consolidation": {
+ "type": "integer",
+ "title": "Pending Consolidation",
+ "description": "Number of memories not yet processed into mental models",
+ "default": 0
+ },
+ "total_mental_models": {
+ "type": "integer",
+ "title": "Total Mental Models",
+ "description": "Total number of mental models",
+ "default": 0
}
},
"type": "object",
@@ -2886,6 +3265,7 @@
"example": {
"bank_id": "user123",
"failed_operations": 0,
+ "last_consolidated_at": "2024-01-15T10:30:00Z",
"links_breakdown": {
"fact": {
"entity": 40,
@@ -2908,9 +3288,11 @@
"observation": 20,
"preference": 30
},
+ "pending_consolidation": 0,
"pending_operations": 2,
"total_documents": 10,
"total_links": 300,
+ "total_mental_models": 45,
"total_nodes": 150
}
},
@@ -3043,6 +3425,45 @@
"document_id": "session_1"
}
},
+ "ConsolidationResponse": {
+ "properties": {
+ "status": {
+ "type": "string",
+ "title": "Status",
+ "description": "Status of the consolidation (completed or queued)"
+ },
+ "processed": {
+ "type": "integer",
+ "title": "Processed",
+ "description": "Number of memories processed"
+ },
+ "created": {
+ "type": "integer",
+ "title": "Created",
+ "description": "Number of mental models created"
+ },
+ "updated": {
+ "type": "integer",
+ "title": "Updated",
+ "description": "Number of mental models updated"
+ },
+ "message": {
+ "type": "string",
+ "title": "Message",
+ "description": "Human-readable summary"
+ }
+ },
+ "type": "object",
+ "required": [
+ "status",
+ "processed",
+ "created",
+ "updated",
+ "message"
+ ],
+ "title": "ConsolidationResponse",
+ "description": "Response model for consolidation trigger endpoint."
+ },
"CreateBankRequest": {
"properties": {
"name": {
@@ -3104,38 +3525,58 @@
"name": "Alice"
}
},
- "CreateMentalModelRequest": {
+ "CreateDirectiveRequest": {
"properties": {
"name": {
"type": "string",
"title": "Name",
- "description": "Human-readable name for the mental model"
+ "description": "Human-readable name for the directive"
},
- "description": {
+ "content": {
"type": "string",
- "title": "Description",
- "description": "One-liner description for quick scanning"
+ "title": "Content",
+ "description": "The directive text to inject into prompts"
},
- "subtype": {
+ "priority": {
+ "type": "integer",
+ "title": "Priority",
+ "description": "Higher priority directives are injected first",
+ "default": 0
+ },
+ "is_active": {
+ "type": "boolean",
+ "title": "Is Active",
+ "description": "Whether this directive is active",
+ "default": true
+ },
+ "tags": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Tags",
+ "description": "Tags for filtering"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "content"
+ ],
+ "title": "CreateDirectiveRequest",
+ "description": "Request model for creating a directive."
+ },
+ "CreateReflectionRequest": {
+ "properties": {
+ "name": {
"type": "string",
- "title": "Subtype",
- "description": "Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)",
- "default": "pinned"
+ "title": "Name",
+ "description": "Human-readable name for the reflection"
},
- "observations": {
- "anyOf": [
- {
- "items": {
- "$ref": "#/components/schemas/ObservationInput"
- },
- "type": "array"
- },
- {
- "type": "null"
- }
- ],
- "title": "Observations",
- "description": "For directives only: list of user-provided observations. Required when subtype='directive'."
+ "source_query": {
+ "type": "string",
+ "title": "Source Query",
+ "description": "The query to run to generate content"
},
"tags": {
"items": {
@@ -3144,62 +3585,46 @@
"type": "array",
"title": "Tags",
"description": "Tags for scoped visibility"
+ },
+ "max_tokens": {
+ "type": "integer",
+ "maximum": 8192.0,
+ "minimum": 256.0,
+ "title": "Max Tokens",
+ "description": "Maximum tokens for generated content",
+ "default": 2048
}
},
"type": "object",
"required": [
"name",
- "description"
+ "source_query"
],
- "title": "CreateMentalModelRequest",
- "description": "Request model for creating a mental model.",
- "examples": [
- {
- "description": "Key product priorities and upcoming features",
- "name": "Product Roadmap",
- "tags": [
- "project-x"
- ]
- },
- {
- "description": "Rules about scheduling meetings",
- "name": "Meeting Rules",
- "observations": [
- {
- "content": "Never schedule meetings before 10am",
- "title": "Morning meetings"
- }
- ],
- "subtype": "directive"
- }
- ]
+ "title": "CreateReflectionRequest",
+ "description": "Request model for creating a reflection.",
+ "example": {
+ "max_tokens": 2048,
+ "name": "Team Communication Preferences",
+ "source_query": "How does the team prefer to communicate?",
+ "tags": [
+ "team"
+ ]
+ }
},
- "CreatedMentalModel": {
+ "CreateReflectionResponse": {
"properties": {
- "id": {
+ "operation_id": {
"type": "string",
- "title": "Id",
- "description": "Mental model ID"
- },
- "name": {
- "type": "string",
- "title": "Name",
- "description": "Human-readable name"
- },
- "description": {
- "type": "string",
- "title": "Description",
- "description": "What this model tracks"
+ "title": "Operation Id",
+ "description": "Operation ID to track progress"
}
},
"type": "object",
"required": [
- "id",
- "name",
- "description"
+ "operation_id"
],
- "title": "CreatedMentalModel",
- "description": "A mental model created during reflection."
+ "title": "CreateReflectionResponse",
+ "description": "Response model for reflection creation."
},
"DeleteDocumentResponse": {
"properties": {
@@ -3277,6 +3702,91 @@
"success": true
}
},
+ "DirectiveListResponse": {
+ "properties": {
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/DirectiveResponse"
+ },
+ "type": "array",
+ "title": "Items"
+ }
+ },
+ "type": "object",
+ "required": [
+ "items"
+ ],
+ "title": "DirectiveListResponse",
+ "description": "Response model for listing directives."
+ },
+ "DirectiveResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id"
+ },
+ "bank_id": {
+ "type": "string",
+ "title": "Bank Id"
+ },
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "content": {
+ "type": "string",
+ "title": "Content"
+ },
+ "priority": {
+ "type": "integer",
+ "title": "Priority",
+ "default": 0
+ },
+ "is_active": {
+ "type": "boolean",
+ "title": "Is Active",
+ "default": true
+ },
+ "tags": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Tags"
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At"
+ },
+ "updated_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Updated At"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "bank_id",
+ "name",
+ "content"
+ ],
+ "title": "DirectiveResponse",
+ "description": "Response model for a directive."
+ },
"DispositionTraits": {
"properties": {
"skepticism": {
@@ -3675,6 +4185,33 @@
"title": "FactsIncludeOptions",
"description": "Options for including facts (based_on) in reflect results."
},
+ "FeaturesInfo": {
+ "properties": {
+ "mental_models": {
+ "type": "boolean",
+ "title": "Mental Models",
+ "description": "Whether mental models (auto-consolidation) are enabled"
+ },
+ "mcp": {
+ "type": "boolean",
+ "title": "Mcp",
+ "description": "Whether MCP (Model Context Protocol) server is enabled"
+ },
+ "worker": {
+ "type": "boolean",
+ "title": "Worker",
+ "description": "Whether the background worker is enabled"
+ }
+ },
+ "type": "object",
+ "required": [
+ "mental_models",
+ "mcp",
+ "worker"
+ ],
+ "title": "FeaturesInfo",
+ "description": "Feature flags indicating which capabilities are enabled."
+ },
"GraphDataResponse": {
"properties": {
"nodes": {
@@ -4063,329 +4600,6 @@
"timestamp": "2024-01-15T10:30:00Z"
}
},
- "MentalModelFreshnessResponse": {
- "properties": {
- "is_up_to_date": {
- "type": "boolean",
- "title": "Is Up To Date",
- "description": "Whether the model has been refreshed since the last memory was added"
- },
- "last_refresh_at": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Last Refresh At",
- "description": "When the model was last refreshed (ISO format)"
- },
- "memories_since_refresh": {
- "type": "integer",
- "title": "Memories Since Refresh",
- "description": "Number of memories added since last refresh"
- },
- "reasons": {
- "items": {
- "type": "string"
- },
- "type": "array",
- "title": "Reasons",
- "description": "Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed"
- }
- },
- "type": "object",
- "required": [
- "is_up_to_date",
- "last_refresh_at",
- "memories_since_refresh"
- ],
- "title": "MentalModelFreshnessResponse",
- "description": "Freshness information for a mental model."
- },
- "MentalModelListResponse": {
- "properties": {
- "items": {
- "items": {
- "$ref": "#/components/schemas/MentalModelResponse"
- },
- "type": "array",
- "title": "Items"
- }
- },
- "type": "object",
- "required": [
- "items"
- ],
- "title": "MentalModelListResponse",
- "description": "Response model for listing mental models."
- },
- "MentalModelObservationResponse": {
- "properties": {
- "title": {
- "type": "string",
- "title": "Title",
- "description": "Short summary title for the observation"
- },
- "content": {
- "type": "string",
- "title": "Content",
- "description": "The observation content - detailed explanation"
- },
- "evidence": {
- "items": {
- "$ref": "#/components/schemas/ObservationEvidenceResponse"
- },
- "type": "array",
- "title": "Evidence",
- "description": "Supporting evidence with quotes"
- },
- "created_at": {
- "type": "string",
- "title": "Created At",
- "description": "When this observation was first created (ISO format)"
- },
- "trend": {
- "type": "string",
- "title": "Trend",
- "description": "Computed trend: stable, strengthening, weakening, new, stale"
- },
- "evidence_count": {
- "type": "integer",
- "title": "Evidence Count",
- "description": "Number of evidence items supporting this observation"
- },
- "evidence_span": {
- "additionalProperties": true,
- "type": "object",
- "title": "Evidence Span",
- "description": "Time span of evidence: {from: iso_date, to: iso_date}"
- }
- },
- "type": "object",
- "required": [
- "title",
- "content",
- "created_at",
- "trend",
- "evidence_count",
- "evidence_span"
- ],
- "title": "MentalModelObservationResponse",
- "description": "An observation within a mental model with its supporting evidence."
- },
- "MentalModelResponse": {
- "properties": {
- "id": {
- "type": "string",
- "title": "Id"
- },
- "bank_id": {
- "type": "string",
- "title": "Bank Id"
- },
- "subtype": {
- "type": "string",
- "title": "Subtype"
- },
- "name": {
- "type": "string",
- "title": "Name"
- },
- "description": {
- "type": "string",
- "title": "Description"
- },
- "observations": {
- "items": {
- "$ref": "#/components/schemas/MentalModelObservationResponse"
- },
- "type": "array",
- "title": "Observations",
- "description": "Structured observations with per-observation fact attribution"
- },
- "version": {
- "type": "integer",
- "title": "Version",
- "description": "Version number of the mental model observations",
- "default": 0
- },
- "entity_id": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Entity Id"
- },
- "links": {
- "items": {
- "type": "string"
- },
- "type": "array",
- "title": "Links",
- "default": []
- },
- "tags": {
- "items": {
- "type": "string"
- },
- "type": "array",
- "title": "Tags",
- "default": []
- },
- "last_updated": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Last Updated"
- },
- "last_refresh_at": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Last Refresh At",
- "description": "When observations were last refreshed (ISO format)"
- },
- "freshness": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/MentalModelFreshnessResponse"
- },
- {
- "type": "null"
- }
- ],
- "description": "Freshness info (null for directive subtypes which don't need refresh)"
- },
- "created_at": {
- "type": "string",
- "title": "Created At"
- }
- },
- "type": "object",
- "required": [
- "id",
- "bank_id",
- "subtype",
- "name",
- "description",
- "created_at"
- ],
- "title": "MentalModelResponse",
- "description": "Response model for a mental model.",
- "example": {
- "bank_id": "test-bank",
- "created_at": "2024-01-10T08:00:00Z",
- "description": "Who's on the team and their roles",
- "freshness": {
- "is_up_to_date": true,
- "last_refresh_at": "2024-01-15T10:30:00Z",
- "memories_since_refresh": 0,
- "reasons": []
- },
- "id": "team-structure",
- "last_refresh_at": "2024-01-15T10:30:00Z",
- "last_updated": "2024-01-15T10:30:00Z",
- "links": [],
- "name": "Team Structure",
- "observations": [
- {
- "content": "The team prefers async communication over synchronous meetings",
- "created_at": "2024-01-15T10:30:00Z",
- "evidence": [
- {
- "memory_id": "uuid1",
- "quote": "I prefer Slack over meetings",
- "relevance": "Shows async preference",
- "timestamp": "2024-01-10T08:00:00Z"
- }
- ],
- "evidence_count": 1,
- "evidence_span": {
- "from": "2024-01-10T08:00:00Z",
- "to": "2024-01-10T08:00:00Z"
- },
- "title": "Prefers async communication",
- "trend": "stable"
- }
- ],
- "subtype": "structural",
- "tags": [
- "project-x"
- ],
- "version": 1
- }
- },
- "ObservationEvidenceResponse": {
- "properties": {
- "memory_id": {
- "type": "string",
- "title": "Memory Id",
- "description": "ID of the memory unit this evidence comes from"
- },
- "quote": {
- "type": "string",
- "title": "Quote",
- "description": "Exact quote from the memory supporting the observation"
- },
- "relevance": {
- "type": "string",
- "title": "Relevance",
- "description": "Brief explanation of how this quote supports the observation"
- },
- "timestamp": {
- "type": "string",
- "title": "Timestamp",
- "description": "When the source memory was created (ISO format)"
- }
- },
- "type": "object",
- "required": [
- "memory_id",
- "quote",
- "relevance",
- "timestamp"
- ],
- "title": "ObservationEvidenceResponse",
- "description": "A single piece of evidence supporting an observation."
- },
- "ObservationInput": {
- "properties": {
- "title": {
- "type": "string",
- "title": "Title",
- "description": "Short title/header for the observation"
- },
- "content": {
- "type": "string",
- "title": "Content",
- "description": "Content of the observation"
- }
- },
- "type": "object",
- "required": [
- "title",
- "content"
- ],
- "title": "ObservationInput",
- "description": "Input model for a single observation."
- },
"OperationResponse": {
"properties": {
"id": {
@@ -4548,6 +4762,14 @@
"type": "integer",
"title": "Total"
},
+ "limit": {
+ "type": "integer",
+ "title": "Limit"
+ },
+ "offset": {
+ "type": "integer",
+ "title": "Offset"
+ },
"operations": {
"items": {
"$ref": "#/components/schemas/OperationResponse"
@@ -4560,12 +4782,16 @@
"required": [
"bank_id",
"total",
+ "limit",
+ "offset",
"operations"
],
"title": "OperationsListResponse",
"description": "Response model for list operations endpoint.",
"example": {
"bank_id": "user123",
+ "limit": 20,
+ "offset": 0,
"operations": [
{
"created_at": "2024-01-15T10:30:00Z",
@@ -4596,7 +4822,7 @@
}
],
"title": "Types",
- "description": "List of fact types to recall: 'world', 'experience'. Defaults to both if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall)."
+ "description": "List of fact types to recall: 'world', 'experience', 'mental_model'. Defaults to world and experience if not specified. Note: 'opinion' is accepted but ignored (opinions are excluded from recall)."
},
"budget": {
"$ref": "#/components/schemas/Budget",
@@ -4952,14 +5178,6 @@
"type": "array",
"title": "Memories",
"description": "Memory facts used to generate the response"
- },
- "mental_models": {
- "items": {
- "$ref": "#/components/schemas/ReflectMentalModel"
- },
- "type": "array",
- "title": "Mental Models",
- "description": "Mental models accessed during reflection"
}
},
"type": "object",
@@ -5303,14 +5521,6 @@
}
],
"description": "Execution trace of tool and LLM calls. Only present when include.tool_calls is set."
- },
- "mental_models_created": {
- "items": {
- "$ref": "#/components/schemas/CreatedMentalModel"
- },
- "type": "array",
- "title": "Mental Models Created",
- "description": "Mental models created during this reflection (via the learn tool)."
}
},
"type": "object",
@@ -5343,13 +5553,6 @@
}
]
},
- "mental_models_created": [
- {
- "description": "Track AI-related decisions and plans",
- "id": "mm-new-1",
- "name": "AI Strategy"
- }
- ],
"structured_output": {
"key_points": [
"Used in healthcare",
@@ -5460,51 +5663,98 @@
"title": "ReflectTrace",
"description": "Execution trace of LLM and tool calls during reflection."
},
- "RefreshMentalModelsRequest": {
+ "ReflectionListResponse": {
"properties": {
- "tags": {
- "anyOf": [
- {
- "items": {
- "type": "string"
- },
- "type": "array"
- },
- {
- "type": "null"
- }
- ],
- "title": "Tags",
- "description": "Tags to apply to newly created mental models"
- },
- "subtype": {
- "anyOf": [
- {
- "type": "string",
- "enum": [
- "structural",
- "emergent",
- "pinned",
- "learned"
- ]
- },
- {
- "type": "null"
- }
- ],
- "title": "Subtype",
- "description": "Only refresh models of this subtype. If not specified, refreshes all subtypes."
+ "items": {
+ "items": {
+ "$ref": "#/components/schemas/ReflectionResponse"
+ },
+ "type": "array",
+ "title": "Items"
}
},
"type": "object",
- "title": "RefreshMentalModelsRequest",
- "description": "Request model for refresh mental models endpoint.",
- "example": {
- "subtype": "structural",
- "tags": [
- "project-x"
- ]
- }
+ "required": [
+ "items"
+ ],
+ "title": "ReflectionListResponse",
+ "description": "Response model for listing reflections."
+ },
+ "ReflectionResponse": {
+ "properties": {
+ "id": {
+ "type": "string",
+ "title": "Id"
+ },
+ "bank_id": {
+ "type": "string",
+ "title": "Bank Id"
+ },
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "source_query": {
+ "type": "string",
+ "title": "Source Query"
+ },
+ "content": {
+ "type": "string",
+ "title": "Content"
+ },
+ "tags": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Tags"
+ },
+ "last_refreshed_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Refreshed At"
+ },
+ "created_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Created At"
+ },
+ "reflect_response": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Reflect Response",
+ "description": "Full reflect API response payload including based_on facts and mental_models"
+ }
+ },
+ "type": "object",
+ "required": [
+ "id",
+ "bank_id",
+ "name",
+ "source_query",
+ "content"
+ ],
+ "title": "ReflectionResponse",
+ "description": "Response model for a reflection."
},
"RetainRequest": {
"properties": {
@@ -5691,6 +5941,76 @@
"title": "ToolCallsIncludeOptions",
"description": "Options for including tool calls in reflect results."
},
+ "UpdateDirectiveRequest": {
+ "properties": {
+ "name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Name",
+ "description": "New name"
+ },
+ "content": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Content",
+ "description": "New content"
+ },
+ "priority": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Priority",
+ "description": "New priority"
+ },
+ "is_active": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Active",
+ "description": "New active status"
+ },
+ "tags": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tags",
+ "description": "New tags"
+ }
+ },
+ "type": "object",
+ "title": "UpdateDirectiveRequest",
+ "description": "Request model for updating a directive."
+ },
"UpdateDispositionRequest": {
"properties": {
"disposition": {
@@ -5704,7 +6024,7 @@
"title": "UpdateDispositionRequest",
"description": "Request model for updating disposition traits."
},
- "UpdateMentalModelRequest": {
+ "UpdateReflectionRequest": {
"properties": {
"name": {
"anyOf": [
@@ -5716,27 +6036,14 @@
}
],
"title": "Name",
- "description": "New name for the mental model"
- },
- "description": {
- "anyOf": [
- {
- "type": "string"
- },
- {
- "type": "null"
- }
- ],
- "title": "Description",
- "description": "New description/rule text"
+ "description": "New name for the reflection"
}
},
"type": "object",
- "title": "UpdateMentalModelRequest",
- "description": "Request model for updating a mental model.",
+ "title": "UpdateReflectionRequest",
+ "description": "Request model for updating a reflection.",
"example": {
- "description": "Updated description with new rules",
- "name": "Updated Name"
+ "name": "Updated Team Communication Preferences"
}
},
"ValidationError": {
@@ -5771,6 +6078,34 @@
"type"
],
"title": "ValidationError"
+ },
+ "VersionResponse": {
+ "properties": {
+ "api_version": {
+ "type": "string",
+ "title": "Api Version",
+ "description": "API version string"
+ },
+ "features": {
+ "$ref": "#/components/schemas/FeaturesInfo",
+ "description": "Enabled feature flags"
+ }
+ },
+ "type": "object",
+ "required": [
+ "api_version",
+ "features"
+ ],
+ "title": "VersionResponse",
+ "description": "Response model for the version/info endpoint.",
+ "example": {
+ "api_version": "1.0.0",
+ "features": {
+ "mcp": true,
+ "mental_models": false,
+ "worker": true
+ }
+ }
}
}
}