Compare commits

...
Author SHA1 Message Date
Nicolò Boschi c94e99041f fix: normalize named tool_choice to required + filtered tools for OpenAI-compatible providers
LM Studio (and Ollama) reject the named tool_choice dict format
{"type": "function", "function": {"name": "..."}} with HTTP 400.

The reflect agent uses this format on iterations 0-2 to force sequential
tool selection, causing reflect to fail entirely on LM Studio.

The fix converts named tool_choice dicts to tool_choice="required" with
the tools list filtered to just the requested tool — semantically identical
and accepted by all providers including LM Studio and Ollama.

Closes #520
2026-03-09 14:10:52 +01:00
Chris Bartholomew fa3501d448 Fix Iris parser httpx read timeout for file uploads (#524)
The httpx.AsyncClient was created without a timeout parameter,
defaulting to 5 seconds for reads. This is too short for uploading
PDFs to presigned URLs and waiting for Iris API responses. Set
explicit timeouts: 30s default, 120s for reads.
2026-03-09 11:22:44 +01:00
Chris Bartholomew f88b50a45e fix: serialize alembic upgrades in-process (#521) 2026-03-09 11:22:24 +01:00
Nicolò Boschi 1b4ad7f435 feat: change tags for a document (#517)
* feat: add update document tags endpoint with observation invalidation

Adds PATCH /v1/default/banks/{bank_id}/documents/{document_id} to change
tags on a document without re-processing content.

- Updates tags on the document and all associated memory units atomically
- Invalidates observations derived from the document's memory units
- Resets consolidated_at on the document's own units for re-consolidation
- Also resets consolidated_at on co-source memories from other documents
  that shared those observations (matching delete_document behavior)
- Triggers async consolidation when observations are invalidated
- 9 new tests covering all invalidation scenarios

UI: adds inline tag editor to the document detail panel in the control plane
Docs: new "Update Document Tags" section in documents.mdx with Python/JS examples

* refactor: simplify UpdateDocumentTagsResponse to {success: true}

* refactor: make PATCH /documents generic update_document endpoint

Renames update_document_tags → update_document (engine + HTTP + clients + UI).
Currently only tags are supported; the structure is open for future fields.
Tags are the only field with side effects (observation invalidation + re-consolidation).
2026-03-07 09:00:13 +01:00
Chris Bartholomew d2504ac5ed Fix GCS auth for Workload Identity Federation credentials (#518)
* Fix GCS auth for external_account credentials (Workload Identity)

obstore's built-in credential parsing only supports service_account and
authorized_user JSON types. Use google.auth as a credential_provider
callback to support all credential types including external_account
(Workload Identity Federation), impersonated credentials, and metadata
server credentials.

* Hide GOOGLE_APPLICATION_CREDENTIALS during GCSStore construction

GCSStore eagerly parses the credential file from env vars even when a
custom credential_provider is passed. Temporarily unset the env var
during construction so obstore doesn't choke on external_account
credential files (Workload Identity Federation).

* Support HINDSIGHT_GOOGLE_CREDENTIALS_FILE for GCS auth

When GOOGLE_APPLICATION_CREDENTIALS must be unset to prevent obstore
from parsing unsupported credential types (e.g. external_account),
google.auth can load credentials from HINDSIGHT_GOOGLE_CREDENTIALS_FILE
instead. This avoids mutating env vars at runtime.

* Simplify GCS credential workaround: hide env var during construction

Remove HINDSIGHT_GOOGLE_CREDENTIALS_FILE indirection. Instead, let
google.auth.default() load credentials normally via GOOGLE_APPLICATION_CREDENTIALS,
then temporarily hide the env var during GCSStore() construction so obstore
doesn't try to parse credential types it doesn't support.

* Work around obstore bug: hide env var during GCSStore construction

obstore always parses credential files from GOOGLE_APPLICATION_CREDENTIALS
and the well-known ADC path, even when credential_provider is supplied
(contrary to docs). This crashes on external_account credentials from
Workload Identity Federation.

Temporarily hide the env var during GCSStore() construction. google.auth
has already loaded credentials by this point via credential_provider.
2026-03-07 08:59:51 +01:00
BenandClaude Opus 4.6 d9d7021a49 doc: Upgrading OpenClaw's Memory with Hindsight (#515)
* doc: add adding-memory-to-openclaw-with-hindsight blog post

* doc: update OpenClaw blog cover image

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* doc: update OpenClaw blog title

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* doc: add Hindsight Cloud note to external API section

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-06 12:35:12 -05:00
Nicolò Boschi e2baca8bfe feat: mental model history tracking and UI diff view (#516)
* feat: mental model refresh history tracking and UI diff view

- DB migration: add history JSONB column to mental_models table
- Track previous content on each refresh in update_mental_model
- Add get_mental_model_history() engine method
- New GET /mental-models/{id}/history endpoint
- Control plane proxy route and getMentalModelHistory() in api.ts
- MentalModelDetailModal: add History tab with lazy loading, carousel
  navigation (left=older, right=newer), word-level content diff view

* fix: resolve alembic migration head conflict for mental model history

* feat: mental model history tracking, side-by-side diff UI, and config flag

- Track content changes on every mental model update/refresh (persisted in JSONB history column)
- New GET /mental-models/{id}/history endpoint returning changes most-recent-first
- Side-by-side diff view in History tab (Before/After columns, line-level highlights)
- Actions dropdown in detail panel (Edit, Refresh, View History, Delete)
- HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY config flag (default: true)
- Also adds missing HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY to configuration docs
- Python client wrapper method get_mental_model_history()
- Tests for history persistence (recorded, ordered, name-only skipped, missing returns None)
- Fix NameError: timezone not imported in update_mental_model

* fix: call get_mental_model_history before delete in doc example
2026-03-06 17:50:48 +01:00
Nicolò Boschi 576473b6aa feat: observation history tracking and diff UI (#513)
* feat: add source facts token limits to consolidation and recall

- Add two new configurable (per-bank) parameters:
  - consolidation_source_facts_max_tokens: total token budget for source
    facts across all observations in the consolidation prompt (-1 = unlimited)
  - consolidation_source_facts_max_tokens_per_observation: per-observation
    cap so each observation gets a fair share of source facts (-1 = unlimited,
    default 256)
- Both are also exposed as recall API parameters via SourceFactsIncludeOptions
  (max_tokens and max_tokens_per_observation)
- Consolidation now uses resolve_full_config to respect bank-level overrides
- Improve consolidation prompt: temporal metadata (occurred_start=, | Involving:)
  is now clearly separated from observation text, with a concrete example showing
  the expected synthesis style and explicit rules not to copy raw fact lines
- Add tests for recall source facts capping and consolidation config forwarding
- Expose all three new fields in the control plane bank config UI
- Document new env vars in configuration.md
- Regenerate OpenAPI spec and all SDK clients

* fix: reorder observations UI fields and rename Label Groups to Entity Labels

* fix: revert Entities section title (only rename inner label)

* doc: add consolidation source facts and batch size fields to memory-banks docs

* feat: add observation history tracking and UI diff view

- Track observation changes over time in a JSONB history column,
  appending each update's previous state (text, tags, dates, sources)
  instead of overwriting
- Add HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY config flag (default: true)
  to toggle history recording
- Expose history field in get_memory_unit for observations
- Fix observations/[modelId] route that was proxying to wrong endpoint
- Add History tab in observation modal and History section in panel,
  showing word-level and tag diffs between each change (newest first)
- Extract shared ObservationHistoryView component used by both modal and panel
- Add --random-port flag to start.sh to run multiple dev instances
- Scope Next.js distDir by port to prevent lock file collisions between instances
- Restyle consolidation pending badge (rounded-md with border) and add
  inline refresh button; fix loading flicker on data refresh

* feat: dedicated observation history endpoint with source facts diff

- Add GET /memories/{id}/history endpoint returning enriched history with
  resolved source fact texts and is_new flags per change
- Deprecate history field in GET /memories/{id} (always returns empty list)
- Reconstruct cumulative source facts per history entry by working backwards
  from current state, marking newly added facts with is_new
- Replace inline history panel with "View History" button opening modal
- History modal fetches from dedicated endpoint lazily on tab switch
- Timeline view now opens MemoryDetailModal instead of side panel
- History view uses prev/next navigation (left = older, right = newer)
- Fix --random-port: pass dynamic API_PORT as HINDSIGHT_CP_DATAPLANE_API_URL
  to control plane, preserving caller values over .env
2026-03-06 16:16:05 +01:00
Nicolò Boschi 99220d0527 feat: per-request file parser selection with fallback chains (#514)
* feat: allow per-request file parser selection with fallback chains

Clients can now specify which parser(s) to use when calling the file
retain endpoint, instead of being locked to the server-side default.

Changes:
- `parser` field added to `FileRetainRequest` (request-level default)
  and `FileRetainMetadata` (per-file override); accepts a single name
  or an ordered fallback chain (list)
- Resolution priority: per-file > request-level > server default
- `HINDSIGHT_API_FILE_PARSER` now accepts a comma-separated fallback
  chain (e.g. `iris,markitdown`); fully backward-compatible
- New `HINDSIGHT_API_FILE_PARSER_ALLOWLIST` env var restricts which
  parsers clients may request (defaults to all registered parsers)
- Invalid/disallowed parser names are rejected with HTTP 400
- `FileParserRegistry.convert_with_fallback()` tries each parser in
  order, falling back on UnsupportedFileTypeError, empty content, or
  any other error
- Worker updated to use the fallback chain stored per-task
- OpenAPI spec and all generated clients regenerated

* fix: handle on_file_convert_complete hook and rebase onto main

- Return ConvertResult dataclass from convert_with_fallback() instead
  of a plain str, carrying both the content and the winning parser name
- Use winning_parser_name in the on_file_convert_complete hook so
  parser_name reflects the parser that actually succeeded, not the chain
- Update all test calls to submit_async_file_retain() to use the new
  per-item parser field instead of the removed top-level parser= kwarg

* docs: document HINDSIGHT_API_FILE_PARSER fallback chain and ALLOWLIST
2026-03-06 16:15:43 +01:00
Nicolò Boschi 8540c33236 refactor: remove dead code and clarify observations vs mental models (#512)
* refactor: remove dead code and clarify observations vs mental models

- Delete engine/mental_models/ module (stale Pydantic models with wrong
  schema, describing an old design where mental models were directives;
  had no importers outside itself)
- Remove unused imports in api/http.py (acquire_with_retry, Observation)
- Remove unused Pydantic models in api/http.py (BanksResponse,
  ObservationEvidenceResponse)
- Add clarifying NOTE to consolidation/consolidator.py distinguishing
  observations (auto-generated bottom-up) from mental models (user-defined
  pinned reflections refreshed via reflect)

* chore: run generate scripts after dead code removal
2026-03-06 14:39:33 +01:00
Nicolò Boschi 5d05962db0 feat: add source facts token limits to consolidation and recall (#509)
* feat: add source facts token limits to consolidation and recall

- Add two new configurable (per-bank) parameters:
  - consolidation_source_facts_max_tokens: total token budget for source
    facts across all observations in the consolidation prompt (-1 = unlimited)
  - consolidation_source_facts_max_tokens_per_observation: per-observation
    cap so each observation gets a fair share of source facts (-1 = unlimited,
    default 256)
- Both are also exposed as recall API parameters via SourceFactsIncludeOptions
  (max_tokens and max_tokens_per_observation)
- Consolidation now uses resolve_full_config to respect bank-level overrides
- Improve consolidation prompt: temporal metadata (occurred_start=, | Involving:)
  is now clearly separated from observation text, with a concrete example showing
  the expected synthesis style and explicit rules not to copy raw fact lines
- Add tests for recall source facts capping and consolidation config forwarding
- Expose all three new fields in the control plane bank config UI
- Document new env vars in configuration.md
- Regenerate OpenAPI spec and all SDK clients

* fix: reorder observations UI fields and rename Label Groups to Entity Labels

* fix: revert Entities section title (only rename inner label)

* doc: add consolidation source facts and batch size fields to memory-banks docs
2026-03-06 13:01:33 +01:00
Chris BartholomewandNicolò Boschi 1d17dea2f1 Add on_file_convert_complete extension hook after file-to-markdown conversion (#507)
* Add file upload API with parser selection and conversion hooks

- Add FileRetainRequest.parser field for per-request parser selection
- Add FileConvertResult dataclass and on_file_convert_complete extension hook
- Fire hook after file-to-markdown conversion with output text for metering
- Fix obstore.Bytes incompatibility with httpx in Iris parser (GCS returns
  obstore.Bytes instead of plain bytes)
- Export new types from extensions __init__

* remove parser field from FileRetainRequest API

Parser selection remains server-side only via HINDSIGHT_API_FILE_PARSER config.

* test: add tests for on_file_convert_complete extension hook

Verifies that the hook is called with correct parameters on success,
called once per file for multi-file uploads, and not called when
file conversion fails.

* test: verify tenant_id propagation to on_file_convert_complete hook

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-03-06 09:56:53 +01:00
Nicolò Boschi 928dc696e8 doc: document all missing bank config fields in memory-banks.mdx (#508)
- Add retain_chunk_size (max chars per chunk for fact extraction)
- Rename mission → reflect_mission to match actual API field name
- Add mcp_enabled_tools (per-bank MCP tool allowlist)
- Add llm_gemini_safety_settings (Gemini/VertexAI content filtering)
2026-03-06 09:55:45 +01:00
73 changed files with 6414 additions and 427 deletions
@@ -0,0 +1,30 @@
"""Add history column to mental_models
Revision ID: c3d4e5f6g7h8
Revises: a2b3c4d5e6f7, a2b3c4d5e6f8
Create Date: 2026-03-06
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "c3d4e5f6g7h8"
down_revision: str | Sequence[str] | None = ("a2b3c4d5e6f7", "a2b3c4d5e6f8")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")
+206 -33
View File
@@ -71,9 +71,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
from hindsight_api.config import get_config
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
@@ -100,7 +98,12 @@ class ChunkIncludeOptions(BaseModel):
class SourceFactsIncludeOptions(BaseModel):
"""Options for including source facts for observation-type results."""
max_tokens: int = Field(default=4096, description="Maximum tokens for source facts")
max_tokens: int = Field(
default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)"
)
max_tokens_per_observation: int = Field(
default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)"
)
class IncludeOptions(BaseModel):
@@ -474,6 +477,11 @@ class FileRetainMetadata(BaseModel):
metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata")
tags: list[str] | None = Field(default=None, description="Tags for this file")
timestamp: str | None = Field(default=None, description="ISO timestamp")
parser: str | list[str] | None = Field(
default=None,
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
"E.g. 'iris' or ['iris', 'markitdown'].",
)
class FileRetainRequest(BaseModel):
@@ -482,14 +490,21 @@ class FileRetainRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"parser": "iris",
"files_metadata": [
{"document_id": "report_2024", "tags": ["quarterly"]},
{"context": "meeting notes"},
{"context": "meeting notes", "parser": ["iris", "markitdown"]},
],
}
}
)
parser: str | list[str] | None = Field(
default=None,
description="Default parser or ordered fallback chain for all files in this request. "
"E.g. 'markitdown' or ['iris', 'markitdown']. Falls back to server default if not set. "
"Per-file 'parser' in files_metadata takes precedence over this value.",
)
files_metadata: list[FileRetainMetadata] | None = Field(
default=None,
description="Metadata for each file (optional, must match number of files if provided)",
@@ -759,14 +774,6 @@ class ReflectResponse(BaseModel):
)
class BanksResponse(BaseModel):
"""Response model for banks list endpoint."""
model_config = ConfigDict(json_schema_extra={"example": {"banks": ["user123", "bank_alice", "bank_bob"]}})
banks: list[str]
class DispositionTraits(BaseModel):
"""Disposition traits that influence how memories are formed and interpreted."""
@@ -1199,6 +1206,30 @@ class DocumentResponse(BaseModel):
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
class UpdateDocumentRequest(BaseModel):
"""Request model for updating a document's mutable fields."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"tags": ["team-a", "team-b"],
}
}
)
tags: list[str] | None = Field(
default=None,
description="New tags for the document and its memory units. "
"Triggers observation invalidation and re-consolidation.",
)
class UpdateDocumentResponse(BaseModel):
"""Response model for update document endpoint."""
success: bool = True
class DeleteDocumentResponse(BaseModel):
"""Response model for delete document endpoint."""
@@ -1305,15 +1336,6 @@ class BankStatsResponse(BaseModel):
# Mental Model models
class ObservationEvidenceResponse(BaseModel):
"""A single piece of evidence supporting an observation."""
memory_id: str = Field(description="ID of the memory unit this evidence comes from")
quote: str = Field(description="Exact quote from the memory supporting the observation")
relevance: str = Field(description="Brief explanation of how this quote supports the observation")
timestamp: str = Field(description="When the source memory was created (ISO format)")
# =========================================================================
# Directive Models
# =========================================================================
@@ -2139,7 +2161,7 @@ def _register_routes(app: FastAPI):
@app.get(
"/v1/default/banks/{bank_id}/memories/{memory_id}",
summary="Get memory unit",
description="Get a single memory unit by ID with all its metadata including entities and tags.",
description="Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.",
operation_id="get_memory",
tags=["Memory"],
)
@@ -2169,6 +2191,39 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /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",
description="Get the full history of an observation, with each change's source facts resolved to their text.",
operation_id="get_observation_history",
tags=["Memory"],
)
async def api_get_observation_history(
bank_id: str,
memory_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get the history of a single observation by ID."""
try:
data = await app.state.memory.get_observation_history(
bank_id=bank_id,
memory_id=memory_id,
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 (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 /v1/default/banks/{bank_id}/memories/{memory_id}/history: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/memories/recall",
response_model=RecallResponse,
@@ -2224,6 +2279,9 @@ def _register_routes(app: FastAPI):
# Determine source facts inclusion settings
include_source_facts = request.include.source_facts is not None
max_source_facts_tokens = request.include.source_facts.max_tokens if include_source_facts else 4096
max_source_facts_tokens_per_observation = (
request.include.source_facts.max_tokens_per_observation if include_source_facts else -1
)
pre_recall = time.time() - handler_start
# Run recall with tracing (record metrics)
@@ -2245,6 +2303,7 @@ def _register_routes(app: FastAPI):
max_chunk_tokens=max_chunk_tokens,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
@@ -2713,6 +2772,41 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history",
summary="Get mental model history",
description="Get the refresh history of a mental model, showing content changes over time.",
operation_id="get_mental_model_history",
tags=["Mental Models"],
)
async def api_get_mental_model_history(
bank_id: str,
mental_model_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get the refresh history of a mental model."""
try:
data = await app.state.memory.get_mental_model_history(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
if data is None:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
return data
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/mental-models",
response_model=CreateMentalModelResponse,
@@ -3234,6 +3328,55 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/chunks/{chunk_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/documents/{document_id:path}",
response_model=UpdateDocumentResponse,
summary="Update document",
description="Update mutable fields on a document without re-processing its content.\n\n"
"**Tags** (`tags`): Propagated to all associated memory units. Observations derived from "
"those units are invalidated and queued for re-consolidation under the new tags. "
"Co-source memories from other documents that shared those observations are also reset.\n\n"
"At least one field must be provided.",
operation_id="update_document",
tags=["Documents"],
)
async def api_update_document(
bank_id: str,
document_id: str,
body: UpdateDocumentRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""
Update mutable fields on a document without re-processing its content.
Args:
bank_id: Memory Bank ID (from path)
document_id: Document ID (from path)
body: Fields to update (tags, metadata, context)
"""
if body.tags is None:
raise HTTPException(status_code=422, detail="At least one field (tags) must be provided")
try:
result = await app.state.memory.update_document(
document_id,
bank_id,
tags=body.tags,
request_context=request_context,
)
if not result:
raise HTTPException(status_code=404, detail="Document not found")
return UpdateDocumentResponse(success=True)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
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}/documents/{document_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/documents/{document_id:path}",
response_model=DeleteDocumentResponse,
@@ -4331,8 +4474,14 @@ def _register_routes(app: FastAPI):
"Use the operations endpoint to monitor progress.\n\n"
"**Request format:** multipart/form-data with:\n"
"- `files`: One or more files to upload\n"
"- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n"
"**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).",
"- `request`: JSON string with FileRetainRequest model\n\n"
"**Parser selection:**\n"
"- Set `parser` in the request body to override the server default for all files.\n"
"- Set `parser` inside a `files_metadata` entry for per-file control.\n"
"- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — "
"each parser is tried in sequence until one succeeds.\n"
"- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.\n"
"- Only parsers enabled on the server may be requested; others return HTTP 400.",
operation_id="file_retain",
tags=["Files"],
)
@@ -4378,20 +4527,39 @@ def _register_routes(app: FastAPI):
detail=f"files_metadata count ({len(request_data.files_metadata)}) must match files count ({len(files)})",
)
# Resolve the registered parser names for allowlist validation
registered_parsers = app.state.memory._parser_registry.list_parsers()
allowlist = config.file_parser_allowlist if config.file_parser_allowlist is not None else registered_parsers
def _resolve_parser(raw: str | list[str] | None) -> list[str]:
"""Normalize parser value to a non-empty list of names."""
if raw is None:
return config.file_parser
return [raw] if isinstance(raw, str) else list(raw)
def _validate_parsers(parsers: list[str], context: str) -> None:
"""Raise HTTP 400 if any parser name is not in the allowlist."""
disallowed = [p for p in parsers if p not in allowlist]
if disallowed:
raise HTTPException(
status_code=400,
detail=f"Parser(s) not available ({context}): {disallowed}. Available: {allowlist}",
)
# Validate request-level parser early (before reading files)
if request_data.parser is not None:
_validate_parsers(_resolve_parser(request_data.parser), "request-level parser")
# Prepare file items and calculate total batch size
import io
file_items = []
total_batch_size = 0
for i, file in enumerate(files):
# Read file content to check size
file_content = await file.read()
size = len(file_content)
total_batch_size += size
# Create a temporary file-like object from the bytes
import io
file_obj = io.BytesIO(file_content)
total_batch_size += len(file_content)
# Create a mock UploadFile with the necessary attributes
class FileWrapper:
@@ -4399,7 +4567,6 @@ def _register_routes(app: FastAPI):
self._content = content
self.filename = filename
self.content_type = content_type
self._buffer = io.BytesIO(content)
async def read(self):
return self._content
@@ -4410,6 +4577,12 @@ def _register_routes(app: FastAPI):
file_meta = request_data.files_metadata[i] if request_data.files_metadata else FileRetainMetadata()
doc_id = file_meta.document_id or f"file_{uuid.uuid4()}"
# Resolve and validate per-file parser chain
# Priority: per-file > request-level > server default
raw_parser = file_meta.parser if file_meta.parser is not None else request_data.parser
parser_chain = _resolve_parser(raw_parser)
_validate_parsers(parser_chain, f"file '{file.filename}'")
item = {
"file": wrapped_file,
"document_id": doc_id,
@@ -4417,6 +4590,7 @@ def _register_routes(app: FastAPI):
"metadata": file_meta.metadata or {},
"tags": file_meta.tags or [],
"timestamp": file_meta.timestamp,
"parser": parser_chain,
}
file_items.append(item)
@@ -4431,7 +4605,6 @@ def _register_routes(app: FastAPI):
result = await app.state.memory.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser=config.file_parser,
document_tags=None,
request_context=request_context,
)
+52 -3
View File
@@ -280,6 +280,7 @@ ENV_FILE_STORAGE_AZURE_CONTAINER = "HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER"
ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME"
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB"
@@ -292,7 +293,13 @@ ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE"
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS"
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
"HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION"
)
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
# Webhook configuration (global, static - server-level only)
ENV_WEBHOOK_URL = "HINDSIGHT_API_WEBHOOK_URL"
@@ -435,7 +442,8 @@ DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in
# File storage defaults
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
DEFAULT_FILE_PARSER = "markitdown" # File parser to use (markitdown is the only supported parser)
DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown")
DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
@@ -443,9 +451,17 @@ DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = (
-1
) # Total token budget for source facts in consolidation recall (-1 = unlimited)
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
256 # Max tokens of source facts per observation in consolidation prompt (-1 = unlimited)
)
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
# Database migrations
@@ -540,6 +556,11 @@ class JsonFormatter(logging.Formatter):
return json.dumps(log_entry)
def _parse_str_list(value: str) -> list[str]:
"""Parse a comma-separated string into a non-empty list of stripped tokens."""
return [v.strip() for v in value.split(",") if v.strip()]
def _validate_extraction_mode(mode: str) -> str:
"""Validate and normalize extraction mode."""
mode_lower = mode.lower()
@@ -699,7 +720,8 @@ class HindsightConfig:
file_storage_azure_container: str | None # Azure container name (required for azure storage)
file_storage_azure_account_name: str | None # Azure storage account name
file_storage_azure_account_key: str | None # Azure storage account key
file_parser: str # File parser to use (e.g., "markitdown", "iris")
file_parser: list[str] # Ordered fallback chain of parsers (e.g. ["iris", "markitdown"])
file_parser_allowlist: list[str] | None # Parsers clients may request (None = all registered)
file_parser_iris_token: str | None # Vectorize API token for iris parser (VECTORIZE_TOKEN)
file_parser_iris_org_id: str | None # Vectorize org ID for iris parser (VECTORIZE_ORG_ID)
file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined)
@@ -709,9 +731,13 @@ class HindsightConfig:
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
enable_observation_history: bool
enable_mental_model_history: bool
consolidation_batch_size: int
consolidation_llm_batch_size: int
consolidation_max_tokens: int
consolidation_source_facts_max_tokens: int
consolidation_source_facts_max_tokens_per_observation: int
observations_mission: str | None
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
@@ -812,6 +838,9 @@ class HindsightConfig:
"entities_allow_free_form",
# Consolidation settings
"enable_observations",
"consolidation_llm_batch_size",
"consolidation_source_facts_max_tokens",
"consolidation_source_facts_max_tokens_per_observation",
"observations_mission",
# Reflect settings
"reflect_mission",
@@ -1136,7 +1165,10 @@ class HindsightConfig:
file_storage_azure_container=os.getenv(ENV_FILE_STORAGE_AZURE_CONTAINER) or None,
file_storage_azure_account_name=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME) or None,
file_storage_azure_account_key=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY) or None,
file_parser=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER),
file_parser=_parse_str_list(os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER)),
file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST))
if os.getenv(ENV_FILE_PARSER_ALLOWLIST)
else None,
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_conversion_max_batch_size_mb=int(
@@ -1153,6 +1185,14 @@ class HindsightConfig:
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
enable_observation_history=os.getenv(
ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY)
).lower()
== "true",
enable_mental_model_history=os.getenv(
ENV_ENABLE_MENTAL_MODEL_HISTORY, str(DEFAULT_ENABLE_MENTAL_MODEL_HISTORY)
).lower()
== "true",
consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
),
@@ -1162,6 +1202,15 @@ class HindsightConfig:
consolidation_max_tokens=int(
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
),
consolidation_source_facts_max_tokens=int(
os.getenv(ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS))
),
consolidation_source_facts_max_tokens_per_observation=int(
os.getenv(
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION,
str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION),
)
),
observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION,
entity_labels=None,
entities_allow_free_form=True,
@@ -9,6 +9,10 @@ Observations are stored in memory_units with fact_type='observation' and include
- proof_count: Number of supporting memories
- source_memory_ids: Array of memory UUIDs that contribute to this observation
- history: JSONB tracking changes over time
NOTE: Observations are distinct from mental models (pinned reflections).
- Observations: auto-generated bottom-up by this engine from raw facts (memory_units table, fact_type='observation')
- Mental models: user-defined queries stored in the mental_models table, refreshed on demand via reflect
"""
import json
@@ -762,13 +766,17 @@ async def _execute_update_action(
logger.debug(f"Update skipped: observation {observation_id} not found in recall results")
return
history = [
{
"previous_text": model.text,
"changed_at": datetime.now(timezone.utc).isoformat(),
"source_memory_ids": [str(mid) for mid in source_memory_ids],
}
]
from ...config import get_config
history_entry = {
"previous_text": model.text,
"previous_tags": list(model.tags or []),
"previous_occurred_start": model.occurred_start,
"previous_occurred_end": model.occurred_end,
"previous_mentioned_at": model.mentioned_at,
"changed_at": datetime.now(timezone.utc).isoformat(),
"new_source_memory_ids": [str(mid) for mid in source_memory_ids],
}
source_ids = list(model.source_fact_ids or []) + source_memory_ids
@@ -783,13 +791,18 @@ async def _execute_update_action(
if perf:
perf.record_timing("embedding", time.time() - t0)
config = get_config()
history_clause = (
"history = COALESCE(history, '[]'::jsonb) || $3::jsonb," if config.enable_observation_history else ""
)
t0 = time.time()
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
embedding = $2::vector,
history = $3,
{history_clause}
source_memory_ids = $4,
proof_count = $5,
tags = $10,
@@ -801,7 +814,7 @@ async def _execute_update_action(
""",
new_text,
embedding_str,
json.dumps(history),
json.dumps([history_entry]),
source_ids,
len(source_ids),
uuid.UUID(observation_id),
@@ -913,10 +926,9 @@ async def _find_related_observations(
"""
# Use recall to find related observations with token budget
# max_tokens naturally limits how many observations are returned
from ...config import get_config
from ...tracing import get_tracer, is_tracing_enabled
config = get_config()
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
tags_match = "all_strict" if tags else "any"
@@ -941,7 +953,8 @@ async def _find_related_observations(
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
max_source_facts_tokens=-1, # No token limit — we need all source facts for consolidation
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
_quiet=True, # Suppress logging
)
finally:
@@ -1005,14 +1018,17 @@ async def _consolidate_batch_with_llm(
observations_text = "[]"
def _fact_line(m: dict[str, Any]) -> str:
parts = [f"[{m['id']}] {m['text']}"]
text = f"[{m['id']}] {m['text']}"
temporal_parts = []
if m.get("occurred_start"):
parts.append(f"occurred_start={m['occurred_start']}")
temporal_parts.append(f"occurred_start={m['occurred_start']}")
if m.get("occurred_end"):
parts.append(f"occurred_end={m['occurred_end']}")
temporal_parts.append(f"occurred_end={m['occurred_end']}")
if m.get("mentioned_at"):
parts.append(f"mentioned_at={m['mentioned_at']}")
return " | ".join(parts)
temporal_parts.append(f"mentioned_at={m['mentioned_at']}")
if temporal_parts:
text += f" ({', '.join(temporal_parts)})"
return text
facts_lines = "\n".join(_fact_line(m) for m in memories)
@@ -35,8 +35,25 @@ Compare the facts against existing observations:
_BATCH_OUTPUT_FORMAT = """
Output a JSON object with three arrays.
Example (showing the required UUID format for all IDs):
{{"creates": [{{"text": "Alice lives in Berlin", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
## EXAMPLE
Input facts:
[a1b2c3d4-e5f6-7890-abcd-ef1234567890] Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)
[b2c3d4e5-f6a7-8901-bcde-f12345678901] Alice said she's exhausted from the project deadlines | Involving: Alice (occurred_start=2024-01-20, mentioned_at=2024-01-20)
Good observation text — clean prose, no metadata, each fact tracked distinctly:
"Alice works long hours, often past midnight."
"Alice feels exhausted from project deadlines."
Bad observation text — NEVER do this (verbatim copy of fact text with metadata):
"Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)"
Observation text rules:
- Write clean prose — NEVER copy raw fact lines or their metadata (temporal fields, "Involving:", "When:" labels, UUIDs).
- Parenthesized metadata like (occurred_start=...) and pipe-separated labels like "| Involving: ..." are fact formatting — strip them entirely from observation text.
- How many observations to create and how much to aggregate is driven by the MISSION above.
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]}}, {{"text": "Alice feels exhausted from project deadlines.", "source_fact_ids": ["b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
"updates": [{{"text": "Alice works at Acme Corp as a senior engineer", "observation_id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
"deletes": [{{"observation_id": "e5f6a7b8-c9d0-1234-efab-345678901234"}}]}}
@@ -16,7 +16,7 @@ import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
import asyncpg
@@ -653,13 +653,19 @@ class MemoryEngine(MemoryEngineInterface):
# Retrieve file from storage
file_data = await self._file_storage.retrieve(storage_key)
# Convert to markdown
parser = self._parser_registry.get_parser(
name=task_dict.get("parser"),
# Convert to markdown using the ordered fallback chain stored in the task payload.
# task_dict["parser"] is always a list[str] set at submission time.
parser_chain: list[str] = task_dict.get("parser") or []
if not parser_chain:
raise ValueError("No parser chain defined for file_convert_retain task")
convert_result = await self._parser_registry.convert_with_fallback(
parsers=parser_chain,
file_data=file_data,
filename=filename,
content_type=task_dict.get("content_type"),
)
markdown_content = await parser.convert(file_data, filename)
markdown_content = convert_result.content
winning_parser = convert_result.parser_name
except Exception as e:
# Re-raise with filename context for better error reporting
error_msg = f"Failed to parse file '{filename}': {str(e)}"
@@ -671,6 +677,31 @@ class MemoryEngine(MemoryEngineInterface):
f"document_id={document_id}, {len(markdown_content)} chars. Submitting retain task."
)
# Fire file conversion hook (e.g., for Iris billing)
if self._operation_validator:
try:
from hindsight_api.extensions.operation_validator import FileConvertResult
from hindsight_api.models import RequestContext
convert_context = RequestContext(
internal=True,
user_initiated=True,
tenant_id=task_dict.get("_tenant_id"),
api_key_id=task_dict.get("_api_key_id"),
)
await self._operation_validator.on_file_convert_complete(
FileConvertResult(
bank_id=bank_id,
parser_name=winning_parser,
filename=filename,
output_chars=len(markdown_content),
output_text=markdown_content,
request_context=convert_context,
)
)
except Exception as e:
logger.warning(f"[FILE_CONVERT_RETAIN] on_file_convert_complete hook failed: {e}")
# Build retain task payload
retain_contents = [
{
@@ -2212,6 +2243,7 @@ class MemoryEngine(MemoryEngineInterface):
max_chunk_tokens: int = 8192,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
max_source_facts_tokens_per_observation: int = -1,
request_context: "RequestContext",
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
@@ -2353,6 +2385,7 @@ class MemoryEngine(MemoryEngineInterface):
quiet=_quiet,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
)
break # Success - exit retry loop
except Exception as e:
@@ -2479,6 +2512,7 @@ class MemoryEngine(MemoryEngineInterface):
quiet: bool = False,
include_source_facts: bool = False,
max_source_facts_tokens: int = 4096,
max_source_facts_tokens_per_observation: int = -1,
) -> RecallResultModel:
"""
Search implementation with modular retrieval and reranking.
@@ -3100,18 +3134,9 @@ class MemoryEngine(MemoryEngineInterface):
encoding = _get_tiktoken_encoding()
source_facts_dict = {}
total_source_tokens = 0
for sid in source_ids_ordered:
if sid not in source_row_by_id:
continue
r = source_row_by_id[sid]
fact_tokens = len(encoding.encode(r["text"]))
if (
max_source_facts_tokens >= 0
and total_source_tokens + fact_tokens > max_source_facts_tokens
):
break
source_facts_dict[sid] = MemoryFact(
def _make_source_fact(sid: str, r: Any) -> MemoryFact:
return MemoryFact(
id=sid,
text=r["text"],
fact_type=r["fact_type"],
@@ -3123,7 +3148,37 @@ class MemoryEngine(MemoryEngineInterface):
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
tags=r["tags"] or None,
)
total_source_tokens += fact_tokens
if max_source_facts_tokens_per_observation >= 0:
# Per-observation capping: each observation independently selects
# source facts up to its token budget.
for obs_id, sids in source_fact_ids_by_obs.items():
obs_tokens = 0
for sid in sids:
if sid not in source_row_by_id:
continue
r = source_row_by_id[sid]
fact_tokens = len(encoding.encode(r["text"]))
if obs_tokens + fact_tokens > max_source_facts_tokens_per_observation:
break
obs_tokens += fact_tokens
if sid not in source_facts_dict:
source_facts_dict[sid] = _make_source_fact(sid, r)
else:
# Global budget: fill in order of first appearance until exhausted.
total_source_tokens = 0
for sid in source_ids_ordered:
if sid not in source_row_by_id:
continue
r = source_row_by_id[sid]
fact_tokens = len(encoding.encode(r["text"]))
if (
max_source_facts_tokens >= 0
and total_source_tokens + fact_tokens > max_source_facts_tokens
):
break
source_facts_dict[sid] = _make_source_fact(sid, r)
total_source_tokens += fact_tokens
# Get entities for each fact if include_entities is requested
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
@@ -3385,6 +3440,140 @@ class MemoryEngine(MemoryEngineInterface):
return result
async def update_document(
self,
document_id: str,
bank_id: str,
*,
tags: list[str] | None = None,
request_context: "RequestContext",
) -> bool:
"""
Update mutable fields on a document without re-processing its content.
Tag changes propagate to all associated memory units and trigger observation
invalidation + re-consolidation (same semantics as delete_document):
- Observations referencing the document's memory units are deleted.
- The document's own units and any co-source memories from other documents
have consolidated_at reset so they are re-consolidated under the new tags.
Args:
document_id: Document ID to update
bank_id: Bank ID that owns the document
tags: New tags to apply to the document and all its memory units (optional)
request_context: Request context for authentication.
Returns:
True if the document was found and updated, False if not found
"""
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankWriteContext
ctx = BankWriteContext(bank_id=bank_id, operation="update_document", request_context=request_context)
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
pool = await self._get_pool()
invalidated_obs = 0
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
set_parts: list[str] = ["updated_at = now()"]
params: list[Any] = []
p = 1
if tags is not None:
set_parts.append(f"tags = ${p}")
params.append(tags)
p += 1
params.extend([document_id, bank_id])
doc_id_found = await conn.fetchval(
f"""
UPDATE {fq_table("documents")}
SET {", ".join(set_parts)}
WHERE id = ${p} AND bank_id = ${p + 1}
RETURNING id
""",
*params,
)
if not doc_id_found:
return False
if tags is not None:
unit_rows = await conn.fetch(
f"SELECT id FROM {fq_table('memory_units')} WHERE document_id = $1 AND fact_type IN ('experience', 'world')",
document_id,
)
unit_ids = [str(row["id"]) for row in unit_rows]
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET tags = $1 WHERE document_id = $2",
tags,
document_id,
)
if unit_ids:
import uuid as uuid_module
unit_uuids = [uuid_module.UUID(uid) for uid in unit_ids]
unit_uuid_set = {str(u) for u in unit_uuids}
affected_obs = await conn.fetch(
f"""
SELECT id, source_memory_ids FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND fact_type = 'observation'
AND source_memory_ids && $2::uuid[]
""",
bank_id,
unit_uuids,
)
if affected_obs:
obs_ids = [obs["id"] for obs in affected_obs]
seen: set[str] = set()
other_source_uuids: list[uuid_module.UUID] = []
for obs in affected_obs:
for src_id in obs["source_memory_ids"] or []:
src_str = str(src_id)
if src_str not in unit_uuid_set and src_str not in seen:
other_source_uuids.append(src_id)
seen.add(src_str)
await conn.execute(
f"DELETE FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[])",
obs_ids,
)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET consolidated_at = NULL
WHERE id = ANY($1::uuid[])
AND fact_type IN ('experience', 'world')
""",
unit_uuids,
)
if other_source_uuids:
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET consolidated_at = NULL
WHERE id = ANY($1::uuid[])
AND fact_type IN ('experience', 'world')
""",
other_source_uuids,
)
invalidated_obs = len(obs_ids)
logger.info(
f"[OBSERVATIONS] Deleted {invalidated_obs} observations, reset "
f"{len(unit_ids)} document source memories and "
f"{len(other_source_uuids)} co-source memories for re-consolidation "
f"after document update on '{document_id}' in bank {bank_id}"
)
if invalidated_obs > 0:
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
return True
async def delete_memory_unit(
self,
unit_id: str,
@@ -4290,7 +4479,11 @@ class MemoryEngine(MemoryEngineInterface):
"observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None,
}
# For observations, include source_memory_ids and fetch source_memories
# For observations, include source_memory_ids
# history is deprecated here - use GET /memories/{id}/history instead
if row["fact_type"] == "observation":
result["history"] = []
if row["fact_type"] == "observation" and row["source_memory_ids"]:
source_ids = row["source_memory_ids"]
result["source_memory_ids"] = [str(sid) for sid in source_ids]
@@ -4319,6 +4512,95 @@ class MemoryEngine(MemoryEngineInterface):
return result
async def get_observation_history(
self,
bank_id: str,
memory_id: str,
request_context: "RequestContext",
) -> list[dict] | None:
"""
Get the history of an observation, with source facts resolved to their text.
Returns None if the memory is not found or is not an observation.
Returns a list of history entries (most recent first), each with source_facts resolved.
"""
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankReadContext
ctx = BankReadContext(bank_id=bank_id, operation="get_observation_history", request_context=request_context)
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"""
SELECT fact_type, history, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = $1 AND bank_id = $2
""",
uuid.UUID(memory_id),
bank_id,
)
if not row:
return None
if row["fact_type"] != "observation":
return []
raw_history = row["history"]
if isinstance(raw_history, str):
raw_history = json.loads(raw_history)
if not raw_history:
return []
# Collect all source memory IDs (current full set + all historical new ones)
current_source_ids: list[str] = [str(sid) for sid in (row["source_memory_ids"] or [])]
all_source_ids: set[uuid.UUID] = set(uuid.UUID(sid) for sid in current_source_ids)
for entry in raw_history:
for sid in entry.get("new_source_memory_ids", []):
try:
all_source_ids.add(uuid.UUID(sid))
except (ValueError, AttributeError):
pass
# Resolve all source memories in one query
source_map: dict[str, dict] = {}
if all_source_ids:
source_rows = await conn.fetch(
f"""
SELECT id, text, fact_type, context
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
list(all_source_ids),
)
for r in source_rows:
source_map[str(r["id"])] = {
"id": str(r["id"]),
"text": r["text"],
"type": r["fact_type"],
"context": r["context"] or None,
}
# Reconstruct cumulative source IDs per change by working backwards from current state.
# Source IDs are only ever accumulated (never removed), so:
# after_change_N = before_change_N + new_source_memory_ids_N
cumulative_ids: list[str] = list(current_source_ids)
enriched: list[dict] = []
for entry in reversed(raw_history):
new_ids_in_entry: set[str] = set(entry.get("new_source_memory_ids", []))
source_facts = []
for sid in cumulative_ids:
fact = source_map.get(sid, {"id": sid, "text": None, "type": None, "context": None})
source_facts.append({**fact, "is_new": sid in new_ids_in_entry})
enriched_entry = dict(entry)
enriched_entry["source_facts"] = source_facts
enriched.append(enriched_entry)
# Step back: remove the new IDs added by this change to get the prior state
cumulative_ids = [sid for sid in cumulative_ids if sid not in new_ids_in_entry]
enriched.reverse()
return enriched
async def list_documents(
self,
bank_id: str,
@@ -5851,6 +6133,39 @@ class MemoryEngine(MemoryEngineInterface):
return result
async def get_mental_model_history(
self,
bank_id: str,
mental_model_id: str,
*,
request_context: "RequestContext",
) -> list[dict] | None:
"""Get the refresh history of a mental model.
Returns None if the mental model is not found.
Returns a list of history entries (most recent first), each with previous_content and changed_at.
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"""
SELECT history
FROM {fq_table("mental_models")}
WHERE bank_id = $1 AND id = $2
""",
bank_id,
mental_model_id,
)
if row is None:
return None
raw_history = row["history"]
if isinstance(raw_history, str):
raw_history = json.loads(raw_history)
if not raw_history:
return []
return list(reversed(raw_history))
async def create_mental_model(
self,
bank_id: str,
@@ -6071,6 +6386,17 @@ class MemoryEngine(MemoryEngineInterface):
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
# If content is changing, fetch current content first to record history
previous_content: str | None = None
if content is not None:
current_row = await conn.fetchrow(
f"SELECT content FROM {fq_table('mental_models')} WHERE bank_id = $1 AND id = $2",
bank_id,
mental_model_id,
)
if current_row:
previous_content = current_row["content"]
# Build dynamic update
updates = []
params: list[Any] = [bank_id, mental_model_id]
@@ -6086,6 +6412,14 @@ class MemoryEngine(MemoryEngineInterface):
params.append(content)
param_idx += 1
updates.append("last_refreshed_at = NOW()")
# Record history entry with the previous content
if get_config().enable_mental_model_history:
history_entry = json.dumps(
[{"previous_content": previous_content, "changed_at": datetime.now(timezone.utc).isoformat()}]
)
updates.append(f"history = COALESCE(history, '[]'::jsonb) || ${param_idx}::jsonb")
params.append(history_entry)
param_idx += 1
# Also update embedding (convert to string for asyncpg vector type)
embedding_text = f"{name or ''} {content}"
embedding = await embedding_utils.generate_embeddings_batch(self.embeddings, [embedding_text])
@@ -7044,7 +7378,6 @@ class MemoryEngine(MemoryEngineInterface):
self,
bank_id: str,
file_items: list[dict[str, Any]],
parser: str,
document_tags: list[str] | None,
request_context: "RequestContext",
) -> dict[str, Any]:
@@ -7063,7 +7396,7 @@ class MemoryEngine(MemoryEngineInterface):
- metadata: Optional metadata dict
- tags: Optional tags list
- timestamp: Optional timestamp
parser: Parser name (e.g., "markitdown")
- parser: Ordered list of parser names to try (fallback chain)
document_tags: Tags applied to all documents
request_context: Request context for authentication
@@ -7119,7 +7452,7 @@ class MemoryEngine(MemoryEngineInterface):
"storage_key": storage_key,
"original_filename": file.filename,
"content_type": file.content_type or "application/octet-stream",
"parser": parser,
"parser": item["parser"],
"context": item.get("context"),
"metadata": item.get("metadata", {}),
"tags": item.get("tags", []),
@@ -1,14 +0,0 @@
"""
Mental models module for Hindsight.
Mental models contain directives - hard rules that are injected into reflect prompts.
Directives are user-defined and their observations are user-provided (not LLM-generated).
Other types of consolidated knowledge are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
from .models import MentalModel, MentalModelSubtype
__all__ = ["MentalModel", "MentalModelSubtype"]
@@ -1,53 +0,0 @@
"""
Pydantic models for mental models.
"""
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
"""Subtype of mental model.
Currently only DIRECTIVE is supported. Other types of consolidated knowledge
are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
class MentalModel(BaseModel):
"""
A mental model representing synthesized understanding.
Mental models are the agent's consolidated knowledge. Unlike raw facts,
mental models provide:
- A one-liner description for quick scanning/retrieval
- A full summary for deep understanding
- Links to related mental models
"""
id: str = Field(description="Unique identifier within the bank")
bank_id: str = Field(description="Bank this mental model belongs to")
subtype: MentalModelSubtype = Field(description="How this model was created")
name: str = Field(description="Human-readable name")
description: str = Field(description="One-liner for quick scanning and retrieval matching")
summary: str | None = Field(default=None, description="Full synthesized understanding")
# References
entity_id: str | None = Field(default=None, description="Reference to entities table when type=entity")
source_facts: list[str] = Field(default_factory=list, description="Fact IDs used to generate summary")
links: list[str] = Field(default_factory=list, description="Related mental model IDs")
# Tags for scoped visibility (similar to document tags)
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility filtering")
# Timestamps
last_updated: datetime | None = Field(default=None, description="When summary was last regenerated")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
@@ -1,10 +1,31 @@
"""File parser implementations."""
import logging
from dataclasses import dataclass
from .base import FileParser, UnsupportedFileTypeError
from .iris import IrisParser
from .markitdown import MarkitdownParser
__all__ = ["FileParser", "UnsupportedFileTypeError", "IrisParser", "MarkitdownParser", "FileParserRegistry"]
__all__ = [
"FileParser",
"UnsupportedFileTypeError",
"IrisParser",
"MarkitdownParser",
"FileParserRegistry",
"ConvertResult",
]
@dataclass
class ConvertResult:
"""Result of a successful file conversion."""
content: str
parser_name: str
logger = logging.getLogger(__name__)
class FileParserRegistry:
@@ -57,6 +78,51 @@ class FileParserRegistry:
raise ValueError(f"No parser found for {filename}. Available parsers: {list(self._parsers.keys())}")
async def convert_with_fallback(
self,
parsers: list[str],
file_data: bytes,
filename: str,
content_type: str | None = None,
) -> ConvertResult:
"""
Try each parser in order, falling back on failure or empty content.
Moves to the next parser if the current one raises UnsupportedFileTypeError
or returns empty content. Any other exception (RuntimeError, network error,
etc.) also triggers a fallback so the chain is exhausted before failing.
Args:
parsers: Ordered list of parser names to try
file_data: Raw file bytes
filename: Original filename
content_type: MIME type (optional)
Returns:
ConvertResult with the parsed content and the name of the parser that succeeded
Raises:
ValueError: If a parser name is not registered
RuntimeError: If all parsers fail or return empty content
"""
last_error: Exception | None = None
for name in parsers:
parser = self.get_parser(name, filename, content_type)
try:
content = await parser.convert(file_data, filename)
if content and content.strip():
return ConvertResult(content=content, parser_name=name)
logger.warning(f"Parser '{name}' returned empty content for '{filename}', trying next")
last_error = RuntimeError(f"Parser '{name}' returned no content for '{filename}'")
except UnsupportedFileTypeError as e:
logger.warning(f"Parser '{name}' does not support '{filename}', trying next: {e}")
last_error = e
except Exception as e:
logger.warning(f"Parser '{name}' failed for '{filename}', trying next: {e}")
last_error = e
raise last_error or RuntimeError(f"No parsers available for '{filename}'")
def list_parsers(self) -> list[str]:
"""Get list of registered parser names."""
return list(self._parsers.keys())
@@ -62,7 +62,7 @@ class IrisParser(FileParser):
"""
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0)) as client:
# Step 1: Request a presigned upload URL
init_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/files",
@@ -75,9 +75,10 @@ class IrisParser(FileParser):
upload_url: str = init_data["uploadUrl"]
# Step 2: Upload the file bytes to the presigned URL (no auth header)
# Ensure file_data is plain bytes (GCS storage may return obstore.Bytes)
upload_resp = await client.put(
upload_url,
content=file_data,
content=bytes(file_data),
headers={"Content-Type": content_type},
)
_raise_for_status(upload_resp, filename, "file upload")
@@ -522,6 +522,19 @@ class OpenAICompatibleLLM(LLMInterface):
"""
start_time = time.time()
# Normalize named tool_choice dicts to "required" + filter tools.
# Some providers (e.g. LM Studio, Ollama) reject the OpenAI named format
# {"type": "function", "function": {"name": "..."}}. The semantics are
# identical to tool_choice="required" with the tools list restricted to
# just the requested tool, so we apply that transformation universally.
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
forced_name = tool_choice.get("function", {}).get("name")
if forced_name:
filtered = [t for t in tools if t.get("function", {}).get("name") == forced_name]
if filtered:
tools = filtered
tool_choice = "required"
# Build call parameters
call_params: dict[str, Any] = {
"model": self.model,
@@ -1,7 +1,8 @@
"""Google Cloud Storage backend using obstore."""
import logging
from datetime import timedelta
import os
from datetime import datetime, timedelta, timezone
import obstore as obs
from obstore.store import GCSStore
@@ -11,6 +12,30 @@ from .base import FileStorage
logger = logging.getLogger(__name__)
def _make_google_auth_credential_provider():
"""Create a credential provider using google.auth (supports all credential types).
obstore's built-in credential parsing only supports service_account and
authorized_user JSON types. This provider uses the google-auth library
which additionally handles external_account (Workload Identity Federation),
impersonated credentials, and metadata-server credentials.
"""
import google.auth
import google.auth.transport.requests
credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
request = google.auth.transport.requests.Request()
def _provide():
credentials.refresh(request)
expiry = credentials.expiry
if expiry and expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
return {"token": credentials.token, "expires_at": expiry}
return _provide
class GCSFileStorage(FileStorage):
"""
Google Cloud Storage backend.
@@ -27,8 +52,29 @@ class GCSFileStorage(FileStorage):
kwargs: dict = {}
if service_account_key:
kwargs["service_account_key"] = service_account_key
else:
# Use google.auth credential provider for broad credential type support
# (service_account, authorized_user, external_account, metadata server, etc.)
try:
kwargs["credential_provider"] = _make_google_auth_credential_provider()
logger.info("Using google.auth credential provider for GCS")
except Exception as e:
logger.warning(
f"Failed to create google.auth credential provider, falling back to obstore defaults: {e}"
)
self._store = GCSStore(bucket, **kwargs)
# Workaround for https://github.com/developmentseed/obstore/issues/605
# obstore's Rust layer doesn't support external_account credentials (Workload
# Identity Federation) and eagerly parses GOOGLE_APPLICATION_CREDENTIALS even
# when credential_provider is given. Per the obstore maintainer's guidance,
# remove env vars so the Rust code doesn't try to authenticate itself.
# google.auth (used by credential_provider above) has already loaded credentials.
gac = os.environ.pop("GOOGLE_APPLICATION_CREDENTIALS", None)
try:
self._store = GCSStore(bucket, **kwargs)
finally:
if gac is not None:
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = gac
logger.info(f"Initialized GCS file storage: bucket={bucket}")
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
@@ -30,6 +30,8 @@ from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
# File Conversion
FileConvertResult,
# Mental Model operations
MentalModelGetContext,
MentalModelGetResult,
@@ -83,6 +85,8 @@ __all__ = [
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
# Operation Validator - File Conversion
"FileConvertResult",
# Operation Validator - Mental Model
"MentalModelGetContext",
"MentalModelGetResult",
@@ -289,6 +289,28 @@ class MentalModelRefreshResult:
error: str | None = None
# =============================================================================
# File Conversion Post-operation Context
# =============================================================================
@dataclass
class FileConvertResult:
"""Result context for post-file-conversion hook.
Fired after a file is converted to markdown, before the retain step.
"""
bank_id: str
parser_name: str
filename: str
output_chars: int
output_text: str
request_context: "RequestContext"
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect/consolidate operations.
@@ -496,6 +518,31 @@ class OperationValidatorExtension(Extension, ABC):
"""
pass
# =========================================================================
# File Conversion - Post-operation hook (optional - override to implement)
# =========================================================================
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
"""
Called after a file is converted to markdown (before the retain step).
Override to implement post-conversion logic such as:
- Billing for premium parsers (e.g., Iris)
- Usage tracking
- Audit logging
Args:
result: Result context containing:
- bank_id: Bank identifier
- parser_name: Name of the parser used (e.g., 'markitdown', 'iris')
- filename: Original filename
- output_chars: Character count of the converted markdown
- request_context: Request context with auth info
- success: Whether the conversion succeeded
- error: Error message (if failed)
"""
pass
# =========================================================================
# Mental Model - Pre-operation validation hook (optional - override to implement)
# =========================================================================
+5
View File
@@ -268,6 +268,7 @@ def main():
file_storage_azure_account_name=config.file_storage_azure_account_name,
file_storage_azure_account_key=config.file_storage_azure_account_key,
file_parser=config.file_parser,
file_parser_allowlist=config.file_parser_allowlist,
file_parser_iris_token=config.file_parser_iris_token,
file_parser_iris_org_id=config.file_parser_iris_org_id,
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
@@ -275,9 +276,13 @@ def main():
enable_file_upload_api=config.enable_file_upload_api,
file_delete_after_retain=config.file_delete_after_retain,
enable_observations=config.enable_observations,
enable_observation_history=config.enable_observation_history,
enable_mental_model_history=config.enable_mental_model_history,
consolidation_batch_size=config.consolidation_batch_size,
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
consolidation_max_tokens=config.consolidation_max_tokens,
consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens,
consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
observations_mission=config.observations_mission,
entity_labels=config.entity_labels,
entities_allow_free_form=config.entities_allow_free_form,
+13 -2
View File
@@ -18,6 +18,7 @@ No alembic.ini required - all configuration is done programmatically.
import hashlib
import logging
import os
import threading
import time
from pathlib import Path
@@ -33,6 +34,13 @@ logger = logging.getLogger(__name__)
# Advisory lock ID for migrations (arbitrary unique number)
MIGRATION_LOCK_ID = 123456789
# Alembic's command.upgrade() is NOT thread-safe: it uses module-level global
# proxies (context._proxy, script) that get overwritten when two threads call
# upgrade() concurrently. This causes migrations to target the wrong schema
# and crash with "relation already exists" or KeyError: 'script'.
# Serialize all Alembic invocations with a process-level lock.
_alembic_lock = threading.Lock()
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
"""
@@ -144,9 +152,12 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
if schema:
alembic_cfg.set_main_option("target_schema", schema)
# Run migrations
# Run migrations under a process-level lock. Alembic uses module-level
# global proxies that are not thread-safe, so concurrent command.upgrade()
# calls from different threads corrupt each other's context.
try:
command.upgrade(alembic_cfg, "head")
with _alembic_lock:
command.upgrade(alembic_cfg, "head")
except ResolutionError as e:
# This happens during rolling deployments when a newer version of the code
# has already run migrations, and this older replica doesn't have the new
+81 -1
View File
@@ -6,12 +6,14 @@ Note: Consolidation runs automatically after retain via SyncTaskBackend in tests
import uuid
from datetime import datetime, timezone
from unittest.mock import patch
from unittest.mock import AsyncMock, call, patch
import pytest
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.consolidation.consolidator import (
_aggregate_source_fields,
_find_related_observations,
run_consolidation_job,
)
from hindsight_api.engine.memory_engine import MemoryEngine
@@ -2418,3 +2420,81 @@ class TestAggregateSourceFields:
assert agg.occurred_end == d
assert agg.mentioned_at == d
assert agg.tags == ["x"]
class TestConsolidationSourceFactsConfig:
"""Tests that consolidation uses the source_facts token config when calling recall."""
@pytest.fixture(autouse=True)
def enable_observations(self):
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original
@pytest.mark.asyncio
async def test_consolidation_passes_source_facts_max_tokens_to_recall(
self, memory: MemoryEngine, request_context
):
"""consolidation_source_facts_max_tokens from config is forwarded to recall_async."""
bank_id = f"test-sf-config-total-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
raw = _get_raw_config()
fake_config = type(raw)(**{
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
"consolidation_source_facts_max_tokens": 999,
"consolidation_source_facts_max_tokens_per_observation": -1,
})
try:
with (
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
):
await _find_related_observations(
memory_engine=memory,
bank_id=bank_id,
query="test query",
request_context=request_context,
)
assert mock_recall.called
_, kwargs = mock_recall.call_args
assert kwargs.get("max_source_facts_tokens") == 999
assert kwargs.get("max_source_facts_tokens_per_observation") == -1
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_passes_source_facts_per_obs_tokens_to_recall(
self, memory: MemoryEngine, request_context
):
"""consolidation_source_facts_max_tokens_per_observation from config is forwarded to recall_async."""
bank_id = f"test-sf-config-per-obs-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
raw = _get_raw_config()
fake_config = type(raw)(**{
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
"consolidation_source_facts_max_tokens": -1,
"consolidation_source_facts_max_tokens_per_observation": 128,
})
try:
with (
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
):
await _find_related_observations(
memory_engine=memory,
bank_id=bank_id,
query="test query",
request_context=request_context,
)
assert mock_recall.called
_, kwargs = mock_recall.call_args
assert kwargs.get("max_source_facts_tokens") == -1
assert kwargs.get("max_source_facts_tokens_per_observation") == 128
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+216 -2
View File
@@ -2,12 +2,22 @@
End-to-end tests for file retain (upload, convert, retain) functionality.
"""
import asyncio
import io
import json
import pytest
from httpx import ASGITransport, AsyncClient
from hindsight_api.extensions import FileConvertResult, OperationValidatorExtension, ValidationResult
from hindsight_api.extensions.operation_validator import (
RecallContext,
RecallResult,
ReflectContext,
RetainContext,
RetainResult,
)
@pytest.fixture
def sample_pdf_content():
@@ -393,13 +403,13 @@ async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_v
"metadata": {"source": "test"},
"tags": ["test_tag"],
"timestamp": None,
"parser": ["markitdown"],
}
]
result = await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser="markitdown",
document_tags=["two_phase_test"],
request_context=context,
)
@@ -511,6 +521,7 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["failing_converter"],
}
]
@@ -518,7 +529,6 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
result = await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser="failing_converter",
document_tags=None,
request_context=context,
)
@@ -551,3 +561,207 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
assert operation["error_message"] is not None
assert "Mock conversion error" in operation["error_message"]
assert "test.fail" in operation["error_message"]
class FileConvertTrackingValidator(OperationValidatorExtension):
"""Validator that tracks on_file_convert_complete hook calls."""
def __init__(self):
super().__init__({})
self.convert_calls: list[FileConvertResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
async def on_retain_complete(self, result: RetainResult) -> None:
pass
async def on_recall_complete(self, result: RecallResult) -> None:
pass
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
self.convert_calls.append(result)
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_called(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete hook is called after file conversion with correct parameters."""
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
context = RequestContext(internal=True, api_key_id="test-key-id", tenant_id="test-tenant")
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
mock_file = MockFile(sample_txt_content, "report.txt", "text/plain")
file_items = [
{
"file": mock_file,
"document_id": "hook_test_doc",
"context": "test context",
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["markitdown"],
}
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.1)
assert len(validator.convert_calls) == 1
result = validator.convert_calls[0]
assert result.bank_id == bank_id
assert result.filename == "report.txt"
assert result.parser_name == "markitdown"
assert result.output_chars > 0
assert result.output_text is not None
assert len(result.output_text) == result.output_chars
assert result.success is True
assert result.error is None
assert result.request_context is not None
assert result.request_context.api_key_id == "test-key-id"
assert result.request_context.tenant_id == "test-tenant"
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_called_for_each_file(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete is called once per file when uploading multiple files."""
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_multi_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
context = RequestContext(internal=True)
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
file_items = [
{
"file": MockFile(b"First document content", "first.txt", "text/plain"),
"document_id": "doc_1",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["markitdown"],
},
{
"file": MockFile(b"Second document content", "second.txt", "text/plain"),
"document_id": "doc_2",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["markitdown"],
},
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.2)
assert len(validator.convert_calls) == 2
filenames = {r.filename for r in validator.convert_calls}
assert filenames == {"first.txt", "second.txt"}
for result in validator.convert_calls:
assert result.bank_id == bank_id
assert result.parser_name == "markitdown"
assert result.output_chars > 0
assert result.success is True
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_not_called_on_conversion_failure(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete is NOT called when file conversion fails."""
from hindsight_api.engine.parsers.base import FileParser
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_fail_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
class FailingParser(FileParser):
async def convert(self, file_data: bytes, filename: str) -> str:
raise RuntimeError("Mock conversion failure")
def supports(self, filename: str, content_type: str | None = None) -> bool:
return filename.endswith(".hookfail")
def name(self) -> str:
return "hookfail_parser"
memory_no_llm_verify._parser_registry.register(FailingParser())
context = RequestContext(internal=True)
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
file_items = [
{
"file": MockFile(sample_txt_content, "bad.hookfail", "application/octet-stream"),
"document_id": "fail_hook_doc",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["hookfail_parser"],
}
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.2)
assert len(validator.convert_calls) == 0
@@ -75,6 +75,9 @@ async def test_hierarchical_fields_categorization():
assert "retain_custom_instructions" in configurable
assert "retain_chunk_size" in configurable
assert "enable_observations" in configurable
assert "consolidation_llm_batch_size" in configurable
assert "consolidation_source_facts_max_tokens" in configurable
assert "consolidation_source_facts_max_tokens_per_observation" in configurable
assert "observations_mission" in configurable
assert "reflect_mission" in configurable
assert "disposition_skepticism" in configurable
@@ -86,7 +89,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 14
assert len(configurable) == 17
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
@@ -0,0 +1,321 @@
"""
Reproduce issue #520: Reflect fails with LM Studio due to unsupported tool_choice format.
The reflect agent forces tool selection via named tool_choice dicts on the first few iterations:
{"type": "function", "function": {"name": "search_mental_models"}}
LM Studio (and Ollama) reject this format with HTTP 400:
"Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'."
The fix should convert named tool_choice to "required" and filter the tools list
to only the requested tool for providers that don't support named tool_choice.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import APIStatusError
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
# Reflect agent tools (subset matching what agent.py uses)
REFLECT_TOOLS = [
{
"type": "function",
"function": {
"name": "search_mental_models",
"description": "Search consolidated mental models",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "search_observations",
"description": "Search raw observations",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "recall",
"description": "Recall semantic memories",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "done",
"description": "Finish and return the answer",
"parameters": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
},
},
},
]
def _make_lmstudio_llm() -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider="lmstudio",
api_key="local",
base_url="http://localhost:1234/v1",
model="openai/gpt-oss-20b",
)
def _lmstudio_400_error(msg: str = "Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'.") -> APIStatusError:
"""Simulate the HTTP 400 LM Studio returns for unsupported tool_choice format."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.headers = {}
return APIStatusError(
message=msg,
response=mock_response,
body={"error": {"message": msg, "type": "invalid_request_error"}},
)
def _make_tool_call_response(tool_name: str, arguments: dict) -> MagicMock:
"""Build a mock successful tool call response from the LLM API."""
mock_tc = MagicMock()
mock_tc.id = "call_abc123"
mock_tc.function.name = tool_name
mock_tc.function.arguments = json.dumps(arguments)
mock_response = MagicMock()
mock_response.usage.prompt_tokens = 120
mock_response.usage.completion_tokens = 40
mock_response.usage.total_tokens = 160
mock_response.choices[0].finish_reason = "tool_calls"
mock_response.choices[0].message.content = None
mock_response.choices[0].message.tool_calls = [mock_tc]
return mock_response
class TestLMStudioNamedToolChoiceBug:
"""
Reproduces issue #520.
The reflect agent (agent.py lines 546-555) sets tool_choice to a named dict
on the first iterations to force sequential retrieval:
iteration=0, has_mental_models=True → {"type": "function", "function": {"name": "search_mental_models"}}
iteration=0, has_mental_models=False → {"type": "function", "function": {"name": "search_observations"}}
iteration=1, has_mental_models=True → {"type": "function", "function": {"name": "search_observations"}}
iteration=1 or (2 with models) → {"type": "function", "function": {"name": "recall"}}
LM Studio rejects these dict formats with HTTP 400.
"""
@pytest.mark.asyncio
async def test_lmstudio_named_tool_choice_no_longer_causes_400(self):
"""
Regression test for issue #520: named tool_choice dict is converted to
"required" + filtered tools before the API call, so LM Studio never
sees the unsupported format and the 400 error no longer occurs.
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
# Should succeed — no 400 because the dict is converted before sending
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What is the user's name?"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "search_mental_models"
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"forced_tool_name",
["search_mental_models", "search_observations", "recall"],
)
async def test_all_reflect_forced_tools_fail_on_lmstudio(self, forced_tool_name: str):
"""
Each named tool_choice the reflect agent uses on iterations 0-2 triggers
the same 400 error on LM Studio.
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.side_effect = _lmstudio_400_error()
with pytest.raises(APIStatusError) as exc_info:
await llm.call_with_tools(
messages=[{"role": "user", "content": "Test query"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_lmstudio_string_tool_choice_works_fine(self):
"""
String tool_choice values ("auto", "none", "required") ARE supported by LM Studio.
Only the dict format {"type": "function", "function": {"name": "..."}} fails.
This test confirms the control case works.
"""
llm = _make_lmstudio_llm()
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What is the user's name?"}],
tools=REFLECT_TOOLS,
tool_choice="required", # string form — LM Studio accepts this
max_retries=0,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "search_mental_models"
# Confirm "required" was sent, not a dict
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
class TestExpectedFixBehavior:
"""
Tests that document the EXPECTED behavior after the fix is applied.
For lmstudio (and ollama) providers, when tool_choice is a named dict:
{"type": "function", "function": {"name": "search_mental_models"}}
The fix should:
1. Convert tool_choice to "required"
2. Filter tools to only the requested tool
These tests currently FAIL (because the fix is not yet implemented).
After the fix is applied, they should PASS.
"""
@pytest.mark.asyncio
async def test_fix_converts_named_tool_choice_to_required(self):
"""
After fix: named tool_choice dict is converted to "required" for lmstudio.
The API receives tool_choice="required" instead of the unsupported dict.
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What is the user's name?"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "search_mental_models"
sent_kwargs = mock_create.call_args.kwargs
# Fix: dict was converted to "required"
assert sent_kwargs["tool_choice"] == "required", (
f"Expected tool_choice='required', got {sent_kwargs['tool_choice']!r}"
)
# Fix: tools filtered to just the requested one
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"forced_tool_name",
["search_mental_models", "search_observations", "recall"],
)
async def test_fix_filters_tools_to_requested_tool(self, forced_tool_name: str):
"""
After fix: tools list is filtered to only the forced tool so the model
can only call that one tool (equivalent to the named tool_choice behavior).
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
success_response = _make_tool_call_response(forced_tool_name, {"query": "test"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
await llm.call_with_tools(
messages=[{"role": "user", "content": "Test query"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == forced_tool_name
@pytest.mark.asyncio
async def test_fix_also_applies_to_openai_provider(self):
"""
The fix is generalized: all providers convert named tool_choice to
"required" + filtered tools. OpenAI natively supports the dict format
too, so the behaviour is semantically identical either way.
"""
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
openai_llm = OpenAICompatibleLLM(
provider="openai",
api_key="sk-test",
base_url="",
model="gpt-4o-mini",
)
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
success_response = _make_tool_call_response("search_mental_models", {"query": "test"})
with patch.object(openai_llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
await openai_llm.call_with_tools(
messages=[{"role": "user", "content": "Test"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
sent_kwargs = mock_create.call_args.kwargs
# Generalized fix applies to OpenAI too
assert sent_kwargs["tool_choice"] == "required"
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
+107
View File
@@ -656,6 +656,113 @@ class TestDirectivesPromptInjection:
assert directives_pos < critical_rules_pos
class TestMentalModelHistory:
"""Test mental model history persistence."""
async def test_history_recorded_on_content_update(self, memory: MemoryEngine, request_context):
"""Test that updating content records a history entry."""
bank_id = f"test-mm-history-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="Original content",
request_context=request_context,
)
# No history yet
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert history == []
# Update content
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="Updated content",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 1
assert history[0]["previous_content"] == "Original content"
assert "changed_at" in history[0]
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_ordered_most_recent_first(self, memory: MemoryEngine, request_context):
"""Test that history is returned most recent first."""
bank_id = f"test-mm-history-order-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="v1",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v2",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v3",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 2
# Most recent first: second update recorded "v2" as previous, first recorded "v1"
assert history[0]["previous_content"] == "v2"
assert history[1]["previous_content"] == "v1"
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_not_recorded_on_name_only_update(self, memory: MemoryEngine, request_context):
"""Test that updating only name does not record history."""
bank_id = f"test-mm-history-name-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Original Name",
source_query="What is the test?",
content="Content",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
name="Updated Name",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert history == []
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_returns_none_for_missing_model(self, memory: MemoryEngine, request_context):
"""Test that history returns None when mental model doesn't exist."""
bank_id = f"test-mm-history-missing-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
result = await memory.get_mental_model_history(
bank_id, "nonexistent-id", request_context=request_context
)
assert result is None
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelRefreshTagSecurity:
"""Test that mental model refresh respects tag-based security boundaries."""
@@ -0,0 +1,48 @@
import threading
import time
from hindsight_api import migrations
def test_run_migrations_internal_serializes_alembic_upgrade(monkeypatch):
max_concurrent_upgrades = 0
active_upgrades = 0
active_lock = threading.Lock()
start_barrier = threading.Barrier(2)
def fake_upgrade(_cfg, _revision):
nonlocal max_concurrent_upgrades, active_upgrades
with active_lock:
active_upgrades += 1
max_concurrent_upgrades = max(max_concurrent_upgrades, active_upgrades)
time.sleep(0.05)
with active_lock:
active_upgrades -= 1
monkeypatch.setattr(migrations.command, "upgrade", fake_upgrade)
errors = []
def run_in_thread(schema):
try:
start_barrier.wait()
migrations._run_migrations_internal(
"postgresql://user:pass@localhost/db",
"/tmp/alembic",
schema=schema,
)
except Exception as exc: # pragma: no cover - diagnostic path
errors.append(exc)
threads = [
threading.Thread(target=run_in_thread, args=("tenant_alpha",)),
threading.Thread(target=run_in_thread, args=("tenant_beta",)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert not errors
assert max_concurrent_upgrades == 1
@@ -455,3 +455,281 @@ class TestClearObservationsForMemory:
assert await _get_consolidated_at(conn, m2) is None
await memory.delete_bank(bank_id, request_context=request_context)
# ---------------------------------------------------------------------------
# Tests: update_document
# ---------------------------------------------------------------------------
async def _insert_document_with_memories(
conn, bank_id: str, doc_id: str, memories: list[tuple[str, str]]
) -> list[uuid.UUID]:
"""Insert a document and attach memory units to it. Returns list of memory UUIDs."""
await conn.execute(
"""
INSERT INTO documents (id, bank_id, original_text, content_hash, created_at, updated_at)
VALUES ($1, $2, 'some doc', 'hash123', NOW(), NOW())
""",
doc_id,
bank_id,
)
mem_ids = []
for text, fact_type in memories:
mem_id = uuid.uuid4()
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, event_date, document_id, created_at, updated_at, consolidated_at)
VALUES ($1, $2, $3, $4, NOW(), $5, NOW(), NOW(), NOW())
""",
mem_id,
bank_id,
text,
fact_type,
doc_id,
)
mem_ids.append(mem_id)
return mem_ids
class TestUpdateDocumentTagsObservationCleanup:
@pytest.mark.asyncio
async def test_update_tags_returns_updated_document(
self, memory: MemoryEngine, request_context: RequestContext
):
"""update_document returns the updated document with new tags."""
bank_id = f"test-tag-update-basic-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
await _insert_document_with_memories(conn, bank_id, doc_id, [("Alice loves hiking.", "experience")])
result = await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
assert result is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_returns_none_for_missing_document(
self, memory: MemoryEngine, request_context: RequestContext
):
"""update_document returns False when document does not exist."""
bank_id = f"test-tag-update-missing-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
result = await memory.update_document(
"nonexistent-doc", bank_id, tags=["tag"], request_context=request_context
)
assert result is False
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_propagates_to_memory_units(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Changing document tags also updates all associated memory unit tags."""
bank_id = f"test-tag-update-propagate-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience"), ("Alice hikes weekly.", "world")]
)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
for mem_id in mem_ids:
tags = await conn.fetchval(
"SELECT tags FROM memory_units WHERE id = $1", mem_id
)
assert list(tags) == ["new-tag"], f"Memory unit {mem_id} should have updated tags"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_invalidates_observations(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Observations referencing the document's memory units are deleted on tag change."""
bank_id = f"test-tag-update-obs-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
obs_ids = await _get_observation_ids(conn, bank_id)
assert str(obs_id) not in obs_ids, "Observation should have been invalidated"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_resets_consolidated_at_on_affected_units(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Affected memory units get consolidated_at reset for re-consolidation under new tags."""
bank_id = f"test-tag-update-reset-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
# Verify memory starts consolidated
assert await _get_consolidated_at(conn, mem_ids[0]) is not None
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
consolidated_at = await _get_consolidated_at(conn, mem_ids[0])
assert consolidated_at is None, "Memory unit should be reset for re-consolidation"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_triggers_consolidation_when_observations_invalidated(
self, memory: MemoryEngine, request_context: RequestContext
):
"""submit_async_consolidation is called when observations are invalidated."""
bank_id = f"test-tag-update-cons-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate:
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
mock_consolidate.assert_awaited_once()
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_no_consolidation_when_no_observations(
self, memory: MemoryEngine, request_context: RequestContext
):
"""submit_async_consolidation is NOT called when no observations are invalidated."""
bank_id = f"test-tag-update-nocons-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
# No observations inserted
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate:
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
mock_consolidate.assert_not_awaited()
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_resets_co_source_memories_from_other_documents(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Co-source memories from other documents that shared an invalidated observation are also reset."""
bank_id = f"test-tag-update-cosource-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
doc_mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
# Unrelated memory from another document — co-sourced in the same observation
other_mem = await _insert_memory(conn, bank_id, "Alice also rock-climbs.")
obs_id = await _insert_observation(
conn, bank_id, "Alice loves outdoor activities.", doc_mem_ids + [other_mem]
)
# Verify other_mem starts consolidated
assert await _get_consolidated_at(conn, other_mem) is not None
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
obs_ids = await _get_observation_ids(conn, bank_id)
assert str(obs_id) not in obs_ids, "Observation should have been invalidated"
# other_mem (co-source from another document) must also be reset
consolidated_at = await _get_consolidated_at(conn, other_mem)
assert consolidated_at is None, "Co-source memory from other document should be reset"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_does_not_affect_unrelated_observations(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Observations referencing memories from a different document are not affected."""
bank_id = f"test-tag-update-unrelated-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
# Unrelated memory not in the document
unrelated = await _insert_memory(conn, bank_id, "Bob likes cycling.")
unrelated_obs_id = await _insert_observation(
conn, bank_id, "Bob is a cyclist.", [unrelated]
)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
obs_ids = await _get_observation_ids(conn, bank_id)
assert str(unrelated_obs_id) in obs_ids, "Unrelated observation should remain untouched"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,170 @@
"""Tests for source_facts token limiting in recall.
Covers:
- max_source_facts_tokens: total token budget across all source facts
- max_source_facts_tokens_per_observation: per-observation cap
Both parameters are tested at the recall_async level and verified to produce
fewer source facts when the budget is tight vs. unlimited.
"""
import pytest
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
@pytest.fixture(autouse=True)
def enable_observations():
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original
async def _setup_bank_with_observations(memory, bank_id, request_context):
"""Retain several memories and trigger consolidation to produce observations with source facts."""
contents = [
"Alice is a software engineer who loves Python programming.",
"Alice has been working at TechCorp for 5 years.",
"Alice recently completed a machine learning certification course.",
"Alice mentors junior developers on the team.",
"Alice prefers functional programming patterns in her code.",
]
for content in contents:
await memory.retain_async(
bank_id=bank_id,
content=content,
request_context=request_context,
)
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
class TestRecallSourceFactsPerObservationCap:
@pytest.mark.asyncio
async def test_per_observation_cap_reduces_source_facts(self, memory, request_context):
"""A tight per-observation token cap should return fewer source facts than unlimited."""
bank_id = "test-sf-per-obs-cap"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
result_limited = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens_per_observation=1, # Effectively cuts all source facts
budget=Budget.MID,
request_context=request_context,
)
result_unlimited = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens_per_observation=-1,
budget=Budget.MID,
request_context=request_context,
)
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
limited_count = len(result_limited.source_facts) if result_limited.source_facts else 0
if unlimited_count > 0:
assert limited_count <= unlimited_count, (
f"Per-observation cap should yield fewer source facts ({limited_count} <= {unlimited_count})"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_per_observation_cap_does_not_mix_between_observations(self, memory, request_context):
"""Each observation's source facts are capped independently — not as a shared pool."""
bank_id = "test-sf-per-obs-independent"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
# With a generous per-observation limit each observation can have facts;
# with a global limit of 1 token the first observation would consume the whole budget.
result_per_obs = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens=4096, # large global budget
max_source_facts_tokens_per_observation=512, # reasonable per-obs limit
budget=Budget.MID,
request_context=request_context,
)
# Should not raise; source_facts may be populated for multiple observations
assert result_per_obs.source_facts is not None or len(result_per_obs.results) == 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
class TestRecallSourceFactsTotalBudget:
@pytest.mark.asyncio
async def test_total_budget_limits_source_facts(self, memory, request_context):
"""A tight total token budget should return fewer source facts than unlimited."""
bank_id = "test-sf-total-budget"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
result_tight = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens=1, # Effectively cuts all source facts
budget=Budget.MID,
request_context=request_context,
)
result_unlimited = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens=-1,
budget=Budget.MID,
request_context=request_context,
)
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
tight_count = len(result_tight.source_facts) if result_tight.source_facts else 0
if unlimited_count > 0:
assert tight_count <= unlimited_count, (
f"Total budget should yield fewer source facts ({tight_count} <= {unlimited_count})"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_no_source_facts_without_flag(self, memory, request_context):
"""source_facts should be None when include_source_facts is not set."""
bank_id = "test-sf-no-flag"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
result = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=False, # default
budget=Budget.MID,
request_context=request_context,
)
assert result.source_facts is None or len(result.source_facts) == 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+185 -5
View File
@@ -210,8 +210,9 @@ paths:
- Memory
/v1/default/banks/{bank_id}/memories/{memory_id}:
get:
description: Get a single memory unit by ID with all its metadata including
entities and tags.
description: "Get a single memory unit by ID with all its metadata including\
\ entities and tags. Note: the 'history' field is deprecated and always returns\
\ an empty list - use GET /memories/{memory_id}/history instead."
operationId: get_memory
parameters:
- explode: false
@@ -253,6 +254,51 @@ paths:
summary: Get 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\
\ facts resolved to their text."
operationId: get_observation_history
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
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get observation history
tags:
- Memory
/v1/default/banks/{bank_id}/memories/recall:
post:
description: |-
@@ -838,6 +884,51 @@ paths:
summary: Update mental model
tags:
- Mental Models
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history:
get:
description: "Get the refresh history of a mental model, showing content changes\
\ over time."
operationId: get_mental_model_history
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: mental_model_id
required: true
schema:
title: Mental Model Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get mental model history
tags:
- Mental Models
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh:
post:
description: Submit an async task to re-run the source query through reflect
@@ -1344,6 +1435,61 @@ paths:
summary: Get document details
tags:
- Documents
patch:
description: |-
Update mutable fields on a document without re-processing its content.
**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
At least one field must be provided.
operationId: update_document
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: document_id
required: true
schema:
title: Document 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/UpdateDocumentRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateDocumentResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Update document
tags:
- Documents
/v1/default/banks/{bank_id}/tags:
get:
description: "List all unique tags in a memory bank with usage counts. Supports\
@@ -2492,9 +2638,14 @@ paths:
**Request format:** multipart/form-data with:
- `files`: One or more files to upload
- `request`: JSON string with FileRetainRequest model (files_metadata)
- `request`: JSON string with FileRetainRequest model
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
**Parser selection:**
- Set `parser` in the request body to override the server default for all files.
- Set `parser` inside a `files_metadata` entry for per-file control.
- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds.
- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
- Only parsers enabled on the server may be requested; others return HTTP 400.
operationId: file_retain
parameters:
- explode: false
@@ -4591,9 +4742,15 @@ components:
properties:
max_tokens:
default: 4096
description: Maximum tokens for source facts
description: Maximum total tokens for source facts across all observations
(-1 = unlimited)
title: Max Tokens
type: integer
max_tokens_per_observation:
default: -1
description: Maximum tokens of source facts per observation (-1 = unlimited)
title: Max Tokens Per Observation
type: integer
title: SourceFactsIncludeOptions
TagItem:
description: Single tag with usage count.
@@ -4689,6 +4846,29 @@ components:
required:
- disposition
title: UpdateDispositionRequest
UpdateDocumentRequest:
description: Request model for updating a document's mutable fields.
example:
tags:
- team-a
- team-b
properties:
tags:
items:
type: string
nullable: true
type: array
title: UpdateDocumentRequest
UpdateDocumentResponse:
description: Response model for update document endpoint.
example:
success: true
properties:
success:
default: true
title: Success
type: boolean
title: UpdateDocumentResponse
UpdateMentalModelRequest:
description: Request model for updating a mental model.
example:
+141
View File
@@ -591,3 +591,144 @@ func (a *DocumentsAPIService) ListDocumentsExecute(r ApiListDocumentsRequest) (*
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiUpdateDocumentRequest struct {
ctx context.Context
ApiService *DocumentsAPIService
bankId string
documentId string
updateDocumentRequest *UpdateDocumentRequest
authorization *string
}
func (r ApiUpdateDocumentRequest) UpdateDocumentRequest(updateDocumentRequest UpdateDocumentRequest) ApiUpdateDocumentRequest {
r.updateDocumentRequest = &updateDocumentRequest
return r
}
func (r ApiUpdateDocumentRequest) Authorization(authorization string) ApiUpdateDocumentRequest {
r.authorization = &authorization
return r
}
func (r ApiUpdateDocumentRequest) Execute() (*UpdateDocumentResponse, *http.Response, error) {
return r.ApiService.UpdateDocumentExecute(r)
}
/*
UpdateDocument Update document
Update mutable fields on a document without re-processing its content.
**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
At least one field must be provided.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param documentId
@return ApiUpdateDocumentRequest
*/
func (a *DocumentsAPIService) UpdateDocument(ctx context.Context, bankId string, documentId string) ApiUpdateDocumentRequest {
return ApiUpdateDocumentRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
documentId: documentId,
}
}
// Execute executes the request
// @return UpdateDocumentResponse
func (a *DocumentsAPIService) UpdateDocumentExecute(r ApiUpdateDocumentRequest) (*UpdateDocumentResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPatch
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *UpdateDocumentResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DocumentsAPIService.UpdateDocument")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/documents/{document_id}"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"document_id"+"}", url.PathEscape(parameterValueToString(r.documentId, "documentId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.updateDocumentRequest == nil {
return localVarReturnValue, nil, reportError("updateDocumentRequest 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.updateDocumentRequest
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
}
+7 -2
View File
@@ -78,9 +78,14 @@ Use the operations endpoint to monitor progress.
**Request format:** multipart/form-data with:
- `files`: One or more files to upload
- `request`: JSON string with FileRetainRequest model (files_metadata)
- `request`: JSON string with FileRetainRequest model
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
**Parser selection:**
- Set `parser` in the request body to override the server default for all files.
- Set `parser` inside a `files_metadata` entry for per-file control.
- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds.
- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
- Only parsers enabled on the server may be requested; others return HTTP 400.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
+127 -1
View File
@@ -483,7 +483,7 @@ func (r ApiGetMemoryRequest) Execute() (interface{}, *http.Response, error) {
/*
GetMemory Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@@ -589,6 +589,132 @@ func (a *MemoryAPIService) GetMemoryExecute(r ApiGetMemoryRequest) (interface{},
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetObservationHistoryRequest struct {
ctx context.Context
ApiService *MemoryAPIService
bankId string
memoryId string
authorization *string
}
func (r ApiGetObservationHistoryRequest) Authorization(authorization string) ApiGetObservationHistoryRequest {
r.authorization = &authorization
return r
}
func (r ApiGetObservationHistoryRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.GetObservationHistoryExecute(r)
}
/*
GetObservationHistory Get observation history
Get the full history of an observation, with each change's source facts resolved to their text.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param memoryId
@return ApiGetObservationHistoryRequest
*/
func (a *MemoryAPIService) GetObservationHistory(ctx context.Context, bankId string, memoryId string) ApiGetObservationHistoryRequest {
return ApiGetObservationHistoryRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
memoryId: memoryId,
}
}
// Execute executes the request
// @return interface{}
func (a *MemoryAPIService) GetObservationHistoryExecute(r ApiGetObservationHistoryRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.GetObservationHistory")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/memories/{memory_id}/history"
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{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// 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", "")
}
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
}
type ApiListMemoriesRequest struct {
ctx context.Context
ApiService *MemoryAPIService
+126
View File
@@ -409,6 +409,132 @@ func (a *MentalModelsAPIService) GetMentalModelExecute(r ApiGetMentalModelReques
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetMentalModelHistoryRequest struct {
ctx context.Context
ApiService *MentalModelsAPIService
bankId string
mentalModelId string
authorization *string
}
func (r ApiGetMentalModelHistoryRequest) Authorization(authorization string) ApiGetMentalModelHistoryRequest {
r.authorization = &authorization
return r
}
func (r ApiGetMentalModelHistoryRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.GetMentalModelHistoryExecute(r)
}
/*
GetMentalModelHistory Get mental model history
Get the refresh history of a mental model, showing content changes over time.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param mentalModelId
@return ApiGetMentalModelHistoryRequest
*/
func (a *MentalModelsAPIService) GetMentalModelHistory(ctx context.Context, bankId string, mentalModelId string) ApiGetMentalModelHistoryRequest {
return ApiGetMentalModelHistoryRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
mentalModelId: mentalModelId,
}
}
// Execute executes the request
// @return interface{}
func (a *MentalModelsAPIService) GetMentalModelHistoryExecute(r ApiGetMentalModelHistoryRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MentalModelsAPIService.GetMentalModelHistory")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"mental_model_id"+"}", url.PathEscape(parameterValueToString(r.mentalModelId, "mentalModelId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// 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", "")
}
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
}
type ApiListMentalModelsRequest struct {
ctx context.Context
ApiService *MentalModelsAPIService
@@ -19,8 +19,10 @@ var _ MappedNullable = &SourceFactsIncludeOptions{}
// SourceFactsIncludeOptions Options for including source facts for observation-type results.
type SourceFactsIncludeOptions struct {
// Maximum tokens for source facts
// Maximum total tokens for source facts across all observations (-1 = unlimited)
MaxTokens *int32 `json:"max_tokens,omitempty"`
// Maximum tokens of source facts per observation (-1 = unlimited)
MaxTokensPerObservation *int32 `json:"max_tokens_per_observation,omitempty"`
}
// NewSourceFactsIncludeOptions instantiates a new SourceFactsIncludeOptions object
@@ -31,6 +33,8 @@ func NewSourceFactsIncludeOptions() *SourceFactsIncludeOptions {
this := SourceFactsIncludeOptions{}
var maxTokens int32 = 4096
this.MaxTokens = &maxTokens
var maxTokensPerObservation int32 = -1
this.MaxTokensPerObservation = &maxTokensPerObservation
return &this
}
@@ -41,6 +45,8 @@ func NewSourceFactsIncludeOptionsWithDefaults() *SourceFactsIncludeOptions {
this := SourceFactsIncludeOptions{}
var maxTokens int32 = 4096
this.MaxTokens = &maxTokens
var maxTokensPerObservation int32 = -1
this.MaxTokensPerObservation = &maxTokensPerObservation
return &this
}
@@ -76,6 +82,38 @@ func (o *SourceFactsIncludeOptions) SetMaxTokens(v int32) {
o.MaxTokens = &v
}
// GetMaxTokensPerObservation returns the MaxTokensPerObservation field value if set, zero value otherwise.
func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservation() int32 {
if o == nil || IsNil(o.MaxTokensPerObservation) {
var ret int32
return ret
}
return *o.MaxTokensPerObservation
}
// GetMaxTokensPerObservationOk returns a tuple with the MaxTokensPerObservation field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservationOk() (*int32, bool) {
if o == nil || IsNil(o.MaxTokensPerObservation) {
return nil, false
}
return o.MaxTokensPerObservation, true
}
// HasMaxTokensPerObservation returns a boolean if a field has been set.
func (o *SourceFactsIncludeOptions) HasMaxTokensPerObservation() bool {
if o != nil && !IsNil(o.MaxTokensPerObservation) {
return true
}
return false
}
// SetMaxTokensPerObservation gets a reference to the given int32 and assigns it to the MaxTokensPerObservation field.
func (o *SourceFactsIncludeOptions) SetMaxTokensPerObservation(v int32) {
o.MaxTokensPerObservation = &v
}
func (o SourceFactsIncludeOptions) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -89,6 +127,9 @@ func (o SourceFactsIncludeOptions) ToMap() (map[string]interface{}, error) {
if !IsNil(o.MaxTokens) {
toSerialize["max_tokens"] = o.MaxTokens
}
if !IsNil(o.MaxTokensPerObservation) {
toSerialize["max_tokens_per_observation"] = o.MaxTokensPerObservation
}
return toSerialize, nil
}
@@ -0,0 +1,127 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
)
// checks if the UpdateDocumentRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &UpdateDocumentRequest{}
// UpdateDocumentRequest Request model for updating a document's mutable fields.
type UpdateDocumentRequest struct {
Tags []string `json:"tags,omitempty"`
}
// NewUpdateDocumentRequest instantiates a new UpdateDocumentRequest 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 NewUpdateDocumentRequest() *UpdateDocumentRequest {
this := UpdateDocumentRequest{}
return &this
}
// NewUpdateDocumentRequestWithDefaults instantiates a new UpdateDocumentRequest 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 NewUpdateDocumentRequestWithDefaults() *UpdateDocumentRequest {
this := UpdateDocumentRequest{}
return &this
}
// GetTags returns the Tags field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *UpdateDocumentRequest) GetTags() []string {
if o == nil {
var ret []string
return ret
}
return o.Tags
}
// GetTagsOk returns a tuple with the Tags 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 *UpdateDocumentRequest) GetTagsOk() ([]string, bool) {
if o == nil || IsNil(o.Tags) {
return nil, false
}
return o.Tags, true
}
// HasTags returns a boolean if a field has been set.
func (o *UpdateDocumentRequest) HasTags() bool {
if o != nil && !IsNil(o.Tags) {
return true
}
return false
}
// SetTags gets a reference to the given []string and assigns it to the Tags field.
func (o *UpdateDocumentRequest) SetTags(v []string) {
o.Tags = v
}
func (o UpdateDocumentRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o UpdateDocumentRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if o.Tags != nil {
toSerialize["tags"] = o.Tags
}
return toSerialize, nil
}
type NullableUpdateDocumentRequest struct {
value *UpdateDocumentRequest
isSet bool
}
func (v NullableUpdateDocumentRequest) Get() *UpdateDocumentRequest {
return v.value
}
func (v *NullableUpdateDocumentRequest) Set(val *UpdateDocumentRequest) {
v.value = val
v.isSet = true
}
func (v NullableUpdateDocumentRequest) IsSet() bool {
return v.isSet
}
func (v *NullableUpdateDocumentRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableUpdateDocumentRequest(val *UpdateDocumentRequest) *NullableUpdateDocumentRequest {
return &NullableUpdateDocumentRequest{value: val, isSet: true}
}
func (v NullableUpdateDocumentRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableUpdateDocumentRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -0,0 +1,130 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
)
// checks if the UpdateDocumentResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &UpdateDocumentResponse{}
// UpdateDocumentResponse Response model for update document endpoint.
type UpdateDocumentResponse struct {
Success *bool `json:"success,omitempty"`
}
// NewUpdateDocumentResponse instantiates a new UpdateDocumentResponse 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 NewUpdateDocumentResponse() *UpdateDocumentResponse {
this := UpdateDocumentResponse{}
var success bool = true
this.Success = &success
return &this
}
// NewUpdateDocumentResponseWithDefaults instantiates a new UpdateDocumentResponse 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 NewUpdateDocumentResponseWithDefaults() *UpdateDocumentResponse {
this := UpdateDocumentResponse{}
var success bool = true
this.Success = &success
return &this
}
// GetSuccess returns the Success field value if set, zero value otherwise.
func (o *UpdateDocumentResponse) GetSuccess() bool {
if o == nil || IsNil(o.Success) {
var ret bool
return ret
}
return *o.Success
}
// GetSuccessOk returns a tuple with the Success field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *UpdateDocumentResponse) GetSuccessOk() (*bool, bool) {
if o == nil || IsNil(o.Success) {
return nil, false
}
return o.Success, true
}
// HasSuccess returns a boolean if a field has been set.
func (o *UpdateDocumentResponse) HasSuccess() bool {
if o != nil && !IsNil(o.Success) {
return true
}
return false
}
// SetSuccess gets a reference to the given bool and assigns it to the Success field.
func (o *UpdateDocumentResponse) SetSuccess(v bool) {
o.Success = &v
}
func (o UpdateDocumentResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o UpdateDocumentResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
if !IsNil(o.Success) {
toSerialize["success"] = o.Success
}
return toSerialize, nil
}
type NullableUpdateDocumentResponse struct {
value *UpdateDocumentResponse
isSet bool
}
func (v NullableUpdateDocumentResponse) Get() *UpdateDocumentResponse {
return v.value
}
func (v *NullableUpdateDocumentResponse) Set(val *UpdateDocumentResponse) {
v.value = val
v.isSet = true
}
func (v NullableUpdateDocumentResponse) IsSet() bool {
return v.isSet
}
func (v *NullableUpdateDocumentResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableUpdateDocumentResponse(val *UpdateDocumentResponse) *NullableUpdateDocumentResponse {
return &NullableUpdateDocumentResponse{value: val, isSet: true}
}
func (v NullableUpdateDocumentResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableUpdateDocumentResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -88,6 +88,8 @@ hindsight_client_api/models/token_usage.py
hindsight_client_api/models/tool_calls_include_options.py
hindsight_client_api/models/update_directive_request.py
hindsight_client_api/models/update_disposition_request.py
hindsight_client_api/models/update_document_request.py
hindsight_client_api/models/update_document_response.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
@@ -913,6 +913,19 @@ class Hindsight:
"""
return _run_async(self._mental_models_api.delete_mental_model(bank_id, mental_model_id, _request_timeout=self._timeout))
def get_mental_model_history(self, bank_id: str, mental_model_id: str):
"""
Get the content change history of a mental model.
Returns a list of history entries (most recent first), each with
``previous_content`` and ``changed_at`` fields.
Args:
bank_id: The memory bank ID
mental_model_id: The mental model ID
"""
return _run_async(self._mental_models_api.get_mental_model_history(bank_id, mental_model_id, _request_timeout=self._timeout))
# Directives methods
def create_directive(
@@ -113,6 +113,8 @@ from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.update_document_request import UpdateDocumentRequest
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
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
@@ -23,6 +23,8 @@ from hindsight_client_api.models.chunk_response import ChunkResponse
from hindsight_client_api.models.delete_document_response import DeleteDocumentResponse
from hindsight_client_api.models.document_response import DocumentResponse
from hindsight_client_api.models.list_documents_response import ListDocumentsResponse
from hindsight_client_api.models.update_document_request import UpdateDocumentRequest
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
from hindsight_client_api.api_client import ApiClient, RequestSerialized
from hindsight_client_api.api_response import ApiResponse
@@ -1268,3 +1270,324 @@ class DocumentsApi:
)
@validate_call
async def update_document(
self,
bank_id: StrictStr,
document_id: StrictStr,
update_document_request: UpdateDocumentRequest,
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,
) -> UpdateDocumentResponse:
"""Update document
Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided.
:param bank_id: (required)
:type bank_id: str
:param document_id: (required)
:type document_id: str
:param update_document_request: (required)
:type update_document_request: UpdateDocumentRequest
: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_document_serialize(
bank_id=bank_id,
document_id=document_id,
update_document_request=update_document_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': "UpdateDocumentResponse",
'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_document_with_http_info(
self,
bank_id: StrictStr,
document_id: StrictStr,
update_document_request: UpdateDocumentRequest,
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[UpdateDocumentResponse]:
"""Update document
Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided.
:param bank_id: (required)
:type bank_id: str
:param document_id: (required)
:type document_id: str
:param update_document_request: (required)
:type update_document_request: UpdateDocumentRequest
: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_document_serialize(
bank_id=bank_id,
document_id=document_id,
update_document_request=update_document_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': "UpdateDocumentResponse",
'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_document_without_preload_content(
self,
bank_id: StrictStr,
document_id: StrictStr,
update_document_request: UpdateDocumentRequest,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Update document
Update mutable fields on a document without re-processing its content. **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset. At least one field must be provided.
:param bank_id: (required)
:type bank_id: str
:param document_id: (required)
:type document_id: str
:param update_document_request: (required)
:type update_document_request: UpdateDocumentRequest
: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_document_serialize(
bank_id=bank_id,
document_id=document_id,
update_document_request=update_document_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': "UpdateDocumentResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _update_document_serialize(
self,
bank_id,
document_id,
update_document_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 document_id is not None:
_path_params['document_id'] = document_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_document_request is not None:
_body_params = update_document_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}/documents/{document_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
)
@@ -61,7 +61,7 @@ class FilesApi:
) -> FileRetainResponse:
"""Convert files to memories
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400.
:param bank_id: (required)
:type bank_id: str
@@ -141,7 +141,7 @@ class FilesApi:
) -> ApiResponse[FileRetainResponse]:
"""Convert files to memories
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400.
:param bank_id: (required)
:type bank_id: str
@@ -221,7 +221,7 @@ class FilesApi:
) -> RESTResponseType:
"""Convert files to memories
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400.
:param bank_id: (required)
:type bank_id: str
@@ -1022,7 +1022,7 @@ class MemoryApi:
) -> object:
"""Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
:param bank_id: (required)
:type bank_id: str
@@ -1098,7 +1098,7 @@ class MemoryApi:
) -> ApiResponse[object]:
"""Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
:param bank_id: (required)
:type bank_id: str
@@ -1174,7 +1174,7 @@ class MemoryApi:
) -> RESTResponseType:
"""Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags.
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
:param bank_id: (required)
:type bank_id: str
@@ -1294,6 +1294,299 @@ class MemoryApi:
@validate_call
async def get_observation_history(
self,
bank_id: StrictStr,
memory_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> object:
"""Get observation history
Get the full history of an observation, with each change's source facts resolved to their text.
:param bank_id: (required)
:type bank_id: str
:param memory_id: (required)
:type memory_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_observation_history_serialize(
bank_id=bank_id,
memory_id=memory_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def get_observation_history_with_http_info(
self,
bank_id: StrictStr,
memory_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[object]:
"""Get observation history
Get the full history of an observation, with each change's source facts resolved to their text.
:param bank_id: (required)
:type bank_id: str
:param memory_id: (required)
:type memory_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_observation_history_serialize(
bank_id=bank_id,
memory_id=memory_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def get_observation_history_without_preload_content(
self,
bank_id: StrictStr,
memory_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Get observation history
Get the full history of an observation, with each change's source facts resolved to their text.
:param bank_id: (required)
:type bank_id: str
:param memory_id: (required)
:type memory_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_observation_history_serialize(
bank_id=bank_id,
memory_id=memory_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _get_observation_history_serialize(
self,
bank_id,
memory_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
if 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
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='GET',
resource_path='/v1/default/banks/{bank_id}/memories/{memory_id}/history',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def list_memories(
self,
@@ -936,6 +936,299 @@ class MentalModelsApi:
@validate_call
async def get_mental_model_history(
self,
bank_id: StrictStr,
mental_model_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> object:
"""Get mental model history
Get the refresh history of a mental model, showing content changes over time.
:param bank_id: (required)
:type bank_id: str
:param mental_model_id: (required)
:type mental_model_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_mental_model_history_serialize(
bank_id=bank_id,
mental_model_id=mental_model_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def get_mental_model_history_with_http_info(
self,
bank_id: StrictStr,
mental_model_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[object]:
"""Get mental model history
Get the refresh history of a mental model, showing content changes over time.
:param bank_id: (required)
:type bank_id: str
:param mental_model_id: (required)
:type mental_model_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_mental_model_history_serialize(
bank_id=bank_id,
mental_model_id=mental_model_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def get_mental_model_history_without_preload_content(
self,
bank_id: StrictStr,
mental_model_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Get mental model history
Get the refresh history of a mental model, showing content changes over time.
:param bank_id: (required)
:type bank_id: str
:param mental_model_id: (required)
:type mental_model_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._get_mental_model_history_serialize(
bank_id=bank_id,
mental_model_id=mental_model_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _get_mental_model_history_serialize(
self,
bank_id,
mental_model_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
if mental_model_id is not None:
_path_params['mental_model_id'] = mental_model_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='GET',
resource_path='/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def list_mental_models(
self,
@@ -87,6 +87,8 @@ from hindsight_client_api.models.token_usage import TokenUsage
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
from hindsight_client_api.models.update_directive_request import UpdateDirectiveRequest
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.update_document_request import UpdateDocumentRequest
from hindsight_client_api.models.update_document_response import UpdateDocumentResponse
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
@@ -26,8 +26,9 @@ class SourceFactsIncludeOptions(BaseModel):
"""
Options for including source facts for observation-type results.
""" # noqa: E501
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum tokens for source facts")
__properties: ClassVar[List[str]] = ["max_tokens"]
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)")
max_tokens_per_observation: Optional[StrictInt] = Field(default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)")
__properties: ClassVar[List[str]] = ["max_tokens", "max_tokens_per_observation"]
model_config = ConfigDict(
populate_by_name=True,
@@ -80,7 +81,8 @@ class SourceFactsIncludeOptions(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096,
"max_tokens_per_observation": obj.get("max_tokens_per_observation") if obj.get("max_tokens_per_observation") is not None else -1
})
return _obj
@@ -0,0 +1,92 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.16
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 UpdateDocumentRequest(BaseModel):
"""
Request model for updating a document's mutable fields.
""" # noqa: E501
tags: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["tags"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of UpdateDocumentRequest 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 tags (nullable) is None
# and model_fields_set contains the field
if self.tags is None and "tags" in self.model_fields_set:
_dict['tags'] = None
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of UpdateDocumentRequest from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"tags": obj.get("tags")
})
return _obj
@@ -0,0 +1,87 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.16
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, StrictBool
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
class UpdateDocumentResponse(BaseModel):
"""
Response model for update document endpoint.
""" # noqa: E501
success: Optional[StrictBool] = True
__properties: ClassVar[List[str]] = ["success"]
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 UpdateDocumentResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of UpdateDocumentResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"success": obj.get("success") if obj.get("success") is not None else True
})
return _obj
@@ -82,7 +82,13 @@ import type {
GetMemoryResponses,
GetMentalModelData,
GetMentalModelErrors,
GetMentalModelHistoryData,
GetMentalModelHistoryErrors,
GetMentalModelHistoryResponses,
GetMentalModelResponses,
GetObservationHistoryData,
GetObservationHistoryErrors,
GetObservationHistoryResponses,
GetOperationStatusData,
GetOperationStatusErrors,
GetOperationStatusResponses,
@@ -155,6 +161,9 @@ import type {
UpdateDirectiveData,
UpdateDirectiveErrors,
UpdateDirectiveResponses,
UpdateDocumentData,
UpdateDocumentErrors,
UpdateDocumentResponses,
UpdateMentalModelData,
UpdateMentalModelErrors,
UpdateMentalModelResponses,
@@ -252,7 +261,7 @@ export const listMemories = <ThrowOnError extends boolean = false>(
/**
* Get memory unit
*
* Get a single memory unit by ID with all its metadata including entities and tags.
* Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
*/
export const getMemory = <ThrowOnError extends boolean = false>(
options: Options<GetMemoryData, ThrowOnError>,
@@ -263,6 +272,23 @@ export const getMemory = <ThrowOnError extends boolean = false>(
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/memories/{memory_id}", ...options });
/**
* Get observation history
*
* Get the full history of an observation, with each change's source facts resolved to their text.
*/
export const getObservationHistory = <ThrowOnError extends boolean = false>(
options: Options<GetObservationHistoryData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetObservationHistoryResponses,
GetObservationHistoryErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/memories/{memory_id}/history",
...options,
});
/**
* Recall memory
*
@@ -483,6 +509,23 @@ export const updateMentalModel = <ThrowOnError extends boolean = false>(
},
});
/**
* Get mental model history
*
* Get the refresh history of a mental model, showing content changes over time.
*/
export const getMentalModelHistory = <ThrowOnError extends boolean = false>(
options: Options<GetMentalModelHistoryData, ThrowOnError>,
) =>
(options.client ?? client).get<
GetMentalModelHistoryResponses,
GetMentalModelHistoryErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history",
...options,
});
/**
* Refresh mental model
*
@@ -639,6 +682,31 @@ export const getDocument = <ThrowOnError extends boolean = false>(
ThrowOnError
>({ url: "/v1/default/banks/{bank_id}/documents/{document_id}", ...options });
/**
* Update document
*
* Update mutable fields on a document without re-processing its content.
*
* **Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
*
* At least one field must be provided.
*/
export const updateDocument = <ThrowOnError extends boolean = false>(
options: Options<UpdateDocumentData, ThrowOnError>,
) =>
(options.client ?? client).patch<
UpdateDocumentResponses,
UpdateDocumentErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/documents/{document_id}",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
/**
* List tags
*
@@ -1096,9 +1164,14 @@ export const retainMemories = <ThrowOnError extends boolean = false>(
*
* **Request format:** multipart/form-data with:
* - `files`: One or more files to upload
* - `request`: JSON string with FileRetainRequest model (files_metadata)
* - `request`: JSON string with FileRetainRequest model
*
* **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
* **Parser selection:**
* - Set `parser` in the request body to override the server default for all files.
* - Set `parser` inside a `files_metadata` entry for per-file control.
* - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain each parser is tried in sequence until one succeeds.
* - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
* - Only parsers enabled on the server may be requested; others return HTTP 400.
*/
export const fileRetain = <ThrowOnError extends boolean = false>(
options: Options<FileRetainData, ThrowOnError>,
@@ -1960,9 +1960,15 @@ export type SourceFactsIncludeOptions = {
/**
* Max Tokens
*
* Maximum tokens for source facts
* Maximum total tokens for source facts across all observations (-1 = unlimited)
*/
max_tokens?: number;
/**
* Max Tokens Per Observation
*
* Maximum tokens of source facts per observation (-1 = unlimited)
*/
max_tokens_per_observation?: number;
};
/**
@@ -2075,6 +2081,32 @@ export type UpdateDispositionRequest = {
disposition: DispositionTraits;
};
/**
* UpdateDocumentRequest
*
* Request model for updating a document's mutable fields.
*/
export type UpdateDocumentRequest = {
/**
* Tags
*
* New tags for the document and its memory units. Triggers observation invalidation and re-consolidation.
*/
tags?: Array<string> | null;
};
/**
* UpdateDocumentResponse
*
* Response model for update document endpoint.
*/
export type UpdateDocumentResponse = {
/**
* Success
*/
success?: boolean;
};
/**
* UpdateMentalModelRequest
*
@@ -2543,6 +2575,45 @@ export type GetMemoryResponses = {
200: unknown;
};
export type GetObservationHistoryData = {
body?: never;
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}/history";
};
export type GetObservationHistoryErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetObservationHistoryError =
GetObservationHistoryErrors[keyof GetObservationHistoryErrors];
export type GetObservationHistoryResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type RecallMemoriesData = {
body: RecallRequest;
headers?: {
@@ -3037,6 +3108,45 @@ export type UpdateMentalModelResponses = {
export type UpdateMentalModelResponse =
UpdateMentalModelResponses[keyof UpdateMentalModelResponses];
export type GetMentalModelHistoryData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Mental Model Id
*/
mental_model_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history";
};
export type GetMentalModelHistoryErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetMentalModelHistoryError =
GetMentalModelHistoryErrors[keyof GetMentalModelHistoryErrors];
export type GetMentalModelHistoryResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type RefreshMentalModelData = {
body?: never;
headers?: {
@@ -3451,6 +3561,48 @@ export type GetDocumentResponses = {
export type GetDocumentResponse =
GetDocumentResponses[keyof GetDocumentResponses];
export type UpdateDocumentData = {
body: UpdateDocumentRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Document Id
*/
document_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/documents/{document_id}";
};
export type UpdateDocumentErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateDocumentError =
UpdateDocumentErrors[keyof UpdateDocumentErrors];
export type UpdateDocumentResponses = {
/**
* Successful Response
*/
200: UpdateDocumentResponse;
};
export type UpdateDocumentResponse2 =
UpdateDocumentResponses[keyof UpdateDocumentResponses];
export type ListTagsData = {
body?: never;
headers?: {
+1
View File
@@ -10,6 +10,7 @@
# next.js
/.next/
/.next-*/
/out/
# production
+6
View File
@@ -3,8 +3,14 @@ import path from "path";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
// Use a port-scoped distDir so multiple dev instances don't collide on the lock file
const distDir = process.env.PORT && process.env.PORT !== '9999'
? `.next-${process.env.PORT}`
: '.next';
const nextConfig: NextConfig = {
output: 'standalone',
distDir,
basePath: basePath,
assetPrefix: basePath,
// Disable request logging in production
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ bankId: string; mentalModelId: string }> }
) {
try {
const { bankId, mentalModelId } = await params;
if (!bankId || !mentalModelId) {
return NextResponse.json(
{ error: "bank_id and mental_model_id are required" },
{ status: 400 }
);
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${mentalModelId}/history`,
{ method: "GET", headers: getDataplaneHeaders() }
);
if (!response.ok) {
if (response.status === 404) {
return NextResponse.json({ error: "Mental model not found" }, { status: 404 });
}
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error fetching mental model history:", error);
return NextResponse.json({ error: "Failed to fetch mental model history" }, { status: 500 });
}
}
@@ -13,17 +13,14 @@ export async function GET(
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}`,
`${DATAPLANE_URL}/v1/default/banks/${bankId}/memories/${modelId}`,
{ method: "GET", headers: getDataplaneHeaders() }
);
if (!response.ok) {
const errorText = await response.text();
console.error("API error getting mental model:", errorText);
return NextResponse.json(
{ error: "Failed to get mental model" },
{ status: response.status }
);
console.error("API error getting observation:", errorText);
return NextResponse.json({ error: "Failed to get observation" }, { status: response.status });
}
const data = await response.json();
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
import { sdk, lowLevelClient, DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(
request: NextRequest,
@@ -26,6 +26,42 @@ export async function GET(
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ documentId: string }> }
) {
try {
const { documentId } = await params;
const searchParams = request.nextUrl.searchParams;
const bankId = searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const body = await request.json();
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/documents/${documentId}`,
{
method: "PATCH",
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(body),
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
return NextResponse.json(error, { status: response.status });
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error updating document tags:", error);
return NextResponse.json({ error: "Failed to update document tags" }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ documentId: string }> }
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ memoryId: string }> }
) {
try {
const { memoryId } = await params;
const searchParams = request.nextUrl.searchParams;
const bankId = searchParams.get("bank_id");
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
const response = await fetch(
`${DATAPLANE_URL}/v1/default/banks/${bankId}/memories/${memoryId}/history`,
{
method: "GET",
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
}
);
if (!response.ok) {
if (response.status === 404) {
return NextResponse.json({ error: "Memory not found" }, { status: 404 });
}
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error fetching observation history:", error);
return NextResponse.json({ error: "Failed to fetch observation history" }, { status: 500 });
}
}
@@ -39,6 +39,9 @@ type RetainEdits = {
type ObservationsEdits = {
enable_observations: boolean | null;
consolidation_llm_batch_size: number | null;
consolidation_source_facts_max_tokens: number | null;
consolidation_source_facts_max_tokens_per_observation: number | null;
observations_mission: string | null;
};
@@ -143,6 +146,10 @@ function retainSlice(config: Record<string, any>): RetainEdits {
function observationsSlice(config: Record<string, any>): ObservationsEdits {
return {
enable_observations: config.enable_observations ?? null,
consolidation_llm_batch_size: config.consolidation_llm_batch_size ?? null,
consolidation_source_facts_max_tokens: config.consolidation_source_facts_max_tokens ?? null,
consolidation_source_facts_max_tokens_per_observation:
config.consolidation_source_facts_max_tokens_per_observation ?? null,
observations_mission: config.observations_mission ?? null,
};
}
@@ -489,7 +496,7 @@ export function BankConfigView() {
>
<FieldRow
label="Free Form Entities"
description="Extract regular named entities (people, places, concepts) alongside label groups. Disable to restrict extraction to label groups only."
description="Extract regular named entities (people, places, concepts) alongside entity labels. Disable to restrict extraction to entity labels only."
>
<div className="flex justify-end items-center gap-2">
<Label
@@ -550,6 +557,64 @@ export function BankConfigView() {
placeholder="e.g. Observations are stable facts about people and projects. Always include preferences, skills, and recurring patterns. Ignore one-off events and ephemeral state."
rows={3}
/>
<FieldRow
label="LLM Batch Size"
description="Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls at the cost of larger prompts. Leave blank to use the server default."
>
<Input
type="number"
min={1}
max={64}
value={observationsEdits.consolidation_llm_batch_size ?? ""}
onChange={(e) =>
setObservationsEdits((prev) => ({
...prev,
consolidation_llm_batch_size: e.target.value
? parseInt(e.target.value, 10)
: null,
}))
}
placeholder="Server default"
/>
</FieldRow>
<FieldRow
label="Source Facts Max Tokens"
description="Total token budget for source facts included with observations during consolidation. -1 = unlimited."
>
<Input
type="number"
min={-1}
value={observationsEdits.consolidation_source_facts_max_tokens ?? ""}
onChange={(e) =>
setObservationsEdits((prev) => ({
...prev,
consolidation_source_facts_max_tokens: e.target.value
? parseInt(e.target.value, 10)
: null,
}))
}
placeholder="Server default"
/>
</FieldRow>
<FieldRow
label="Source Facts Max Tokens Per Observation"
description="Per-observation token cap for source facts during consolidation. Each observation gets at most this many tokens of source facts. -1 = unlimited."
>
<Input
type="number"
min={-1}
value={observationsEdits.consolidation_source_facts_max_tokens_per_observation ?? ""}
onChange={(e) =>
setObservationsEdits((prev) => ({
...prev,
consolidation_source_facts_max_tokens_per_observation: e.target.value
? parseInt(e.target.value, 10)
: null,
}))
}
placeholder="Server default"
/>
</FieldRow>
</ConfigSection>
{/* Reflect Section */}
@@ -1015,7 +1080,7 @@ function EntityLabelsEditor({
<div className="px-6 py-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Label Groups</p>
<p className="text-sm font-medium">Entity Labels</p>
<p className="text-xs text-muted-foreground mt-0.5">
Classification labels extracted at retain time. Leave empty to disable.
</p>
@@ -1028,7 +1093,7 @@ function EntityLabelsEditor({
</div>
{value.length === 0 && (
<p className="text-xs text-muted-foreground italic">No label groups defined.</p>
<p className="text-xs text-muted-foreground italic">No entity labels defined.</p>
)}
<div className="space-y-2">
@@ -280,7 +280,7 @@ export function DataView({ factType }: DataViewProps) {
return (
<div>
{loading ? (
{loading && !data ? (
<div className="text-center py-12">
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
<p className="text-muted-foreground">Loading memories...</p>
@@ -370,11 +370,11 @@ export function DataView({ factType }: DataViewProps) {
{/* Consolidation status for observations */}
{factType === "observation" && consolidationStatus && (
<div
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium border ${
consolidationStatus.pending_consolidation === 0
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"
? "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20"
: "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20"
}`}
title={
consolidationStatus.pending_consolidation === 0
@@ -391,9 +391,23 @@ export function DataView({ factType }: DataViewProps) {
<>
<Clock className="w-3 h-3" />
{consolidationStatus.pending_consolidation} Pending
<button
onClick={() =>
loadData(
fetchLimit,
searchQuery || undefined,
tagFilters.length > 0 ? tagFilters : undefined
)
}
disabled={loading}
className="ml-0.5 opacity-70 hover:opacity-100 disabled:opacity-40 transition-opacity"
title="Refresh observations"
>
<RefreshCw className={`w-3 h-3 ${loading ? "animate-spin" : ""}`} />
</button>
</>
)}
</div>
</span>
)}
</div>
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
@@ -874,6 +888,7 @@ export function DataView({ factType }: DataViewProps) {
data={data}
filteredRows={filteredTableRows}
bankId={currentBank || undefined}
onMemoryClick={(id) => setModalMemoryId(id)}
/>
)}
</>
@@ -899,12 +914,13 @@ function TimelineView({
data,
filteredRows,
bankId,
onMemoryClick,
}: {
data: any;
filteredRows: any[];
bankId?: string;
onMemoryClick: (id: string) => void;
}) {
const [selectedItem, setSelectedItem] = useState<any>(null);
const [granularity, setGranularity] = useState<Granularity>("month");
const [currentIndex, setCurrentIndex] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null);
@@ -1182,10 +1198,8 @@ function TimelineView({
{group.items.map((item: any, idx: number) => (
<div
key={item.id || idx}
onClick={() => setSelectedItem(item)}
className={`flex items-start cursor-pointer group ${
selectedItem?.id === item.id ? "opacity-100" : "hover:opacity-80"
}`}
onClick={() => onMemoryClick(item.id)}
className={`flex items-start cursor-pointer group ${"hover:opacity-80"}`}
>
{/* Date & Time */}
<div className="w-[60px] text-right pr-3 pt-1 flex-shrink-0">
@@ -1200,21 +1214,13 @@ function TimelineView({
{/* Connector dot */}
<div className="flex-shrink-0 pt-2">
<div
className={`w-1.5 h-1.5 rounded-full z-10 ${
selectedItem?.id === item.id
? "bg-primary"
: "bg-muted-foreground/50 group-hover:bg-primary"
}`}
className={`w-1.5 h-1.5 rounded-full z-10 ${"bg-muted-foreground/50 group-hover:bg-primary"}`}
/>
</div>
{/* Card */}
<div
className={`ml-3 flex-1 p-2 rounded border transition-colors ${
selectedItem?.id === item.id
? "bg-primary/10 border-primary"
: "bg-card border-border hover:border-primary/50"
}`}
className={`ml-3 flex-1 p-2 rounded border transition-colors ${"bg-card border-border hover:border-primary/50"}`}
>
<p className="text-xs text-foreground line-clamp-2 leading-relaxed">
{item.text}
@@ -1252,18 +1258,6 @@ function TimelineView({
))}
</div>
</div>
{/* Detail Panel - Fixed on Right */}
{selectedItem && (
<div className="fixed right-0 top-0 h-screen w-[420px] bg-card border-l-2 border-primary shadow-2xl z-50 overflow-y-auto animate-in slide-in-from-right duration-300 ease-out">
<MemoryDetailPanel
memory={selectedItem}
onClose={() => setSelectedItem(null)}
inPanel
bankId={bankId}
/>
</div>
)}
</div>
);
}
@@ -23,7 +23,16 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { X, Trash2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import {
X,
Trash2,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Pencil,
Check,
} from "lucide-react";
const ITEMS_PER_PAGE = 50;
@@ -44,6 +53,11 @@ export function DocumentsView() {
const [loadingDocument, setLoadingDocument] = useState(false);
const [deletingDocumentId, setDeletingDocumentId] = useState<string | null>(null);
// Tag editing state
const [editingTags, setEditingTags] = useState(false);
const [tagInput, setTagInput] = useState("");
const [savingTags, setSavingTags] = useState(false);
// Delete confirmation dialog state
const [documentToDelete, setDocumentToDelete] = useState<{
id: string;
@@ -85,6 +99,8 @@ export function DocumentsView() {
setLoadingDocument(true);
setSelectedDocument({ id: documentId }); // Set placeholder to show loading
setEditingTags(false);
setTagInput("");
try {
const doc: any = await client.getDocument(documentId, currentBank);
@@ -133,6 +149,41 @@ export function DocumentsView() {
setDocumentToDelete({ id: documentId, memoryCount });
};
const startEditTags = () => {
setTagInput((selectedDocument?.tags ?? []).join(", "));
setEditingTags(true);
};
const cancelEditTags = () => {
setEditingTags(false);
setTagInput("");
};
const saveDocumentTags = async () => {
if (!currentBank || !selectedDocument) return;
const newTags = tagInput
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
setSavingTags(true);
try {
await client.updateDocument(selectedDocument.id, currentBank, newTags);
setSelectedDocument({ ...selectedDocument, tags: newTags });
// Update tags in the documents list too
setDocuments((prev) =>
prev.map((d) => (d.id === selectedDocument.id ? { ...d, tags: newTags } : d))
);
setEditingTags(false);
setTagInput("");
} catch (error) {
console.error("Error updating document tags:", error);
} finally {
setSavingTags(false);
}
};
// Auto-load documents when component mounts or bank changes
useEffect(() => {
if (currentBank) {
@@ -417,11 +468,64 @@ export function DocumentsView() {
)}
{/* Tags */}
{selectedDocument.tags && selectedDocument.tags.length > 0 && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Tags
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-muted-foreground uppercase">Tags</div>
{!editingTags && (
<Button
variant="ghost"
size="sm"
onClick={startEditTags}
className="h-6 px-2 gap-1 text-xs"
>
<Pencil className="h-3 w-3" />
Edit
</Button>
)}
</div>
{editingTags ? (
<div className="space-y-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="tag1, tag2, tag3"
className="text-sm h-8"
onKeyDown={(e) => {
if (e.key === "Enter") saveDocumentTags();
if (e.key === "Escape") cancelEditTags();
}}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Comma-separated. Leave empty to remove all tags.
</p>
<div className="flex gap-2">
<Button
size="sm"
onClick={saveDocumentTags}
disabled={savingTags}
className="h-7 px-3 gap-1 text-xs"
>
{savingTags ? (
<span className="animate-spin"></span>
) : (
<Check className="h-3 w-3" />
)}
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={cancelEditTags}
disabled={savingTags}
className="h-7 px-3 gap-1 text-xs"
>
<X className="h-3 w-3" />
Cancel
</Button>
</div>
</div>
) : selectedDocument.tags && selectedDocument.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedDocument.tags.map((tag: string, i: number) => (
<span
@@ -432,8 +536,10 @@ export function DocumentsView() {
</span>
))}
</div>
</div>
)}
) : (
<div className="text-sm text-muted-foreground">No tags</div>
)}
</div>
{/* Delete Button */}
<div className="pt-2 border-t border-border">
@@ -5,9 +5,10 @@ import { client } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2, Calendar, Users, FileText, Layers, Tag } from "lucide-react";
import { Loader2, Calendar, Users, FileText, Layers, Tag, History } from "lucide-react";
import { TagList } from "@/components/ui/tag-list";
import { Button } from "@/components/ui/button";
import { ObservationHistoryView, type HistoryEntry } from "@/components/observation-history-view";
interface SourceMemory {
id: string;
@@ -38,14 +39,15 @@ interface MemoryDetail {
interface MemoryDetailModalProps {
memoryId: string | null;
onClose: () => void;
initialTab?: string;
}
export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps) {
export function MemoryDetailModal({ memoryId, onClose, initialTab }: MemoryDetailModalProps) {
const { currentBank } = useBank();
const [memory, setMemory] = useState<MemoryDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("memory");
const [activeTab, setActiveTab] = useState(initialTab ?? "memory");
// Document and chunk data
const [document, setDocument] = useState<any>(null);
@@ -53,6 +55,10 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
const [loadingDocument, setLoadingDocument] = useState(false);
const [loadingChunk, setLoadingChunk] = useState(false);
// History data (fetched lazily from dedicated endpoint)
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
const [loadingHistory, setLoadingHistory] = useState(false);
// Source memory modal (for viewing source memories of observations)
const [sourceMemoryModalId, setSourceMemoryModalId] = useState<string | null>(null);
@@ -66,7 +72,8 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
setMemory(null);
setDocument(null);
setChunk(null);
setActiveTab("memory");
setHistory(null);
setActiveTab(initialTab ?? "memory");
try {
const data = await client.getMemory(memoryId, currentBank);
@@ -82,6 +89,33 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
loadMemory();
}, [memoryId, currentBank]);
// Load history lazily when history tab is selected
useEffect(() => {
if (
activeTab !== "history" ||
!memory ||
memory.type !== "observation" ||
!currentBank ||
history !== null
)
return;
const loadHistory = async () => {
setLoadingHistory(true);
try {
const data = await client.getObservationHistory(memory.id, currentBank);
setHistory(data);
} catch (err) {
console.error("Error loading history:", err);
setHistory([]);
} finally {
setLoadingHistory(false);
}
};
loadHistory();
}, [activeTab, memory, currentBank, history]);
// Load document when tab is selected
useEffect(() => {
if (activeTab !== "document" || !memory?.document_id || !currentBank || document) return;
@@ -152,162 +186,208 @@ export function MemoryDetailModal({ memoryId, onClose }: MemoryDetailModalProps)
</div>
) : memory ? (
isObservation ? (
/* Observation view - no tabs since chunk/document don't apply */
<div className="flex-1 overflow-y-auto space-y-4">
{/* Text */}
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">Text</div>
<p className="text-sm text-foreground leading-relaxed">{memory.text}</p>
</div>
/* Observation view - tabs for Info and History */
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="memory" className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5" />
Observation
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center gap-1.5">
<History className="w-3.5 h-3.5" />
History
{history && history.length > 0 ? ` (${history.length})` : ""}
</TabsTrigger>
</TabsList>
{/* Dates */}
{memory.occurred_start && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Occurred
<div className="flex-1 overflow-y-auto mt-4">
<TabsContent value="memory" className="mt-0 space-y-4">
{/* Text */}
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Text
</div>
<p className="text-sm text-foreground leading-relaxed">{memory.text}</p>
</div>
<div className="flex items-center gap-2 text-sm text-foreground">
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>
{new Date(memory.occurred_start).toLocaleString()}
{memory.occurred_end && memory.occurred_end !== memory.occurred_start && (
<>
<span className="text-muted-foreground mx-1"></span>
{new Date(memory.occurred_end).toLocaleString()}
</>
)}
</span>
</div>
</div>
)}
{memory.mentioned_at && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Mentioned
</div>
<div className="flex items-center gap-2 text-sm text-foreground">
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>{new Date(memory.mentioned_at).toLocaleString()}</span>
</div>
</div>
)}
{/* Entities */}
{memory.entities && memory.entities.length > 0 && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Users className="w-3 h-3" />
Entities
</div>
<div className="flex flex-wrap gap-1.5">
{memory.entities.map((entity, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-xs"
>
{entity}
</span>
))}
</div>
</div>
)}
{/* Tags */}
<TagList tags={memory.tags} showLabel />
{/* Observation Scopes */}
{memory.observation_scopes && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Tag className="w-3 h-3" />
Observation Scopes
</div>
{typeof memory.observation_scopes === "string" ? (
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{memory.observation_scopes}
</span>
) : (
<div className="space-y-1.5">
{(memory.observation_scopes as string[][]).map((scope, i) => (
<TagList key={i} tags={scope} />
))}
{/* Dates */}
{memory.occurred_start && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Occurred
</div>
<div className="flex items-center gap-2 text-sm text-foreground">
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>
{new Date(memory.occurred_start).toLocaleString()}
{memory.occurred_end &&
memory.occurred_end !== memory.occurred_start && (
<>
<span className="text-muted-foreground mx-1"></span>
{new Date(memory.occurred_end).toLocaleString()}
</>
)}
</span>
</div>
</div>
)}
</div>
)}
{/* Source Memories */}
{memory.source_memories && memory.source_memories.length > 0 && (
<div className="border-t border-border pt-4">
<div className="text-xs font-bold text-muted-foreground uppercase mb-3">
Source Memories ({memory.source_memories.length})
</div>
<div className="space-y-3">
{memory.source_memories.map((source, i) => (
<div
key={source.id || i}
className="p-3 bg-muted/50 rounded-lg border border-border/50"
>
<div className="flex items-start justify-between gap-2 mb-2">
<span
className={`px-2 py-0.5 rounded text-xs flex-shrink-0 ${
source.type === "experience"
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-blue-500/10 text-blue-600 dark:text-blue-400"
}`}
>
{source.type}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => setSourceMemoryModalId(source.id)}
>
View
</Button>
</div>
<p className="text-sm text-foreground mb-2">{source.text}</p>
{source.context && (
<p className="text-xs text-muted-foreground mb-2 italic">
Context: {source.context}
</p>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
{source.occurred_start && (
<div className="p-2 bg-background/50 rounded">
<div className="text-muted-foreground mb-0.5">Occurred</div>
<div className="font-medium">
{new Date(source.occurred_start).toLocaleString()}
</div>
</div>
)}
{source.mentioned_at && (
<div className="p-2 bg-background/50 rounded">
<div className="text-muted-foreground mb-0.5">Mentioned</div>
<div className="font-medium">
{new Date(source.mentioned_at).toLocaleString()}
</div>
</div>
)}
</div>
{memory.mentioned_at && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Mentioned
</div>
))}
</div>
</div>
)}
<div className="flex items-center gap-2 text-sm text-foreground">
<Calendar className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>{new Date(memory.mentioned_at).toLocaleString()}</span>
</div>
</div>
)}
{/* ID */}
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Memory ID
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{memory.id}
</code>
{/* Entities */}
{memory.entities && memory.entities.length > 0 && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Users className="w-3 h-3" />
Entities
</div>
<div className="flex flex-wrap gap-1.5">
{memory.entities.map((entity, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-xs"
>
{entity}
</span>
))}
</div>
</div>
)}
{/* Tags */}
<TagList tags={memory.tags} showLabel />
{/* Observation Scopes */}
{memory.observation_scopes && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2 flex items-center gap-1">
<Tag className="w-3 h-3" />
Observation Scopes
</div>
{typeof memory.observation_scopes === "string" ? (
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{memory.observation_scopes}
</span>
) : (
<div className="space-y-1.5">
{(memory.observation_scopes as string[][]).map((scope, i) => (
<TagList key={i} tags={scope} />
))}
</div>
)}
</div>
)}
{/* Source Memories */}
{memory.source_memories && memory.source_memories.length > 0 && (
<div className="border-t border-border pt-4">
<div className="text-xs font-bold text-muted-foreground uppercase mb-3">
Source Memories ({memory.source_memories.length})
</div>
<div className="space-y-3">
{memory.source_memories.map((source, i) => (
<div
key={source.id || i}
className="p-3 bg-muted/50 rounded-lg border border-border/50"
>
<div className="flex items-start justify-between gap-2 mb-2">
<span
className={`px-2 py-0.5 rounded text-xs flex-shrink-0 ${
source.type === "experience"
? "bg-green-500/10 text-green-600 dark:text-green-400"
: "bg-blue-500/10 text-blue-600 dark:text-blue-400"
}`}
>
{source.type}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => setSourceMemoryModalId(source.id)}
>
View
</Button>
</div>
<p className="text-sm text-foreground mb-2">{source.text}</p>
{source.context && (
<p className="text-xs text-muted-foreground mb-2 italic">
Context: {source.context}
</p>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
{source.occurred_start && (
<div className="p-2 bg-background/50 rounded">
<div className="text-muted-foreground mb-0.5">Occurred</div>
<div className="font-medium">
{new Date(source.occurred_start).toLocaleString()}
</div>
</div>
)}
{source.mentioned_at && (
<div className="p-2 bg-background/50 rounded">
<div className="text-muted-foreground mb-0.5">Mentioned</div>
<div className="font-medium">
{new Date(source.mentioned_at).toLocaleString()}
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* ID */}
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">
Memory ID
</div>
<code className="text-xs font-mono text-muted-foreground break-all">
{memory.id}
</code>
</div>
</TabsContent>
<TabsContent value="history" className="mt-0">
{loadingHistory ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : history && history.length > 0 ? (
<ObservationHistoryView
history={history}
current={{
text: memory.text,
tags: memory.tags,
occurred_start: memory.occurred_start,
occurred_end: memory.occurred_end,
mentioned_at: memory.mentioned_at,
}}
/>
) : (
<p className="text-sm text-muted-foreground italic">
No history recorded yet.
</p>
)}
</TabsContent>
</div>
</div>
</Tabs>
) : (
/* World/Experience view - with tabs */
<Tabs
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { TagList } from "@/components/ui/tag-list";
import { Copy, Check, X, Loader2, Calendar } from "lucide-react";
import { Copy, Check, X, Loader2, Calendar, History } from "lucide-react";
import { DocumentChunkModal } from "./document-chunk-modal";
import { MemoryDetailModal } from "./memory-detail-modal";
import { client } from "@/lib/api";
@@ -29,6 +29,7 @@ export function MemoryDetailPanel({
const [fullMemory, setFullMemory] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [sourceMemoryModalId, setSourceMemoryModalId] = useState<string | null>(null);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
// Fetch full memory data when panel opens
// For mental models, use getMentalModel to get source memories
@@ -296,6 +297,20 @@ export function MemoryDetailPanel({
</div>
)}
{/* View History button (observations only) */}
{isObservation && (
<div className="border-t border-border pt-5">
<Button
variant="outline"
className="w-full flex items-center gap-2"
onClick={() => setHistoryModalOpen(true)}
>
<History className="h-4 w-4" />
View History
</Button>
</div>
)}
{/* Memory ID */}
{memoryId && (
<div>
@@ -333,6 +348,15 @@ export function MemoryDetailPanel({
memoryId={sourceMemoryModalId}
onClose={() => setSourceMemoryModalId(null)}
/>
{/* History Modal */}
{historyModalOpen && memoryId && bankId && (
<MemoryDetailModal
memoryId={memoryId}
onClose={() => setHistoryModalOpen(false)}
initialTab="history"
/>
)}
</>
);
}
@@ -4,8 +4,10 @@ import { useState, useEffect } from "react";
import { client, MentalModel } from "@/lib/api";
import { useBank } from "@/lib/bank-context";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Loader2, Zap } from "lucide-react";
import { Loader2, Zap, FileText, History, ChevronLeft, ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -100,20 +102,197 @@ export function MentalModelDetailContent({ mentalModel }: MentalModelDetailConte
);
}
type HistoryEntry = { previous_content: string | null; changed_at: string };
type LineDiff = { type: "same" | "removed" | "added"; text: string };
function diffLines(a: string, b: string): { left: LineDiff[]; right: LineDiff[] } {
const aLines = a.split("\n");
const bLines = b.split("\n");
const m = aLines.length;
const n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] =
aLines[i - 1] === bLines[j - 1]
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
const ops: LineDiff[] = [];
let i = m,
j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
ops.push({ type: "same", text: aLines[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: "added", text: bLines[j - 1] });
j--;
} else {
ops.push({ type: "removed", text: aLines[i - 1] });
i--;
}
}
ops.reverse();
// Pair removed/added lines side-by-side; same lines appear on both sides
const left: LineDiff[] = [];
const right: LineDiff[] = [];
let k = 0;
while (k < ops.length) {
const op = ops[k];
if (op.type === "same") {
left.push(op);
right.push(op);
k++;
} else {
// collect a block of removed/added and align them
const removed: string[] = [];
const added: string[] = [];
while (k < ops.length && ops[k].type !== "same") {
if (ops[k].type === "removed") removed.push(ops[k].text);
else added.push(ops[k].text);
k++;
}
const maxLen = Math.max(removed.length, added.length);
for (let r = 0; r < maxLen; r++) {
left.push(
r < removed.length ? { type: "removed", text: removed[r] } : { type: "same", text: "" }
);
right.push(
r < added.length ? { type: "added", text: added[r] } : { type: "same", text: "" }
);
}
}
}
return { left, right };
}
function SideBySideDiff({ before, after }: { before: string; after: string }) {
const { left, right } = diffLines(before, after);
const hasChanges = left.some((l) => l.type !== "same") || right.some((r) => r.type !== "same");
if (!hasChanges) return <span className="text-sm text-muted-foreground italic">unchanged</span>;
return (
<div className="grid grid-cols-2 divide-x divide-border border border-border rounded-md overflow-hidden text-xs font-mono">
<div>
<div className="px-3 py-1.5 bg-muted text-muted-foreground font-sans font-semibold text-xs uppercase tracking-wide border-b border-border">
Before
</div>
{left.map((line, idx) => (
<div
key={idx}
className={`px-3 py-0.5 whitespace-pre-wrap leading-5 min-h-[1.25rem] ${
line.type === "removed"
? "bg-red-500/10 text-red-700 dark:text-red-400"
: "text-foreground"
}`}
>
{line.text}
</div>
))}
</div>
<div>
<div className="px-3 py-1.5 bg-muted text-muted-foreground font-sans font-semibold text-xs uppercase tracking-wide border-b border-border">
After
</div>
{right.map((line, idx) => (
<div
key={idx}
className={`px-3 py-0.5 whitespace-pre-wrap leading-5 min-h-[1.25rem] ${
line.type === "added"
? "bg-green-500/10 text-green-700 dark:text-green-400"
: "text-foreground"
}`}
>
{line.text}
</div>
))}
</div>
</div>
);
}
function MentalModelHistoryView({
history,
currentContent,
}: {
history: HistoryEntry[];
currentContent: string;
}) {
const [idx, setIdx] = useState(0);
const entry = history[idx];
const afterContent = idx === 0 ? currentContent : (history[idx - 1].previous_content ?? "");
return (
<div className="space-y-3">
{/* Navigation header */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Change <span className="font-semibold text-foreground">{history.length - idx}</span> of{" "}
{history.length} &middot; {new Date(entry.changed_at).toLocaleString()}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={idx === history.length - 1}
onClick={() => setIdx(idx + 1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={idx === 0}
onClick={() => setIdx(idx - 1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Change card */}
{entry.previous_content !== null ? (
<SideBySideDiff before={entry.previous_content} after={afterContent} />
) : (
<div className="border border-border rounded-lg p-3">
<span className="text-sm text-muted-foreground italic">
Previous content not available
</span>
</div>
)}
</div>
);
}
interface MentalModelDetailModalProps {
mentalModelId: string | null;
onClose: () => void;
initialTab?: string;
}
/**
* Modal wrapper for MentalModelDetailContent.
* Fetches the mental model by ID and displays it in a dialog.
*/
export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDetailModalProps) {
export function MentalModelDetailModal({
mentalModelId,
onClose,
initialTab,
}: MentalModelDetailModalProps) {
const { currentBank } = useBank();
const [mentalModel, setMentalModel] = useState<MentalModel | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(initialTab ?? "model");
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
const [loadingHistory, setLoadingHistory] = useState(false);
useEffect(() => {
if (!mentalModelId || !currentBank) return;
@@ -122,6 +301,8 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
setLoading(true);
setError(null);
setMentalModel(null);
setHistory(null);
setActiveTab(initialTab ?? "model");
try {
const data = await client.getMentalModel(currentBank, mentalModelId);
@@ -137,6 +318,26 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
loadMentalModel();
}, [mentalModelId, currentBank]);
// Load history lazily when history tab is selected
useEffect(() => {
if (activeTab !== "history" || !mentalModel || !currentBank || history !== null) return;
const loadHistory = async () => {
setLoadingHistory(true);
try {
const data = await client.getMentalModelHistory(currentBank, mentalModel.id);
setHistory(data);
} catch (err) {
console.error("Error loading mental model history:", err);
setHistory([]);
} finally {
setLoadingHistory(false);
}
};
loadHistory();
}, [activeTab, mentalModel, currentBank, history]);
const isOpen = mentalModelId !== null;
return (
@@ -156,9 +357,41 @@ export function MentalModelDetailModal({ mentalModelId, onClose }: MentalModelDe
</div>
</div>
) : mentalModel ? (
<div className="flex-1 overflow-y-auto">
<MentalModelDetailContent mentalModel={mentalModel} />
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="model" className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5" />
Mental Model
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center gap-1.5">
<History className="w-3.5 h-3.5" />
History
{history && history.length > 0 ? ` (${history.length})` : ""}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto mt-4">
<TabsContent value="model" className="mt-0">
<MentalModelDetailContent mentalModel={mentalModel} />
</TabsContent>
<TabsContent value="history" className="mt-0">
{loadingHistory ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : history && history.length > 0 ? (
<MentalModelHistoryView history={history} currentContent={mentalModel.content} />
) : (
<p className="text-sm text-muted-foreground italic">No history recorded yet.</p>
)}
</TabsContent>
</div>
</Tabs>
) : null}
</DialogContent>
</Dialog>
@@ -50,9 +50,19 @@ import {
Pencil,
LayoutGrid,
List,
History,
MoreVertical,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MemoryDetailModal } from "./memory-detail-modal";
import { DirectiveDetailModal } from "./directive-detail-modal";
import { MentalModelDetailModal } from "./mental-model-detail-modal";
interface ReflectResponseBasedOnFact {
id: string;
@@ -927,6 +937,7 @@ function MentalModelDetailPanel({
const [refreshing, setRefreshing] = useState(false);
const [viewMemoryId, setViewMemoryId] = useState<string | null>(null);
const [viewDirectiveId, setViewDirectiveId] = useState<string | null>(null);
const [showHistoryModal, setShowHistoryModal] = useState(false);
const handleRefresh = async () => {
if (!currentBank) return;
@@ -1031,27 +1042,44 @@ function MentalModelDetailPanel({
<div className="flex-1 mr-4">
<div className="flex items-center gap-2">
<h3 className="text-xl font-bold text-foreground">{mentalModel.name}</h3>
<Button variant="ghost" size="sm" onClick={onEdit} className="h-7 w-7 p-0">
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground mt-1">{mentalModel.source_query}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="h-8"
>
{refreshing ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<RefreshCw className="w-4 h-4 mr-1" />
)}
Refresh
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 gap-1" disabled={refreshing}>
{refreshing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
<span className="text-xs">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowHistoryModal(true)}>
<History className="h-4 w-4 mr-2" />
View History
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
<X className="h-4 w-4" />
</Button>
@@ -1222,18 +1250,6 @@ function MentalModelDetailPanel({
</p>
</div>
)}
<div className="pt-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive hover:border-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
</div>
@@ -1249,6 +1265,13 @@ function MentalModelDetailPanel({
onClose={() => setViewDirectiveId(null)}
/>
)}
{/* Mental Model History Modal */}
<MentalModelDetailModal
mentalModelId={showHistoryModal ? mentalModel.id : null}
onClose={() => setShowHistoryModal(false)}
initialTab="history"
/>
</div>
);
}
@@ -0,0 +1,296 @@
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
export interface HistoryEntry {
previous_text: string;
previous_tags: string[];
previous_occurred_start: string | null;
previous_occurred_end: string | null;
previous_mentioned_at: string | null;
changed_at: string;
new_source_memory_ids: string[];
source_facts?: {
id: string;
text: string | null;
type: string | null;
context: string | null;
is_new: boolean;
}[];
}
interface CurrentState {
text: string;
tags: string[];
occurred_start: string | null;
occurred_end: string | null;
mentioned_at: string | null;
}
function diffWords(a: string, b: string): { type: "same" | "removed" | "added"; text: string }[] {
const aWords = a.split(/(\s+)/);
const bWords = b.split(/(\s+)/);
const m = aWords.length;
const n = bWords.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] =
aWords[i - 1] === bWords[j - 1]
? dp[i - 1][j - 1] + 1
: Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
let i = m,
j = n;
const ops: { type: "same" | "removed" | "added"; text: string }[] = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && aWords[i - 1] === bWords[j - 1]) {
ops.push({ type: "same", text: aWords[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
ops.push({ type: "added", text: bWords[j - 1] });
j--;
} else {
ops.push({ type: "removed", text: aWords[i - 1] });
i--;
}
}
return ops.reverse();
}
function TextDiff({ before, after }: { before: string; after: string }) {
const parts = diffWords(before, after);
const hasChanges = parts.some((p) => p.type !== "same");
if (!hasChanges) return <span className="text-sm text-muted-foreground italic">unchanged</span>;
return (
<span className="text-sm leading-relaxed">
{parts.map((part, idx) =>
part.type === "same" ? (
<span key={idx}>{part.text}</span>
) : part.type === "removed" ? (
<span
key={idx}
className="bg-red-500/15 text-red-700 dark:text-red-400 line-through rounded-sm px-0.5"
>
{part.text}
</span>
) : (
<span
key={idx}
className="bg-green-500/15 text-green-700 dark:text-green-400 rounded-sm px-0.5"
>
{part.text}
</span>
)
)}
</span>
);
}
function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
const removed = before.filter((t) => !after.includes(t));
const added = after.filter((t) => !before.includes(t));
const kept = before.filter((t) => after.includes(t));
if (removed.length === 0 && added.length === 0)
return <span className="text-sm text-muted-foreground italic">unchanged</span>;
return (
<div className="flex gap-1 flex-wrap">
{kept.map((t, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-700 border border-amber-500/20 font-mono"
>
#{t}
</span>
))}
{removed.map((t, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-red-500/15 text-red-700 dark:text-red-400 border border-red-500/20 font-mono line-through"
>
#{t}
</span>
))}
{added.map((t, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 rounded-md bg-green-500/15 text-green-700 dark:text-green-400 border border-green-500/20 font-mono"
>
+#{t}
</span>
))}
</div>
);
}
function DateDiff({
label,
before,
after,
}: {
label: string;
before: string | null;
after: string | null;
}) {
if (!before && !after) return null;
const changed = before !== after;
return (
<div>
<span className="text-xs text-muted-foreground">{label}: </span>
{changed ? (
<>
<span className="text-xs bg-red-500/15 text-red-700 dark:text-red-400 line-through rounded-sm px-0.5">
{before ? new Date(before).toLocaleString() : "—"}
</span>
{" → "}
<span className="text-xs bg-green-500/15 text-green-700 dark:text-green-400 rounded-sm px-0.5">
{after ? new Date(after).toLocaleString() : "—"}
</span>
</>
) : (
<span className="text-xs">{after ? new Date(after).toLocaleString() : "—"}</span>
)}
</div>
);
}
function SourceFactItem({ fact }: { fact: NonNullable<HistoryEntry["source_facts"]>[number] }) {
const typeColors =
fact.type === "experience"
? "bg-green-500/10 text-green-700 dark:text-green-400"
: "bg-blue-500/10 text-blue-700 dark:text-blue-400";
return (
<div
className={`p-2 rounded border space-y-1 ${
fact.is_new ? "border-green-500/40 bg-green-500/5" : "border-border/50 bg-muted/30"
}`}
>
<div className="flex items-center gap-1.5">
{fact.type && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium flex-shrink-0 ${typeColors}`}
>
{fact.type}
</span>
)}
{fact.is_new && (
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-green-500/15 text-green-700 dark:text-green-400 border border-green-500/30">
new
</span>
)}
{fact.context && (
<span className="text-[10px] text-muted-foreground italic truncate">{fact.context}</span>
)}
</div>
{fact.text ? (
<p className="text-xs text-foreground leading-relaxed">{fact.text}</p>
) : (
<p className="text-xs text-muted-foreground italic">(memory no longer available)</p>
)}
</div>
);
}
export function ObservationHistoryView({
history,
current,
}: {
history: HistoryEntry[];
current: CurrentState;
}) {
// index 0 = most recent change
const entries = [...history].reverse();
const [idx, setIdx] = useState(0);
const entry = entries[idx];
const isLatest = idx === 0;
const afterText = isLatest ? current.text : entries[idx - 1].previous_text;
const afterTags = isLatest ? current.tags : entries[idx - 1].previous_tags;
const afterOccurredStart = isLatest
? current.occurred_start
: entries[idx - 1].previous_occurred_start;
const afterOccurredEnd = isLatest ? current.occurred_end : entries[idx - 1].previous_occurred_end;
const afterMentionedAt = isLatest ? current.mentioned_at : entries[idx - 1].previous_mentioned_at;
return (
<div className="space-y-3">
{/* Navigation header */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Change <span className="font-semibold text-foreground">{history.length - idx}</span> of{" "}
{history.length} &middot; {new Date(entry.changed_at).toLocaleString()}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={idx === entries.length - 1}
onClick={() => setIdx(idx + 1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={idx === 0}
onClick={() => setIdx(idx - 1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Change card */}
<div className="border border-border rounded-lg p-3 space-y-3">
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Text</div>
<TextDiff before={entry.previous_text} after={afterText} />
</div>
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Tags</div>
<TagsDiff before={entry.previous_tags} after={afterTags} />
</div>
<div className="space-y-1">
<div className="text-xs font-bold text-muted-foreground uppercase mb-1">Dates</div>
<DateDiff
label="Occurred start"
before={entry.previous_occurred_start}
after={afterOccurredStart}
/>
<DateDiff
label="Occurred end"
before={entry.previous_occurred_end}
after={afterOccurredEnd}
/>
<DateDiff
label="Mentioned at"
before={entry.previous_mentioned_at}
after={afterMentionedAt}
/>
</div>
{entry.source_facts && entry.source_facts.length > 0 && (
<div>
<div className="text-xs font-bold text-muted-foreground uppercase mb-2">
Source Facts ({entry.source_facts.length})
</div>
<div className="space-y-1.5">
{entry.source_facts.map((fact) => (
<SourceFactItem key={fact.id} fact={fact} />
))}
</div>
</div>
)}
</div>
</div>
);
}
+59
View File
@@ -336,6 +336,20 @@ export class ControlPlaneClient {
return this.fetchApi(`/api/documents/${documentId}?bank_id=${bankId}`);
}
/**
* Update tags on a document and its associated memory units
*/
async updateDocument(documentId: string, bankId: string, tags: string[]) {
return this.fetchApi<{ success: boolean }>(
`/api/documents/${encodeURIComponent(documentId)}?bank_id=${bankId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tags }),
}
);
}
/**
* Delete document and all its associated memory units
*/
@@ -413,9 +427,42 @@ export class ControlPlaneClient {
chunk_id: string | null;
tags: string[];
observation_scopes: string | string[][] | null;
history?: {
previous_text: string;
previous_tags: string[];
previous_occurred_start: string | null;
previous_occurred_end: string | null;
previous_mentioned_at: string | null;
changed_at: string;
new_source_memory_ids: string[];
}[];
}>(`/api/memories/${memoryId}?bank_id=${bankId}`);
}
/**
* Get the history of an observation with resolved source facts
*/
async getObservationHistory(memoryId: string, bankId: string) {
return this.fetchApi<
{
previous_text: string;
previous_tags: string[];
previous_occurred_start: string | null;
previous_occurred_end: string | null;
previous_mentioned_at: string | null;
changed_at: string;
new_source_memory_ids: string[];
source_facts: {
id: string;
text: string | null;
type: string | null;
context: string | null;
is_new: boolean;
}[];
}[]
>(`/api/memories/${memoryId}/history?bank_id=${bankId}`);
}
/**
* Get bank profile
*/
@@ -780,6 +827,18 @@ export class ControlPlaneClient {
});
}
/**
* Get the refresh history of a mental model
*/
async getMentalModelHistory(bankId: string, mentalModelId: string) {
return this.fetchApi<
{
previous_content: string | null;
changed_at: string;
}[]
>(`/api/banks/${bankId}/mental-models/${mentalModelId}/history`);
}
/**
* Get API version and feature flags
* Use this to check which capabilities are available in the dataplane
+19 -1
View File
@@ -34,7 +34,25 @@
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"types/**/*.d.ts"
"types/**/*.d.ts",
".next-*/types/**/*.ts",
".next-*/dev/types/**/*.ts",
".next-49944/types/**/*.ts",
".next-49944/dev/types/**/*.ts",
".next-50612/types/**/*.ts",
".next-50612/dev/types/**/*.ts",
".next-54508/types/**/*.ts",
".next-54508/dev/types/**/*.ts",
".next-55630/types/**/*.ts",
".next-55630/dev/types/**/*.ts",
".next-58976/types/**/*.ts",
".next-58976/dev/types/**/*.ts",
".next-64080/types/**/*.ts",
".next-64080/dev/types/**/*.ts",
".next-50432/types/**/*.ts",
".next-50432/dev/types/**/*.ts",
".next-54840/types/**/*.ts",
".next-54840/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
@@ -0,0 +1,328 @@
---
title: "The Memory Upgrade Every OpenClaw User Needs"
authors: [hindsight]
date: 2026-03-06
tags: [openclaw]
image: /img/blog/adding-memory-to-openclaw-with-hindsight.png
hide_table_of_contents: true
---
## TL;DR
<!-- truncate -->
- OpenClaw's built-in memory is file-based -- markdown files on disk with SQLite vector search. It works, but the agent has to decide what to remember. Hindsight automates the entire pipeline.
- Hindsight is open source and runs locally by default. Your conversations, extracted knowledge, and memory store never leave your machine unless you choose otherwise.
- One plugin install, three commands to set up. The `hindsight-embed` daemon bundles the full memory engine (API + PostgreSQL) into a single process.
- Memories auto-inject into context before each response -- no tool calls, no retrieval logic to write.
- For teams, an external API mode connects to a shared Hindsight server so multiple OpenClaw instances can share memory.
## The Problem
OpenClaw is an always-on AI assistant that lives in your messaging apps -- WhatsApp, Telegram, Slack, Discord, iMessage, and more. It connects to an LLM, executes tasks on your behalf, and communicates through the channels you already use.
OpenClaw has memory built in, and it's a thoughtful design. The system uses plain Markdown files on disk: daily notes in `memory/YYYY-MM-DD.md` for session-level context, and a curated `MEMORY.md` for long-term knowledge. A SQLite-backed vector index (using `sqlite-vec`) enables semantic search over these files, and there's even an experimental QMD backend that combines BM25 keyword search with vector retrieval.
But there's a fundamental constraint: **the agent has to decide what to remember**. The docs say it directly -- "If you want something to stick, ask the bot to write it." Memory is append-only text that the model must explicitly choose to save. Today's and yesterday's daily notes load automatically at session start, but anything older requires the agent to actively search with the `memory_search` tool.
In practice, this means:
- Important facts slip through because the model didn't think to write them down.
- The quality of memory depends on how well the LLM follows its own instructions to persist information.
- As daily notes accumulate, finding the right context requires the agent to search at the right time with the right query -- and models don't do this consistently.
There's also a data question that matters for OpenClaw users specifically. OpenClaw runs on *your* machine and talks to *your* messaging apps. The expectation is local-first, private by default. Any memory solution that routes your conversations through a third-party cloud service breaks that model.
## The Approach
[Hindsight](https://github.com/vectorize-io/hindsight) is an open-source memory engine that replaces OpenClaw's memory layer with automated, structured knowledge extraction. The key differences from the built-in system:
**Automatic capture, not manual.** Every conversation is captured after each turn without the agent needing to decide what's worth remembering. Hindsight extracts facts, entities, and relationships in the background -- the model doesn't need to be prompted to "save this."
**Structured knowledge, not flat text.** Instead of appending lines to a Markdown file, Hindsight extracts discrete facts ("production database runs on port 5433"), tracks entities (people, services, projects), and maps relationships between them ("auth service depends on Redis for sessions").
**Auto-recall, not tool-based retrieval.** OpenClaw's built-in memory exposes a `memory_search` tool that the agent can call, but models don't use search tools consistently. Hindsight sidesteps this entirely by injecting relevant memory into context *before* every agent response. The agent doesn't need to know the memory system exists.
**Feedback loop prevention.** When memories are injected into context before a response, they become part of the conversation. Without care, those injected memories would get re-stored and re-extracted as new facts, causing exponential growth and duplicates. The plugin automatically strips its own `<hindsight_memories>` tags before retention, preventing this loop.
**Local-first, open source.** Hindsight runs through `hindsight-embed`, a daemon that bundles the memory API and a PostgreSQL instance into a single process on your machine. No data leaves your environment. The entire codebase is open source.
```
┌─────────────────┐ ┌────────────────────────────────┐
│ OpenClaw │ │ hindsight-embed daemon │
│ Gateway │──────▶│ ┌──────────┐ ┌───────────┐ │
│ │◀──────│ │ Memory │ │PostgreSQL │ │
│ WhatsApp/Slack/ │ │ │ API │─│(embedded) │ │
│ Telegram/... │ │ └──────────┘ └───────────┘ │
└─────────────────┘ └────────────────────────────────┘
runs locally · port 9077
```
## Implementation
### Step 1: Configure an LLM Provider
Hindsight needs an LLM for memory extraction. This is separate from your agent's primary model -- it runs in the background and handles fact/entity/relationship extraction.
```bash
# Option A: OpenAI (uses gpt-4o-mini)
export OPENAI_API_KEY="YOUR_API_KEY"
# Option B: Anthropic (uses claude-3-5-haiku)
export ANTHROPIC_API_KEY="YOUR_API_KEY"
# Option C: Gemini (uses gemini-2.5-flash)
export GEMINI_API_KEY="YOUR_API_KEY"
# Option D: Groq (uses openai/gpt-oss-20b)
export GROQ_API_KEY="YOUR_API_KEY"
# Option E: Claude Code (no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=claude-code
# Option F: OpenAI Codex (no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=openai-codex
```
You can also point it at any OpenAI-compatible endpoint, including OpenRouter:
```bash
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_MODEL=xiaomi/mimo-v2-flash
export HINDSIGHT_API_LLM_API_KEY=YOUR_API_KEY
export HINDSIGHT_API_LLM_BASE_URL=https://openrouter.ai/api/v1
```
A smaller, cheaper model is the right call here. Memory extraction doesn't need your most capable model.
### Step 2: Install the Plugin
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
You should see output confirming the install and that Hindsight takes over the memory slot:
```
Exclusive slot "memory" switched from "memory-core" to "hindsight-openclaw".
Installed plugin: hindsight-openclaw
```
This confirms Hindsight is replacing OpenClaw's built-in memory, not running alongside it.
### Step 3: Launch
```bash
openclaw gateway
```
The Hindsight daemon starts automatically on port 9077. You should see confirmation in the gateway output:
```
[Hindsight] ✓ Using provider: openai, model: gpt-4o-mini
```
That's the entire setup. No Docker, no database provisioning, no config files. Everything runs on your machine.
### Verifying It's Working
After a few conversations, check the gateway logs to confirm memory operations are happening:
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
```
You should see lines like:
```
[Hindsight] Retained X messages for session ...
[Hindsight] Auto-recall: Injecting X memories
```
If you want to browse what your agent has learned, the Hindsight daemon includes a web UI:
```bash
uvx hindsight-embed@latest -p openclaw ui
```
### External API Mode: Shared Memory Across Instances
The default local daemon is ideal for a single OpenClaw instance. But if you're running multiple instances -- say, one on your laptop and one on a server -- or if your team wants shared agent memory, the plugin supports connecting to a remote Hindsight API server.
Configure via environment variables:
```bash
export HINDSIGHT_EMBED_API_URL=https://your-hindsight-server.example.com
export HINDSIGHT_EMBED_API_TOKEN=YOUR_API_TOKEN
openclaw gateway
```
Or in `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://your-hindsight-server.example.com",
"hindsightApiToken": "YOUR_API_TOKEN"
}
}
}
}
}
```
In this mode, no local daemon starts. The plugin performs a health check against the remote API on startup and routes all memory operations -- retain, recall, reflect -- through it. You can verify it's working in the logs:
```bash
# [Hindsight] External API mode enabled: https://your-hindsight-server.example.com
# [Hindsight] External API health check passed
```
> **Note:** Environment variables take precedence over plugin config. If you have both set, the env var wins.
> **Want to skip self-hosting entirely?** [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) works as the external API endpoint — just use your Cloud URL and API token above. This does mean your memory data leaves your machine, which breaks the fully-local setup. For personal use where privacy is paramount, stick with the local daemon. But for teams or multi-instance setups where shared memory matters more, Cloud is the fastest path.
## Memory Isolation
By default, the plugin creates separate memory banks based on the agent, channel, and user context -- so each unique combination gets its own isolated memory store. The bank ID is derived from configurable fields via `dynamicBankGranularity`:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"dynamicBankGranularity": ["provider", "user"]
}
}
}
}
}
```
In this example, memories are isolated per provider + user, meaning the same user shares memories across all channels within a provider.
Available isolation fields:
- `agent` -- the bot identity
- `channel` -- the conversation or group ID
- `user` -- the person interacting with the bot
- `provider` -- the messaging platform (Slack, Telegram, etc.)
The default is `["agent", "channel", "user"]`, which gives full isolation per user per channel per agent. Set `dynamicBankId: false` to use a single shared bank for all conversations. Use `bankIdPrefix` to namespace banks across environments (e.g. `"prod"`, `"staging"`).
## Retention and Recall Controls
The plugin ships with sensible defaults, but most behaviors are configurable.
**Retention** controls what gets stored:
| Option | Default | Description |
|--------|---------|-------------|
| `autoRetain` | `true` | Auto-retain conversations after each turn. Set `false` to disable. |
| `retainRoles` | `["user", "assistant"]` | Which message roles to include in retained transcript. |
| `retainEveryNTurns` | `1` | Retain every Nth turn. Values > 1 enable chunked retention with a sliding window. |
| `retainOverlapTurns` | `0` | Extra prior turns included when chunked retention fires. |
**Recall** controls what gets injected:
| Option | Default | Description |
|--------|---------|-------------|
| `autoRecall` | `true` | Auto-inject memories before each turn. Set `false` when the agent has its own recall tool. |
| `recallBudget` | `"mid"` | Recall effort: `low`, `mid`, or `high`. Higher budgets use more retrieval strategies. |
| `recallMaxTokens` | `1024` | Max tokens for recall response -- controls how much memory context is injected per turn. |
| `recallTypes` | `["world", "experience"]` | Memory types to recall. Excludes verbose `observation` entries by default. |
| `recallTopK` | unlimited | Hard cap on number of memories injected per turn. |
| `recallContextTurns` | `1` | Number of prior user turns to include when composing the recall query. |
| `recallPromptPreamble` | built-in string | Custom text placed above recalled memories in the injected context. |
Example: high-fidelity recall with multi-turn context:
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"recallBudget": "high",
"recallMaxTokens": 2048,
"recallContextTurns": 3,
"recallTopK": 10
}
}
}
}
}
```
## Pitfalls & Edge Cases
**Memory extraction is asynchronous.** Facts are extracted after each turn in the background. If you end a session and immediately start a new one, the most recent facts may still be processing. In practice this might be a second or two, so don't expect instant availability across sessions.
**Extraction quality depends on your model choice.** A very small or low-quality extraction model will miss nuanced technical details. `gpt-4o-mini` and `claude-3-5-haiku` are solid defaults -- capable enough for reliable fact extraction, cheap enough to run on every turn.
**The recall window is bounded.** The default `recallMaxTokens` of 1024 means not every relevant memory will appear in every response. Retrieval is relevance-ranked, so the most pertinent facts surface first, but be aware of the ceiling. You can increase this to 2048 or higher in config.
**Memory isolation defaults to per-agent + per-channel + per-user.** This means your Slack group chat memories don't bleed into your Telegram DM memories and vice versa. If you want unified memory across channels, adjust `dynamicBankGranularity` to just `["user"]` or set `dynamicBankId: false` for a single shared bank.
**The embedded PostgreSQL needs system libraries.** `hindsight-embed` bundles PostgreSQL via [pg0](https://github.com/vectorize-io/pg0). On minimal Docker images (e.g. `ubuntu:latest`), you'll need to install `libxml2` and `libreadline` before the daemon can start:
```bash
apt-get install -y libxml2 libreadline8t64
```
Check the pg0 README for your platform if you hit other missing library errors.
**PostgreSQL cannot run as root.** If you're running inside Docker, make sure you're using a non-root user. PostgreSQL's `initdb` refuses to run as root, and the daemon will fail with `initdb: error: cannot be run as root`. Create a user and switch to it:
```dockerfile
RUN useradd -m -s /bin/bash myuser
USER myuser
```
**First run downloads ~3GB of dependencies.** On the very first `openclaw gateway` launch, `hindsight-embed` downloads Python packages including PyTorch, sentence-transformers, and (on x86) CUDA libraries. This can take several minutes and may cause the daemon start to time out. The plugin auto-retries, and subsequent launches use the cached packages -- so this is a one-time cost.
**Debug logging.** If something isn't working as expected, enable `debug: true` in the plugin config. This produces verbose logging of recall queries, retention transcripts, bank ID derivation, and more.
## Tradeoffs & Alternatives
**When to stick with OpenClaw's built-in memory:**
- You prefer the transparency of plain Markdown files you can edit in any text editor and version control with Git.
- Your use case is lightweight and session-scoped -- daily notes plus `MEMORY.md` cover your needs.
- You don't want any additional processes running alongside the Gateway.
**When Hindsight is the better choice:**
- You want automated memory extraction without relying on the model to decide what to save.
- You need consistent auto-recall rather than hoping the agent calls `memory_search` at the right time.
- You want structured knowledge (facts, entities, relationships) rather than raw text.
- You need shared memory across multiple OpenClaw instances or team members.
- You want fine-grained control over what gets retained, what gets recalled, and how memory is isolated across agents and channels.
**Local daemon vs. external API:**
The local daemon is simpler and keeps everything on one machine -- the right default for personal use. External API mode adds network latency but enables shared memory and survives machine restarts. Since Hindsight is open source, you can self-host the server on your own infrastructure and keep the same data ownership guarantees.
## Recap
OpenClaw's built-in memory is file-based and transparent, but it depends on the agent deciding what to remember and when to search. Hindsight replaces this with automated extraction and auto-recall -- conversations are captured, facts are extracted in the background, and relevant knowledge is injected into context before every response.
The core insight: memory that works automatically is qualitatively different from memory that depends on model behavior. When the agent doesn't have to choose what to save or when to search, it just has the right context.
And because Hindsight is open source and local-first, you keep the same data ownership model that makes OpenClaw compelling in the first place.
## Next Steps
- Install the plugin and have a few conversations across different channels. Then open the web UI (`uvx hindsight-embed@latest -p openclaw ui`) to see what was captured.
- Experiment with different LLM providers for extraction and compare the quality of captured facts.
- Tune recall with `recallBudget`, `recallMaxTokens`, and `recallContextTurns` to find the right balance for your use case.
- Adjust `dynamicBankGranularity` if you want memories shared across channels or isolated per provider.
- Browse the [Hindsight source on GitHub](https://github.com/vectorize-io/hindsight) to understand the extraction pipeline.
- Read the [full integration docs](https://hindsight.vectorize.io/sdks/integrations/openclaw) for the complete configuration reference.
- If you're running multiple OpenClaw instances, try external API mode with a self-hosted Hindsight server for shared memory.
@@ -107,6 +107,34 @@ hindsight document get my-bank meeting-2024-03-15
</TabItem>
</Tabs>
## Update Document
Update mutable fields on an existing document without re-processing the content. Currently supports updating `tags`.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={documentsPy} section="document-update" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={documentsMjs} section="document-update" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Replace tags with new values
hindsight document update-tags my-bank meeting-2024-03-15 --tags team-a --tags team-b
# Remove all tags
hindsight document update-tags my-bank meeting-2024-03-15
```
</TabItem>
</Tabs>
:::info Observations are re-consolidated
When tags change, any consolidated observations derived from the document's memories are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
:::
## Delete Document
Remove a document and all its associated memories:
@@ -81,6 +81,12 @@ Controls how aggressively facts are extracted:
Only active when `retain_extraction_mode` is `custom`. Replaces the built-in extraction rules entirely with your own instructions.
### retain_chunk_size
Maximum number of characters per chunk when splitting content for fact extraction. Larger chunks mean fewer LLM calls but may reduce extraction quality on long inputs; smaller chunks improve granularity at the cost of more calls.
Default: `3000`
See [Retain configuration](/developer/configuration#retain) for environment variable names and defaults.
### entity_labels {#entity-labels}
@@ -164,9 +170,21 @@ e.g. Observations are stable facts about people and projects.
Ignore one-off events and ephemeral state.
```
### consolidation_llm_batch_size
Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Leave unset to use the server default (`8`).
### consolidation_source_facts_max_tokens
Total token budget for source facts included with observations in the consolidation prompt. Source facts give the LLM evidence to compare new facts against existing observations. `-1` = unlimited. Leave unset to use the server default (`-1`).
### consolidation_source_facts_max_tokens_per_observation
Per-observation token cap for source facts in the consolidation prompt. Each observation independently gets at most this many tokens of source facts, preventing a single observation with many source facts from consuming the entire budget. `-1` = unlimited. Leave unset to use the server default (`256`).
See [Observations configuration](/developer/configuration#observations) for environment variable names and defaults.
### mission
### reflect_mission
A first-person narrative that provides identity and framing context for `reflect`. The agent uses this to ground its reasoning and apply a consistent perspective.
@@ -216,9 +234,32 @@ How much to weight emotional context when reasoning during `reflect`. Scale 1
| `5` | Empathetic — considers emotional context |
:::info
Disposition traits and `mission` only affect the `reflect` operation. `retain_mission` and `observations_mission` are separate per-operation settings.
Disposition traits and `reflect_mission` only affect the `reflect` operation. `retain_mission` and `observations_mission` are separate per-operation settings.
:::
### mcp_enabled_tools
An allowlist of MCP tool names that are enabled for this bank. When set, only the listed tools can be invoked; any tool not in the list returns an error (tools still appear in the MCP tools list for protocol compatibility). Set to `null` (or omit) to allow all tools.
```json
["recall", "reflect"]
```
Available tool names: `retain`, `recall`, `reflect`, `list_banks`, `create_bank`, `list_mental_models`, `get_mental_model`, `create_mental_model`, `update_mental_model`, `delete_mental_model`, `refresh_mental_model`, `list_directives`, `create_directive`, `delete_directive`, `list_memories`, `get_memory`, `delete_memory`, `list_documents`, `get_document`, `delete_document`, `list_operations`, `get_operation`, `cancel_operation`, `list_tags`, `get_bank`, `get_bank_stats`, `update_bank`, `delete_bank`, `clear_memories`.
### llm_gemini_safety_settings
Controls content filtering thresholds for Gemini and VertexAI providers. Accepts a list of safety setting objects in the [Google AI safety settings format](https://ai.google.dev/api/generate-content#v1beta.SafetySetting). When `null` (default), Gemini's built-in safety defaults are used.
```json
[
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}
]
```
Only applies when `HINDSIGHT_API_LLM_PROVIDER` is `gemini` or `vertexai`.
---
## Updating Configuration
@@ -272,6 +272,33 @@ For more details on tag matching modes (`any`, `any_strict`, `all`, `all_strict`
---
## History
Every time a mental model's content changes (via refresh or manual update), the previous version is saved with a timestamp. You can retrieve the full change log with the history endpoint:
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="get-mental-model-history" language="python" />
</TabItem>
</Tabs>
### Response
The endpoint returns a list of history entries, most recent first:
| Field | Type | Description |
|-------|------|-------------|
| `previous_content` | string \| null | The content before this change (`null` if not available) |
| `changed_at` | string | ISO 8601 timestamp of when the change occurred |
Each entry captures the **content before the change** and when it happened. The current content is returned by the standard [Get a Mental Model](#get-a-mental-model) endpoint.
:::note
History tracking is enabled by default. Set `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY=false` to disable it.
:::
---
## Use Cases
| Use Case | Example |
+36 -3
View File
@@ -531,6 +531,7 @@ For advanced authentication (JWT, OAuth, multi-tenant schemas), implement a cust
| `HINDSIGHT_API_RERANKER_MAX_CANDIDATES` | Max candidates to rerank per recall (RRF pre-filters the rest) | `300` |
| `HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS` | Fan-out limit per node in MPFP graph traversal | `20` |
| `HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY` | Max concurrent mental model refreshes | `8` |
| `HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY` | Track history of content changes to each mental model (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
#### Graph Retrieval Algorithms
@@ -603,11 +604,37 @@ Configuration for the file upload and conversion pipeline (used by `POST /v1/def
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_ENABLE_FILE_UPLOAD_API` | Enable the file upload API endpoint | `true` |
| `HINDSIGHT_API_FILE_PARSER` | File parser to use (`markitdown`, `iris`) | `markitdown` |
| `HINDSIGHT_API_FILE_PARSER` | Server-side default parser or fallback chain (comma-separated, e.g. `iris,markitdown`) | `markitdown` |
| `HINDSIGHT_API_FILE_PARSER_ALLOWLIST` | Comma-separated list of parsers clients are allowed to request. If not set, all registered parsers are allowed. | — |
| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE` | Max files per upload request | `10` |
| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB` | Max total upload size per request (MB) | `100` |
| `HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN` | Delete stored files after memory extraction completes | `true` |
#### Parser selection
Clients can override the server default by passing `parser` in the request body of `POST /v1/default/banks/{bank_id}/files/retain`. Both the server default and the per-request field accept a single parser name or an ordered **fallback chain** — each parser is tried in sequence until one succeeds.
```bash
# Server default: try iris first, fall back to markitdown if iris fails
export HINDSIGHT_API_FILE_PARSER=iris,markitdown
# Restrict what clients may request (optional — defaults to all registered parsers)
export HINDSIGHT_API_FILE_PARSER_ALLOWLIST=markitdown,iris
```
```json
// Per-request override (in the JSON body of the file retain endpoint)
{
"parser": "iris",
"files_metadata": [
{ "document_id": "report" },
{ "document_id": "fallback_doc", "parser": ["iris", "markitdown"] }
]
}
```
Clients that request a parser not in the allowlist receive HTTP 400.
#### Parser: markitdown (default)
Local file-to-markdown conversion using [Microsoft's markitdown](https://github.com/microsoft/markitdown). No external service required.
@@ -626,10 +653,13 @@ Cloud-based extraction via [Vectorize Iris](https://docs.vectorize.io/build-depl
**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, JPEG, PNG, GIF, BMP, TIFF, WEBP), HTML, TXT, MD, CSV.
```bash
# Use iris parser (requires Vectorize account)
# Use iris as the only parser
export HINDSIGHT_API_FILE_PARSER=iris
export HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN=your-vectorize-token
export HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID=your-org-id
# Or: try iris first, fall back to markitdown if iris fails or rejects the file type
export HINDSIGHT_API_FILE_PARSER=iris,markitdown
```
```bash
@@ -733,9 +763,12 @@ Observations are consolidated knowledge synthesized from facts.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` |
| `HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY` | Track history of changes to each observation (previous content + timestamp). Disable to reduce storage if audit trails are not needed. | `true` |
| `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` |
| `HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS` | Max tokens for recall when finding related observations during consolidation | `1024` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE` | Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. | `8` |
| `HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE` | Number of facts sent to the LLM in a single consolidation call. Higher values reduce LLM calls and improve throughput at the cost of larger prompts. Set to `1` to disable batching. Configurable per bank. | `8` |
| `HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS` | Total token budget for source facts included with observations in the consolidation prompt. `-1` = unlimited. Configurable per bank. | `-1` |
| `HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION` | Per-observation token cap for source facts in the consolidation prompt. Each observation independently gets at most this many tokens of source facts. `-1` = unlimited. Configurable per bank. | `256` |
| `HINDSIGHT_API_OBSERVATIONS_MISSION` | What this bank should synthesise into durable observations. Replaces the built-in consolidation rules — leave unset to use the server default. | - |
#### Customizing observations: when to use what
+23
View File
@@ -103,6 +103,29 @@ console.log(`Created: ${doc.created_at}`);
// [/docs:document-get]
// [docs:document-update]
// Fix tags on a document retained with the wrong scope
const { data: updateResult, error: updateError } = await sdk.updateDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15-section-1' },
body: { tags: ['team-a', 'team-b'] }
});
if (updateError) {
throw new Error(`Failed to update tags: ${JSON.stringify(updateError)}`);
}
console.log(`Updated: ${updateResult.success}`);
// Remove all tags (make document visible everywhere)
await sdk.updateDocument({
client: apiClient,
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15-section-1' },
body: { tags: [] }
});
// [/docs:document-update]
// [docs:document-delete]
// Delete document and all its memories
const { data: deleteResult } = await sdk.deleteDocument({
+29
View File
@@ -120,6 +120,35 @@ asyncio.run(get_document_example())
# [/docs:document-get]
# [docs:document-update]
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
from hindsight_client_api.models import UpdateDocumentRequest
async def update_document_example():
config = Configuration(host="http://localhost:8888")
api_client = ApiClient(config)
api = DocumentsApi(api_client)
# Fix tags on a document retained with the wrong scope
result = await api.update_document(
bank_id="my-bank",
document_id="meeting-2024-03-15",
update_document_request=UpdateDocumentRequest(tags=["team-a", "team-b"]),
)
print(f"Updated: {result.success}")
# Remove all tags (make document visible everywhere)
await api.update_document(
bank_id="my-bank",
document_id="meeting-2024-03-15",
update_document_request=UpdateDocumentRequest(tags=[]),
)
asyncio.run(update_document_example())
# [/docs:document-update]
# [docs:document-delete]
from hindsight_client_api import ApiClient, Configuration
from hindsight_client_api.api import DocumentsApi
@@ -110,6 +110,18 @@ if mental_model_id:
# [/docs:update-mental-model]
# [docs:get-mental-model-history]
# Get the change history of a mental model
history = client.get_mental_model_history(
bank_id=BANK_ID,
mental_model_id=mental_model_id
)
for entry in history:
print(f"Changed at: {entry['changed_at']}")
print(f"Previous content: {entry['previous_content']}")
# [/docs:get-mental-model-history]
# [docs:delete-mental-model]
# Delete a mental model
client.delete_mental_model(
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+257 -3
View File
@@ -322,7 +322,7 @@
"Memory"
],
"summary": "Get memory unit",
"description": "Get a single memory unit by ID with all its metadata including entities and tags.",
"description": "Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.",
"operationId": "get_memory",
"parameters": [
{
@@ -382,6 +382,72 @@
}
}
},
"/v1/default/banks/{bank_id}/memories/{memory_id}/history": {
"get": {
"tags": [
"Memory"
],
"summary": "Get observation history",
"description": "Get the full history of an observation, with each change's source facts resolved to their text.",
"operationId": "get_observation_history",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "memory_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Memory Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/memories/recall": {
"post": {
"tags": [
@@ -1244,6 +1310,72 @@
}
}
},
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history": {
"get": {
"tags": [
"Mental Models"
],
"summary": "Get mental model history",
"description": "Get the refresh history of a mental model, showing content changes over time.",
"operationId": "get_mental_model_history",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "mental_model_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Mental Model Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh": {
"post": {
"tags": [
@@ -1916,6 +2048,82 @@
}
}
},
"patch": {
"tags": [
"Documents"
],
"summary": "Update document",
"description": "Update mutable fields on a document without re-processing its content.\n\n**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.\n\nAt least one field must be provided.",
"operationId": "update_document",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "document_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Document Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateDocumentRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateDocumentResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
},
"delete": {
"tags": [
"Documents"
@@ -3660,7 +3868,7 @@
"Files"
],
"summary": "Convert files to memories",
"description": "Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\nThis endpoint handles file upload, conversion, and memory creation in a single operation.\n\n**Features:**\n- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n- Automatic file-to-markdown conversion using pluggable parsers\n- Files stored in object storage (PostgreSQL by default, S3 for production)\n- Each file becomes a separate document with optional metadata/tags\n- Always processes asynchronously \u2014 returns operation IDs immediately\n\n**The system automatically:**\n1. Stores uploaded files in object storage\n2. Converts files to markdown\n3. Creates document records with file metadata\n4. Extracts facts and creates memory units (same as regular retain)\n\nUse the operations endpoint to monitor progress.\n\n**Request format:** multipart/form-data with:\n- `files`: One or more files to upload\n- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).",
"description": "Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\nThis endpoint handles file upload, conversion, and memory creation in a single operation.\n\n**Features:**\n- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n- Automatic file-to-markdown conversion using pluggable parsers\n- Files stored in object storage (PostgreSQL by default, S3 for production)\n- Each file becomes a separate document with optional metadata/tags\n- Always processes asynchronously \u2014 returns operation IDs immediately\n\n**The system automatically:**\n1. Stores uploaded files in object storage\n2. Converts files to markdown\n3. Creates document records with file metadata\n4. Extracts facts and creates memory units (same as regular retain)\n\nUse the operations endpoint to monitor progress.\n\n**Request format:** multipart/form-data with:\n- `files`: One or more files to upload\n- `request`: JSON string with FileRetainRequest model\n\n**Parser selection:**\n- Set `parser` in the request body to override the server default for all files.\n- Set `parser` inside a `files_metadata` entry for per-file control.\n- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain \u2014 each parser is tried in sequence until one succeeds.\n- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.\n- Only parsers enabled on the server may be requested; others return HTTP 400.",
"operationId": "file_retain",
"parameters": [
{
@@ -7210,8 +7418,14 @@
"max_tokens": {
"type": "integer",
"title": "Max Tokens",
"description": "Maximum tokens for source facts",
"description": "Maximum total tokens for source facts across all observations (-1 = unlimited)",
"default": 4096
},
"max_tokens_per_observation": {
"type": "integer",
"title": "Max Tokens Per Observation",
"description": "Maximum tokens of source facts per observation (-1 = unlimited)",
"default": -1
}
},
"type": "object",
@@ -7365,6 +7579,46 @@
"title": "UpdateDispositionRequest",
"description": "Request model for updating disposition traits."
},
"UpdateDocumentRequest": {
"properties": {
"tags": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Tags",
"description": "New tags for the document and its memory units. Triggers observation invalidation and re-consolidation."
}
},
"type": "object",
"title": "UpdateDocumentRequest",
"description": "Request model for updating a document's mutable fields.",
"example": {
"tags": [
"team-a",
"team-b"
]
}
},
"UpdateDocumentResponse": {
"properties": {
"success": {
"type": "boolean",
"title": "Success",
"default": true
}
},
"type": "object",
"title": "UpdateDocumentResponse",
"description": "Response model for update document endpoint."
},
"UpdateMentalModelRequest": {
"properties": {
"name": {
+7 -1
View File
@@ -18,6 +18,10 @@ echo "✅ SDK built successfully"
echo ""
echo "🚀 Starting Control Plane (Next.js dev server)..."
# Save caller-provided values before .env can overwrite them
_CALLER_PORT="${PORT:-}"
_CALLER_DATAPLANE_URL="${HINDSIGHT_CP_DATAPLANE_API_URL:-}"
if [ -f "$ROOT_DIR/.env" ]; then
echo "📄 Loading environment from $ROOT_DIR/.env"
# Load env vars from root .env file
@@ -28,7 +32,9 @@ fi
# Map prefixed env vars to Next.js standard vars
export HOSTNAME="${HINDSIGHT_CP_HOSTNAME:-0.0.0.0}"
export PORT="${HINDSIGHT_CP_PORT:-9999}"
# Caller-provided values take priority over .env
export PORT="${_CALLER_PORT:-${HINDSIGHT_CP_PORT:-9999}}"
export HINDSIGHT_CP_DATAPLANE_API_URL="${_CALLER_DATAPLANE_URL:-${HINDSIGHT_CP_DATAPLANE_API_URL:-http://localhost:8888}}"
# Run dev server
npm run dev -w @vectorize-io/hindsight-control-plane
+23 -4
View File
@@ -3,6 +3,14 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Parse --random-port flag
RANDOM_PORT=false
for arg in "$@"; do
if [ "$arg" = "--random-port" ]; then
RANDOM_PORT=true
fi
done
# Load .env to pick up HINDSIGHT_API_PORT if set
ROOT_DIR="$(git rev-parse --show-toplevel)"
if [ -f "$ROOT_DIR/.env" ]; then
@@ -10,8 +18,19 @@ if [ -f "$ROOT_DIR/.env" ]; then
source "$ROOT_DIR/.env"
set +a
fi
API_PORT="${HINDSIGHT_API_PORT:-8888}"
CP_PORT="${HINDSIGHT_CP_PORT:-9999}"
get_free_port() {
python3 -c "import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()"
}
if [ "$RANDOM_PORT" = true ]; then
API_PORT="$(get_free_port)"
CP_PORT="$(get_free_port)"
echo "Using random ports — API: $API_PORT, Control Plane: $CP_PORT"
else
API_PORT="${HINDSIGHT_API_PORT:-8888}"
CP_PORT="${HINDSIGHT_CP_PORT:-9999}"
fi
PIDS=()
@@ -37,7 +56,7 @@ trap cleanup EXIT INT TERM
# Start API
echo "Starting API server..."
"$SCRIPT_DIR/start-api.sh" &
"$SCRIPT_DIR/start-api.sh" --port "$API_PORT" &
API_PID=$!
PIDS+=($API_PID)
@@ -63,7 +82,7 @@ fi
# Start Control Plane
echo ""
"$SCRIPT_DIR/start-control-plane.sh" &
PORT="$CP_PORT" HINDSIGHT_CP_DATAPLANE_API_URL="http://localhost:${API_PORT}" "$SCRIPT_DIR/start-control-plane.sh" &
CP_PID=$!
PIDS+=($CP_PID)