From 78d4940b0195916047d14df9ca461958291242bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Wed, 10 Jun 2026 15:16:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(memory):=20reversible=20curation=20?= =?UTF-8?q?=E2=80=94=20edit/invalidate/revert=20memory=20units?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit (text/context/dates/fact_type/entities), invalidate (move to a separate invalidated_memory_units archive, reversible), and revert raw memory units via PATCH /memories/{id}. Tracks user edits with edited_at. Control-plane UI, docs (Memories API page), and multi-language examples included. RFC #1951. --- .gitignore | 2 + hindsight-api-slim/hindsight_api/admin/cli.py | 1 + ...a1b2d3e4f5_add_invalidated_memory_units.py | 100 ++++ .../versions/o1a2b3c4d5e6_oracle_baseline.py | 43 ++ hindsight-api-slim/hindsight_api/api/http.py | 128 +++++ hindsight-api-slim/hindsight_api/api/mcp.py | 2 + .../hindsight_api/engine/memory_engine.py | 415 +++++++++++++- .../hindsight_api/engine/transfer/export.py | 6 + hindsight-api-slim/hindsight_api/mcp_tools.py | 210 +++++++ .../tests/test_curation_http.py | 103 ++++ hindsight-api-slim/tests/test_mcp_tools.py | 30 +- .../tests/test_memory_curation.py | 533 ++++++++++++++++++ hindsight-cli/.openapi-coverage.toml | 4 + hindsight-cli/src/api.rs | 12 +- hindsight-clients/go/api/openapi.yaml | 107 ++++ hindsight-clients/go/api_memory.go | 155 +++++ .../go/model_update_memory_request.go | 449 +++++++++++++++ .../python/.openapi-generator/FILES | 1 + .../python/hindsight_client_api/__init__.py | 1 + .../hindsight_client_api/api/memory_api.py | 356 ++++++++++++ .../hindsight_client_api/models/__init__.py | 1 + .../models/update_memory_request.py | 141 +++++ .../typescript/generated/sdk.gen.ts | 20 + .../typescript/generated/types.gen.ts | 107 ++++ hindsight-clients/typescript/src/index.ts | 4 + .../src/app/api/list/route.ts | 5 + .../src/app/api/memories/[memoryId]/route.ts | 75 +++ .../src/components/bank-config-view.tsx | 8 +- .../src/components/data-view.tsx | 73 ++- .../src/components/documents-view.tsx | 283 +++++++--- .../src/components/edit-memory-form.tsx | 219 +++++++ .../components/invalidate-memory-dialog.tsx | 74 +++ .../src/components/memory-detail-modal.tsx | 352 +++++++++--- .../src/components/memory-detail-panel.tsx | 156 ++++- hindsight-control-plane/src/lib/api.ts | 45 ++ hindsight-control-plane/src/messages/de.json | 9 +- hindsight-control-plane/src/messages/en.json | 9 +- hindsight-control-plane/src/messages/es.json | 9 +- hindsight-control-plane/src/messages/fr.json | 9 +- hindsight-control-plane/src/messages/ja.json | 9 +- hindsight-control-plane/src/messages/ko.json | 9 +- hindsight-control-plane/src/messages/pt.json | 9 +- .../src/messages/yue-Hant.json | 9 +- .../src/messages/zh-CN.json | 9 +- .../src/messages/zh-TW.json | 9 +- .../docs/developer/api/memories.mdx | 189 +++++++ hindsight-docs/examples/api/memories.go | 139 +++++ hindsight-docs/examples/api/memories.mjs | 106 ++++ hindsight-docs/examples/api/memories.py | 124 ++++ hindsight-docs/examples/api/memories.sh | 85 +++ hindsight-docs/sidebars.ts | 6 + .../src/theme/DocSidebarItem/Link/index.tsx | 3 +- hindsight-docs/static/openapi.json | 216 +++++++ .../references/developer/api/memories.md | 268 +++++++++ skills/hindsight-docs/references/openapi.json | 216 +++++++ 55 files changed, 5445 insertions(+), 218 deletions(-) create mode 100644 hindsight-api-slim/hindsight_api/alembic/versions/c9a1b2d3e4f5_add_invalidated_memory_units.py create mode 100644 hindsight-api-slim/tests/test_curation_http.py create mode 100644 hindsight-api-slim/tests/test_memory_curation.py create mode 100644 hindsight-clients/go/model_update_memory_request.go create mode 100644 hindsight-clients/python/hindsight_client_api/models/update_memory_request.py create mode 100644 hindsight-control-plane/src/components/edit-memory-form.tsx create mode 100644 hindsight-control-plane/src/components/invalidate-memory-dialog.tsx create mode 100644 hindsight-docs/docs/developer/api/memories.mdx create mode 100644 hindsight-docs/examples/api/memories.go create mode 100644 hindsight-docs/examples/api/memories.mjs create mode 100644 hindsight-docs/examples/api/memories.py create mode 100644 hindsight-docs/examples/api/memories.sh create mode 100644 skills/hindsight-docs/references/developer/api/memories.md diff --git a/.gitignore b/.gitignore index a197405672..2adde0e7cd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ node_modules/ # Environment variables and local config .env +.env.bak* +.env.*.bak docker-compose.yml docker-compose.override.yml diff --git a/hindsight-api-slim/hindsight_api/admin/cli.py b/hindsight-api-slim/hindsight_api/admin/cli.py index 66fe4c568a..a83cb9606b 100644 --- a/hindsight-api-slim/hindsight_api/admin/cli.py +++ b/hindsight-api-slim/hindsight_api/admin/cli.py @@ -49,6 +49,7 @@ "entities", "chunks", "memory_units", + "invalidated_memory_units", "unit_entities", "entity_cooccurrences", "memory_links", diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/c9a1b2d3e4f5_add_invalidated_memory_units.py b/hindsight-api-slim/hindsight_api/alembic/versions/c9a1b2d3e4f5_add_invalidated_memory_units.py new file mode 100644 index 0000000000..5de06039ce --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/c9a1b2d3e4f5_add_invalidated_memory_units.py @@ -0,0 +1,100 @@ +"""Add invalidated_memory_units table for curation (edit/invalidate). + +Curation keeps the recall hot-path (``memory_units``) clean by *moving* +invalidated facts into a sibling archive table rather than flagging them in +place. If a row is in ``memory_units`` it is live; if it is in +``invalidated_memory_units`` it has been retired. Recall/consolidation/graph +queries never need a state predicate — the rows simply aren't there. + +The archive mirrors ``memory_units`` column-for-column (so a row round-trips +losslessly on revert) plus: +- ``invalidation_reason`` optional free text recorded on invalidate +- ``invalidated_at`` when it was retired +- ``entity_ids`` snapshot of the unit's entity associations, so revert + can restore them (``unit_entities`` is cascade-deleted + when the live row is removed) + +This migration also adds ``edited_at`` to ``memory_units``: set whenever a user +edits a memory's fields (text, context, dates, fact_type, entities) via curation. +NULL means never manually modified; a non-NULL value answers "has the user ever +changed this?" with the time of the last edit (distinct from ``updated_at``, +which background operations also bump). It is added to ``memory_units`` *before* +the archive is cloned below, so the archive inherits the column and the marker +travels with a fact when it is invalidated. + +Revision ID: c9a1b2d3e4f5 +Revises: b2d4f6a8c1e3 +Create Date: 2026-06-03 +""" + +from collections.abc import Sequence + +from alembic import context, op + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "c9a1b2d3e4f5" +down_revision: str | Sequence[str] | None = "b2d4f6a8c1e3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _pg_schema_prefix() -> str: + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def _pg_upgrade() -> None: + schema = _pg_schema_prefix() + # Add edited_at to the live table FIRST so the archive's LIKE clone below + # inherits it (keeps the two tables column-for-column identical for round-trip). + op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ") + # LIKE ... INCLUDING DEFAULTS clones every memory_units column (incl. the + # embedding vector and edited_at) so an invalidated row can move back verbatim. + # We deliberately omit indexes/constraints — the archive is cold storage, not a + # recall surface; only the lookups below need indexing. + op.execute( + f"CREATE TABLE IF NOT EXISTS {schema}invalidated_memory_units (LIKE {schema}memory_units INCLUDING DEFAULTS)" + ) + op.execute( + f"ALTER TABLE {schema}invalidated_memory_units " + f"ADD COLUMN IF NOT EXISTS invalidation_reason TEXT, " + f"ADD COLUMN IF NOT EXISTS invalidated_at TIMESTAMPTZ DEFAULT now(), " + f"ADD COLUMN IF NOT EXISTS entity_ids UUID[]" + ) + op.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_invalidated_mu_id ON {schema}invalidated_memory_units (id)") + op.execute( + f"CREATE INDEX IF NOT EXISTS idx_invalidated_mu_bank " + f"ON {schema}invalidated_memory_units (bank_id, invalidated_at)" + ) + # Deleting a document (or bank) should clear its archived facts too, mirroring + # the memory_units → documents cascade. + op.execute( + f""" + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invalidated_mu_document_fkey') THEN + ALTER TABLE {schema}invalidated_memory_units + ADD CONSTRAINT invalidated_mu_document_fkey + FOREIGN KEY (document_id, bank_id) + REFERENCES {schema}documents(id, bank_id) ON DELETE CASCADE; + END IF; END $$; + """ + ) + + +def _pg_downgrade() -> None: + schema = _pg_schema_prefix() + # Drops the archive (and its inherited edited_at) wholesale, then removes + # edited_at from the live table. + op.execute(f"DROP TABLE IF EXISTS {schema}invalidated_memory_units") + op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS edited_at") + + +def upgrade() -> None: + # PG-only: Oracle gets the table from the baseline snapshot, matching the + # convention used by sibling column/index migrations in this tree. + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py b/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py index ee1007ff71..67267157a9 100644 --- a/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py +++ b/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py @@ -122,6 +122,7 @@ text_signals CLOB, consolidation_failed_at TIMESTAMP WITH TIME ZONE, search_vector CLOB, + edited_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, CONSTRAINT pk_memory_units PRIMARY KEY (id), @@ -138,6 +139,48 @@ PARTITION BY LIST (bank_id) AUTOMATIC (PARTITION p_default VALUES ('__default__')) """, + # Cold archive for curation: invalidated facts are MOVED here out of + # memory_units so the recall hot-path never sees them. Mirrors memory_units + # plus invalidation bookkeeping and an entity-id snapshot for lossless revert. + """ + CREATE TABLE IF NOT EXISTS invalidated_memory_units ( + id RAW(16) NOT NULL, + bank_id VARCHAR2(256) NOT NULL, + document_id VARCHAR2(512), + chunk_id VARCHAR2(512), + text CLOB NOT NULL, + embedding VECTOR(384, FLOAT32), + context CLOB, + event_date TIMESTAMP WITH TIME ZONE NOT NULL, + occurred_start TIMESTAMP WITH TIME ZONE, + occurred_end TIMESTAMP WITH TIME ZONE, + mentioned_at TIMESTAMP WITH TIME ZONE, + fact_type VARCHAR2(64) DEFAULT 'world' NOT NULL, + confidence_score BINARY_DOUBLE, + access_count NUMBER(10) DEFAULT 0 NOT NULL, + consolidated_at TIMESTAMP WITH TIME ZONE, + observation_scopes CLOB CONSTRAINT imu_obs_scopes_json CHECK (observation_scopes IS JSON OR observation_scopes IS NULL), + tags CLOB DEFAULT '[]' NOT NULL, + metadata CLOB DEFAULT '{}' NOT NULL + CONSTRAINT imu_metadata_json CHECK (metadata IS JSON), + proof_count NUMBER(10) DEFAULT 1, + source_memory_ids CLOB, + history CLOB DEFAULT '[]' + CONSTRAINT imu_history_json CHECK (history IS JSON OR history IS NULL), + text_signals CLOB, + consolidation_failed_at TIMESTAMP WITH TIME ZONE, + search_vector CLOB, + edited_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, + invalidation_reason CLOB, + invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP, + entity_ids CLOB CONSTRAINT imu_entity_ids_json CHECK (entity_ids IS JSON OR entity_ids IS NULL), + CONSTRAINT pk_invalidated_memory_units PRIMARY KEY (id), + CONSTRAINT fk_imu_document FOREIGN KEY (document_id, bank_id) + REFERENCES documents(id, bank_id) ON DELETE CASCADE + ) + """, """ CREATE TABLE IF NOT EXISTS entities ( id RAW(16) DEFAULT SYS_GUID() NOT NULL, diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 2fa59a039e..a6aa39011c 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -1390,6 +1390,83 @@ class UpdateDocumentResponse(BaseModel): success: bool = True +class UpdateMemoryRequest(BaseModel): + """Request model for curating a single memory unit (edit / invalidate / revert). + + Provide ``text`` to correct the fact, and/or ``state`` to invalidate + ('invalidated') or revert ('valid') it. ``reason`` is optional free text + recorded on the memory. At least one of ``text`` or ``state`` must be set. + Only world/experience facts can be curated; observations are derived. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "state": "invalidated", + "reason": "superseded: server decommissioned 2026-06-01", + } + } + ) + + text: str | None = Field( + default=None, + description="New fact text. Re-embeds the memory, drops its derived " + "observations and links, and triggers re-consolidation.", + ) + context: str | None = Field( + default=None, + description="New context for the fact. '' clears it; omit to leave unchanged.", + ) + occurred_start: str | None = Field( + default=None, + description="New occurred-range start (ISO 8601). '' clears it; omit to leave unchanged.", + ) + occurred_end: str | None = Field( + default=None, + description="New occurred-range end (ISO 8601). '' clears it; omit to leave unchanged.", + ) + fact_type: str | None = Field( + default=None, + description="Reclassify the fact: 'world' or 'experience'. Omit to leave unchanged.", + ) + entities: list[str] | None = Field( + default=None, + description="Replace the fact's entities. Names are resolved/find-or-created " + "the same way retain does; '[]' detaches all entities. Omit to leave unchanged.", + ) + state: str | None = Field( + default=None, + description="Curation state: 'invalidated' to soft-retire the memory " + "(excluded from recall/consolidation, links and derived observations " + "pruned, moved to the archive) or 'valid' to revert. Reversible.", + ) + reason: str | None = Field( + default=None, + description="Optional free-text reason recorded when invalidating.", + ) + + @model_validator(mode="after") + def _require_an_edit(self) -> "UpdateMemoryRequest": + if all( + v is None + for v in ( + self.text, + self.context, + self.occurred_start, + self.occurred_end, + self.fact_type, + self.entities, + self.state, + ) + ): + raise ValueError("Provide at least one field to update.") + if self.state is not None and self.state not in ("valid", "invalidated"): + raise ValueError("state must be 'valid' or 'invalidated'.") + if self.fact_type is not None and self.fact_type not in ("world", "experience"): + raise ValueError("fact_type must be 'world' or 'experience'.") + return self + + class DeleteDocumentResponse(BaseModel): """Response model for delete document endpoint.""" @@ -3211,6 +3288,8 @@ async def api_list( type: str | None = None, q: str | None = None, consolidation_state: str | None = None, + state: str | None = None, + document_id: str | None = None, limit: int = 100, offset: int = 0, request_context: RequestContext = Depends(get_request_context), @@ -3236,6 +3315,8 @@ async def api_list( fact_type=type, search_query=q, consolidation_state=consolidation_state, + state=state, + document_id=document_id, limit=limit, offset=offset, request_context=request_context, @@ -3289,6 +3370,53 @@ async def api_get_memory( logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.patch( + "/v1/default/banks/{bank_id}/memories/{memory_id}", + summary="Curate memory unit", + description="Edit a memory's text and/or change its curation state " + "(invalidate / revert). Invalidated memories are excluded from recall, " + "consolidation, and graph maintenance but kept for audit (reversible). " + "Only world/experience facts can be curated; observations are derived.", + operation_id="update_memory", + tags=["Memory"], + ) + async def api_update_memory( + bank_id: str, + memory_id: str, + request: UpdateMemoryRequest, + request_context: RequestContext = Depends(get_request_context), + ): + """Curate a single memory unit (edit text / invalidate / revert).""" + try: + data = await app.state.memory.update_memory_unit( + bank_id=bank_id, + memory_id=memory_id, + text=request.text, + context=request.context, + occurred_start=request.occurred_start, + occurred_end=request.occurred_end, + new_fact_type=request.fact_type, + entities=request.entities, + state=request.state, + reason=request.reason, + request_context=request_context, + ) + if data is None: + raise HTTPException(status_code=404, detail=f"Memory unit '{memory_id}' not found") + return data + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + 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 PATCH /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.get( "/v1/default/banks/{bank_id}/memories/{memory_id}/history", summary="Get observation history", diff --git a/hindsight-api-slim/hindsight_api/api/mcp.py b/hindsight-api-slim/hindsight_api/api/mcp.py index 9e76e8824b..9b6b95883f 100644 --- a/hindsight-api-slim/hindsight_api/api/mcp.py +++ b/hindsight-api-slim/hindsight_api/api/mcp.py @@ -113,6 +113,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP: "delete_directive", "list_memories", "get_memory", + "update_memory", + "invalidate_memory", "list_documents", "get_document", "delete_document", diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index e24d4b216e..2a5fda4307 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -5571,6 +5571,12 @@ async def delete_bank( bank_id, fact_type, ) + # Curation archive holds invalidated facts of the same types. + await conn.execute( + f"DELETE FROM {fq_table('invalidated_memory_units')} WHERE bank_id = $1 AND fact_type = $2", + bank_id, + fact_type, + ) if unit_ids: invalidated_obs = await self._delete_stale_observations_for_memories( @@ -5598,6 +5604,12 @@ async def delete_bank( # Delete memory units (cascades to unit_entities, memory_links) await conn.execute(f"DELETE FROM {fq_table('memory_units')} WHERE bank_id = $1", bank_id) + # Curation archive (rows with NULL document_id aren't covered by + # the documents cascade, so clear by bank explicitly). + await conn.execute( + f"DELETE FROM {fq_table('invalidated_memory_units')} WHERE bank_id = $1", bank_id + ) + # Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id) await conn.execute(f"DELETE FROM {fq_table('entities')} WHERE bank_id = $1", bank_id) @@ -5804,6 +5816,345 @@ async def clear_observations_for_memory( return {"deleted_count": deleted_count} + async def _reembed_memory_text( + self, + *, + text: str, + occurred_start: datetime | None, + occurred_end: datetime | None, + mentioned_at: datetime | None, + entities: list[str], + ) -> str | None: + """Recompute a memory unit's embedding string the same way retain does. + + Mirrors the retain pipeline's date+entity augmentation so an edited or + reverted memory embeds identically to a freshly-retained one. Returns the + pgvector string form (or None if the embedder produced nothing). + """ + from .retain import embedding_processing + from .retain.types import ExtractedFact + + shim = ExtractedFact( + fact_text=text, + fact_type="world", + entities=list(entities or []), + occurred_start=occurred_start, + occurred_end=occurred_end, + mentioned_at=mentioned_at, + ) + augmented = embedding_processing.augment_texts_with_dates([shim], self._format_readable_date) + embeddings = await embedding_processing.generate_embeddings_batch(self.embeddings, augmented) + return str(embeddings[0]) if embeddings else None + + async def _memory_unit_columns(self, conn) -> str: + """Comma-joined, quoted ordinal column list of ``memory_units``. + + Used to move a row verbatim between ``memory_units`` and the curation + archive (``invalidated_memory_units``) without hardcoding the + migration-evolving column set — the archive is created via + ``LIKE memory_units`` so the lists line up. + """ + rows = await conn.fetch( + f"SELECT a.attname FROM pg_attribute a " + f"WHERE a.attrelid = '{fq_table('memory_units')}'::regclass " + f"AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum" + ) + return ", ".join(f'"{r["attname"]}"' for r in rows) + + async def update_memory_unit( + self, + bank_id: str, + memory_id: str, + *, + text: str | None = None, + context: str | None = None, + occurred_start: str | None = None, + occurred_end: str | None = None, + new_fact_type: str | None = None, + entities: list[str] | None = None, + state: str | None = None, + reason: str | None = None, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Curate a single raw memory unit: edit its fields and/or change its state. + + Invalidation keeps the recall hot-path clean by *moving* the row between + tables rather than flagging it: live facts live in ``memory_units``, + invalidated ones in ``invalidated_memory_units``. Recall/consolidation/ + graph queries therefore need no state predicate. + + - **Edit** (``text``/``context``/``occurred_start``/``occurred_end``/ + ``new_fact_type``/``entities``): correct what the LLM extracted. + Re-embeds (text + dates + entities feed the embedding), drops derived + observations + links, and re-consolidates. For date/context fields, + ``""`` clears to NULL and ``None`` leaves unchanged; ``new_fact_type`` + must be world/experience. ``entities`` (when not None) replaces the + unit's entity set: names are resolved/find-or-created via the same + resolver retain uses, ``unit_entities`` + cooccurrence are rebuilt, and + ``[]`` detaches all entities. Entities orphaned by the swap, and any + now-stale cooccurrence rows, are reclaimed by the graph-maintenance + sweep that this edit submits (entity edges live in ``unit_entities``, + not ``memory_links``, so there is nothing to relink directly). + - **Invalidate** (``state='invalidated'``): move the row to the archive + (cascade-pruning its links/entity associations and re-deriving dependent + observations). The embedding + an entity-id snapshot travel with it. + - **Revert** (``state='valid'``): move the row back, restore its entity + associations, and re-consolidate. + + Only ``world``/``experience`` facts can be curated — observations are + derived and regenerate from their sources. Returns the updated memory + (same shape as :meth:`get_memory_unit`) or None if not found. + """ + try: + memory_uuid = uuid.UUID(memory_id) + except ValueError: + raise ValueError(f"Invalid memory_id: '{memory_id}' is not a valid UUID") + if state is not None and state not in ("valid", "invalidated"): + raise ValueError(f"Invalid state '{state}': expected 'valid' or 'invalidated'.") + if text is not None and not text.strip(): + raise ValueError("text must not be empty.") + if new_fact_type is not None and new_fact_type not in ("world", "experience"): + raise ValueError(f"Invalid fact_type '{new_fact_type}': expected 'world' or 'experience'.") + # Normalize the entity list up front: drop blanks/whitespace and de-dup + # case-insensitively (the resolver would coalesce these anyway). A + # provided-but-empty list means "detach all entities"; None means leave + # the unit's entities untouched. + new_entities: list[str] | None = None + if entities is not None: + seen_names: set[str] = set() + new_entities = [] + for name in entities: + cleaned = name.strip() + if cleaned and cleaned.lower() not in seen_names: + seen_names.add(cleaned.lower()) + new_entities.append(cleaned) + + def _parse_edit_date(value: str | None) -> datetime | None: + # "" clears to NULL; an ISO date/datetime parses (UTC if naive). + if not value: + return None + dt = datetime.fromisoformat(value) + return dt if dt.tzinfo else dt.replace(tzinfo=UTC) + + await self._authenticate_tenant(request_context) + if self._operation_validator: + from hindsight_api.extensions import BankWriteContext + + ctx = BankWriteContext(bank_id=bank_id, operation="update_memory_unit", request_context=request_context) + await self._validate_operation(self._operation_validator.validate_bank_write(ctx)) + + backend = await self._get_backend() + from .graph_maintenance import enqueue_relink_victims + from .retain.link_utils import resolve_entities_only + + # Resolve the bank's entity-label taxonomy once when re-resolving entities, + # so corrected entities are matched with the same rules retain uses. + entity_labels = None + if new_entities is not None: + edit_config = await self._config_resolver.resolve_full_config(bank_id, request_context) + entity_labels = getattr(edit_config, "entity_labels", None) + + mu = fq_table("memory_units") + arch = fq_table("invalidated_memory_units") + ue = fq_table("unit_entities") + ml = fq_table("memory_links") + ent = fq_table("entities") + + need_consolidation = False + need_graph = False + found = False + + async with acquire_with_retry(backend) as conn: + async with conn.transaction(): + live = await conn.fetchrow( + f"SELECT text, context, fact_type, event_date, occurred_start, occurred_end, mentioned_at " + f"FROM {mu} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + archived = None + if not live: + archived = await conn.fetchrow( + f"SELECT fact_type FROM {arch} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + record = live or archived + if record is None: + return None + found = True + current_fact_type = record["fact_type"] + if current_fact_type not in ("experience", "world"): + raise ValueError( + f"Memory '{memory_id}' is a {current_fact_type}; only world/experience facts can be " + "curated. Observations are derived and regenerate from their sources." + ) + + collist = await self._memory_unit_columns(conn) + + # --- Edit fields (live rows only): text / context / dates / fact_type / entities --- + doing_edit = any( + v is not None for v in (text, context, occurred_start, occurred_end, new_fact_type) + ) or (new_entities is not None) + if doing_edit: + if not live: + raise ValueError("Cannot edit an invalidated memory; revert it to 'valid' first.") + new_text = text if text is not None else live["text"] + new_context = (context or None) if context is not None else live["context"] + new_fact = new_fact_type if new_fact_type is not None else live["fact_type"] + new_occ_start = ( + _parse_edit_date(occurred_start) if occurred_start is not None else live["occurred_start"] + ) + new_occ_end = _parse_edit_date(occurred_end) if occurred_end is not None else live["occurred_end"] + # event_date (NOT NULL, legacy single date + used by temporal links) + # tracks the occurred start when it's set. + new_event_date = new_occ_start or live["event_date"] + + # Rebuild the unit's entity set FIRST, so the re-embed below picks + # up the corrected canonical names. Reuses retain's resolver + # (find-or-create + cooccurrence) rather than touching entities + # directly. Orphaned entities + stale cooccurrence are swept by + # the graph-maintenance run this edit submits. + if new_entities is not None: + entity_date = new_occ_start or live["mentioned_at"] + _resolved_ids, _e2u, unit_to_entity_ids = await resolve_entities_only( + self.entity_resolver, + conn, + bank_id, + [str(memory_uuid)], + [new_text], + new_context or "", + [entity_date], + [[{"text": name, "type": "CONCEPT"} for name in new_entities]], + entity_labels=entity_labels, + ) + await conn.execute(f"DELETE FROM {ue} WHERE unit_id = $1", str(memory_uuid)) + resolved_for_unit = unit_to_entity_ids.get(str(memory_uuid), []) + if resolved_for_unit: + await self.entity_resolver.link_units_to_entities_batch( + [(str(memory_uuid), eid, entity_date) for eid in resolved_for_unit], + conn=conn, + ) + + ent_rows = await conn.fetch( + f"SELECT e.canonical_name FROM {ue} ue JOIN {ent} e ON ue.entity_id = e.id " + f"WHERE ue.unit_id = $1", + str(memory_uuid), + ) + new_emb = await self._reembed_memory_text( + text=new_text, + occurred_start=new_occ_start, + occurred_end=new_occ_end, + mentioned_at=live["mentioned_at"], + entities=[r["canonical_name"] for r in ent_rows], + ) + await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops) + await conn.execute( + f""" + UPDATE {mu} + SET text = $3, context = $4, fact_type = $5, occurred_start = $6, + occurred_end = $7, event_date = $8, embedding = $9::vector, + consolidated_at = NULL, consolidation_failed_at = NULL, + edited_at = now(), updated_at = now() + WHERE id = $1 AND bank_id = $2 + """, + str(memory_uuid), + bank_id, + new_text, + new_context, + new_fact, + new_occ_start, + new_occ_end, + new_event_date, + new_emb, + ) + await conn.execute(f"DELETE FROM {ml} WHERE from_unit_id = $1 OR to_unit_id = $1", str(memory_uuid)) + await self._delete_stale_observations_for_memories(conn, bank_id, [memory_id]) + need_consolidation = True + need_graph = True + + # --- Invalidate: move live → archive --- + if state == "invalidated" and live: + entity_ids = [ + r["entity_id"] + for r in await conn.fetch(f"SELECT entity_id FROM {ue} WHERE unit_id = $1", str(memory_uuid)) + ] + # Capture relink victims BEFORE the row (and its links) disappear. + await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops) + await conn.execute( + f"INSERT INTO {arch} ({collist}, invalidation_reason, invalidated_at, entity_ids) " + f"SELECT {collist}, $2, now(), $3::uuid[] FROM {mu} WHERE id = $1 AND bank_id = $4", + str(memory_uuid), + reason, + entity_ids, + bank_id, + ) + # Cascade prunes unit_entities + memory_links; sweep runs after + # the delete so it also catches a racing observation insert. + await conn.execute(f"DELETE FROM {mu} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id) + await self._delete_stale_observations_for_memories(conn, bank_id, [memory_id]) + need_consolidation = True + need_graph = True + elif state == "invalidated" and archived and reason is not None: + # Already archived — just update the recorded reason. + await conn.execute( + f"UPDATE {arch} SET invalidation_reason = $3 WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + reason, + ) + + # --- Revert: move archive → live --- + elif state == "valid" and archived: + arch_row = await conn.fetchrow( + f"SELECT entity_ids FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id + ) + await conn.execute( + f"INSERT INTO {mu} ({collist}) SELECT {collist} FROM {arch} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + # Re-consolidate from scratch; links are rebuilt by graph maintenance. + await conn.execute( + f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() " + f"WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + # Restore entity associations for entities that still exist (some may + # have been pruned as orphans after the original move). + if arch_row and arch_row["entity_ids"]: + await conn.execute( + f"INSERT INTO {ue} (unit_id, entity_id) " + f"SELECT $1, eid FROM unnest($2::uuid[]) AS eid " + f"WHERE EXISTS (SELECT 1 FROM {ent} e WHERE e.id = eid AND e.bank_id = $3) " + f"ON CONFLICT DO NOTHING", + str(memory_uuid), + arch_row["entity_ids"], + bank_id, + ) + await conn.execute(f"DELETE FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id) + need_consolidation = True + need_graph = True + + if not found: + return None + + if need_consolidation: + config = await self._config_resolver.resolve_full_config(bank_id, request_context) + if config.enable_auto_consolidation: + try: + await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context) + except Exception as e: + logger.warning(f"Failed to submit consolidation after curating memory in bank {bank_id}: {e}") + if need_graph: + try: + await self.submit_async_graph_maintenance(bank_id=bank_id, request_context=request_context) + except Exception as e: + logger.warning(f"Failed to submit graph maintenance after curating memory in bank {bank_id}: {e}") + + return await self.get_memory_unit(bank_id=bank_id, memory_id=memory_id, request_context=request_context) + async def run_consolidation( self, bank_id: str, @@ -6271,6 +6622,8 @@ async def list_memory_units( fact_type: str | None = None, search_query: str | None = None, consolidation_state: str | None = None, + state: str | None = None, + document_id: str | None = None, limit: int = 100, offset: int = 0, request_context: "RequestContext", @@ -6282,6 +6635,10 @@ async def list_memory_units( bank_id: Filter by bank ID fact_type: Filter by fact type (world, experience) search_query: Full-text search query (searches text and context fields) + document_id: Optional filter to a single source document. + state: Optional curation-state filter ('valid' or 'invalidated'). + Invalidated facts live in a separate archive table; 'invalidated' + reads that archive. Omitted/('valid') lists live facts. consolidation_state: Optional filter on consolidation state. One of 'failed' (consolidation permanently failed and awaiting recovery), 'pending' (not yet consolidated, no failure), or @@ -6300,6 +6657,12 @@ async def list_memory_units( ctx = BankReadContext(bank_id=bank_id, operation="list_memory_units", request_context=request_context) await self._validate_operation(self._operation_validator.validate_bank_read(ctx)) + if state is not None and state not in ("valid", "invalidated"): + raise ValueError(f"Invalid state '{state}': expected 'valid' or 'invalidated'.") + # Invalidated facts live in a separate archive table; pick the source + # accordingly. Default (state is None) lists live facts. + is_archived = state == "invalidated" + source_table = fq_table("invalidated_memory_units") if is_archived else fq_table("memory_units") backend = await self._get_backend() async with acquire_with_retry(backend) as conn: # Build query conditions @@ -6317,6 +6680,11 @@ async def list_memory_units( query_conditions.append(f"fact_type = ${param_count}") query_params.append(fact_type) + if document_id: + param_count += 1 + query_conditions.append(f"document_id = ${param_count}") + query_params.append(document_id) + if search_query: # Full-text search on text and context fields using ILIKE param_count += 1 @@ -6346,7 +6714,7 @@ async def list_memory_units( # Get total count count_query = f""" SELECT COUNT(*) as total - FROM {fq_table("memory_units")} + FROM {source_table} {where_clause} """ count_result = await conn.fetchrow(count_query, *query_params) @@ -6361,12 +6729,18 @@ async def list_memory_units( offset_param = f"${param_count}" query_params.append(offset) + # The archive carries invalidation bookkeeping; the live table doesn't. + curation_cols = ( + "invalidation_reason, invalidated_at" + if is_archived + else "NULL::text AS invalidation_reason, NULL::timestamptz AS invalidated_at" + ) units = await conn.fetch( f""" SELECT id, text, event_date, context, fact_type, document_id, mentioned_at, occurred_start, occurred_end, chunk_id, proof_count, - tags, consolidated_at, consolidation_failed_at - FROM {fq_table("memory_units")} + tags, consolidated_at, consolidation_failed_at, edited_at, {curation_cols} + FROM {source_table} {where_clause} ORDER BY mentioned_at DESC NULLS LAST, created_at DESC LIMIT {limit_param} OFFSET {offset_param} @@ -6424,6 +6798,10 @@ async def list_memory_units( "consolidation_failed_at": ( row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None ), + "state": "invalidated" if is_archived else "valid", + "invalidation_reason": row["invalidation_reason"], + "invalidated_at": row["invalidated_at"].isoformat() if row["invalidated_at"] else None, + "edited_at": row["edited_at"].isoformat() if row["edited_at"] else None, } ) @@ -6461,18 +6839,29 @@ async def get_memory_unit( await self._validate_operation(self._operation_validator.validate_bank_read(ctx)) backend = await self._get_backend() async with acquire_with_retry(backend) as conn: - # Get the memory unit (include source_memory_ids for mental models) + # Get the memory unit (include source_memory_ids for mental models). + # Curation moves invalidated facts to invalidated_memory_units, so fall + # back to the archive (with its invalidation bookkeeping) on a miss. + select_cols = ( + "id, text, context, event_date, occurred_start, occurred_end, " + "mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, " + "observation_scopes, edited_at" + ) row = await conn.fetchrow( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, - mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, - observation_scopes - FROM {fq_table("memory_units")} - WHERE id = $1 AND bank_id = $2 - """, + f"SELECT {select_cols}, NULL::text AS invalidation_reason, NULL::timestamptz AS invalidated_at " + f"FROM {fq_table('memory_units')} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id, ) + unit_state = "valid" + if not row: + row = await conn.fetchrow( + f"SELECT {select_cols}, invalidation_reason, invalidated_at " + f"FROM {fq_table('invalidated_memory_units')} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + unit_state = "invalidated" if not row: return None @@ -6500,6 +6889,10 @@ async def get_memory_unit( "chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None, "tags": row["tags"] if row["tags"] else [], "observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None, + "state": unit_state, + "invalidation_reason": row["invalidation_reason"], + "invalidated_at": row["invalidated_at"].isoformat() if row["invalidated_at"] else None, + "edited_at": row["edited_at"].isoformat() if row["edited_at"] else None, } # For observations, include source_memory_ids diff --git a/hindsight-api-slim/hindsight_api/engine/transfer/export.py b/hindsight-api-slim/hindsight_api/engine/transfer/export.py index c30c553f58..e3835ce44f 100644 --- a/hindsight-api-slim/hindsight_api/engine/transfer/export.py +++ b/hindsight-api-slim/hindsight_api/engine/transfer/export.py @@ -80,6 +80,12 @@ "async_operations", # in-flight ops; drain on the source before migrating "graph_maintenance_queue", # transient work queue; regenerated on import "file_storage", # raw uploads; documents.original_text is already carried + # Curation archive of retired facts — local operational state, not part of + # the live knowledge the export replays. Its rows mirror memory_units (stale + # embedding) and snapshot source-bank entity ids that the import re-resolves + # to fresh ids, so carrying them would only produce dangling associations. + # Revert anything worth keeping on the source before migrating. + "invalidated_memory_units", } ) # Derived columns dropped from carried rows so the target regenerates them with diff --git a/hindsight-api-slim/hindsight_api/mcp_tools.py b/hindsight-api-slim/hindsight_api/mcp_tools.py index 2a2d076ff2..bba68e442c 100644 --- a/hindsight-api-slim/hindsight_api/mcp_tools.py +++ b/hindsight-api-slim/hindsight_api/mcp_tools.py @@ -50,6 +50,8 @@ "delete_directive", "list_memories", "get_memory", + "update_memory", + "invalidate_memory", "list_documents", "get_document", "delete_document", @@ -228,6 +230,8 @@ def register_mcp_tools( "delete_directive", "list_memories", "get_memory", + "update_memory", + "invalidate_memory", "list_documents", "get_document", "delete_document", @@ -299,6 +303,12 @@ def register_mcp_tools( if "get_memory" in tools_to_register: _register_get_memory(mcp, memory, config) + if "update_memory" in tools_to_register: + _register_update_memory(mcp, memory, config) + + if "invalidate_memory" in tools_to_register: + _register_invalidate_memory(mcp, memory, config) + # Document tools if "list_documents" in tools_to_register: _register_list_documents(mcp, memory, config) @@ -2293,6 +2303,206 @@ async def get_memory( return {"error": str(e)} +def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the update_memory (edit) tool.""" + + _EDIT_DOC = """ + Edit a memory unit to correct what was extracted. + + Pass any of text / context / occurred_start / occurred_end / fact_type / + entities. For context and the dates, "" clears the field and omitting it + leaves it unchanged; entities replaces the fact's entity set ([] detaches + all). The memory is re-embedded and its derived observations, links, and + graph are recomputed automatically. + + Only raw world/experience facts can be edited; observations are derived. + To retire or restore a fact, use invalidate_memory instead. + """ + + if config.include_bank_id_param: + + @mcp.tool() + async def update_memory( + memory_id: str, + text: str | None = None, + context: str | None = None, + occurred_start: str | None = None, + occurred_end: str | None = None, + fact_type: str | None = None, + entities: list[str] | None = None, + bank_id: str | None = None, + ) -> str: + f"""{_EDIT_DOC} + Args: + memory_id: The ID of the memory unit to edit. + bank_id: Optional bank (defaults to session bank). Use for cross-bank operations. + """ + try: + target_bank = bank_id or config.bank_id_resolver() + if target_bank is None: + return '{"error": "No bank_id configured"}' + + result = await memory.update_memory_unit( + target_bank, + memory_id, + text=text, + context=context, + occurred_start=occurred_start, + occurred_end=occurred_end, + new_fact_type=fact_type, + entities=entities, + request_context=_get_request_context(config), + ) + if result is None: + return json.dumps({"error": f"Memory '{memory_id}' not found"}) + return json.dumps(result, indent=2, default=str) + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return json.dumps({"error": str(e)}) + except ValueError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + logger.error(f"Error updating memory: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def update_memory( + memory_id: str, + text: str | None = None, + context: str | None = None, + occurred_start: str | None = None, + occurred_end: str | None = None, + fact_type: str | None = None, + entities: list[str] | None = None, + ) -> dict: + f"""{_EDIT_DOC} + Args: + memory_id: The ID of the memory unit to edit. + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.update_memory_unit( + target_bank, + memory_id, + text=text, + context=context, + occurred_start=occurred_start, + occurred_end=occurred_end, + new_fact_type=fact_type, + entities=entities, + request_context=_get_request_context(config), + ) + if result is None: + return {"error": f"Memory '{memory_id}' not found"} + return result + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return {"error": str(e)} + except ValueError as e: + return {"error": str(e)} + except Exception as e: + logger.error(f"Error updating memory: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_invalidate_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the invalidate_memory (retire / restore) tool.""" + + _INVALIDATE_DOC = """ + Soft-retire a memory unit (or restore a previously retired one). + + Invalidating moves the fact out of the active set: it's excluded from + recall, consolidation, and the knowledge graph, its links are pruned, and + its derived observations are recomputed without it — but it's kept for + audit and is fully reversible. Pass restore=True to bring it back. + + Only raw world/experience facts can be invalidated; observations are derived. + """ + + if config.include_bank_id_param: + + @mcp.tool() + async def invalidate_memory( + memory_id: str, + reason: str | None = None, + restore: bool = False, + bank_id: str | None = None, + ) -> str: + f"""{_INVALIDATE_DOC} + Args: + memory_id: The ID of the memory unit to retire (or restore). + reason: Optional free-text reason recorded when invalidating. + restore: Set True to restore a previously invalidated fact. + bank_id: Optional bank (defaults to session bank). Use for cross-bank operations. + """ + try: + target_bank = bank_id or config.bank_id_resolver() + if target_bank is None: + return '{"error": "No bank_id configured"}' + + result = await memory.update_memory_unit( + target_bank, + memory_id, + state="valid" if restore else "invalidated", + reason=reason, + request_context=_get_request_context(config), + ) + if result is None: + return json.dumps({"error": f"Memory '{memory_id}' not found"}) + return json.dumps(result, indent=2, default=str) + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return json.dumps({"error": str(e)}) + except ValueError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + logger.error(f"Error invalidating memory: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool() + async def invalidate_memory( + memory_id: str, + reason: str | None = None, + restore: bool = False, + ) -> dict: + f"""{_INVALIDATE_DOC} + Args: + memory_id: The ID of the memory unit to retire (or restore). + reason: Optional free-text reason recorded when invalidating. + restore: Set True to restore a previously invalidated fact. + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.update_memory_unit( + target_bank, + memory_id, + state="valid" if restore else "invalidated", + reason=reason, + request_context=_get_request_context(config), + ) + if result is None: + return {"error": f"Memory '{memory_id}' not found"} + return result + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return {"error": str(e)} + except ValueError as e: + return {"error": str(e)} + except Exception as e: + logger.error(f"Error invalidating memory: {e}", exc_info=True) + return {"error": str(e)} + + # ========================================================================= # DOCUMENT TOOLS # ========================================================================= diff --git a/hindsight-api-slim/tests/test_curation_http.py b/hindsight-api-slim/tests/test_curation_http.py new file mode 100644 index 0000000000..429d9ee76b --- /dev/null +++ b/hindsight-api-slim/tests/test_curation_http.py @@ -0,0 +1,103 @@ +"""HTTP integration tests for memory curation endpoints. + +Exercises the FastAPI PATCH /memories/{id} route end-to-end over an ASGI +transport, covering the happy path, validation, and not-found mapping. The +deeper cascade behaviour is covered at the engine level in +test_memory_curation.py. +""" + +import uuid + +import httpx +import pytest +import pytest_asyncio + +from hindsight_api import RequestContext +from hindsight_api.api import create_app +from hindsight_api.engine.memory_engine import MemoryEngine +from hindsight_api.engine.retain import embedding_processing + + +@pytest_asyncio.fixture +async def api_client(memory): + 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 + + +async def _insert_fact(memory: MemoryEngine, bank_id: str, text: str) -> str: + """Insert one world fact with a real embedding; returns its id.""" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text]) + mem_id = uuid.uuid4() + pool = await memory._get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at) + VALUES ($1, $2, $3, 'world', $4::vector, NOW(), NOW(), NOW(), NOW()) + """, + mem_id, + bank_id, + text, + str(emb[0]), + ) + return str(mem_id) + + +@pytest.mark.asyncio +async def test_patch_invalidate_and_revert_over_http(api_client, memory): + bank_id = f"curation-http-{uuid.uuid4().hex[:8]}" + mem_id = await _insert_fact(memory, bank_id, "srv-04 runs PostgreSQL 14.") + + # Invalidate via PATCH + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{mem_id}", + json={"state": "invalidated", "reason": "decommissioned"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["state"] == "invalidated" + assert body["invalidation_reason"] == "decommissioned" + + # GET reflects the new state + resp = await api_client.get(f"/v1/default/banks/{bank_id}/memories/{mem_id}") + assert resp.status_code == 200 + assert resp.json()["state"] == "invalidated" + + # Revert via PATCH + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{mem_id}", + json={"state": "valid"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["state"] == "valid" + assert resp.json()["invalidation_reason"] is None + + await memory.delete_bank(bank_id, request_context=RequestContext()) + + +@pytest.mark.asyncio +async def test_patch_not_found_returns_404(api_client, memory): + bank_id = f"curation-http-404-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{uuid.uuid4()}", + json={"state": "invalidated"}, + ) + assert resp.status_code == 404 + await memory.delete_bank(bank_id, request_context=RequestContext()) + + +@pytest.mark.asyncio +async def test_patch_empty_body_is_rejected(api_client, memory): + bank_id = f"curation-http-422-{uuid.uuid4().hex[:8]}" + mem_id = await _insert_fact(memory, bank_id, "A fact.") + # Neither text nor state → request model validation rejects it. + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{mem_id}", + json={}, + ) + assert resp.status_code == 422 + await memory.delete_bank(bank_id, request_context=RequestContext()) diff --git a/hindsight-api-slim/tests/test_mcp_tools.py b/hindsight-api-slim/tests/test_mcp_tools.py index 05023606c3..f6d8b2dc67 100644 --- a/hindsight-api-slim/tests/test_mcp_tools.py +++ b/hindsight-api-slim/tests/test_mcp_tools.py @@ -174,6 +174,7 @@ async def _get_mental_model(**kwargs): # Memory browsing methods memory.list_memory_units = AsyncMock(return_value={"items": [{"id": "mem-1", "content": "Test"}], "total": 1}) memory.get_memory_unit = AsyncMock(return_value={"id": "mem-1", "content": "Test memory"}) + memory.update_memory_unit = AsyncMock(return_value={"id": "mem-1", "state": "valid"}) # Document methods memory.list_documents = AsyncMock(return_value={"items": [{"id": "doc-1", "name": "Test Doc"}], "total": 1}) @@ -361,7 +362,9 @@ def test_mental_model_tools_in_default_set(self): assert "clear_memories" in tools assert "sync_retain" in tools assert "clear_mental_model" in tools - assert len(tools) == 30 + assert "update_memory" in tools + assert "invalidate_memory" in tools + assert len(tools) == 32 @pytest.fixture @@ -1245,6 +1248,31 @@ async def test_get_memory_invalid_uuid_single_bank(self, mock_memory): result = await _tools(mcp)["get_memory"].fn(memory_id="bad") assert "not a valid UUID" in result["error"] + async def test_update_memory_edits_fields(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"update_memory"}, include_bank_id=True) + await _tools(mcp)["update_memory"].fn( + memory_id="mem-1", text="corrected", fact_type="experience", entities=["Alice"] + ) + call_kwargs = mock_memory.update_memory_unit.call_args.kwargs + assert call_kwargs["text"] == "corrected" + assert call_kwargs["new_fact_type"] == "experience" + assert call_kwargs["entities"] == ["Alice"] + # update_memory does not change state — that's invalidate_memory's job. + assert "state" not in call_kwargs + + async def test_invalidate_memory(self, mock_memory): + mock_memory.update_memory_unit.return_value = {"id": "mem-1", "state": "invalidated"} + mcp = _make_mcp_server(mock_memory, {"invalidate_memory"}, include_bank_id=True) + await _tools(mcp)["invalidate_memory"].fn(memory_id="mem-1", reason="stale") + call_kwargs = mock_memory.update_memory_unit.call_args.kwargs + assert call_kwargs["state"] == "invalidated" + assert call_kwargs["reason"] == "stale" + + async def test_invalidate_memory_restore(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"invalidate_memory"}, include_bank_id=True) + await _tools(mcp)["invalidate_memory"].fn(memory_id="mem-1", restore=True) + assert mock_memory.update_memory_unit.call_args.kwargs["state"] == "valid" + async def test_list_memories_single_bank(self, mock_memory): mcp = _make_mcp_server(mock_memory, {"list_memories"}, include_bank_id=False) result = await _tools(mcp)["list_memories"].fn() diff --git a/hindsight-api-slim/tests/test_memory_curation.py b/hindsight-api-slim/tests/test_memory_curation.py new file mode 100644 index 0000000000..fd37a47891 --- /dev/null +++ b/hindsight-api-slim/tests/test_memory_curation.py @@ -0,0 +1,533 @@ +"""Tests for memory curation: edit / invalidate / revert. + +Invalidation MOVES a fact out of ``memory_units`` into the +``invalidated_memory_units`` archive, so the recall hot-path never sees it. +These tests cover the move semantics, lossless revert (incl. entity +associations), edit, the guards, listing, and recall exclusion. +""" + +import uuid +from unittest.mock import AsyncMock, patch + +import pytest + +from hindsight_api import RequestContext +from hindsight_api.engine.memory_engine import MemoryEngine +from hindsight_api.engine.retain import embedding_processing + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _insert_memory( + conn, memory: MemoryEngine, bank_id: str, text: str, fact_type: str = "experience" +) -> uuid.UUID: + """Insert a live memory unit with a real embedding, bypassing the LLM pipeline.""" + mem_id = uuid.uuid4() + emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text]) + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at) + VALUES ($1, $2, $3, $4, $5::vector, NOW(), NOW(), NOW(), NOW()) + """, + mem_id, + bank_id, + text, + fact_type, + str(emb[0]), + ) + return mem_id + + +async def _insert_observation(conn, bank_id: str, text: str, source_memory_ids: list[uuid.UUID]) -> uuid.UUID: + obs_id = uuid.uuid4() + await conn.execute( + """ + INSERT INTO memory_units ( + id, bank_id, text, fact_type, event_date, source_memory_ids, proof_count, created_at, updated_at + ) VALUES ($1, $2, $3, 'observation', NOW(), $4, $5, NOW(), NOW()) + """, + obs_id, + bank_id, + text, + source_memory_ids, + len(source_memory_ids), + ) + return obs_id + + +async def _insert_link(conn, bank_id: str, from_id: uuid.UUID, to_id: uuid.UUID) -> None: + await conn.execute( + """ + INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, bank_id) + VALUES ($1, $2, 'temporal', 0.5, $3) + """, + from_id, + to_id, + bank_id, + ) + + +async def _insert_entity(conn, bank_id: str, name: str) -> uuid.UUID: + eid = uuid.uuid4() + await conn.execute( + "INSERT INTO entities (id, bank_id, canonical_name) VALUES ($1, $2, $3)", + eid, + bank_id, + name, + ) + return eid + + +async def _link_entity(conn, unit_id: uuid.UUID, entity_id: uuid.UUID) -> None: + await conn.execute( + "INSERT INTO unit_entities (unit_id, entity_id) VALUES ($1, $2)", + unit_id, + entity_id, + ) + + +async def _in_live(conn, mem_id: uuid.UUID) -> bool: + return bool(await conn.fetchval("SELECT 1 FROM memory_units WHERE id = $1", mem_id)) + + +async def _archive_row(conn, mem_id: uuid.UUID) -> dict | None: + row = await conn.fetchrow( + "SELECT text, embedding, invalidation_reason, invalidated_at, entity_ids " + "FROM invalidated_memory_units WHERE id = $1", + mem_id, + ) + return dict(row) if row else None + + +async def _link_count(conn, mem_id: uuid.UUID) -> int: + return await conn.fetchval( + "SELECT COUNT(*) FROM memory_links WHERE from_unit_id = $1 OR to_unit_id = $1", + mem_id, + ) + + +async def _entity_ids_for(conn, unit_id: uuid.UUID) -> list[uuid.UUID]: + rows = await conn.fetch("SELECT entity_id FROM unit_entities WHERE unit_id = $1", unit_id) + return [r["entity_id"] for r in rows] + + +async def _obs_ids(conn, bank_id: str) -> list[str]: + rows = await conn.fetch( + "SELECT id FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'", + bank_id, + ) + return [str(r["id"]) for r in rows] + + +async def _consolidated_at(conn, mem_id: uuid.UUID): + return await conn.fetchval("SELECT consolidated_at FROM memory_units WHERE id = $1", mem_id) + + +async def _ensure_bank(memory: MemoryEngine, bank_id: str, request_context: RequestContext) -> None: + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + +# --------------------------------------------------------------------------- +# Invalidate / revert (table move) +# --------------------------------------------------------------------------- + + +class TestInvalidate: + @pytest.mark.asyncio + async def test_invalidate_moves_to_archive_and_prunes(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-inv-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "The deploy server srv-04 runs PostgreSQL 14.") + m2 = await _insert_memory(conn, memory, bank_id, "srv-04 is in the eu-west datacenter.") + obs_id = await _insert_observation(conn, bank_id, "srv-04 runs PG14 in eu-west.", [m1, m2]) + await _insert_link(conn, bank_id, m1, m2) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit( + bank_id, str(m1), state="invalidated", reason="decommissioned", request_context=request_context + ) + + assert result is not None + assert result["state"] == "invalidated" + assert result["invalidation_reason"] == "decommissioned" + assert result["invalidated_at"] is not None + + async with pool.acquire() as conn: + assert not await _in_live(conn, m1), "invalidated row must leave memory_units" + arch = await _archive_row(conn, m1) + assert arch is not None, "row must be in the archive" + assert arch["invalidation_reason"] == "decommissioned" + assert arch["embedding"] is not None, "embedding travels with the archived row" + assert await _link_count(conn, m1) == 0, "links cascade-pruned on move" + assert str(obs_id) not in await _obs_ids(conn, bank_id), "derived observation removed" + assert await _consolidated_at(conn, m2) is None, "surviving source reset for re-consolidation" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_revert_moves_back_and_restores_entities(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-rev-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Alice prefers tea over coffee.") + e1 = await _insert_entity(conn, bank_id, "Alice") + await _link_entity(conn, m1, e1) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit(bank_id, str(m1), state="invalidated", request_context=request_context) + async with pool.acquire() as conn: + assert not await _in_live(conn, m1) + arch = await _archive_row(conn, m1) + assert arch is not None and e1 in (arch["entity_ids"] or []), "entity ids snapshotted on invalidate" + assert await _entity_ids_for(conn, m1) == [], "unit_entities cascade-pruned on move" + + result = await memory.update_memory_unit(bank_id, str(m1), state="valid", request_context=request_context) + + assert result["state"] == "valid" + assert result["invalidation_reason"] is None + async with pool.acquire() as conn: + assert await _in_live(conn, m1), "reverted row back in memory_units" + assert await _archive_row(conn, m1) is None, "archive row removed on revert" + assert await _consolidated_at(conn, m1) is None, "reverted memory re-consolidates" + assert e1 in await _entity_ids_for(conn, m1), "entity associations restored on revert" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_invalidate_idempotent_updates_reason(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-idem-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Bob works at Google.") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit( + bank_id, str(m1), state="invalidated", reason="first", request_context=request_context + ) + result = await memory.update_memory_unit( + bank_id, str(m1), state="invalidated", reason="second", request_context=request_context + ) + + assert result["state"] == "invalidated" + assert result["invalidation_reason"] == "second" + async with pool.acquire() as conn: + assert not await _in_live(conn, m1) + assert (await _archive_row(conn, m1))["invalidation_reason"] == "second" + await memory.delete_bank(bank_id, request_context=request_context) + + +# --------------------------------------------------------------------------- +# Edit +# --------------------------------------------------------------------------- + + +class TestEdit: + @pytest.mark.asyncio + async def test_edit_changes_text_and_rederives(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-edit-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "The assistant visited Paris in 2023.") + obs_id = await _insert_observation(conn, bank_id, "The assistant went to Paris.", [m1]) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit( + bank_id, + str(m1), + text="The user visited Paris in 2023.", + reason="wrong subject", + request_context=request_context, + ) + + assert result["text"] == "The user visited Paris in 2023." + assert result["state"] == "valid" + async with pool.acquire() as conn: + assert await _in_live(conn, m1), "edited row stays live" + row = dict(await conn.fetchrow("SELECT text, consolidated_at FROM memory_units WHERE id = $1", m1)) + assert row["text"] == "The user visited Paris in 2023." + assert row["consolidated_at"] is None, "edited memory re-consolidates" + assert str(obs_id) not in await _obs_ids(conn, bank_id), "stale observation re-derived" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_edit_fields_dates_facttype_context(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editfields-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "A world fact.", fact_type="world") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit( + bank_id, + str(m1), + context="from a chat", + occurred_start="2023-06-01", + new_fact_type="experience", + request_context=request_context, + ) + + assert result["type"] == "experience" + assert result["context"] == "from a chat" + assert result["occurred_start"] is not None and result["occurred_start"].startswith("2023-06-01") + assert result["edited_at"] is not None, "edit records edited_at (user-modified marker)" + async with pool.acquire() as conn: + row = dict( + await conn.fetchrow( + "SELECT fact_type, context, occurred_start, event_date FROM memory_units WHERE id = $1", m1 + ) + ) + assert row["fact_type"] == "experience" + assert row["context"] == "from a chat" + assert row["occurred_start"].date().isoformat() == "2023-06-01" + assert row["event_date"].date().isoformat() == "2023-06-01", "event_date tracks occurred_start" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_edit_replaces_entities(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editent-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Alice met Bob in Paris.") + # Pre-link a wrong entity the LLM extracted. + wrong = await _insert_entity(conn, bank_id, "Carol") + await _link_entity(conn, m1, wrong) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + # Correct the entity set: drop Carol, attach Alice + Bob. + result = await memory.update_memory_unit( + bank_id, + str(m1), + entities=["Alice", "Bob"], + request_context=request_context, + ) + + assert result is not None + assert set(result["entities"]) == {"Alice", "Bob"} + assert result["edited_at"] is not None, "entity edit records the user-modified marker" + async with pool.acquire() as conn: + names = await conn.fetch( + "SELECT e.canonical_name FROM unit_entities ue " + "JOIN entities e ON e.id = ue.entity_id WHERE ue.unit_id = $1", + m1, + ) + assert {r["canonical_name"] for r in names} == {"Alice", "Bob"}, "unit_entities rebuilt" + assert wrong not in await _entity_ids_for(conn, m1), "wrong entity detached" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_edit_empty_entities_detaches_all(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editent0-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "A fact with a spurious entity.") + e = await _insert_entity(conn, bank_id, "Spurious") + await _link_entity(conn, m1, e) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit(bank_id, str(m1), entities=[], request_context=request_context) + + assert result["entities"] == [] + async with pool.acquire() as conn: + assert await _entity_ids_for(conn, m1) == [], "empty list detaches all entities" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_cannot_edit_invalidated_memory(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editinv-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Stale fact.") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit(bank_id, str(m1), state="invalidated", request_context=request_context) + with pytest.raises(ValueError, match="revert"): + await memory.update_memory_unit(bank_id, str(m1), text="corrected", request_context=request_context) + + await memory.delete_bank(bank_id, request_context=request_context) + + +# --------------------------------------------------------------------------- +# Guards / listing / recall +# --------------------------------------------------------------------------- + + +class TestGuardsAndListing: + @pytest.mark.asyncio + async def test_cannot_curate_observation(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-obs-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "source fact") + obs_id = await _insert_observation(conn, bank_id, "a synthesized observation", [m1]) + + with pytest.raises(ValueError, match="observation"): + await memory.update_memory_unit(bank_id, str(obs_id), state="invalidated", request_context=request_context) + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_not_found_returns_none(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-404-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + result = await memory.update_memory_unit( + bank_id, str(uuid.uuid4()), state="invalidated", request_context=request_context + ) + assert result is None + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_list_filters_by_state(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-list-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + keep = await _insert_memory(conn, memory, bank_id, "Valid fact one.") + m2 = await _insert_memory(conn, memory, bank_id, "Fact to retire.") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit( + bank_id, str(m2), state="invalidated", reason="dup", request_context=request_context + ) + + # Default lists live facts only. + live = (await memory.list_memory_units(bank_id, request_context=request_context))["items"] + live_ids = {i["id"] for i in live} + assert str(keep) in live_ids and str(m2) not in live_ids + assert all(i["state"] == "valid" for i in live) + + # state=invalidated reads the archive. + invalid = (await memory.list_memory_units(bank_id, state="invalidated", request_context=request_context))[ + "items" + ] + assert len(invalid) == 1 + assert invalid[0]["id"] == str(m2) + assert invalid[0]["state"] == "invalidated" + assert invalid[0]["invalidation_reason"] == "dup" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_list_filters_by_document(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-doc-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + + pool = await memory._get_pool() + async with pool.acquire() as conn: + await conn.execute("INSERT INTO documents (id, bank_id) VALUES ($1, $2)", doc_id, bank_id) + m_doc = await _insert_memory(conn, memory, bank_id, "Fact from the document.") + await _insert_memory(conn, memory, bank_id, "Fact from elsewhere.") + await conn.execute("UPDATE memory_units SET document_id = $1 WHERE id = $2", doc_id, m_doc) + + # Live listing scoped to the document returns only its fact. + live = (await memory.list_memory_units(bank_id, document_id=doc_id, request_context=request_context))["items"] + assert {i["id"] for i in live} == {str(m_doc)} + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit(bank_id, str(m_doc), state="invalidated", request_context=request_context) + + # Invalidated archive is filterable by document too (carries document_id). + scoped = ( + await memory.list_memory_units( + bank_id, state="invalidated", document_id=doc_id, request_context=request_context + ) + )["items"] + assert {i["id"] for i in scoped} == {str(m_doc)} + other = ( + await memory.list_memory_units( + bank_id, state="invalidated", document_id="nope", request_context=request_context + ) + )["items"] + assert other == [] + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_recall_excludes_invalidated(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-recall-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + unit_ids = await memory.retain_async( + bank_id, + "The Anaconda XR7 telescope has a 9000mm focal length.", + request_context=request_context, + ) + assert unit_ids, "retain should produce at least one memory unit" + + def _hit(res) -> bool: + return any("anaconda" in f.text.lower() or "telescope" in f.text.lower() for f in res.results) + + before = await memory.recall_async( + bank_id, "Anaconda XR7 telescope focal length", request_context=request_context + ) + assert _hit(before), "fact should be recalled before invalidation" + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + for uid in unit_ids: + await memory.update_memory_unit(bank_id, uid, state="invalidated", request_context=request_context) + + after = await memory.recall_async( + bank_id, "Anaconda XR7 telescope focal length", request_context=request_context + ) + assert not _hit(after), "invalidated fact must be excluded from recall" + + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-cli/.openapi-coverage.toml b/hindsight-cli/.openapi-coverage.toml index 8c81379cef..3fdfc2bf80 100644 --- a/hindsight-cli/.openapi-coverage.toml +++ b/hindsight-cli/.openapi-coverage.toml @@ -42,6 +42,10 @@ reprocess_document = "UI-only endpoint for the control plane document detail dia # Clear mental model content is a new endpoint; CLI subcommand not yet implemented. clear_mental_model = "Not yet exposed in the CLI; use the HTTP API or SDK" +# Memory curation (edit / invalidate / revert) is exposed via the HTTP API, SDKs, +# and the control plane, not the end-user CLI. update_memory covers all three. +update_memory = "Curation endpoint; exposed via the API, SDKs, and control plane, not the CLI" + # UI-only endpoints powering the control-plane LLM Requests (per-bank tracing) tab. # The trace waterfall, token charts, and metadata viewers don't map to a useful # CLI command. diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index ea0797c20a..eaa4e0e54f 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -520,7 +520,17 @@ impl ApiClient { self.runtime.block_on(async { let response = self .client - .list_memories(bank_id, None, limit, offset, q, type_filter, None) + .list_memories( + bank_id, + None, // consolidation_state + None, // document_id + limit, + offset, + q, + None, // state + type_filter, + None, // authorization + ) .await?; Ok(response.into_inner()) }) diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 66cd7f00ae..9578eaa604 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -190,6 +190,22 @@ paths: nullable: true type: string style: form + - explode: true + in: query + name: state + required: false + schema: + nullable: true + type: string + style: form + - explode: true + in: query + name: document_id + required: false + schema: + nullable: true + type: string + style: form - explode: true in: query name: limit @@ -278,6 +294,58 @@ paths: summary: Get memory unit tags: - Memory + patch: + description: "Edit a memory's text and/or change its curation state (invalidate\ + \ / revert). Invalidated memories are excluded from recall, consolidation,\ + \ and graph maintenance but kept for audit (reversible). Only world/experience\ + \ facts can be curated; observations are derived." + operationId: update_memory + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: path + name: memory_id + required: true + schema: + title: Memory Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMemoryRequest' + required: true + responses: + "200": + content: + application/json: + schema: {} + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Curate memory unit + tags: + - Memory /v1/default/banks/{bank_id}/memories/{memory_id}/history: get: description: "Get the full history of an observation, with each change's source\ @@ -7577,6 +7645,45 @@ components: title: Success type: boolean title: UpdateDocumentResponse + UpdateMemoryRequest: + description: |- + Request model for curating a single memory unit (edit / invalidate / revert). + + Provide ``text`` to correct the fact, and/or ``state`` to invalidate + ('invalidated') or revert ('valid') it. ``reason`` is optional free text + recorded on the memory. At least one of ``text`` or ``state`` must be set. + Only world/experience facts can be curated; observations are derived. + example: + reason: "superseded: server decommissioned 2026-06-01" + state: invalidated + properties: + text: + nullable: true + type: string + context: + nullable: true + type: string + occurred_start: + nullable: true + type: string + occurred_end: + nullable: true + type: string + fact_type: + nullable: true + type: string + entities: + items: + type: string + nullable: true + type: array + state: + nullable: true + type: string + reason: + nullable: true + type: string + title: UpdateMemoryRequest UpdateMentalModelRequest: description: Request model for updating a mental model. example: diff --git a/hindsight-clients/go/api_memory.go b/hindsight-clients/go/api_memory.go index 087fd0ea68..66bdd51c79 100644 --- a/hindsight-clients/go/api_memory.go +++ b/hindsight-clients/go/api_memory.go @@ -740,6 +740,8 @@ type ApiListMemoriesRequest struct { type_ *string q *string consolidationState *string + state *string + documentId *string limit *int32 offset *int32 authorization *string @@ -760,6 +762,16 @@ func (r ApiListMemoriesRequest) ConsolidationState(consolidationState string) Ap return r } +func (r ApiListMemoriesRequest) State(state string) ApiListMemoriesRequest { + r.state = &state + return r +} + +func (r ApiListMemoriesRequest) DocumentId(documentId string) ApiListMemoriesRequest { + r.documentId = &documentId + return r +} + func (r ApiListMemoriesRequest) Limit(limit int32) ApiListMemoriesRequest { r.limit = &limit return r @@ -827,6 +839,12 @@ func (a *MemoryAPIService) ListMemoriesExecute(r ApiListMemoriesRequest) (*ListM if r.consolidationState != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "consolidation_state", r.consolidationState, "form", "") } + if r.state != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "state", r.state, "form", "") + } + if r.documentId != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "document_id", r.documentId, "form", "") + } if r.limit != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "") } else { @@ -1509,3 +1527,140 @@ func (a *MemoryAPIService) RetainMemoriesExecute(r ApiRetainMemoriesRequest) (*R return localVarReturnValue, localVarHTTPResponse, nil } + +type ApiUpdateMemoryRequest struct { + ctx context.Context + ApiService *MemoryAPIService + bankId string + memoryId string + updateMemoryRequest *UpdateMemoryRequest + authorization *string +} + +func (r ApiUpdateMemoryRequest) UpdateMemoryRequest(updateMemoryRequest UpdateMemoryRequest) ApiUpdateMemoryRequest { + r.updateMemoryRequest = &updateMemoryRequest + return r +} + +func (r ApiUpdateMemoryRequest) Authorization(authorization string) ApiUpdateMemoryRequest { + r.authorization = &authorization + return r +} + +func (r ApiUpdateMemoryRequest) Execute() (interface{}, *http.Response, error) { + return r.ApiService.UpdateMemoryExecute(r) +} + +/* +UpdateMemory Curate memory unit + +Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @param memoryId + @return ApiUpdateMemoryRequest +*/ +func (a *MemoryAPIService) UpdateMemory(ctx context.Context, bankId string, memoryId string) ApiUpdateMemoryRequest { + return ApiUpdateMemoryRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + memoryId: memoryId, + } +} + +// Execute executes the request +// @return interface{} +func (a *MemoryAPIService) UpdateMemoryExecute(r ApiUpdateMemoryRequest) (interface{}, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue interface{} + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.UpdateMemory") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/memories/{memory_id}" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"memory_id"+"}", url.PathEscape(parameterValueToString(r.memoryId, "memoryId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.updateMemoryRequest == nil { + return localVarReturnValue, nil, reportError("updateMemoryRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + // body params + localVarPostBody = r.updateMemoryRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/hindsight-clients/go/model_update_memory_request.go b/hindsight-clients/go/model_update_memory_request.go new file mode 100644 index 0000000000..890cd39ef8 --- /dev/null +++ b/hindsight-clients/go/model_update_memory_request.go @@ -0,0 +1,449 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.1 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" +) + +// checks if the UpdateMemoryRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UpdateMemoryRequest{} + +// UpdateMemoryRequest Request model for curating a single memory unit (edit / invalidate / revert). Provide ``text`` to correct the fact, and/or ``state`` to invalidate ('invalidated') or revert ('valid') it. ``reason`` is optional free text recorded on the memory. At least one of ``text`` or ``state`` must be set. Only world/experience facts can be curated; observations are derived. +type UpdateMemoryRequest struct { + Text NullableString `json:"text,omitempty"` + Context NullableString `json:"context,omitempty"` + OccurredStart NullableString `json:"occurred_start,omitempty"` + OccurredEnd NullableString `json:"occurred_end,omitempty"` + FactType NullableString `json:"fact_type,omitempty"` + Entities []string `json:"entities,omitempty"` + State NullableString `json:"state,omitempty"` + Reason NullableString `json:"reason,omitempty"` +} + +// NewUpdateMemoryRequest instantiates a new UpdateMemoryRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUpdateMemoryRequest() *UpdateMemoryRequest { + this := UpdateMemoryRequest{} + return &this +} + +// NewUpdateMemoryRequestWithDefaults instantiates a new UpdateMemoryRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUpdateMemoryRequestWithDefaults() *UpdateMemoryRequest { + this := UpdateMemoryRequest{} + return &this +} + +// GetText returns the Text field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetText() string { + if o == nil || IsNil(o.Text.Get()) { + var ret string + return ret + } + return *o.Text.Get() +} + +// GetTextOk returns a tuple with the Text field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetTextOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Text.Get(), o.Text.IsSet() +} + +// HasText returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasText() bool { + if o != nil && o.Text.IsSet() { + return true + } + + return false +} + +// SetText gets a reference to the given NullableString and assigns it to the Text field. +func (o *UpdateMemoryRequest) SetText(v string) { + o.Text.Set(&v) +} +// SetTextNil sets the value for Text to be an explicit nil +func (o *UpdateMemoryRequest) SetTextNil() { + o.Text.Set(nil) +} + +// UnsetText ensures that no value is present for Text, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetText() { + o.Text.Unset() +} + +// GetContext returns the Context field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetContext() string { + if o == nil || IsNil(o.Context.Get()) { + var ret string + return ret + } + return *o.Context.Get() +} + +// GetContextOk returns a tuple with the Context field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetContextOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Context.Get(), o.Context.IsSet() +} + +// HasContext returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasContext() bool { + if o != nil && o.Context.IsSet() { + return true + } + + return false +} + +// SetContext gets a reference to the given NullableString and assigns it to the Context field. +func (o *UpdateMemoryRequest) SetContext(v string) { + o.Context.Set(&v) +} +// SetContextNil sets the value for Context to be an explicit nil +func (o *UpdateMemoryRequest) SetContextNil() { + o.Context.Set(nil) +} + +// UnsetContext ensures that no value is present for Context, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetContext() { + o.Context.Unset() +} + +// GetOccurredStart returns the OccurredStart field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetOccurredStart() string { + if o == nil || IsNil(o.OccurredStart.Get()) { + var ret string + return ret + } + return *o.OccurredStart.Get() +} + +// GetOccurredStartOk returns a tuple with the OccurredStart field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetOccurredStartOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.OccurredStart.Get(), o.OccurredStart.IsSet() +} + +// HasOccurredStart returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasOccurredStart() bool { + if o != nil && o.OccurredStart.IsSet() { + return true + } + + return false +} + +// SetOccurredStart gets a reference to the given NullableString and assigns it to the OccurredStart field. +func (o *UpdateMemoryRequest) SetOccurredStart(v string) { + o.OccurredStart.Set(&v) +} +// SetOccurredStartNil sets the value for OccurredStart to be an explicit nil +func (o *UpdateMemoryRequest) SetOccurredStartNil() { + o.OccurredStart.Set(nil) +} + +// UnsetOccurredStart ensures that no value is present for OccurredStart, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetOccurredStart() { + o.OccurredStart.Unset() +} + +// GetOccurredEnd returns the OccurredEnd field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetOccurredEnd() string { + if o == nil || IsNil(o.OccurredEnd.Get()) { + var ret string + return ret + } + return *o.OccurredEnd.Get() +} + +// GetOccurredEndOk returns a tuple with the OccurredEnd field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetOccurredEndOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.OccurredEnd.Get(), o.OccurredEnd.IsSet() +} + +// HasOccurredEnd returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasOccurredEnd() bool { + if o != nil && o.OccurredEnd.IsSet() { + return true + } + + return false +} + +// SetOccurredEnd gets a reference to the given NullableString and assigns it to the OccurredEnd field. +func (o *UpdateMemoryRequest) SetOccurredEnd(v string) { + o.OccurredEnd.Set(&v) +} +// SetOccurredEndNil sets the value for OccurredEnd to be an explicit nil +func (o *UpdateMemoryRequest) SetOccurredEndNil() { + o.OccurredEnd.Set(nil) +} + +// UnsetOccurredEnd ensures that no value is present for OccurredEnd, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetOccurredEnd() { + o.OccurredEnd.Unset() +} + +// GetFactType returns the FactType field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetFactType() string { + if o == nil || IsNil(o.FactType.Get()) { + var ret string + return ret + } + return *o.FactType.Get() +} + +// GetFactTypeOk returns a tuple with the FactType field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetFactTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.FactType.Get(), o.FactType.IsSet() +} + +// HasFactType returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasFactType() bool { + if o != nil && o.FactType.IsSet() { + return true + } + + return false +} + +// SetFactType gets a reference to the given NullableString and assigns it to the FactType field. +func (o *UpdateMemoryRequest) SetFactType(v string) { + o.FactType.Set(&v) +} +// SetFactTypeNil sets the value for FactType to be an explicit nil +func (o *UpdateMemoryRequest) SetFactTypeNil() { + o.FactType.Set(nil) +} + +// UnsetFactType ensures that no value is present for FactType, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetFactType() { + o.FactType.Unset() +} + +// GetEntities returns the Entities field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetEntities() []string { + if o == nil { + var ret []string + return ret + } + return o.Entities +} + +// GetEntitiesOk returns a tuple with the Entities field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetEntitiesOk() ([]string, bool) { + if o == nil || IsNil(o.Entities) { + return nil, false + } + return o.Entities, true +} + +// HasEntities returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasEntities() bool { + if o != nil && !IsNil(o.Entities) { + return true + } + + return false +} + +// SetEntities gets a reference to the given []string and assigns it to the Entities field. +func (o *UpdateMemoryRequest) SetEntities(v []string) { + o.Entities = v +} + +// GetState returns the State field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetState() string { + if o == nil || IsNil(o.State.Get()) { + var ret string + return ret + } + return *o.State.Get() +} + +// GetStateOk returns a tuple with the State field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetStateOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.State.Get(), o.State.IsSet() +} + +// HasState returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasState() bool { + if o != nil && o.State.IsSet() { + return true + } + + return false +} + +// SetState gets a reference to the given NullableString and assigns it to the State field. +func (o *UpdateMemoryRequest) SetState(v string) { + o.State.Set(&v) +} +// SetStateNil sets the value for State to be an explicit nil +func (o *UpdateMemoryRequest) SetStateNil() { + o.State.Set(nil) +} + +// UnsetState ensures that no value is present for State, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetState() { + o.State.Unset() +} + +// GetReason returns the Reason field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetReason() string { + if o == nil || IsNil(o.Reason.Get()) { + var ret string + return ret + } + return *o.Reason.Get() +} + +// GetReasonOk returns a tuple with the Reason field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetReasonOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Reason.Get(), o.Reason.IsSet() +} + +// HasReason returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasReason() bool { + if o != nil && o.Reason.IsSet() { + return true + } + + return false +} + +// SetReason gets a reference to the given NullableString and assigns it to the Reason field. +func (o *UpdateMemoryRequest) SetReason(v string) { + o.Reason.Set(&v) +} +// SetReasonNil sets the value for Reason to be an explicit nil +func (o *UpdateMemoryRequest) SetReasonNil() { + o.Reason.Set(nil) +} + +// UnsetReason ensures that no value is present for Reason, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetReason() { + o.Reason.Unset() +} + +func (o UpdateMemoryRequest) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UpdateMemoryRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if o.Text.IsSet() { + toSerialize["text"] = o.Text.Get() + } + if o.Context.IsSet() { + toSerialize["context"] = o.Context.Get() + } + if o.OccurredStart.IsSet() { + toSerialize["occurred_start"] = o.OccurredStart.Get() + } + if o.OccurredEnd.IsSet() { + toSerialize["occurred_end"] = o.OccurredEnd.Get() + } + if o.FactType.IsSet() { + toSerialize["fact_type"] = o.FactType.Get() + } + if o.Entities != nil { + toSerialize["entities"] = o.Entities + } + if o.State.IsSet() { + toSerialize["state"] = o.State.Get() + } + if o.Reason.IsSet() { + toSerialize["reason"] = o.Reason.Get() + } + return toSerialize, nil +} + +type NullableUpdateMemoryRequest struct { + value *UpdateMemoryRequest + isSet bool +} + +func (v NullableUpdateMemoryRequest) Get() *UpdateMemoryRequest { + return v.value +} + +func (v *NullableUpdateMemoryRequest) Set(val *UpdateMemoryRequest) { + v.value = val + v.isSet = true +} + +func (v NullableUpdateMemoryRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableUpdateMemoryRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUpdateMemoryRequest(val *UpdateMemoryRequest) *NullableUpdateMemoryRequest { + return &NullableUpdateMemoryRequest{value: val, isSet: true} +} + +func (v NullableUpdateMemoryRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUpdateMemoryRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 40ad3dc2f8..3a6c323302 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -133,6 +133,7 @@ hindsight_client_api/models/update_directive_request.py hindsight_client_api/models/update_disposition_request.py hindsight_client_api/models/update_document_request.py hindsight_client_api/models/update_document_response.py +hindsight_client_api/models/update_memory_request.py hindsight_client_api/models/update_mental_model_request.py hindsight_client_api/models/update_webhook_request.py hindsight_client_api/models/validation_error.py diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 4a1d07f58a..091cb38fef 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -157,6 +157,7 @@ from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest from hindsight_client_api.models.update_document_request import UpdateDocumentRequest from hindsight_client_api.models.update_document_response import UpdateDocumentResponse +from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest from hindsight_client_api.models.validation_error import ValidationError diff --git a/hindsight-clients/python/hindsight_client_api/api/memory_api.py b/hindsight-clients/python/hindsight_client_api/api/memory_api.py index 28907fdde9..3f6201d310 100644 --- a/hindsight-clients/python/hindsight_client_api/api/memory_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/memory_api.py @@ -30,6 +30,7 @@ from hindsight_client_api.models.reflect_response import ReflectResponse from hindsight_client_api.models.retain_request import RetainRequest from hindsight_client_api.models.retain_response import RetainResponse +from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest from hindsight_client_api.api_client import ApiClient, RequestSerialized from hindsight_client_api.api_response import ApiResponse @@ -1628,6 +1629,8 @@ async def list_memories( type: Optional[StrictStr] = None, q: Optional[StrictStr] = None, consolidation_state: Optional[StrictStr] = None, + state: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, limit: Optional[StrictInt] = None, offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, @@ -1656,6 +1659,10 @@ async def list_memories( :type q: str :param consolidation_state: :type consolidation_state: str + :param state: + :type state: str + :param document_id: + :type document_id: str :param limit: :type limit: int :param offset: @@ -1689,6 +1696,8 @@ async def list_memories( type=type, q=q, consolidation_state=consolidation_state, + state=state, + document_id=document_id, limit=limit, offset=offset, authorization=authorization, @@ -1720,6 +1729,8 @@ async def list_memories_with_http_info( type: Optional[StrictStr] = None, q: Optional[StrictStr] = None, consolidation_state: Optional[StrictStr] = None, + state: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, limit: Optional[StrictInt] = None, offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, @@ -1748,6 +1759,10 @@ async def list_memories_with_http_info( :type q: str :param consolidation_state: :type consolidation_state: str + :param state: + :type state: str + :param document_id: + :type document_id: str :param limit: :type limit: int :param offset: @@ -1781,6 +1796,8 @@ async def list_memories_with_http_info( type=type, q=q, consolidation_state=consolidation_state, + state=state, + document_id=document_id, limit=limit, offset=offset, authorization=authorization, @@ -1812,6 +1829,8 @@ async def list_memories_without_preload_content( type: Optional[StrictStr] = None, q: Optional[StrictStr] = None, consolidation_state: Optional[StrictStr] = None, + state: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, limit: Optional[StrictInt] = None, offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, @@ -1840,6 +1859,10 @@ async def list_memories_without_preload_content( :type q: str :param consolidation_state: :type consolidation_state: str + :param state: + :type state: str + :param document_id: + :type document_id: str :param limit: :type limit: int :param offset: @@ -1873,6 +1896,8 @@ async def list_memories_without_preload_content( type=type, q=q, consolidation_state=consolidation_state, + state=state, + document_id=document_id, limit=limit, offset=offset, authorization=authorization, @@ -1899,6 +1924,8 @@ def _list_memories_serialize( type, q, consolidation_state, + state, + document_id, limit, offset, authorization, @@ -1938,6 +1965,14 @@ def _list_memories_serialize( _query_params.append(('consolidation_state', consolidation_state)) + if state is not None: + + _query_params.append(('state', state)) + + if document_id is not None: + + _query_params.append(('document_id', document_id)) + if limit is not None: _query_params.append(('limit', limit)) @@ -3246,3 +3281,324 @@ def _retain_memories_serialize( ) + + + @validate_call + async def update_memory( + self, + bank_id: StrictStr, + memory_id: StrictStr, + update_memory_request: UpdateMemoryRequest, + 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: + """Curate memory unit + + Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. + + :param bank_id: (required) + :type bank_id: str + :param memory_id: (required) + :type memory_id: str + :param update_memory_request: (required) + :type update_memory_request: UpdateMemoryRequest + :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_memory_serialize( + bank_id=bank_id, + memory_id=memory_id, + update_memory_request=update_memory_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': "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 update_memory_with_http_info( + self, + bank_id: StrictStr, + memory_id: StrictStr, + update_memory_request: UpdateMemoryRequest, + 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]: + """Curate memory unit + + Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. + + :param bank_id: (required) + :type bank_id: str + :param memory_id: (required) + :type memory_id: str + :param update_memory_request: (required) + :type update_memory_request: UpdateMemoryRequest + :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_memory_serialize( + bank_id=bank_id, + memory_id=memory_id, + update_memory_request=update_memory_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': "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 update_memory_without_preload_content( + self, + bank_id: StrictStr, + memory_id: StrictStr, + update_memory_request: UpdateMemoryRequest, + 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: + """Curate memory unit + + Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. + + :param bank_id: (required) + :type bank_id: str + :param memory_id: (required) + :type memory_id: str + :param update_memory_request: (required) + :type update_memory_request: UpdateMemoryRequest + :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_memory_serialize( + bank_id=bank_id, + memory_id=memory_id, + update_memory_request=update_memory_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': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _update_memory_serialize( + self, + bank_id, + memory_id, + update_memory_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 memory_id is not None: + _path_params['memory_id'] = memory_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_memory_request is not None: + _body_params = update_memory_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}/memories/{memory_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/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index 0dbd715e41..64fd7ea5c3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -127,6 +127,7 @@ from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest from hindsight_client_api.models.update_document_request import UpdateDocumentRequest from hindsight_client_api.models.update_document_response import UpdateDocumentResponse +from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest from hindsight_client_api.models.validation_error import ValidationError diff --git a/hindsight-clients/python/hindsight_client_api/models/update_memory_request.py b/hindsight-clients/python/hindsight_client_api/models/update_memory_request.py new file mode 100644 index 0000000000..2d892b89bc --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/update_memory_request.py @@ -0,0 +1,141 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.1 + 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, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class UpdateMemoryRequest(BaseModel): + """ + Request model for curating a single memory unit (edit / invalidate / revert). Provide ``text`` to correct the fact, and/or ``state`` to invalidate ('invalidated') or revert ('valid') it. ``reason`` is optional free text recorded on the memory. At least one of ``text`` or ``state`` must be set. Only world/experience facts can be curated; observations are derived. + """ # noqa: E501 + text: Optional[StrictStr] = None + context: Optional[StrictStr] = None + occurred_start: Optional[StrictStr] = None + occurred_end: Optional[StrictStr] = None + fact_type: Optional[StrictStr] = None + entities: Optional[List[StrictStr]] = None + state: Optional[StrictStr] = None + reason: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["text", "context", "occurred_start", "occurred_end", "fact_type", "entities", "state", "reason"] + + 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 UpdateMemoryRequest 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, + ) + # set to None if text (nullable) is None + # and model_fields_set contains the field + if self.text is None and "text" in self.model_fields_set: + _dict['text'] = None + + # set to None if context (nullable) is None + # and model_fields_set contains the field + if self.context is None and "context" in self.model_fields_set: + _dict['context'] = None + + # set to None if occurred_start (nullable) is None + # and model_fields_set contains the field + if self.occurred_start is None and "occurred_start" in self.model_fields_set: + _dict['occurred_start'] = None + + # set to None if occurred_end (nullable) is None + # and model_fields_set contains the field + if self.occurred_end is None and "occurred_end" in self.model_fields_set: + _dict['occurred_end'] = None + + # set to None if fact_type (nullable) is None + # and model_fields_set contains the field + if self.fact_type is None and "fact_type" in self.model_fields_set: + _dict['fact_type'] = None + + # set to None if entities (nullable) is None + # and model_fields_set contains the field + if self.entities is None and "entities" in self.model_fields_set: + _dict['entities'] = None + + # set to None if state (nullable) is None + # and model_fields_set contains the field + if self.state is None and "state" in self.model_fields_set: + _dict['state'] = None + + # set to None if reason (nullable) is None + # and model_fields_set contains the field + if self.reason is None and "reason" in self.model_fields_set: + _dict['reason'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of UpdateMemoryRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "text": obj.get("text"), + "context": obj.get("context"), + "occurred_start": obj.get("occurred_start"), + "occurred_end": obj.get("occurred_end"), + "fact_type": obj.get("fact_type"), + "entities": obj.get("entities"), + "state": obj.get("state"), + "reason": obj.get("reason") + }) + return _obj + + diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index f69dfc0cd1..399e43e168 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -214,6 +214,9 @@ import type { UpdateDocumentData, UpdateDocumentErrors, UpdateDocumentResponses, + UpdateMemoryData, + UpdateMemoryErrors, + UpdateMemoryResponses, UpdateMentalModelData, UpdateMentalModelErrors, UpdateMentalModelResponses, @@ -317,6 +320,23 @@ export const getMemory = ( ...options, }); +/** + * Curate memory unit + * + * Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. + */ +export const updateMemory = ( + options: Options +) => + (options.client ?? client).patch({ + url: "/v1/default/banks/{bank_id}/memories/{memory_id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + /** * Get observation history * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index f482be0b8e..1f3e84f3f6 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -3535,6 +3535,67 @@ export type UpdateDocumentResponse = { success?: boolean; }; +/** + * UpdateMemoryRequest + * + * Request model for curating a single memory unit (edit / invalidate / revert). + * + * Provide ``text`` to correct the fact, and/or ``state`` to invalidate + * ('invalidated') or revert ('valid') it. ``reason`` is optional free text + * recorded on the memory. At least one of ``text`` or ``state`` must be set. + * Only world/experience facts can be curated; observations are derived. + */ +export type UpdateMemoryRequest = { + /** + * Text + * + * New fact text. Re-embeds the memory, drops its derived observations and links, and triggers re-consolidation. + */ + text?: string | null; + /** + * Context + * + * New context for the fact. '' clears it; omit to leave unchanged. + */ + context?: string | null; + /** + * Occurred Start + * + * New occurred-range start (ISO 8601). '' clears it; omit to leave unchanged. + */ + occurred_start?: string | null; + /** + * Occurred End + * + * New occurred-range end (ISO 8601). '' clears it; omit to leave unchanged. + */ + occurred_end?: string | null; + /** + * Fact Type + * + * Reclassify the fact: 'world' or 'experience'. Omit to leave unchanged. + */ + fact_type?: string | null; + /** + * Entities + * + * Replace the fact's entities. Names are resolved/find-or-created the same way retain does; '[]' detaches all entities. Omit to leave unchanged. + */ + entities?: Array | null; + /** + * State + * + * Curation state: 'invalidated' to soft-retire the memory (excluded from recall/consolidation, links and derived observations pruned, moved to the archive) or 'valid' to revert. Reversible. + */ + state?: string | null; + /** + * Reason + * + * Optional free-text reason recorded when invalidating. + */ + reason?: string | null; +}; + /** * UpdateMentalModelRequest * @@ -3956,6 +4017,14 @@ export type ListMemoriesData = { * Consolidation State */ consolidation_state?: string | null; + /** + * State + */ + state?: string | null; + /** + * Document Id + */ + document_id?: string | null; /** * Limit */ @@ -4024,6 +4093,44 @@ export type GetMemoryResponses = { 200: unknown; }; +export type UpdateMemoryData = { + body: UpdateMemoryRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Memory Id + */ + memory_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/memories/{memory_id}"; +}; + +export type UpdateMemoryErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateMemoryError = UpdateMemoryErrors[keyof UpdateMemoryErrors]; + +export type UpdateMemoryResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + export type GetObservationHistoryData = { body?: never; headers?: { diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index 1ab44fe850..818b76d270 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -433,6 +433,8 @@ export class HindsightClient { type?: string; q?: string; consolidationState?: "failed" | "pending" | "done"; + state?: "valid" | "invalidated"; + documentId?: string; signal?: AbortSignal; } ): Promise { @@ -445,6 +447,8 @@ export class HindsightClient { type: options?.type, q: options?.q, consolidation_state: options?.consolidationState, + state: options?.state, + document_id: options?.documentId, }, signal: options?.signal, }); diff --git a/hindsight-control-plane/src/app/api/list/route.ts b/hindsight-control-plane/src/app/api/list/route.ts index 31979dd106..eaf1110ccb 100644 --- a/hindsight-control-plane/src/app/api/list/route.ts +++ b/hindsight-control-plane/src/app/api/list/route.ts @@ -29,6 +29,9 @@ export async function GET(request: NextRequest) { consolidationStateParam === "done" ? consolidationStateParam : undefined; + const stateParam = searchParams.get("state"); + const state = stateParam === "valid" || stateParam === "invalidated" ? stateParam : undefined; + const documentId = searchParams.get("document_id") || undefined; const response = await hindsightClient.listMemories(bankId, { limit, @@ -36,6 +39,8 @@ export async function GET(request: NextRequest) { type, q, consolidationState, + state, + documentId, }); return NextResponse.json(response, { status: 200 }); diff --git a/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts b/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts index 7c78c0473f..a09f0802de 100644 --- a/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts +++ b/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts @@ -55,3 +55,78 @@ export async function GET( ); } } + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ memoryId: string }> } +) { + try { + const { memoryId } = await params; + const body = await request.json(); + const bankId = body.bank_id || request.nextUrl.searchParams.get("bank_id"); + + if (!bankId) { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "bank_id is required", + errorKey: "api.errors.validation.bankIdRequired", + }), + { status: 400 } + ); + } + + // Curation fields only; bank_id is a routing param, not part of the body. + const { text, context, occurred_start, occurred_end, fact_type, entities, state, reason } = + body; + + const response = await fetch( + dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(memoryId)}`), + { + method: "PATCH", + headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ + text, + context, + occurred_start, + occurred_end, + fact_type, + entities, + state, + reason, + }), + } + ); + + if (!response.ok) { + if (response.status === 404) { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Memory not found", + errorKey: "api.errors.memories.notFound", + }), + { status: 404 } + ); + } + const detail = await response.text(); + return NextResponse.json( + localizeApiErrorPayload(request, { + error: detail || `API returned ${response.status}`, + errorKey: "api.errors.memories.update", + }), + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error updating memory:", error); + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Failed to update memory", + errorKey: "api.errors.memories.update", + }), + { status: 500 } + ); + } +} diff --git a/hindsight-control-plane/src/components/bank-config-view.tsx b/hindsight-control-plane/src/components/bank-config-view.tsx index 79de47b39e..9ea1c72480 100644 --- a/hindsight-control-plane/src/components/bank-config-view.tsx +++ b/hindsight-control-plane/src/components/bank-config-view.tsx @@ -172,7 +172,11 @@ function getMcpToolGroups(t: (key: string) => string): McpToolGroup[] { label: t("mcpGroupDirectives"), tools: ["list_directives", "create_directive", "delete_directive"], }, - { key: "memories", label: t("mcpGroupMemories"), tools: ["list_memories", "get_memory"] }, + { + key: "memories", + label: t("mcpGroupMemories"), + tools: ["list_memories", "get_memory", "update_memory", "invalidate_memory"], + }, { key: "documents", label: t("mcpGroupDocuments"), @@ -211,6 +215,8 @@ const MCP_ALL_TOOLS: string[] = [ "delete_directive", "list_memories", "get_memory", + "update_memory", + "invalidate_memory", "list_documents", "get_document", "delete_document", diff --git a/hindsight-control-plane/src/components/data-view.tsx b/hindsight-control-plane/src/components/data-view.tsx index c510345f0d..9cc48d4c51 100644 --- a/hindsight-control-plane/src/components/data-view.tsx +++ b/hindsight-control-plane/src/components/data-view.tsx @@ -79,6 +79,9 @@ export function DataView({ const [currentPage, setCurrentPage] = useState(1); const [selectedGraphNode, setSelectedGraphNode] = useState(null); const [modalMemoryId, setModalMemoryId] = useState(null); + // Table view: toggle between live facts (graph-fed) and invalidated facts (archive). + const [showInvalidated, setShowInvalidated] = useState(false); + const [invalidatedRows, setInvalidatedRows] = useState([]); const itemsPerPage = 100; // Fetch limit state - how many memories to load from the API @@ -161,10 +164,33 @@ export function DataView({ } }; - // Table rows are already filtered server-side + // Invalidated facts live in a separate archive, not the graph — fetch them via list. + const loadInvalidated = useCallback(async () => { + if (!currentBank) return; + try { + const resp: any = await client.listMemories(currentBank, { + state: "invalidated", + type: factType, + limit: fetchLimit, + }); + setInvalidatedRows(resp?.items ?? []); + } catch { + setInvalidatedRows([]); + } + }, [currentBank, factType, fetchLimit]); + + useEffect(() => { + if (showInvalidated && viewMode === "table") { + loadInvalidated(); + } + }, [showInvalidated, viewMode, loadInvalidated]); + + // Table rows: live rows are graph-fed (filtered server-side); invalidated rows + // come from the archive via list. const filteredTableRows = useMemo(() => { + if (showInvalidated) return invalidatedRows; return data?.table_rows ?? []; - }, [data]); + }, [data, showInvalidated, invalidatedRows]); // Helper to get normalized link type const getLinkTypeCategory = (type: string | undefined): string => { @@ -943,6 +969,41 @@ export function DataView({ {!compactMode && viewMode === "table" && (
+ {factType !== "observation" && ( +
+
+ + +
+ {showInvalidated && ( + {t("invalidatedHint")} + )} +
+ )}
{filteredTableRows.length > 0 ? ( @@ -1166,7 +1227,13 @@ export function DataView({ )} {/* Memory Detail Modal */} - setModalMemoryId(null)} /> + setModalMemoryId(null)} + onChanged={() => { + if (showInvalidated) loadInvalidated(); + }} + />
); } diff --git a/hindsight-control-plane/src/components/documents-view.tsx b/hindsight-control-plane/src/components/documents-view.tsx index ca270c93d3..e630a6148f 100644 --- a/hindsight-control-plane/src/components/documents-view.tsx +++ b/hindsight-control-plane/src/components/documents-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { client, LLMRequestEntry } from "@/lib/api"; @@ -74,6 +74,7 @@ import { Download, Upload, Lock, + RotateCcw, } from "lucide-react"; const ITEMS_PER_PAGE = 50; @@ -354,6 +355,115 @@ function ChunkMemoriesHeader({ ); } +// Document-level audit: facts extracted from this document that were later +// invalidated (moved to the curation archive, so they no longer appear in the +// chunk memory views). Each can be restored in place. +function InvalidatedFactsSection({ bankId, documentId }: { bankId: string; documentId: string }) { + const t = useTranslations("documentsView"); + const tCuration = useTranslations("memoryDetailPanel"); + const [rows, setRows] = useState([]); + const [loaded, setLoaded] = useState(false); + const [restoringId, setRestoringId] = useState(null); + + const load = useCallback(async () => { + if (!bankId || !documentId) return; + try { + const resp: any = await client.listMemories(bankId, { + state: "invalidated", + documentId, + limit: 200, + }); + setRows(resp?.items ?? []); + } catch { + setRows([]); + } finally { + setLoaded(true); + } + }, [bankId, documentId]); + + useEffect(() => { + load(); + }, [load]); + + const restore = async (id: string) => { + setRestoringId(id); + try { + await client.updateMemory(id, bankId, { state: "valid" }); + await load(); + } finally { + setRestoringId(null); + } + }; + + if (!loaded || rows.length === 0) return null; + + return ( + +
+ {rows.map((row) => ( +
+
+
+ {row.fact_type && ( + + {row.fact_type} + + )} +
{row.text}
+
+ {row.entities && ( +
+ {row.entities + .split(", ") + .filter(Boolean) + .slice(0, 6) + .map((e: string, i: number) => ( + + {e} + + ))} +
+ )} + {row.occurred_start && ( +
+ {new Date(row.occurred_start).toLocaleDateString()} + {row.occurred_end && row.occurred_end !== row.occurred_start && ( + <> → {new Date(row.occurred_end).toLocaleDateString()} + )} +
+ )} + {(row.invalidation_reason || row.invalidated_at) && ( +
+ {row.invalidation_reason && ( + <> + {tCuration("curationReasonLabel")}: {row.invalidation_reason} + + )} + {row.invalidation_reason && row.invalidated_at && " · "} + {row.invalidated_at && new Date(row.invalidated_at).toLocaleString()} +
+ )} +
+ +
+ ))} +
+
+ ); +} + function ChunkRow({ chunk }: { chunk: any }) { const [expanded, setExpanded] = useState(false); const [memoriesExpanded, setMemoriesExpanded] = useState(false); @@ -1142,9 +1252,9 @@ export function DocumentsView() { General - - - Content + + + Memories
- {/* Content Tab */} - - {!features?.store_document_text && !selectedDocument.original_text ? ( -
- - {t("textNotStoredWarning")} -
- ) : selectedDocument.original_text !== undefined ? ( - editingContent ? ( -
-
-
- - -
-
-