Compare commits

...
Author SHA1 Message Date
Nicolò Boschi f55dbc86b3 ci: add test-claude-code-integration job to run plugin unit tests 2026-03-23 15:11:03 +01:00
Nicolò Boschi 5f4a87fa41 fix(claude-code): set author to Hindsight Team in plugin.json 2026-03-23 15:08:25 +01:00
Nicolò Boschi 80f2d7cbc6 docs(claude-code): add ToS hint for claude-code LLM provider option 2026-03-23 12:30:46 +01:00
Nicolò Boschi 8d8a10d70a fix(claude-code): use ~/.hindsight/claude-code.json for user config
Matches the ~/.openclaw/openclaw.json convention. Removes the confusing
CLAUDE_PLUGIN_DATA path whose name depends on marketplace+plugin identifiers.
2026-03-23 12:29:14 +01:00
Nicolò Boschi 4081445bfe feat(claude-code): user settings.json at CLAUDE_PLUGIN_DATA for stable config
Plugin now checks CLAUDE_PLUGIN_DATA/settings.json after the versioned
plugin default, giving users a path that persists across updates:
  ~/.claude/plugins/data/hindsight-memory-hindsight/settings.json

Loading order: defaults → plugin settings.json → user settings.json → env vars
2026-03-23 12:25:26 +01:00
Nicolò Boschi 6d0bb2b834 test(claude-code): add 116 unit tests for plugin hooks and lib modules 2026-03-23 12:16:57 +01:00
Nicolò Boschi b40918958a remove install.sh — users install via claude plugin commands directly 2026-03-23 12:10:43 +01:00
Nicolò Boschi 2f7fe35bdd fix(claude-code): fix plugin installation and release workflow
- Fix plugin.json author field (string → object) to pass claude plugin validate
- Add hindsight-integrations/.claude-plugin/marketplace.json so users can install
  via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations
- Update README and install.sh with correct two-command install flow
- Fix release-integration.yml: add explicit package.json check for typescript type
  and add plugin type for integrations with neither pyproject.toml nor package.json
  (prevents claude-code from incorrectly falling into the typescript build path)
- Add CHANGELOG.md for the claude-code integration
2026-03-23 12:09:46 +01:00
Fabio ScarsiandClaude Opus 4.6 f4390bdc2e feat: Add Claude Code integration plugin (#651)
* feat: Add Claude Code integration plugin

Complete port of hindsight-openclaw (v0.4.19) adapted to Claude Code's
hook-based plugin architecture. Pure Python stdlib, no external dependencies.

- Auto-recall via UserPromptSubmit hook (additionalContext injection)
- Auto-retain via async Stop hook (chunked retention with sliding window)
- Daemon management (auto-start/stop hindsight-embed via uvx)
- Dynamic bank IDs with per-agent/project/channel/user granularity
- All 34 configuration options with env var overrides
- File-based state persistence with fcntl locking
- Graceful degradation on all error paths

Works with Claude Code Channels (Telegram, Discord, Slack) and
interactive sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: Set correct chunked retention defaults (10/2, not 1/0)

retainEveryNTurns=10 and retainOverlapTurns=2 are the production-tested
values — every 10 turns, retain a 12-turn sliding window. The previous
defaults (1/0) would retain every single turn with no overlap, defeating
the chunked retention design that prevents API bombardment.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: Align recallBudget and daemonIdleTimeout with Openclaw defaults

recallBudget: "low" → "mid" (Openclaw default)
daemonIdleTimeout: 300 → 0 (Openclaw default, never auto-stop)

As an official Hindsight integration, defaults should match Openclaw.
Users can optimize locally via settings.json or env vars.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 12:06:54 +01:00
Mr. Khachaturov e0f0da5d2d docs: update HindClaw integration listing (#653)
Rename hindsight-openclaw-pro → HindClaw and update description to
reflect the current architecture: server-side Hindsight extensions
(hindclaw-extension on PyPI), Terraform provider for infrastructure
management, and the hindclaw-openclaw gateway plugin.

Link points to https://github.com/mrkhachaturov/hindclaw.
2026-03-23 11:11:00 +01:00
Nicolò Boschi a9e6d9f731 test: add unit tests for pg_trgm auto-detection and ValidationResult.accept_with() enrichment (#650)
* test: add unit tests for pg_trgm auto-detection and ValidationResult.accept_with() enrichment

Two recent PRs landed without dedicated tests:
- #626/#649 (pg_trgm fallback in EntityResolver): add 5 mocked unit tests
  covering the trigram→full fallback, single-check guarantee, and sticky
  downgrade behaviour.
- #639 (accept_with() enrichment): add 7 pure unit tests for the factory
  method plus 5 integration tests verifying the engine applies enriched
  contents (retain) and tags/tag_groups (recall) returned by validators.
  Also verifies RecallContext carries tag filter state.

* fix: remove 504 from reflect OpenAPI spec to fix progenitor Rust client build

progenitor-impl-0.11.2 panics with `assertion failed: response_types.len() <= 1`
when an endpoint declares more than one response type. PR #643 added
`responses={504: ...}` to the reflect decorator, which injected a second
response type into the generated OpenAPI spec and broke the Rust client build.

Remove the `responses=` kwarg — the 504 is still raised at runtime via
JSONResponse(status_code=504), it just won't appear in the OpenAPI schema.
Regenerate openapi.json accordingly.

* chore: sync generated files and ruff formatting (lint + docs skill)
2026-03-23 10:33:09 +01:00
8ce06e3e7c Add wall-clock timeout to reflect operations (#643)
* Initial plan

* feat: add wall-clock timeout to reflect operations (fixes vectorize-io/hindsight#642)

Add a configurable wall-clock timeout (default: 300s / 5 minutes) for
the entire reflect operation. This prevents reflect calls from hanging
for up to 40 minutes when LLM calls are slow or iteration counts are
high.

Changes:
- Add DEFAULT_REFLECT_WALL_TIMEOUT (300s) config constant
- Add HINDSIGHT_API_REFLECT_WALL_TIMEOUT env variable support
- Wrap run_reflect_agent() with asyncio.wait_for() in reflect_async()
- Return HTTP 504 on timeout in the reflect HTTP endpoint
- Add unit test for wall-clock timeout enforcement

Co-authored-by: ThePlenkov <[email protected]>
Agent-Logs-Url: https://github.com/ThePlenkov/hindsight/sessions/a123d68b-aca1-4040-8bba-8c4f0fab2e2c

* fix: address PR review findings (OpenAPI 504, docs, type hints, main.py TypeError, overlapping exceptions, lazy logging)

Co-authored-by: ThePlenkov <[email protected]>
Agent-Logs-Url: https://github.com/ThePlenkov/hindsight/sessions/dd574a88-53a3-4f9e-bba7-5a40b0eddb99

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: ThePlenkov <[email protected]>
2026-03-23 09:19:26 +01:00
Coderandcoder999999999 365fa3ce50 Fix pg_trgm unavailability causing startup crash and silent retain failures (#626) (#649)
On managed PostgreSQL services (e.g. Azure Flexible Server), the pg_trgm
extension may not be available, causing two failures:

1. Migration c1a2b3d4e5f6 crashes on CREATE EXTENSION
2. Even if migration is bypassed, the default 'trigram' entity lookup
   strategy uses the % operator which requires pg_trgm, causing retain
   background tasks to fail silently

Changes:
- Migration now gracefully skips pg_trgm and index creation if the
  extension cannot be loaded
- EntityResolver auto-detects pg_trgm availability on first use and
  falls back to 'full' lookup strategy with a warning log

Co-authored-by: coder999999999 <[email protected]>
2026-03-23 09:18:59 +01:00
Mr. Khachaturov 2eb1019da9 feat(extensions): add context enrichment to OperationValidatorExtension (#639)
Validators can now return enriched data via ValidationResult.accept_with()
instead of only accepting or rejecting operations. The engine applies
returned fields (contents, tags, tag_groups) to the operation parameters.

- Add accept_with() factory to ValidationResult with optional enrichment
  fields: contents, tags, tags_match, tag_groups
- Add tags, tags_match, tag_groups to RecallContext so validators can
  see current filter state
- Update _validate_operation to return ValidationResult
- Apply enrichment from result at all retain (2 sites) and recall call
  sites in MemoryEngine
- Existing validators using accept()/reject() work unchanged
2026-03-23 08:57:02 +01:00
Sebastian B Otaeguiandfeniix 2f2db2a6e2 fix: strip markdown code fences from all LLM providers, not just local (#646)
LLM providers like MiniMax wrap JSON responses in markdown code fences
(```json ... ```), causing JSON parse failures and 5-11 retries per
extraction. The existing fence stripping logic was gated to only
"lmstudio" and "ollama" providers (and for Ollama, unreachable due to
the _call_ollama_native redirect).

Changes:
- Extract _strip_code_fences() helper function
- Apply fence stripping to all providers in call() (not just local)
- Add fence stripping safety net to _call_ollama_native()
- Add 10 tests covering bare JSON, fenced JSON, malformed fences,
  and real-world MiniMax response format

Fixes vectorize-io/hindsight#645

Co-authored-by: feniix <feniix@desktop>
2026-03-22 21:29:16 +01:00
Vitali Avagyan caa53ee370 docs: add gitcgr code graph badge (#648) 2026-03-22 21:28:29 +01:00
Nicolò Boschi 5cdc714a38 fix(recall): reject empty queries with 400 and fix SQL parameter gap (#632)
* fix(recall): reject empty queries with 400 and fix SQL parameter gap causing IndeterminateDatatypeError

When query text contains only punctuation/symbols (no word characters after
normalization), the BM25 arms are skipped but the old code still placed `limit`
at \$3 in the params list. If tags or tag_groups were also set, their params
(\$4+) were referenced in the SQL while \$3 was a gap, causing PostgreSQL to
raise IndeterminateDatatypeError.

Fix the parameter layout so `limit` is only appended to params when tokens are
present (i.e. when BM25 arms actually use LIMIT \$3), and shift tags_param_idx
from 4 to 3 in the no-tokens path.

Also add a field_validator on RecallRequest.query that rejects empty-after-
normalization queries at the API layer with a 400 before they reach the DB.

* refactor: extract tokenize_query helper and reuse in RecallRequest validator
2026-03-21 20:24:36 +01:00
Simon Oberreuterandsoberreu <soberreu> 78aa7c537e Fix: POST files/retain uses authentication headers (#636)
Co-authored-by: soberreu <soberreu>
2026-03-21 20:24:12 +01:00
Andrew Barnes 3f31cbf505 fix: allow claude-agent-sdk installation on Linux/Docker (#644)
Remove the sys_platform == 'darwin' constraint that prevented
claude-agent-sdk from installing on Linux, breaking the claude-code
provider in Docker containers.

Fixes #640
2026-03-21 20:23:38 +01:00
Nicolò Boschi b7abf8565a release(litellm): v0.5.0 2026-03-21 09:18:40 +01:00
Nicolò Boschi 682cbf38ee chore(litellm): update uv.lock 2026-03-21 09:18:26 +01:00
Nicolò Boschi 5e8952c54a fix(litellm): fall back to last user message when hindsight_query not provided (#641)
* fix(litellm): fall back to last user message when hindsight_query not provided

inject_memories=True no longer requires an explicit hindsight_query. The
injection path now falls back to extracting the last user message, matching
the documented Quick Start behavior that was broken since #167 (v0.4.18).

* test(litellm): add regression tests for inject_memories without hindsight_query
2026-03-21 09:16:58 +01:00
DK09876andClaude Opus 4.6 8364b9c5d5 fix: MCP tool calls fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ (#635)
* fix: MCP tool calls fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ

When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are
configured with different values, MCP transport auth passes but tool
execution fails because the MCP token gets re-validated against the
tenant API key in the engine layer.

Add mcp_authenticated flag to RequestContext so the engine skips tenant
re-validation when MCP transport auth already succeeded.

Fixes #627

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

* test: strengthen assertion to verify no auth error in tool response

The original test only checked that "banks" key existed in the response,
which was true even for error responses like {"error": "...", "banks": []}.
Now asserts "error" not in parsed to properly catch auth failures.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 18:36:05 +01:00
DK09876andClaude Opus 4.6 5a486883e8 fix: add readme field to integration pyproject.toml files for PyPI (#634)
PyPI was not displaying package READMEs because the `readme` field
was missing from pyproject.toml. Hatchling requires this to be
explicitly declared. Fixes langgraph, agno, hermes, and pydantic-ai.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 17:11:20 +01:00
BenandClaude Sonnet 4.6 d2c32cb8e4 blog: Give NemoClaw the Best Agent Memory Available In One Command (#631)
* docs(blog): add NemoClaw persistent memory blog post

Covers external API mode, OpenShell network egress policy pattern,
and the LaunchAgent symlink gotcha from the live test run.

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

* docs(blog): update NemoClaw blog post with SEO-optimized draft

- Add slug, TL;DR, pitfalls, tradeoffs table, recap, next steps sections
- Restructure into numbered implementation steps
- Remove internal blog links that don't exist yet

* docs(blog): fix docs link to include /recall/ path

* docs(blog): add correct internal links to NemoClaw blog post

* docs(blog): make hindsight-nemoclaw setup command the primary path

One-command setup is now the default; manual 4-step process moved to
'Manual Alternative' section for reference.

* docs(blog): update title to lead with NemoClaw and best-in-class memory

* Add cover image to NemoClaw memory blog post

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-20 16:29:08 +01:00
Nicolò Boschi ce691549ba doc: add langgraph and nemoclaw (#633) 2026-03-20 16:18:05 +01:00
Nicolò Boschi 72b61214f6 release(nemoclaw): v0.1.1 2026-03-20 15:21:26 +01:00
Nicolò Boschi 103994c25f fix nemoclaw release 2026-03-20 15:21:15 +01:00
Nicolò Boschi 36b5627d2c fix nemoclaw release 2026-03-20 15:18:44 +01:00
Ben d284de28c7 feat(nemoclaw): add hindsight-nemoclaw setup CLI package (#630)
* feat(nemoclaw): add hindsight-nemoclaw setup CLI package

Automates the full NemoClaw sandbox setup:
- Installs @vectorize-io/hindsight-openclaw plugin
- Configures external API mode in ~/.openclaw/openclaw.json
- Reads current openshell sandbox policy, merges Hindsight egress rule, re-applies
- Restarts the OpenClaw gateway

Options: --dry-run, --skip-policy, --skip-plugin-install
36 unit tests passing

* docs: add NEMOCLAW.md setup guide

* feat(nemoclaw): add README, docs page, and release pipeline

* revert: remove release.yml changes from nemoclaw PR
2026-03-20 15:16:55 +01:00
Nicolò Boschi 93609f74ab release(langgraph): v0.1.1 2026-03-20 13:45:56 +01:00
Nicolò Boschi 9a5f83adb4 fix: release integrations 2026-03-20 13:45:46 +01:00
DK09876andClaude Opus 4.6 b4320254b2 feat: add LangGraph integration (#610)
* feat: add LangGraph integration with tools, nodes, and store patterns

Add hindsight-langgraph SDK providing three integration patterns:
- Tools: retain/recall/reflect as LangChain tools for ReAct agents
- Nodes: automatic memory injection and storage as graph steps
- Store: LangGraph BaseStore implementation for checkpoint-based memory

Fix: remove `from __future__ import annotations` in nodes.py which
prevented LangGraph from passing RunnableConfig to node functions
(runtime type inspection saw string annotations instead of actual types).

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

* chore: register langgraph with independent versioning system

- Set version to 0.1.0 (integrations are versioned independently)
- Add langgraph to VALID_INTEGRATIONS in release-integration.sh
- Add changelog page for langgraph integration

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

* chore: remove manual cookbook recipe page

The sync-cookbook script will auto-generate this from the notebook
in hindsight-cookbook once PR #17 is merged.

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

* fix: comprehensive improvements to langgraph integration

Code fixes:
- Retain node only stores latest messages instead of all history (prevents duplicates)
- Handle multimodal msg.content (list type) in nodes
- Fix store docstring separator "/" → "."
- Apply search filters before pagination in store
- Add ttl parameter to store.aput for LangGraph BaseStore compat
- Fix _ensure_bank to not cache failed bank creations
- Fix falsy value bugs (or → is not None) in tools
- Remove from __future__ import annotations from all files
- Consistent default budget="mid" across tools/nodes/store
- Bump langgraph floor to >=0.3.0, remove duplicate dev deps

Docs fixes:
- Fix broken Cloud client example (base_url is required)
- Complete API reference tables with all parameters
- Add Limitations and Notes section (async-only store, etc.)
- Add Requirements section
- Fix broken cookbook link and Cloud claim in blog post

All 61 unit tests pass. E2E tested against Hindsight Cloud:
tools, nodes, store, configure(), multimodal content.

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

* chore: remove blog post (lives in hindsight-marketing-content)

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

* chore: remove Hindsight Cloud section from langgraph docs

Keep OSS docs self-hosted-first, consistent with other integration
docs (crewai, pydantic-ai, agno). Cloud setup details live in the
cookbook notebooks.

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

* docs: explicitly mention LangChain compatibility in langgraph integration

The tools pattern (create_hindsight_tools) only depends on
langchain-core and works with plain LangChain via bind_tools() —
no LangGraph required. Update docs to make this clear with both
LangGraph and LangChain quick start examples.

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

* fix: address PR review findings

1. Guard manual test files with if __name__ == "__main__" so pytest
   doesn't collect and execute them during test runs
2. Remove semantic fallback in HindsightStore.aget() — only return
   exact document_id matches, not unrelated semantic search hits
3. Make langgraph an optional dependency — tools pattern only needs
   langchain-core. Install with pip install hindsight-langgraph[langgraph]
   for nodes and store patterns. Lazy imports with clear error messages.
4. Clean up README to be self-hosted-first, consistent with other
   integration docs
5. Update docs requirements section to reflect optional langgraph dep

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

* fix: address PR review feedback for langgraph integration

- Fix #2: Add per-bank asyncio.Lock to _ensure_bank for concurrency safety
- Fix #3: Clamp search score to max(0.0, ...) to prevent negative values
- Fix #4: Implement suffix matching in _handle_list_namespaces
- Fix #5: Truncate namespaces to max_depth instead of filtering (per BaseStore contract)
- Fix #6: Remove list_namespaces/alist_namespaces overrides — let base class handle prefix=/suffix= kwargs
- Fix #7: Document ephemeral namespace tracking and get() limitations in class docstring
- Fix #8: Add stable ID to recall node SystemMessage, document ordering behavior
- Fix #9: Change budget/max_tokens/recall_tags_match defaults to None so global config fallback works
- Fix #10: Conditionally populate __all__ so import * works without langgraph installed
- Fix #11: Bump langgraph lower bound from >=0.3.0 to >=0.5.0
- Fix #12: Extract _resolve_client to shared _client.py module

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

* fix: address remaining review gaps for langgraph integration

- Add output_key parameter to create_recall_node for prompt ordering control
- Add prefix/suffix/combined filter tests for list_namespaces
- Add output_key unit tests (memory text, none on empty, none on error)
- Remove unused imports and backward-compat alias in tools.py
- Update docs with output_key usage example and API reference

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

* fix: relax langgraph version constraint to >=0.3.0

Research confirmed all required APIs (BaseStore, SearchItem, Result,
GetOp, PutOp, SearchOp, ListNamespacesOp) are available since
langgraph-checkpoint 2.0.7, which maps to langgraph >=0.2.63.
Using >=0.3.0 as a clean semver boundary — >=0.5.0 was unnecessarily
conservative and excluded many compatible versions.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-20 13:36:57 +01:00
Nicolò Boschi 97f7a365e8 fix(hindsight-api): add script entry points so uvx hindsight-api works directly (#629)
The hindsight-api meta-package was missing [project.scripts], causing
`uvx hindsight-api@{version}` to fail with exit code 28 when used in
hindsight-embed's daemon launcher.

Re-export the same scripts defined in hindsight-api-slim so uvx can
resolve the executable without requiring --from.
2026-03-20 13:26:34 +01:00
Christian Navolskyi 20e17f28ad Enhance OpenAI client initialization with query params (#623)
Extract query parameters from base_url when creating the OpenAI client.
2026-03-19 20:46:51 +01:00
Nicolò Boschi 80b1badf74 feat(docs): Integrations Hub + unified page hero (#620)
* fix(security): address all Dependabot vulnerability alerts

Python (uv.lock, pyproject.toml):
- authlib 1.6.6 → 1.6.9 (JWS header injection, OIDC hash binding, Bleichenbacher padding oracle)
- pyasn1 0.6.2 → 0.6.3 (unbounded recursion DoS)
- pyjwt 2.10.1 → 2.12.1 (unknown crit header extensions - also in integration-tests and crewai)
- orjson 3.11.4 → 3.11.7 (deeply nested JSON recursion DoS)
- tornado 6.5.2 → 6.5.5 (multipart DoS, incomplete cookie validation)

npm (package.json, package-lock.json):
- next ^16.1.6 → ^16.1.7 (HTTP smuggling, CSRF bypass, cache DoS, null origin bypass)
- fast-xml-parser override updated to >=5.5.6 (numeric entity expansion bypass)
- undici override added >=7.24.0 (WebSocket overflow, smuggling, CRLF injection, DoS)
- flatted override added >=3.4.0 (unbounded recursion DoS)
- svgo override added >=3.3.3 (DOCTYPE entity expansion DoS)
- dompurify override added >=3.3.2 (XSS vulnerability)

* feat(docs): add Integrations Hub and unified page hero

- Add /integrations page with search, type filter, and card grid
- Integrations defined in a single JSON file (src/data/integrations.json)
  supporting official and community entries with icon, author, and link
- Scrolling integrations banner moved from global navbar to /integrations only
- Remove IntegrationsGrid component; replace all usages with link to hub
- Add PageHero component with full-bleed gradient background, shared across
  Cookbook, FAQ, Best Practices, Changelog, and Blog index pages
- Remove FAQ from top navbar (already in Resources dropdown)
- Move integration changelogs table to bottom of changelog page
2026-03-19 20:29:55 +01:00
Nicolò Boschi ea662d062e feat: fact_types and mental model exclusion filters for reflect (#615)
* feat: add fact_types and mental model exclusion filters to reflect and mental models

Adds three new filtering options to both the reflect endpoint and mental model creation/refresh:

- `fact_types`: restrict which fact types (world, experience, observation) are retrieved.
  Disables irrelevant agent tools entirely (no wasted tokens).
- `exclude_mental_models`: skip the search_mental_models tool altogether.
- `exclude_mental_model_ids`: exclude specific mental models by ID (merged with the
  existing self-exclusion logic during mental model refresh).

For mental models, options are persisted in the existing `trigger` JSONB column so they
are automatically applied on every refresh. The `UpdateMentalModelRequest` already
proxies `trigger`, so no extra endpoint changes are needed.

Also fixes the test fixture (`pg0_db_url` in conftest.py) to correctly resolve pg0://
URLs and run migrations before tests, which was causing all DB-dependent tests to fail
with "relation public.banks does not exist" when HINDSIGHT_API_DATABASE_URL=pg0://uuuu.

* fix: guard against disabled-tool hallucination and regenerate clients

- Add enabled_tools guard in reflect agent: if an LLM calls a tool that
  was excluded (e.g. recall when fact_types=["observation"]), return an
  error result instead of executing it
- Regenerate OpenAPI spec and all SDK clients (Go, Python, TypeScript)
  to include new fact_types / exclude_mental_models fields

* fix: add missing ReflectRequest fields in Rust CLI struct initializers

* fix: filter hallucinated tool calls before trace to prevent disabled tools appearing in results

* chore: merge main, fix lint formatting and update skills openapi.json

* feat: expose fact_types, exclude_mental_models, exclude_mental_model_ids in control plane UI

* fix: add missing trigger fields to MentalModel type in control plane api.ts

* fix: add missing trigger fields to local MentalModel interface in mental-models-view

* feat: tabbed mental model dialogs (Basic / Options tabs)

* refactor: shared FactTypeFilter component, tabbed mental model dialogs use General tab, clean up labels

* feat: pill-style toggle buttons for fact type filter (blue/emerald/amber per type)

* fix: add spacing between Fact Types label and pills, rename to Exclude all mental models
2026-03-19 17:03:41 +01:00
Chris Bartholomew 94cf89b570 Fix non-atomic async operation creation (#619)
* Fix non-atomic async operation creation in _submit_async_operation

Previously the method performed two separate database round-trips:
1. INSERT into async_operations with no task_payload (null)
2. submit_task → UPDATE to set task_payload

A process crash or network error between steps 1 and 2 left a row with
task_payload IS NULL permanently. The worker's claim query requires
task_payload IS NOT NULL, so these orphaned rows could never be picked up
and the queue appeared degraded indefinitely.

Fix: build full_payload before the INSERT and include task_payload in the
same INSERT statement, making operation creation atomic. submit_task is
still called afterwards — for SyncTaskBackend it executes the task
immediately (unchanged behaviour); for BrokerTaskBackend it becomes an
idempotent UPDATE (payload already set) kept for symmetry.

* Preserve datetime payloads in atomic async insert
2026-03-19 16:38:04 +01:00
Chris Bartholomew 439424559e Fix orphaned batch_retain parents when child fails via unhandled exception (#618)
* Fix orphaned batch_retain parents when child fails via unhandled exception

When a child retain operation fails with an unhandled exception (e.g. a DB
constraint violation), the memory engine's transaction is rolled back entirely,
including any call to _maybe_update_parent_operation. The poller's fallback
_mark_failed then updates the child status but leaves the parent batch_retain
permanently stuck in 'pending'.

Fix: wrap _mark_failed in a transaction and call a new poller-level
_maybe_update_parent_operation after marking the child failed. This mirrors
the memory engine's own parent-update logic and ensures the parent is
resolved to completed/failed regardless of how the child failure was detected.

The poller's implementation locks the parent row, checks all siblings, and
only finalises the parent once all siblings have reached a terminal state.
Errors in parent propagation are logged but do not affect the child failure
path, which is the critical state change.

* Add tests for _mark_failed parent propagation in WorkerPoller

Tests cover the new _maybe_update_parent_operation logic:
- Last sibling fails → parent batch_retain becomes failed
- Sole child fails → parent becomes failed
- Sibling still pending → parent stays pending (no premature resolution)
- No parent in result_metadata → safe no-op
- End-to-end: unhandled exception via execute_task propagates to parent
2026-03-19 14:55:26 +01:00
Nicolò Boschi 4c4b3568db fix(security): address all Dependabot vulnerability alerts (#617)
Python (uv.lock, pyproject.toml):
- authlib 1.6.6 → 1.6.9 (JWS header injection, OIDC hash binding, Bleichenbacher padding oracle)
- pyasn1 0.6.2 → 0.6.3 (unbounded recursion DoS)
- pyjwt 2.10.1 → 2.12.1 (unknown crit header extensions - also in integration-tests and crewai)
- orjson 3.11.4 → 3.11.7 (deeply nested JSON recursion DoS)
- tornado 6.5.2 → 6.5.5 (multipart DoS, incomplete cookie validation)

npm (package.json, package-lock.json):
- next ^16.1.6 → ^16.1.7 (HTTP smuggling, CSRF bypass, cache DoS, null origin bypass)
- fast-xml-parser override updated to >=5.5.6 (numeric entity expansion bypass)
- undici override added >=7.24.0 (WebSocket overflow, smuggling, CRLF injection, DoS)
- flatted override added >=3.4.0 (unbounded recursion DoS)
- svgo override added >=3.3.3 (DOCTYPE entity expansion DoS)
- dompurify override added >=3.3.2 (XSS vulnerability)
2026-03-19 14:27:52 +01:00
Nicolò Boschi a706905653 feat(skill): validate links, strip images, include openapi.json and changelog (#614)
* feat(skill): validate links, strip images, include openapi.json and changelog

- Add post-processing step to rewrite Docusaurus site-root paths (e.g.
  /developer/foo) to proper relative .md paths within the skill
- Strip markdown and HTML images from all generated files since assets
  are not bundled with the skill
- Copy hindsight-docs/static/openapi.json into references/openapi.json
  and map /api-reference links to it
- Include changelog.md from src/pages/ alongside faq and best-practices
- Add final validation step that fails the build if any link still
  points outside the skill directory

* ci: run generate-docs-skill in verify-generated-files job

* fix(skill): strip unresolvable site-root links instead of leaving them broken

* fix(skill): write file when images stripped but no links rewritten

* chore(skill): regenerate with fixed links, stripped images, changelog and openapi

* fix(skill): handle changelog as directory, add agno/hermes integrations, rebase on main
2026-03-19 12:32:33 +01:00
Nicolò Boschi fe12be47a0 feat: add scrolling integrations banner to all doc pages (#616)
- Add IntegrationsBanner component with infinite left-to-right CSS scroll animation showing all clients, integrations, and LLM providers
- Place banner below the navbar on every page via Navbar theme wrapper
- Add Agno and Hermes to both the IntegrationsGrid and the banner
- Remove right border from doc sidebar via custom.css
2026-03-19 12:32:23 +01:00
Nicolò Boschi a56cd044e5 feat: 4-tab code parity across all documentation examples (#613)
* feat: independent versioning for integrations

- Add per-integration changelog pages at /changelog/integrations/<name>
- Move main changelog to changelog/index.md (URL unchanged)
- Add --integration flag to generate-changelog for LLM-based per-integration changelog generation
- Add scripts/release-integration.sh <name> <version> for cutting integration releases
- Add .github/workflows/release-integration.yml to publish on integrations/** tags
- Remove integrations from main release.sh and release.yml cycle

* fix: add agno and hermes integration docs to version-0.4 for production build

* chore: apply ruff formatting to generate_changelog.py

* feat: add 4-tab code parity across all documentation examples

Every code snippet Tabs block now has Python, Node.js, CLI, and Go variants.
Raw HTTP/curl tabs replaced with proper SDK calls.

New example files:
- Go: retain.go, recall.go, reflect.go, memory-banks.go, directives.go,
  mental-models.go, documents.go, main-methods.go
- Shell: memory-banks.sh, directives.sh, mental-models.sh
- Node.js: mental-models.mjs

Extended example files with missing sections:
- recall.mjs/sh: world/experience/observation types, token-budget, all tag modes
- reflect.sh: reflect-with-params, reflect-disposition, reflect-sources, reflect-with-tags
- reflect.mjs: reflect-with-tags, fixed reflect-sources API usage
- retain.mjs/sh: retain-conversation, retain-batch, retain-files-batch

SDK/CLI additions:
- TypeScript: getMentalModelHistory method
- CLI recall: --tags, --tags-match flags
- CLI reflect: --tags, --tags-match, --include-facts flags
- CLI directive update: --is-active flag
- CLI bank set-config: --retain-mission, --retain-extraction-mode,
  --observations-mission, --reflect-mission, --disposition-* flags

Build validation:
- scripts/check-code-parity.mjs validates 4-tab parity across all MDX files
- Integrated into npm run build — fails if any Tabs block is missing a variant

* fix: fix doc examples for Go, Node.js, CLI + add mental model with-id examples

- Fix Go Budget constants: BUDGET_HIGH/LOW/MID → HIGH/LOW/MID
- Fix Go documents.go: ListDocuments returns []map[string]interface{}, use map access
- Fix Go retain.go: use correct relative path for sample.pdf
- Fix Node.js createMentalModel: use positional args (name, sourceQuery) not object
- Add CLI 'history' subcommand for mental models (api.rs, main.rs, mental_model.rs)
- Rebuild TypeScript/Python clients to support id param in createMentalModel
- Add create-mental-model-with-id examples across all 4 languages and docs

* fix: move id param to end of create_mental_model signature for backwards compat
2026-03-19 11:31:51 +01:00
Chris Bartholomew 438ce98b40 Fix entity_id null constraint for non-ASCII entity names (#612)
* Fix entity_id null constraint for non-ASCII entity names (Turkish İ etc.)

Python's str.lower() and PostgreSQL's LOWER() produce different results for
some Unicode characters. The most common case is Turkish İ (U+0130):
  Python:     'İstanbul'.lower() == 'i\u0307stanbul' (i + combining dot, 2 chars)
  PostgreSQL: LOWER('İstanbul') == 'istanbul' (plain i, 1 char)

In _resolve_from_candidates, the fallback SELECT for conflicted entity names
passed Python-lowercased strings to LOWER(canonical_name) = ANY($names), so
PostgreSQL couldn't match them. entity_ids[idx] stayed None, which then
caused a NOT NULL violation on unit_entities.entity_id, failing the entire
retain.

Fix: pass original mixed-case names to the fallback SELECT and use
LOWER(canonical_name) = ANY(SELECT LOWER(n) FROM unnest($2) AS n) so
PostgreSQL lowercases both sides identically. The query also returns the
original input_name so we can add a Python-lowercased key to id_by_name
for the assignment loop that uses Python-lowercased keys.

* Add regression test for Unicode entity conflict
2026-03-19 10:32:47 +01:00
Nicolò Boschi 446c75f3e2 fix: correctly map LLM fact_type \"assistant\" to \"experience\" for DB storage (#609)
The Pydantic model extraction paths (batch API and parallel extraction) used
fact_from_llm.fact_type directly, bypassing the \"assistant\" → \"experience\"
conversion and causing DB CHECK constraint violations.

Unified the conversion logic across all paths:
- \"assistant\" → \"experience\"
- \"world\" → \"world\"
- anything else: fall back to fact_kind (\"assistant\" → \"experience\"), else \"world\"
2026-03-19 10:32:08 +01:00
Ben 276a4ba7e8 blog: Hermes Agent persistent memory (#599)
* blog: add Hermes Agent persistent memory integration post
2026-03-18 17:00:35 -04:00
Nicolò Boschi 31f1c53c8f feat: independent versioning for integrations (#565)
* feat: independent versioning for integrations

- Add per-integration changelog pages at /changelog/integrations/<name>
- Move main changelog to changelog/index.md (URL unchanged)
- Add --integration flag to generate-changelog for LLM-based per-integration changelog generation
- Add scripts/release-integration.sh <name> <version> for cutting integration releases
- Add .github/workflows/release-integration.yml to publish on integrations/** tags
- Remove integrations from main release.sh and release.yml cycle

* fix: add agno and hermes integration docs to version-0.4 for production build

* chore: apply ruff formatting to generate_changelog.py
2026-03-18 17:53:46 +01:00
265 changed files with 31404 additions and 1550 deletions
+111
View File
@@ -0,0 +1,111 @@
name: Release Integration
on:
push:
tags:
- 'integrations/**'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
steps:
- uses: actions/checkout@v6
- name: Extract integration info
id: info
run: |
# refs/tags/integrations/litellm/v0.1.0 → integration=litellm, version=0.1.0
TAG="${GITHUB_REF#refs/tags/}"
INTEGRATION=$(echo "$TAG" | cut -d'/' -f2)
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
echo "integration=$INTEGRATION" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Integration: $INTEGRATION, Version: $VERSION"
- name: Detect integration type
id: type
run: |
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
echo "type=python" >> $GITHUB_OUTPUT
elif [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/package.json" ]; then
echo "type=typescript" >> $GITHUB_OUTPUT
else
echo "type=plugin" >> $GITHUB_OUTPUT
fi
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
- name: Install uv
if: steps.type.outputs.type == 'python'
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
if: steps.type.outputs.type == 'python'
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build Python package
if: steps.type.outputs.type == 'python'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: uv build --out-dir dist
- name: Publish Python package to PyPI
if: steps.type.outputs.type == 'python'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/${{ steps.info.outputs.integration }}/dist
skip-existing: true
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
# ── Plugin integrations (claude-code) — no package to publish ───────────
- name: Plugin release
if: steps.type.outputs.type == 'plugin'
run: |
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
- name: Set up Node.js
if: steps.type.outputs.type == 'typescript'
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm ci
- name: Build TypeScript package
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -231
View File
@@ -46,30 +46,10 @@ jobs:
working-directory: ./hindsight-all-slim
run: uv build --out-dir dist
- name: Build hindsight-litellm
working-directory: ./hindsight-integrations/litellm
run: uv build --out-dir dist
- name: Build hindsight-embed
working-directory: ./hindsight-embed
run: uv build --out-dir dist
- name: Build hindsight-crewai
working-directory: ./hindsight-integrations/crewai
run: uv build --out-dir dist
- name: Build hindsight-pydantic-ai
working-directory: ./hindsight-integrations/pydantic-ai
run: uv build --out-dir dist
- name: Build hindsight-hermes
working-directory: ./hindsight-integrations/hermes
run: uv build --out-dir dist
- name: Build hindsight-agno
working-directory: ./hindsight-integrations/agno
run: uv build --out-dir dist
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -101,42 +81,12 @@ jobs:
packages-dir: ./hindsight-all-slim/dist
skip-existing: true
- name: Publish hindsight-litellm to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/litellm/dist
skip-existing: true
- name: Publish hindsight-embed to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-embed/dist
skip-existing: true
- name: Publish hindsight-crewai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/crewai/dist
skip-existing: true
- name: Publish hindsight-pydantic-ai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/pydantic-ai/dist
skip-existing: true
- name: Publish hindsight-hermes to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/hermes/dist
skip-existing: true
- name: Publish hindsight-agno to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/agno/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v7
@@ -148,12 +98,7 @@ jobs:
hindsight-api/dist/*
hindsight-all/dist/*
hindsight-all-slim/dist/*
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
hindsight-integrations/crewai/dist/*
hindsight-integrations/pydantic-ai/dist/*
hindsight-integrations/hermes/dist/*
hindsight-integrations/agno/dist/*
retention-days: 1
release-typescript-client:
@@ -205,153 +150,6 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-openclaw-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/openclaw
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/openclaw
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: openclaw-integration
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/ai-sdk
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/ai-sdk
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: ai-sdk-integration
path: hindsight-integrations/ai-sdk/*.tgz
retention-days: 1
release-chat-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/chat
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/chat
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/chat
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/chat
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: chat-integration
path: hindsight-integrations/chat/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -609,7 +407,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -632,24 +430,6 @@ jobs:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClaw Integration
uses: actions/download-artifact@v8
with:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v8
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Chat Integration
uses: actions/download-artifact@v8
with:
name: chat-integration
path: ./artifacts/chat-integration
- name: Download Control Plane
uses: actions/download-artifact@v8
with:
@@ -689,19 +469,9 @@ jobs:
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all-slim/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/hermes/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/agno/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Chat Integration
cp artifacts/chat-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+22
View File
@@ -75,6 +75,24 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: npm run build
test-claude-code-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/claude-code
run: python -m pytest tests/ -v
build-ai-sdk-integration:
runs-on: ubuntu-latest
@@ -1775,6 +1793,9 @@ jobs:
- name: Run generate-clients
run: ./scripts/generate-clients.sh
- name: Run generate-docs-skill
run: ./scripts/generate-docs-skill.sh
- name: Run lint
run: ./scripts/hooks/lint.sh
@@ -1789,6 +1810,7 @@ jobs:
echo "Please run the following commands locally and commit the changes:"
echo " ./scripts/generate-openapi.sh"
echo " ./scripts/generate-clients.sh"
echo " ./scripts/generate-docs-skill.sh"
echo " ./scripts/hooks/lint.sh"
echo ""
git diff --stat
+1
View File
@@ -7,6 +7,7 @@
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![gitcgr](https://gitcgr.com/badge/vectorize-io/hindsight.svg)](https://gitcgr.com/vectorize-io/hindsight)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
<br/>
@@ -11,6 +11,7 @@ block; see migrations.py for how this is handled safely.
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
revision: str = "c1a2b3d4e5f6"
@@ -25,9 +26,21 @@ def _get_schema_prefix() -> str:
def upgrade() -> None:
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
# pg_trgm ships with most PostgreSQL installations as a contrib module.
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
# On managed services (e.g. Azure Flexible Server), the extension may not be
# available or may require manual enablement. We gracefully skip the index
# creation if the extension cannot be loaded — the entity resolver will
# auto-detect and fall back to the "full" lookup strategy at runtime. See #626.
conn = op.get_bind()
try:
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
except Exception:
# Extension not available (managed Postgres, insufficient privileges, etc.)
# Roll back the failed statement and skip index creation.
conn.execute(sa.text("ROLLBACK"))
conn.execute(sa.text("BEGIN"))
return
schema = _get_schema_prefix()
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
@@ -169,6 +169,15 @@ class RecallRequest(BaseModel):
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
)
@field_validator("query")
@classmethod
def validate_query_not_empty(cls, v: str) -> str:
from ..engine.search.retrieval import tokenize_query
if not tokenize_query(v):
raise ValueError("query must contain at least one word character after normalization")
return v
@model_validator(mode="after")
def validate_tags_exclusive(self) -> "RecallRequest":
if self.tags is not None and self.tag_groups is not None:
@@ -669,6 +678,25 @@ class ReflectRequest(BaseModel):
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
)
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
default=None,
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
)
exclude_mental_models: bool = Field(
default=False,
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
)
exclude_mental_model_ids: list[str] | None = Field(
default=None,
description="Exclude specific mental models by ID from the reflect loop.",
)
@field_validator("fact_types")
@classmethod
def validate_reflect_fact_types(cls, v: list[str] | None) -> list[str] | None:
if v is not None and len(v) == 0:
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
return v
@model_validator(mode="after")
def validate_tags_exclusive(self) -> "ReflectRequest":
@@ -1435,6 +1463,25 @@ class MentalModelTrigger(BaseModel):
default=False,
description="If true, refresh this mental model after observations consolidation (real-time mode)",
)
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
default=None,
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
)
exclude_mental_models: bool = Field(
default=False,
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
)
exclude_mental_model_ids: list[str] | None = Field(
default=None,
description="Exclude specific mental models by ID from the reflect loop.",
)
@field_validator("fact_types")
@classmethod
def validate_fact_types(cls, v: list[str] | None) -> list[str] | None:
if v is not None and len(v) == 0:
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
return v
class MentalModelResponse(BaseModel):
@@ -2505,6 +2552,9 @@ def _register_routes(app: FastAPI):
tags=request.tags,
tags_match=request.tags_match,
tag_groups=request.tag_groups,
fact_types=request.fact_types,
exclude_mental_models=request.exclude_mental_models,
exclude_mental_model_ids=request.exclude_mental_model_ids,
)
# Build based_on (memories + mental_models + directives) if facts are requested
@@ -2580,6 +2630,12 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except TimeoutError as e:
logger.error("Timeout in /v1/default/banks/%s/reflect: %s", bank_id, e)
raise HTTPException(
status_code=504,
detail=str(e) or "Reflect operation timed out. Consider reducing the budget or simplifying the query.",
)
except Exception as e:
import traceback
+16 -2
View File
@@ -83,6 +83,9 @@ _current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
# Context variable for MCP pre-authentication flag (set when MCP_AUTH_TOKEN validates)
_current_mcp_authenticated: ContextVar[bool] = ContextVar("current_mcp_authenticated", default=False)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
@@ -104,6 +107,11 @@ def get_current_api_key_id() -> str | None:
return _current_api_key_id.get()
def get_current_mcp_authenticated() -> bool:
"""Get whether the request was pre-authenticated by MCP transport auth."""
return _current_mcp_authenticated.get()
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -164,6 +172,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
include_bank_id_param=multi_bank,
tools=base_tools,
)
@@ -312,6 +321,7 @@ class MCPMiddleware:
tenant_context = None
auth_tenant_id: str | None = None
auth_api_key_id: str | None = None
mcp_pre_authenticated = False
if MCP_AUTH_TOKEN:
# Legacy authentication mode - validate against static token
if not auth_token:
@@ -320,8 +330,9 @@ class MCPMiddleware:
if auth_token != MCP_AUTH_TOKEN:
await self._send_error(send, 401, "Invalid authentication token")
return
# Legacy mode doesn't use tenant schemas
# Legacy mode: mark as pre-authenticated so tenant extension won't re-validate
tenant_context = None
mcp_pre_authenticated = True
else:
# Use TenantExtension.authenticate_mcp() for auth
try:
@@ -368,13 +379,15 @@ class MCPMiddleware:
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
# Set bank_id, api_key, tenant_id, and api_key_id context
# Set bank_id, api_key, tenant_id, api_key_id, and mcp_authenticated context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Store tenant_id and api_key_id from authentication for usage metering
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
# Store MCP pre-authentication flag to skip tenant re-validation
mcp_auth_token = _current_mcp_authenticated.set(mcp_pre_authenticated)
try:
new_scope = scope.copy()
new_scope["path"] = new_path
@@ -419,6 +432,7 @@ class MCPMiddleware:
_current_tenant_id.reset(tenant_id_token)
if api_key_id_token is not None:
_current_api_key_id.reset(api_key_id_token)
_current_mcp_authenticated.reset(mcp_auth_token)
if schema_token is not None:
_current_schema.reset(schema_token)
@@ -338,6 +338,7 @@ ENV_WORKER_CONSOLIDATION_MAX_SLOTS = "HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLO
# Reflect agent settings
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
# Disposition settings
@@ -499,6 +500,7 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
# Disposition defaults (None = not set, fall back to bank DB value or 3)
DEFAULT_DISPOSITION_SKEPTICISM = None
@@ -801,6 +803,7 @@ class HindsightConfig:
# Reflect agent settings
reflect_max_iterations: int
reflect_max_context_tokens: int
reflect_wall_timeout: int
# OpenTelemetry tracing configuration
otel_traces_enabled: bool
@@ -1274,6 +1277,7 @@ class HindsightConfig:
reflect_max_context_tokens=int(
os.getenv(ENV_REFLECT_MAX_CONTEXT_TOKENS, str(DEFAULT_REFLECT_MAX_CONTEXT_TOKENS))
),
reflect_wall_timeout=int(os.getenv(ENV_REFLECT_WALL_TIMEOUT, str(DEFAULT_REFLECT_WALL_TIMEOUT))),
reflect_mission=os.getenv(ENV_REFLECT_MISSION) or None,
# Disposition settings (None = fall back to DB value)
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
@@ -75,6 +75,7 @@ class EntityResolver:
"""
self.pool = pool
self.entity_lookup = entity_lookup
self._pg_trgm_checked = False
# Keyed by asyncio task id so concurrent retain batches never mix their
# pending updates. flush_pending_stats() pops only the calling task's items.
self._pending_stats: dict[int, list[_EntityStat]] = {}
@@ -202,6 +203,20 @@ class EntityResolver:
taxonomy_lookup: set[str] | None = None,
) -> list[str]:
if self.entity_lookup == "trigram":
# Auto-detect pg_trgm availability on first call and fall back to
# "full" strategy if the extension is not installed. See #626.
if not self._pg_trgm_checked:
self._pg_trgm_checked = True
has_trgm = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")
if not has_trgm:
logger.warning(
"pg_trgm extension is not available — falling back to 'full' "
"entity lookup strategy. Install pg_trgm for faster entity "
"resolution on large banks. See: "
"https://github.com/vectorize-io/hindsight/issues/626"
)
self.entity_lookup = "full"
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
return await self._resolve_entities_batch_trigram(conn, bank_id, entities_data, unit_event_date)
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
@@ -477,19 +492,42 @@ class EntityResolver:
id_by_name: dict[str, str] = {row["name_lower"]: row["id"] for row in inserted_rows}
# Fallback SELECT for names that conflicted (another worker won the race).
missing = [n for n, _ in sorted_groups if n not in id_by_name]
if missing:
#
# IMPORTANT: we must let PostgreSQL do the lowercasing on BOTH sides of the
# comparison. Python's str.lower() and PostgreSQL's LOWER() differ for some
# Unicode characters — most notably Turkish İ (U+0130):
# Python: 'İstanbul'.lower() == 'i\u0307stanbul' (i + combining dot, 2 chars)
# PostgreSQL: LOWER('İstanbul') == 'istanbul' (plain i, 1 char)
# Passing a Python-lowercased name to "LOWER(canonical_name) = ANY($2::text[])"
# would fail to match the stored entity, leaving entity_id as None and causing
# a NOT NULL constraint violation on unit_entities.entity_id.
#
# Fix: pass the original (mixed-case) input names and use
# "LOWER(canonical_name) = ANY(SELECT LOWER(n) FROM unnest($2) AS n)" so
# PostgreSQL lowercases both sides identically. The query also returns the
# original input_name so we can index id_by_name by Python's lower() of that
# name, which is what the assignment loop below uses as its lookup key.
missing_original = [g.name for name_lower, g in sorted_groups if name_lower not in id_by_name]
if missing_original:
existing_rows = await conn.fetch(
f"""
SELECT id, LOWER(canonical_name) AS name_lower
FROM {fq_table("entities")}
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
SELECT e.id, LOWER(e.canonical_name) AS name_lower, inputs.input_name
FROM {fq_table("entities")} e
JOIN (
SELECT LOWER(n) AS input_name_lower, n AS input_name
FROM unnest($2::text[]) AS n
) AS inputs ON LOWER(e.canonical_name) = inputs.input_name_lower
WHERE e.bank_id = $1
""",
bank_id,
missing,
missing_original,
)
for row in existing_rows:
id_by_name[row["name_lower"]] = row["id"]
# Also index by Python's lower() of the original input name so the
# assignment loop (which uses Python-lowercased keys) finds it even
# when Python and PostgreSQL produce different lowercase strings.
id_by_name[row["input_name"].lower()] = row["id"]
# Assign entity IDs back and queue one stat per original mention so that
# flush_pending_stats() increments mention_count by the true mention count,
@@ -67,6 +67,13 @@ def fq_table(table_name: str) -> str:
return f"{get_current_schema()}.{table_name}"
def _json_default(obj: Any) -> str:
"""JSON serializer for types commonly carried through async task payloads."""
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
# Tables that must be schema-qualified (for runtime validation)
_PROTECTED_TABLES = frozenset(
[
@@ -492,24 +499,28 @@ class MemoryEngine(MemoryEngineInterface):
"""The configured tenant extension, if any."""
return self._tenant_extension
async def _validate_operation(self, validation_coro) -> None:
async def _validate_operation(self, validation_coro) -> "ValidationResult | None":
"""
Run validation if an operation validator is configured.
Args:
validation_coro: Coroutine that returns a ValidationResult
Returns:
The ValidationResult (may contain enrichment fields), or None if no validator.
Raises:
OperationValidationError: If validation fails
"""
if self._operation_validator is None:
return
return None
from hindsight_api.extensions import OperationValidationError
from hindsight_api.extensions import OperationValidationError, ValidationResult
result = await validation_coro
if not result.allowed:
raise OperationValidationError(result.reason or "Operation not allowed", result.status_code)
return result
async def _authenticate_tenant(self, request_context: "RequestContext | None") -> str:
"""
@@ -538,6 +549,12 @@ class MemoryEngine(MemoryEngineInterface):
if request_context.internal:
return _current_schema.get()
# For MCP requests already authenticated via MCP_AUTH_TOKEN, skip tenant re-validation.
# The MCP transport layer already verified the token; re-validating against the tenant
# extension would fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
if request_context.mcp_authenticated:
return _current_schema.get()
# Authenticate through tenant extension (always set, may be default no-auth extension)
tenant_context = await self._tenant_extension.authenticate(request_context)
@@ -868,14 +885,23 @@ class MemoryEngine(MemoryEngineInterface):
tags = mental_model.get("tags")
tags_match = "all_strict" if tags else "any"
# Read reflect options from trigger (if stored)
trigger_data = mental_model.get("trigger") or {}
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
# Run reflect to generate new content, excluding the mental model being refreshed
# Always add self to excluded IDs to prevent circular reference
reflect_result = await self.reflect_async(
bank_id=bank_id,
query=source_query,
request_context=internal_context,
tags=tags,
tags_match=tags_match,
exclude_mental_model_ids=[mental_model_id],
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
)
generated_content = reflect_result.text or "No content generated"
@@ -2030,7 +2056,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_type_override=fact_type_override,
confidence_score=confidence_score,
)
await self._validate_operation(self._operation_validator.validate_retain(ctx))
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
if result and result.contents is not None:
contents = result.contents
# Apply batch-level document_id to contents that don't have their own (backwards compatibility)
if document_id:
@@ -2397,8 +2425,18 @@ class MemoryEngine(MemoryEngineInterface):
max_entity_tokens=max_entity_tokens,
include_chunks=include_chunks,
max_chunk_tokens=max_chunk_tokens,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
await self._validate_operation(self._operation_validator.validate_recall(ctx))
result = await self._validate_operation(self._operation_validator.validate_recall(ctx))
if result:
if result.tags is not None:
tags = result.tags
if result.tags_match is not None:
tags_match = result.tags_match
if result.tag_groups is not None:
tag_groups = result.tag_groups
# Map budget enum to thinking_budget number (default to MID if None)
budget_mapping = {Budget.LOW: 100, Budget.MID: 300, Budget.HIGH: 1000}
@@ -5113,6 +5151,8 @@ class MemoryEngine(MemoryEngineInterface):
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
exclude_mental_model_ids: list[str] | None = None,
fact_types: list[str] | None = None,
exclude_mental_models: bool = False,
_skip_span: bool = False,
) -> ReflectResult:
"""
@@ -5188,6 +5228,7 @@ class MemoryEngine(MemoryEngineInterface):
effective_budget = budget or Budget.LOW
max_iterations = max(1, int(base_max_iterations * budget_multipliers.get(effective_budget, 1.0)))
max_context_tokens = config.reflect_max_context_tokens
wall_timeout = config.reflect_wall_timeout
# Run agentic loop - acquire connections only when needed for DB operations
# (not held during LLM calls which can be slow)
@@ -5233,6 +5274,11 @@ class MemoryEngine(MemoryEngineInterface):
pending_consolidation=pending_consolidation,
)
# Determine which tools to enable based on fact_types and exclude_mental_models
include_observations = fact_types is None or "observation" in fact_types
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
include_recall = bool(recall_fact_types)
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
return await tool_recall(
self,
@@ -5244,6 +5290,7 @@ class MemoryEngine(MemoryEngineInterface):
tags_match=tags_match,
tag_groups=tag_groups,
max_chunk_tokens=max_chunk_tokens,
fact_types=recall_fact_types if fact_types is not None else None,
)
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
@@ -5266,15 +5313,17 @@ class MemoryEngine(MemoryEngineInterface):
if directives:
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
# Check if the bank has any mental models
async with pool.acquire() as conn:
mental_model_count = await conn.fetchval(
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
bank_id,
)
has_mental_models = mental_model_count > 0
if has_mental_models:
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
# Check if the bank has any mental models (skip check if all mental models are excluded)
has_mental_models = False
if not exclude_mental_models:
async with pool.acquire() as conn:
mental_model_count = await conn.fetchval(
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
bank_id,
)
has_mental_models = mental_model_count > 0
if has_mental_models:
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
# Run the agent with parent span for reflect operation (skip if called from another operation)
if not _skip_span:
@@ -5284,29 +5333,52 @@ class MemoryEngine(MemoryEngineInterface):
span_context = None
try:
agent_result = await run_reflect_agent(
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
bank_id=bank_id,
query=query,
bank_profile=profile,
search_mental_models_fn=search_mental_models_fn,
search_observations_fn=search_observations_fn,
recall_fn=recall_fn,
expand_fn=expand_fn,
context=context,
max_iterations=max_iterations,
max_tokens=max_tokens,
response_schema=response_schema,
directives=directives,
has_mental_models=has_mental_models,
budget=effective_budget,
max_context_tokens=max_context_tokens,
)
try:
agent_result = await asyncio.wait_for(
run_reflect_agent(
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
bank_id=bank_id,
query=query,
bank_profile=profile,
search_mental_models_fn=search_mental_models_fn,
search_observations_fn=search_observations_fn,
recall_fn=recall_fn,
expand_fn=expand_fn,
context=context,
max_iterations=max_iterations,
max_tokens=max_tokens,
response_schema=response_schema,
directives=directives,
has_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
budget=effective_budget,
max_context_tokens=max_context_tokens,
),
timeout=wall_timeout,
)
except asyncio.TimeoutError:
total_time = time.time() - reflect_start
logger.error(
"[REFLECT %s] Wall-clock timeout after %.1fs (limit: %ss) for query: %.50s...",
reflect_id,
total_time,
wall_timeout,
query,
)
raise TimeoutError(
f"Reflect operation timed out after {wall_timeout} seconds. "
f"Consider reducing the budget or simplifying the query."
)
total_time = time.time() - reflect_start
logger.info(
f"[REFLECT {reflect_id}] Complete: {len(agent_result.text)} chars, "
f"{agent_result.iterations} iterations, {agent_result.tools_called} tool calls | {total_time:.3f}s"
"[REFLECT %s] Complete: %d chars, %d iterations, %d tool calls | %.3fs",
reflect_id,
len(agent_result.text),
agent_result.iterations,
agent_result.tools_called,
total_time,
)
# Convert agent tool trace to ToolCallTrace objects
@@ -6430,6 +6502,12 @@ class MemoryEngine(MemoryEngineInterface):
tags = mental_model.get("tags")
tags_match = "all_strict" if tags else "any"
# Read reflect options from trigger (if stored)
trigger_data = mental_model.get("trigger") or {}
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
# Run reflect with the source query, excluding the mental model being refreshed
# Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh"
reflect_result = await self.reflect_async(
@@ -6438,7 +6516,9 @@ class MemoryEngine(MemoryEngineInterface):
request_context=request_context,
tags=tags,
tags_match=tags_match,
exclude_mental_model_ids=[mental_model_id],
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
_skip_span=True,
)
@@ -7407,21 +7487,10 @@ class MemoryEngine(MemoryEngineInterface):
operation_id = uuid.uuid4()
# Insert operation record into database
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status)
VALUES ($1, $2, $3, $4, $5)
""",
operation_id,
bank_id,
operation_type,
json.dumps(result_metadata or {}),
"pending",
)
# Build and submit task payload
# Build full payload before INSERT so task_payload is included atomically.
# Previously the INSERT omitted task_payload and a separate submit_task call
# did an UPDATE — a crash between the two left a null-payload row that the
# worker's claim query (task_payload IS NOT NULL) could never pick up.
full_payload = {
"type": task_type,
"operation_id": str(operation_id),
@@ -7429,6 +7498,24 @@ class MemoryEngine(MemoryEngineInterface):
**task_payload,
}
# Insert operation record with task_payload in a single atomic statement
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status, task_payload)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
""",
operation_id,
bank_id,
operation_type,
json.dumps(result_metadata or {}, default=_json_default),
"pending",
json.dumps(full_payload, default=_json_default),
)
# For SyncTaskBackend: executes the task immediately.
# For BrokerTaskBackend: does an idempotent UPDATE (payload already set above),
# kept for symmetry and to support any future notification mechanisms.
await self._task_backend.submit_task(full_payload)
logger.info(f"{operation_type} task queued for bank_id={bank_id}, operation_id={operation_id}")
@@ -7462,7 +7549,9 @@ class MemoryEngine(MemoryEngineInterface):
contents=[dict(c) for c in contents],
request_context=request_context,
)
await self._validate_operation(self._operation_validator.validate_retain(ctx))
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
if result and result.contents is not None:
contents = result.contents
# Validate no duplicate document_ids in the batch
# Having duplicate document_ids causes race conditions in document upserts during parallel processing
@@ -24,6 +24,7 @@ import os
import re
import time
from typing import Any
from urllib.parse import parse_qs, urlparse, urlunparse
import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
@@ -39,6 +40,25 @@ logger = logging.getLogger(__name__)
DEFAULT_LLM_SEED = 4242
def _strip_code_fences(content: str) -> str:
"""Strip markdown code fences from LLM response if present.
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
wrap JSON responses in ```json ... ``` fences even when json_object
response format is requested. This strips the fences while preserving
the JSON content inside. Returns the original content unchanged if
no fences are detected.
"""
if "```" not in content:
return content
try:
if "```json" in content:
return content.split("```json")[1].split("```")[0].strip()
return content.split("```")[1].split("```")[0].strip()
except (IndexError, ValueError):
return content
class OpenAICompatibleLLM(LLMInterface):
"""
LLM provider for OpenAI-compatible APIs.
@@ -108,10 +128,18 @@ class OpenAICompatibleLLM(LLMInterface):
# Get timeout config
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
# Create OpenAI client
# Create OpenAI client — extract query params from base_url (e.g. Azure api-version)
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
parsed = urlparse(self.base_url)
if parsed.query:
clean_url = urlunparse(parsed._replace(query=""))
client_kwargs["base_url"] = clean_url
default_query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
client_kwargs["default_query"] = default_query
self.base_url = clean_url
else:
client_kwargs["base_url"] = self.base_url
if self.timeout:
client_kwargs["timeout"] = self.timeout
@@ -313,20 +341,14 @@ class OpenAICompatibleLLM(LLMInterface):
if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
# For local models, they may wrap JSON in markdown code blocks
if self.provider in ("lmstudio", "ollama"):
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content
json_data = json.loads(content)
else:
# Log raw LLM response for debugging JSON parse issues
# Strip markdown code fences if present — any provider may
# produce these (confirmed with MiniMax, some Ollama models,
# Claude via proxies). No-op when content is already bare JSON.
clean_content = _strip_code_fences(content)
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content in case stripping was wrong
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
@@ -721,26 +743,33 @@ class OpenAICompatibleLLM(LLMInterface):
result = response.json()
content = result.get("message", {}).get("content", "")
# Parse JSON response
# Strip markdown code fences if present (safety net —
# Ollama with schema enforcement usually returns bare JSON,
# but some models may still wrap in fences)
clean_content = _strip_code_fences(content)
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: ollama/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to raw content
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: ollama/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
# Extract token usage from Ollama response
duration = time.time() - start_time
@@ -316,6 +316,8 @@ async def run_reflect_agent(
response_schema: dict | None = None,
directives: list[dict[str, Any]] | None = None,
has_mental_models: bool = False,
include_observations: bool = True,
include_recall: bool = True,
budget: str | None = None,
max_context_tokens: int = 100_000,
) -> ReflectAgentResult:
@@ -355,7 +357,14 @@ async def run_reflect_agent(
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist)
tools = get_reflect_tools(directive_rules=directive_rules)
tools = get_reflect_tools(
directive_rules=directive_rules,
include_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
)
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
# Build initial messages (directives are injected into system prompt at START and END)
system_prompt = build_system_prompt_for_tools(
@@ -538,19 +547,18 @@ async def run_reflect_agent(
llm_start = time.time()
# Determine tool_choice for this iteration.
# Force the full hierarchical retrieval path before allowing auto:
# With mental models:
# 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto
# Without mental models:
# 0 → search_observations, 1 → recall, 2+ → auto
if iteration == 0 and has_mental_models:
iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}}
elif iteration == 0:
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
elif iteration == 1 and has_mental_models:
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
elif iteration == 1 or (iteration == 2 and has_mental_models):
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
# Force the full hierarchical retrieval path (only for enabled tools) before allowing auto.
# Build the forced sequence from the tools that are actually enabled.
forced_sequence = []
if has_mental_models:
forced_sequence.append("search_mental_models")
if include_observations:
forced_sequence.append("search_observations")
if include_recall:
forced_sequence.append("recall")
if iteration < len(forced_sequence):
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
else:
iter_tool_choice = "auto"
@@ -769,7 +777,17 @@ async def run_reflect_agent(
# Execute other tools in parallel (exclude done tool in all its format variants)
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
if other_tools:
# Add assistant message with tool calls
# Partition into enabled vs hallucinated (not in enabled_tools set)
allowed_tools = []
hallucinated_tools = []
for tc in other_tools:
norm = _normalize_tool_name(tc.name)
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
hallucinated_tools.append(tc)
else:
allowed_tools.append(tc)
# Build assistant message with all tool calls (LLM requires them for history)
messages.append(
{
"role": "assistant",
@@ -777,6 +795,23 @@ async def run_reflect_agent(
}
)
# Immediately reject hallucinated tool calls without adding to trace
for tc in hallucinated_tools:
messages.append(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"content": json.dumps(
{
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
}
),
}
)
other_tools = allowed_tools
# Execute tools in parallel
tool_tasks = [
_execute_tool_with_timing(
@@ -785,6 +820,7 @@ async def run_reflect_agent(
search_observations_fn,
recall_fn,
expand_fn,
enabled_tools=enabled_tools,
)
for tc in other_tools
]
@@ -974,6 +1010,7 @@ async def _execute_tool_with_timing(
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
enabled_tools: frozenset[str] | None = None,
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
from hindsight_api.tracing import get_tracer
@@ -1007,6 +1044,7 @@ async def _execute_tool_with_timing(
search_observations_fn,
recall_fn,
expand_fn,
enabled_tools=enabled_tools,
)
# Set success attributes
@@ -1046,11 +1084,16 @@ async def _execute_tool(
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
enabled_tools: frozenset[str] | None = None,
) -> dict[str, Any]:
"""Execute a single tool by name."""
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
# Guard against LLMs hallucinating calls to tools that were not provided
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
if tool_name == "search_mental_models":
query = args.get("query")
if not query:
@@ -200,6 +200,7 @@ async def tool_recall(
tag_groups: "list | None" = None,
connection_budget: int = 1,
max_chunk_tokens: int = 1000,
fact_types: list[str] | None = None,
) -> dict[str, Any]:
"""
Search memories using TEMPR retrieval.
@@ -217,15 +218,18 @@ async def tool_recall(
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
connection_budget: Max DB connections for this recall (default 1 for internal ops)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
Returns:
Dict with list of matching memories including raw chunk text
"""
# Only world/experience are valid for raw recall (observation is handled by search_observations)
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
include_chunks = True
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
fact_type=["experience", "world"],
fact_type=recall_fact_type,
max_tokens=max_tokens,
enable_trace=False,
request_context=request_context,
@@ -227,7 +227,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
}
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
def get_reflect_tools(
directive_rules: list[str] | None = None,
include_mental_models: bool = True,
include_observations: bool = True,
include_recall: bool = True,
) -> list[dict]:
"""
Get the list of tools for the reflect agent.
@@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
Args:
directive_rules: Optional list of directive rule strings. If provided,
the done() tool will require directive compliance confirmation.
include_mental_models: Whether to include the search_mental_models tool.
include_observations: Whether to include the search_observations tool.
include_recall: Whether to include the recall tool.
Returns:
List of tool definitions in OpenAI format
"""
tools = [
TOOL_SEARCH_MENTAL_MODELS,
TOOL_SEARCH_OBSERVATIONS,
TOOL_RECALL,
TOOL_EXPAND,
]
tools = []
if include_mental_models:
tools.append(TOOL_SEARCH_MENTAL_MODELS)
if include_observations:
tools.append(TOOL_SEARCH_OBSERVATIONS)
if include_recall:
tools.append(TOOL_RECALL)
tools.append(TOOL_EXPAND)
# Use directive-aware done tool if directives are present
if directive_rules:
@@ -1083,30 +1083,16 @@ async def _extract_facts_from_chunk(
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience" for storage
if fact_type == "assistant":
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
raw_fact_type = llm_fact.get("fact_type")
if raw_fact_type == "assistant":
fact_type = "experience"
# Validate fact_type (after conversion)
if fact_type not in ["world", "experience", "opinion"]:
# Try to fix common mistakes - check if they swapped fact_type and fact_kind
fact_kind = llm_fact.get("fact_kind")
if fact_kind == "assistant":
fact_type = "experience"
elif fact_kind in ["world", "experience", "opinion"]:
fact_type = fact_kind
else:
# Default to 'world' if we can't determine
fact_type = "world"
logger.warning(
f"Fact {i}: defaulting to fact_type='world' "
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
)
elif raw_fact_type == "world":
fact_type = "world"
else:
raw_fact_kind = llm_fact.get("fact_kind")
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
@@ -1754,23 +1740,17 @@ async def extract_facts_from_contents_batch_api(
who = get_value("who")
why = get_value("why")
# Critical field: fact_type
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience"
if fact_type == "assistant":
# Critical field: fact_type — only "assistant" maps to "experience", everything else is "world"
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
raw_fact_type = llm_fact.get("fact_type")
if raw_fact_type == "assistant":
fact_type = "experience"
# Validate fact_type
if fact_type not in ["world", "experience", "opinion"]:
fact_kind = llm_fact.get("fact_kind")
if fact_kind == "assistant":
fact_type = "experience"
elif fact_kind in ["world", "experience", "opinion"]:
fact_type = fact_kind
else:
fact_type = "world"
elif raw_fact_type == "world":
fact_type = "world"
else:
raw_fact_kind = llm_fact.get("fact_kind")
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
# Build combined fact text
combined_parts = [what]
@@ -1933,7 +1913,7 @@ async def extract_facts_from_contents_batch_api(
for fact_from_llm in chunk_facts:
extracted_fact = ExtractedFactType(
fact_text=fact_from_llm.fact,
fact_type=fact_from_llm.fact_type,
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
entities=[e.text for e in (fact_from_llm.entities or [])],
occurred_start=_parse_datetime(fact_from_llm.occurred_start) if fact_from_llm.occurred_start else None,
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
@@ -2110,7 +2090,7 @@ async def extract_facts_from_contents(
# mentioned_at is always the event_date (when the conversation/document occurred)
extracted_fact = ExtractedFactType(
fact_text=fact_from_llm.fact,
fact_type=fact_from_llm.fact_type,
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
entities=[e.text for e in (fact_from_llm.entities or [])],
# occurred_start/end: from LLM only, leave None if not provided
occurred_start=_parse_datetime(fact_from_llm.occurred_start)
@@ -10,6 +10,7 @@ Implements:
import asyncio
import logging
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Optional
@@ -26,6 +27,15 @@ from .types import MPFPTimings, RetrievalResult
logger = logging.getLogger(__name__)
def tokenize_query(query_text: str) -> list[str]:
"""Normalize query text and split into BM25 tokens.
Strips punctuation, lowercases, and splits on whitespace.
Returns an empty list when the query contains no word characters.
"""
return re.sub(r"[^\w\s]", " ", query_text.lower()).split()
@dataclass
class ParallelRetrievalResult:
"""Result from parallel retrieval across all methods."""
@@ -129,12 +139,9 @@ async def retrieve_semantic_bm25_combined(
Returns:
Dict mapping fact_type -> (semantic_results, bm25_results)
"""
import re
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
tokens = [token for token in sanitized_text.split() if token]
tokens = tokenize_query(query_text)
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
hnsw_fetch = max(limit * 5, 100)
@@ -148,11 +155,15 @@ async def retrieve_semantic_bm25_combined(
# --- Parameter layout ---
# $1 = query_emb_str (semantic arms)
# $2 = bank_id
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
# $4 = bm25_text (only when tokens present)
# $N = tags (N=4 when no tokens, N=5 when tokens present)
# $M+ = tag_groups params (one per leaf, starting after tags param)
tags_param_idx = 5 if tokens else 4
# When tokens present:
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
# $4 = bm25_text
# $5 = tags (if present)
# $6+ = tag_groups params (one per leaf)
# When no tokens ($3 is skipped — not included in params to avoid type inference gap):
# $3 = tags (if present)
# $4+ = tag_groups params (one per leaf)
tags_param_idx = 5 if tokens else 3
tags_clause = build_tags_where_clause_simple(tags, tags_param_idx, match=tags_match)
# tag_groups params start immediately after the tags param slot
@@ -222,9 +233,10 @@ async def retrieve_semantic_bm25_combined(
query = "\nUNION ALL\n".join(arms)
params: list = [query_emb_str, bank_id, limit]
params: list = [query_emb_str, bank_id]
if tokens:
params.append(bm25_text_param)
params.append(limit) # $3: BM25 LIMIT (only referenced when tokens are present)
params.append(bm25_text_param) # $4
if tags:
params.append(tags)
params.extend(groups_params)
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
from hindsight_api.engine.response_models import ReflectResult
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.models import RequestContext
@@ -25,17 +26,51 @@ class OperationValidationError(Exception):
@dataclass
class ValidationResult:
"""Result of an operation validation."""
"""Result of an operation validation.
Validators return this to accept or reject an operation. When accepting,
validators can optionally return modified data that the engine will use
instead of the original request parameters. This enables context enrichment
(e.g., injecting tags or tag_groups).
"""
allowed: bool
reason: str | None = None
status_code: int = 403 # Default to Forbidden
# Optional enrichment fields — returned by validator, used by engine if present.
# None means "no modification" (engine uses original values).
contents: list[dict] | None = None # Enriched retain contents (e.g., injected tags/strategy)
tags: list[str] | None = None # Enriched recall tags
tags_match: "TagsMatch | None" = None # Enriched recall tags match mode
tag_groups: "list[TagGroup] | None" = None # Enriched recall tag_groups
@classmethod
def accept(cls) -> "ValidationResult":
"""Create an accepted validation result."""
"""Create an accepted validation result (no enrichment)."""
return cls(allowed=True)
@classmethod
def accept_with(
cls,
*,
contents: list[dict] | None = None,
tags: list[str] | None = None,
tags_match: "TagsMatch | None" = None,
tag_groups: "list[TagGroup] | None" = None,
) -> "ValidationResult":
"""Create an accepted validation result with enriched data.
The engine will use the returned values instead of the original request
parameters. Only non-None fields are applied; None means "keep original".
"""
return cls(
allowed=True,
contents=contents,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
@classmethod
def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
"""Create a rejected validation result with a reason and HTTP status code."""
@@ -52,10 +87,12 @@ class RetainContext:
"""Context for a retain operation validation (pre-operation).
Contains ALL user-provided parameters for the retain operation.
To enrich contents (e.g., inject tags or strategy), return them
via ValidationResult.accept_with(contents=...).
"""
bank_id: str
contents: list[dict] # List of {content, context, event_date, document_id}
contents: list[dict] # List of {content, context, event_date, document_id, tags, strategy}
request_context: "RequestContext"
document_id: str | None = None
fact_type_override: str | None = None
@@ -67,6 +104,8 @@ class RecallContext:
"""Context for a recall operation validation (pre-operation).
Contains ALL user-provided parameters for the recall operation.
To enrich tag filters (e.g., inject tag_groups), return them
via ValidationResult.accept_with(tag_groups=...).
"""
bank_id: str
@@ -81,6 +120,9 @@ class RecallContext:
max_entity_tokens: int = 500
include_chunks: bool = False
max_chunk_tokens: int = 8192
tags: list[str] | None = None
tags_match: "TagsMatch" = "any"
tag_groups: "list[TagGroup] | None" = None
@dataclass
+2 -172
View File
@@ -13,6 +13,7 @@ Stop with Ctrl+C.
import argparse
import asyncio
import atexit
import dataclasses
import os
import signal
import sys
@@ -152,178 +153,7 @@ def main():
# Configure Python logging based on log level
# Update config with CLI override if provided
if args.log_level != config.log_level:
config = HindsightConfig(
database_url=config.database_url,
database_schema=config.database_schema,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
llm_provider=config.llm_provider,
llm_api_key=config.llm_api_key,
llm_model=config.llm_model,
llm_base_url=config.llm_base_url,
llm_max_concurrent=config.llm_max_concurrent,
llm_max_retries=config.llm_max_retries,
llm_initial_backoff=config.llm_initial_backoff,
llm_max_backoff=config.llm_max_backoff,
llm_timeout=config.llm_timeout,
llm_groq_service_tier=config.llm_groq_service_tier,
llm_openai_service_tier=config.llm_openai_service_tier,
llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
llm_gemini_safety_settings=config.llm_gemini_safety_settings,
retain_llm_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model,
retain_llm_base_url=config.retain_llm_base_url,
retain_llm_max_concurrent=config.retain_llm_max_concurrent,
retain_llm_max_retries=config.retain_llm_max_retries,
retain_llm_initial_backoff=config.retain_llm_initial_backoff,
retain_llm_max_backoff=config.retain_llm_max_backoff,
retain_llm_timeout=config.retain_llm_timeout,
reflect_llm_provider=config.reflect_llm_provider,
reflect_llm_api_key=config.reflect_llm_api_key,
reflect_llm_model=config.reflect_llm_model,
reflect_llm_base_url=config.reflect_llm_base_url,
reflect_llm_max_concurrent=config.reflect_llm_max_concurrent,
reflect_llm_max_retries=config.reflect_llm_max_retries,
reflect_llm_initial_backoff=config.reflect_llm_initial_backoff,
reflect_llm_max_backoff=config.reflect_llm_max_backoff,
reflect_llm_timeout=config.reflect_llm_timeout,
consolidation_llm_provider=config.consolidation_llm_provider,
consolidation_llm_api_key=config.consolidation_llm_api_key,
consolidation_llm_model=config.consolidation_llm_model,
consolidation_llm_base_url=config.consolidation_llm_base_url,
consolidation_llm_max_concurrent=config.consolidation_llm_max_concurrent,
consolidation_llm_max_retries=config.consolidation_llm_max_retries,
consolidation_llm_initial_backoff=config.consolidation_llm_initial_backoff,
consolidation_llm_max_backoff=config.consolidation_llm_max_backoff,
consolidation_llm_timeout=config.consolidation_llm_timeout,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
embeddings_tei_url=config.embeddings_tei_url,
embeddings_openai_base_url=config.embeddings_openai_base_url,
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
embeddings_cohere_model=config.embeddings_cohere_model,
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
embeddings_litellm_model=config.embeddings_litellm_model,
embeddings_litellm_sdk_api_key=config.embeddings_litellm_sdk_api_key,
embeddings_litellm_sdk_model=config.embeddings_litellm_sdk_model,
embeddings_litellm_sdk_api_base=config.embeddings_litellm_sdk_api_base,
reranker_provider=config.reranker_provider,
reranker_local_model=config.reranker_local_model,
reranker_local_force_cpu=config.reranker_local_force_cpu,
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
reranker_local_fp16=config.reranker_local_fp16,
reranker_local_bucket_batching=config.reranker_local_bucket_batching,
reranker_local_batch_size=config.reranker_local_batch_size,
reranker_tei_url=config.reranker_tei_url,
reranker_tei_batch_size=config.reranker_tei_batch_size,
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
reranker_max_candidates=config.reranker_max_candidates,
reranker_cohere_api_key=config.reranker_cohere_api_key,
reranker_cohere_model=config.reranker_cohere_model,
reranker_cohere_base_url=config.reranker_cohere_base_url,
reranker_litellm_api_base=config.reranker_litellm_api_base,
reranker_litellm_api_key=config.reranker_litellm_api_key,
reranker_litellm_model=config.reranker_litellm_model,
reranker_litellm_max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
reranker_litellm_sdk_api_key=config.reranker_litellm_sdk_api_key,
reranker_litellm_sdk_model=config.reranker_litellm_sdk_model,
reranker_litellm_sdk_api_base=config.reranker_litellm_sdk_api_base,
reranker_zeroentropy_api_key=config.reranker_zeroentropy_api_key,
reranker_zeroentropy_model=config.reranker_zeroentropy_model,
host=args.host,
port=args.port,
base_path=config.base_path,
log_level=args.log_level,
log_format=config.log_format,
mcp_enabled=config.mcp_enabled,
mcp_enabled_tools=config.mcp_enabled_tools,
enable_bank_config_api=config.enable_bank_config_api,
graph_retriever=config.graph_retriever,
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
recall_max_concurrent=config.recall_max_concurrent,
recall_connection_budget=config.recall_connection_budget,
recall_max_query_tokens=config.recall_max_query_tokens,
retain_max_completion_tokens=config.retain_max_completion_tokens,
retain_chunk_size=config.retain_chunk_size,
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_mission=config.retain_mission,
retain_custom_instructions=config.retain_custom_instructions,
retain_default_strategy=config.retain_default_strategy,
retain_strategies=config.retain_strategies,
retain_batch_tokens=config.retain_batch_tokens,
retain_entity_lookup=config.retain_entity_lookup,
retain_batch_enabled=config.retain_batch_enabled,
retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds,
file_storage_type=config.file_storage_type,
file_storage_s3_bucket=config.file_storage_s3_bucket,
file_storage_s3_region=config.file_storage_s3_region,
file_storage_s3_endpoint=config.file_storage_s3_endpoint,
file_storage_s3_access_key_id=config.file_storage_s3_access_key_id,
file_storage_s3_secret_access_key=config.file_storage_s3_secret_access_key,
file_storage_gcs_bucket=config.file_storage_gcs_bucket,
file_storage_gcs_service_account_key=config.file_storage_gcs_service_account_key,
file_storage_azure_container=config.file_storage_azure_container,
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,
file_conversion_max_batch_size=config.file_conversion_max_batch_size,
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,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
run_migrations_on_startup=config.run_migrations_on_startup,
db_pool_min_size=config.db_pool_min_size,
db_pool_max_size=config.db_pool_max_size,
db_command_timeout=config.db_command_timeout,
db_acquire_timeout=config.db_acquire_timeout,
worker_enabled=config.worker_enabled,
worker_id=config.worker_id,
worker_poll_interval_ms=config.worker_poll_interval_ms,
worker_max_retries=config.worker_max_retries,
worker_http_port=config.worker_http_port,
worker_max_slots=config.worker_max_slots,
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
reflect_max_iterations=config.reflect_max_iterations,
reflect_max_context_tokens=config.reflect_max_context_tokens,
reflect_mission=config.reflect_mission,
disposition_skepticism=config.disposition_skepticism,
disposition_literalism=config.disposition_literalism,
disposition_empathy=config.disposition_empathy,
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
otel_traces_enabled=config.otel_traces_enabled,
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
otel_service_name=config.otel_service_name,
otel_deployment_environment=config.otel_deployment_environment,
webhook_url=config.webhook_url,
webhook_secret=config.webhook_secret,
webhook_event_types=config.webhook_event_types,
webhook_delivery_poll_interval_seconds=config.webhook_delivery_poll_interval_seconds,
)
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
config.configure_logging()
if not args.daemon:
config.log_config()
@@ -42,6 +42,9 @@ class MCPToolsConfig:
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
api_key_id_resolver: Callable[[], str | None] | None = None
# How to resolve mcp_authenticated flag (set when MCP_AUTH_TOKEN validates)
mcp_authenticated_resolver: Callable[[], bool] | None = None
# Whether to include bank_id as a parameter on tools (for multi-bank support)
include_bank_id_param: bool = False
@@ -64,7 +67,10 @@ def _get_request_context(config: MCPToolsConfig) -> RequestContext:
api_key = config.api_key_resolver() if config.api_key_resolver else None
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
mcp_authenticated = config.mcp_authenticated_resolver() if config.mcp_authenticated_resolver else False
return RequestContext(
api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id, mcp_authenticated=mcp_authenticated
)
def parse_timestamp(timestamp: str) -> datetime | None:
@@ -21,6 +21,7 @@ class RequestContext:
api_key_id: str | None = None # UUID of the API key used for authentication
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
internal: bool = False # True for background/internal operations (skips extension auth)
mcp_authenticated: bool = False # True when MCP transport auth already validated (skips tenant re-auth)
user_initiated: bool = False # True for async operations that originated from a user request
allowed_bank_ids: list[str] | None = None # None = unrestricted (all banks)
@@ -302,19 +302,106 @@ class WorkerPoller:
)
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
"""Mark a task as failed with error message."""
"""Mark a task as failed with error message, then propagate to parent if applicable."""
table = fq_table("async_operations", schema)
# Truncate error message if too long (max 5000 chars in schema)
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
await self._pool.execute(
f"""
UPDATE {table}
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
WHERE operation_id = $1
""",
operation_id,
error_message,
)
async with self._pool.acquire() as conn:
async with conn.transaction():
await conn.execute(
f"""
UPDATE {table}
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
WHERE operation_id = $1
""",
operation_id,
error_message,
)
await self._maybe_update_parent_operation(operation_id, schema, conn)
async def _maybe_update_parent_operation(self, child_operation_id: str, schema: str | None, conn) -> None:
"""If this operation is a child of a batch_retain, update the parent status when all siblings are done.
Must be called within an active transaction that has already updated the child's status.
The memory engine has an equivalent method that runs inside task execution transactions.
This poller-level version handles the case where a task fails via an unhandled exception
that bypasses the memory engine's own failure path (e.g. a DB constraint violation that
rolls back the engine's transaction before it can update the parent).
"""
import json
import uuid
table = fq_table("async_operations", schema)
try:
row = await conn.fetchrow(
f"SELECT result_metadata, bank_id FROM {table} WHERE operation_id = $1",
uuid.UUID(child_operation_id),
)
if not row:
return
result_metadata = row["result_metadata"] or {}
if isinstance(result_metadata, str):
result_metadata = json.loads(result_metadata)
parent_operation_id = result_metadata.get("parent_operation_id")
if not parent_operation_id:
return
bank_id = row["bank_id"]
# Lock parent to prevent concurrent sibling updates
parent_row = await conn.fetchrow(
f"SELECT operation_id FROM {table} WHERE operation_id = $1 AND bank_id = $2 FOR UPDATE",
uuid.UUID(parent_operation_id),
bank_id,
)
if not parent_row:
return
# Check whether all siblings are done
siblings = await conn.fetch(
f"""
SELECT status FROM {table}
WHERE bank_id = $1
AND result_metadata::jsonb @> $2::jsonb
""",
bank_id,
json.dumps({"parent_operation_id": parent_operation_id}),
)
if not siblings or not all(s["status"] in ("completed", "failed") for s in siblings):
return
any_failed = any(s["status"] == "failed" for s in siblings)
if any_failed:
await conn.execute(
f"""
UPDATE {table}
SET status = 'failed', error_message = $2, updated_at = now()
WHERE operation_id = $1
""",
uuid.UUID(parent_operation_id),
"One or more sub-batches failed",
)
else:
await conn.execute(
f"""
UPDATE {table}
SET status = 'completed', updated_at = now(), completed_at = now()
WHERE operation_id = $1
""",
uuid.UUID(parent_operation_id),
)
logger.info(
f"Poller updated parent operation {parent_operation_id} to "
f"{'failed' if any_failed else 'completed'} (all siblings done)"
)
except Exception as e:
# Log but don't re-raise — the child has already been marked failed,
# which is the critical state change. A stuck parent will be caught on
# the next run or via monitoring.
logger.error(f"Failed to update parent operation for child {child_operation_id}: {e}")
async def _schedule_retry(self, operation_id: str, retry_at: "Any", error_message: str, schema: str | None):
"""Reset task to pending with a future retry timestamp."""
+6 -3
View File
@@ -45,7 +45,7 @@ dependencies = [
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
"uvloop>=0.22.1",
# Transitive dependency security fixes
"pyasn1>=0.6.2", # DoS vulnerability fix
"pyasn1>=0.6.3", # DoS vulnerability fix
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
"langchain-core>=1.2.11", # Serialization injection + SSRF vulnerability fix
"langsmith>=0.6.3", # SSRF via tracing header injection fix
@@ -53,9 +53,12 @@ dependencies = [
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
"cryptography>=46.0.5", # Subgroup attack vulnerability fix
"filelock>=3.20.1", # TOCTOU race condition fix
"authlib>=1.6.6", # Account takeover vulnerability fix
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
"orjson>=3.11.6", # Unbounded recursion DoS fix
"tornado>=6.5.5", # DoS multipart/incomplete cookie validation fix
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"claude-agent-sdk>=0.1.27; sys_platform == 'darwin'",
"claude-agent-sdk>=0.1.27",
]
[project.optional-dependencies]
+20 -6
View File
@@ -48,7 +48,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
Session-scoped fixture that ensures pg0 is running, migrations are applied,
and returns the database URL.
If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management).
If HINDSIGHT_API_DATABASE_URL is a plain postgresql:// URL, uses it directly.
If HINDSIGHT_API_DATABASE_URL is a pg0:// URL, resolves it to a real URL first.
Otherwise, starts pg0 once for the entire test session.
Uses filelock to ensure only one pytest-xdist worker starts pg0.
@@ -58,10 +59,23 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate
processes that share the same pg0 instance. pg0 will persist for the next test run.
"""
if db_url:
# Use provided database URL directly
from hindsight_api.pg0 import parse_pg0_url as _parse_pg0_url
# Determine pg0 instance name/port from db_url (if it's a pg0:// URL) or use defaults
if db_url and not _parse_pg0_url(db_url)[0]:
# Plain postgresql:// URL - use it directly but still run migrations
from hindsight_api.migrations import run_migrations
run_migrations(db_url)
return db_url
if db_url:
_, pg0_name, pg0_port = _parse_pg0_url(db_url)
pg0_instance_name = pg0_name or DEFAULT_PG0_INSTANCE_NAME
pg0_instance_port = pg0_port or DEFAULT_PG0_PORT
else:
pg0_instance_name = DEFAULT_PG0_INSTANCE_NAME
pg0_instance_port = DEFAULT_PG0_PORT
# Get shared temp dir for coordination between xdist workers
if worker_id == "master":
# Running without xdist (-n 0 or no -n flag)
@@ -71,8 +85,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
root_tmp_dir = tmp_path_factory.getbasetemp().parent
# Use a lock file to ensure only one worker starts pg0
lock_file = root_tmp_dir / "pg0_setup.lock"
url_file = root_tmp_dir / "pg0_url.txt"
lock_file = root_tmp_dir / f"pg0_setup_{pg0_instance_name}.lock"
url_file = root_tmp_dir / f"pg0_url_{pg0_instance_name}.txt"
with filelock.FileLock(str(lock_file)):
if url_file.exists():
@@ -80,7 +94,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
url = url_file.read_text().strip()
else:
# First worker - start pg0
pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT)
pg0 = EmbeddedPostgres(name=pg0_instance_name, port=pg0_instance_port)
# Run ensure_running in a new event loop
loop = asyncio.new_event_loop()
@@ -0,0 +1,70 @@
"""
Tests for EntityResolver edge cases.
"""
import uuid
from datetime import datetime, timezone
import asyncpg
import pytest
from hindsight_api.engine.entity_resolver import EntityResolver
from hindsight_api.pg0 import resolve_database_url
@pytest.mark.asyncio
async def test_resolve_entities_batch_handles_unicode_lower_conflicts(pg0_db_url):
"""
Existing entities with PostgreSQL/Python lowercase mismatches should resolve
to the conflicted row instead of leaving a missing entity_id.
"""
resolved_url = await resolve_database_url(pg0_db_url)
pool = await asyncpg.create_pool(resolved_url, min_size=1, max_size=2, command_timeout=30)
bank_id = f"test-entity-resolver-{uuid.uuid4().hex[:8]}"
event_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
resolver = EntityResolver(pool=pool, entity_lookup="full")
try:
async with pool.acquire() as conn:
existing_entity_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, $3, $3, 1)
RETURNING id
""",
bank_id,
"İstanbul",
event_date,
)
resolved_ids = await resolver.resolve_entities_batch(
bank_id=bank_id,
entities_data=[
{
"text": "istanbul",
"nearby_entities": [],
"event_date": event_date,
}
],
context="unicode case mismatch",
unit_event_date=event_date,
conn=conn,
)
entity_rows = await conn.fetch(
"""
SELECT id, canonical_name
FROM entities
WHERE bank_id = $1
ORDER BY canonical_name
""",
bank_id,
)
assert resolved_ids == [existing_entity_id]
assert len(entity_rows) == 1
assert entity_rows[0]["id"] == existing_entity_id
assert entity_rows[0]["canonical_name"] == "İstanbul"
finally:
await pool.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
await pool.close()
@@ -0,0 +1,163 @@
"""
Unit tests for EntityResolver pg_trgm auto-detection (PR #626/#649).
These tests verify:
1. When entity_lookup="trigram" and pg_trgm IS available, the trigram path is used.
2. When entity_lookup="trigram" and pg_trgm is NOT available, the resolver falls back
to entity_lookup="full" and uses the full-scan path.
3. The pg_trgm check is only performed once (_pg_trgm_checked flag prevents re-checking).
4. When entity_lookup="full" from the start, the trgm check is never performed.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api.engine.entity_resolver import EntityResolver
def _make_conn(pg_trgm_available: bool) -> MagicMock:
"""Create a minimal mock asyncpg connection for the pg_trgm availability check."""
conn = MagicMock()
conn.fetchval = AsyncMock(return_value=pg_trgm_available)
conn.fetch = AsyncMock(return_value=[])
conn.executemany = AsyncMock()
conn.fetchrow = AsyncMock(return_value=None)
return conn
def _make_resolver(entity_lookup: str = "trigram") -> EntityResolver:
"""Return an EntityResolver with a None pool (not needed for unit tests)."""
return EntityResolver(pool=None, entity_lookup=entity_lookup) # type: ignore[arg-type]
class TestPgTrgmAutoDetection:
"""Unit tests for pg_trgm detection logic inside _resolve_entities_batch_impl."""
@pytest.mark.asyncio
async def test_falls_back_to_full_when_pg_trgm_unavailable(self):
"""When pg_trgm is absent the resolver switches to 'full' and calls the full-scan path."""
resolver = _make_resolver(entity_lookup="trigram")
conn = _make_conn(pg_trgm_available=False)
with (
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
):
await resolver._resolve_entities_batch_impl(
conn=conn,
bank_id="test-bank",
entities_data=[],
context="",
unit_event_date=None,
)
# Trigram path must NOT be called
mock_trgm.assert_not_called()
# Full-scan path must be called as the fallback
mock_full.assert_called_once()
# Strategy is permanently downgraded
assert resolver.entity_lookup == "full"
assert resolver._pg_trgm_checked is True
@pytest.mark.asyncio
async def test_uses_trigram_when_pg_trgm_available(self):
"""When pg_trgm is present the trigram path is used."""
resolver = _make_resolver(entity_lookup="trigram")
conn = _make_conn(pg_trgm_available=True)
with (
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
):
await resolver._resolve_entities_batch_impl(
conn=conn,
bank_id="test-bank",
entities_data=[],
context="",
unit_event_date=None,
)
mock_trgm.assert_called_once()
mock_full.assert_not_called()
assert resolver.entity_lookup == "trigram"
assert resolver._pg_trgm_checked is True
@pytest.mark.asyncio
async def test_pg_trgm_check_performed_only_once(self):
"""The fetchval check is only issued on the first call; subsequent calls skip it."""
resolver = _make_resolver(entity_lookup="trigram")
conn = _make_conn(pg_trgm_available=True)
with patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])):
# First call — check is issued
await resolver._resolve_entities_batch_impl(
conn=conn,
bank_id="test-bank",
entities_data=[],
context="",
unit_event_date=None,
)
# Second call — check must NOT be issued again
await resolver._resolve_entities_batch_impl(
conn=conn,
bank_id="test-bank",
entities_data=[],
context="",
unit_event_date=None,
)
# fetchval (the pg_trgm availability query) should be called exactly once
assert conn.fetchval.call_count == 1
@pytest.mark.asyncio
async def test_full_strategy_skips_pg_trgm_check(self):
"""When entity_lookup='full' from the start, no pg_trgm check is ever issued."""
resolver = _make_resolver(entity_lookup="full")
conn = _make_conn(pg_trgm_available=False)
with patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])):
await resolver._resolve_entities_batch_impl(
conn=conn,
bank_id="test-bank",
entities_data=[],
context="",
unit_event_date=None,
)
# fetchval should never be called when entity_lookup is already "full"
conn.fetchval.assert_not_called()
@pytest.mark.asyncio
async def test_fallback_is_sticky_across_calls(self):
"""After falling back to 'full', subsequent calls also use the full path."""
resolver = _make_resolver(entity_lookup="trigram")
conn = _make_conn(pg_trgm_available=False)
with (
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
):
# First call triggers the fallback
await resolver._resolve_entities_batch_impl(
conn=conn,
bank_id="b",
entities_data=[],
context="",
unit_event_date=None,
)
# Second call — _pg_trgm_checked is True so no re-check; entity_lookup=="full"
await resolver._resolve_entities_batch_impl(
conn=conn,
bank_id="b",
entities_data=[],
context="",
unit_event_date=None,
)
# Trigram path is never called
mock_trgm.assert_not_called()
# Full-scan path is called both times
assert mock_full.call_count == 2
# pg_trgm check was issued exactly once
assert conn.fetchval.call_count == 1
@@ -5,6 +5,7 @@ End-to-end tests for file retain (upload, convert, retain) functionality.
import asyncio
import io
import json
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
@@ -471,6 +472,77 @@ async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_v
assert len(doc["original_text"]) > 0
@pytest.mark.asyncio
async def test_async_file_retain_serializes_datetime_timestamp(memory_no_llm_verify, sample_txt_content):
"""Async file retain should accept Python datetimes in task payloads."""
from hindsight_api.engine.parsers.base import FileParser
from hindsight_api.models import RequestContext
bank_id = f"test_file_timestamp_bank_{datetime.now(timezone.utc).timestamp()}"
timestamp = datetime(2024, 1, 15, 10, 30, tzinfo=timezone.utc)
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
class TimestampParser(FileParser):
async def convert(self, file_data: bytes, filename: str) -> str:
return file_data.decode("utf-8")
def supports(self, filename: str, content_type: str | None = None) -> bool:
return filename.endswith(".txt")
def name(self) -> str:
return "timestamp_parser"
memory_no_llm_verify._parser_registry.register(TimestampParser())
mock_file = MockFile(sample_txt_content, "timestamped.txt", "text/plain")
result = await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=[
{
"file": mock_file,
"document_id": "timestamped_doc",
"context": "timestamp test",
"metadata": {},
"tags": [],
"timestamp": timestamp,
"parser": ["timestamp_parser"],
}
],
document_tags=None,
request_context=context,
)
operation_id = result["operation_ids"][0]
pool = await memory_no_llm_verify._get_pool()
from hindsight_api.engine.memory_engine import get_current_schema
async with pool.acquire() as conn:
row = await conn.fetchrow(
f"""
SELECT status, task_payload->>'timestamp' AS timestamp
FROM {get_current_schema()}.async_operations
WHERE operation_id = $1
""",
operation_id,
)
assert row is not None
assert row["status"] == "completed"
assert row["timestamp"] == "2024-01-15T10:30:00+00:00"
@pytest.mark.asyncio
async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verify, sample_txt_content):
"""Test that when file conversion fails, the operation status is set to 'failed' not 'completed'."""
@@ -4,6 +4,9 @@ This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets,
and that URLs with or without trailing slashes both work (no 307 redirect).
"""
import json
from unittest.mock import patch
import httpx
import pytest
from mcp.client.session import ClientSession
@@ -278,3 +281,93 @@ async def test_mcp_bank_named_messages_routes_to_single_bank(memory):
assert "retain" in tools
assert "list_banks" not in tools, "Bank 'messages' should route to single-bank mode"
@pytest.mark.asyncio
async def test_mcp_tool_execution_with_different_mcp_and_tenant_tokens(memory):
"""Test that MCP tool calls work when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
Regression test for https://github.com/vectorize-io/hindsight/issues/627
When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are configured
with different values, tool calls should succeed because MCP transport auth
already validated the token the tenant extension should not re-validate.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
from hindsight_api.extensions import ApiKeyTenantExtension
mcp_token = "mcp-secret-token"
tenant_key = "tenant-secret-key"
# Configure ApiKeyTenantExtension with a different key than the MCP token
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
memory._tenant_extension = tenant_ext
# Patch MCP_AUTH_TOKEN so the MCP middleware uses legacy auth
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
# Pass auth header via the httpx client (streamable_http_client doesn't accept headers)
async with httpx.AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
headers={"Authorization": f"Bearer {mcp_token}"},
) as http_client:
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
# list_tools should work
tools_result = await session.list_tools()
tool_names = {t.name for t in tools_result.tools}
assert "get_bank" in tool_names
# Tool execution should work (this was failing before the fix)
result = await session.call_tool("list_banks", arguments={})
assert result is not None
assert len(result.content) > 0
parsed = json.loads(result.content[0].text)
assert "banks" in parsed
assert "error" not in parsed, f"Tool call failed with: {parsed.get('error')}"
@pytest.mark.asyncio
async def test_mcp_rejects_wrong_mcp_token_even_if_matches_tenant_key(memory):
"""Test that an invalid MCP token is rejected even if it matches the tenant key.
When MCP_AUTH_TOKEN is set, the MCP middleware should validate against that token,
not the tenant API key.
"""
from httpx import ASGITransport
from hindsight_api.api import create_app
from hindsight_api.extensions import ApiKeyTenantExtension
mcp_token = "mcp-secret-token"
tenant_key = "tenant-secret-key"
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
memory._tenant_extension = tenant_ext
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
# Try connecting with the tenant key (wrong for MCP auth)
response = await http_client.post(
"http://test/mcp/",
headers={
"Authorization": f"Bearer {tenant_key}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
)
assert response.status_code == 401
@@ -5,8 +5,10 @@ These tests verify:
1. Tool name normalization for various LLM output formats
2. Recovery from unknown tool calls
3. Recovery from tool execution errors
4. Wall-clock timeout enforcement
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -416,6 +418,32 @@ class TestReflectAgentMocked:
assert result is not None
assert result.iterations == 3
@pytest.mark.asyncio
async def test_wall_clock_timeout(self, mock_llm: MagicMock, mock_functions: dict[str, AsyncMock]) -> None:
"""Test that asyncio.wait_for can enforce a wall-clock timeout on run_reflect_agent."""
async def slow_llm_call(*args: object, **kwargs: object) -> LLMToolCallResult:
await asyncio.sleep(10) # Simulate a slow LLM call
return LLMToolCallResult(
tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
finish_reason="tool_calls",
)
mock_llm.call_with_tools.side_effect = slow_llm_call
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(
run_reflect_agent(
llm_config=mock_llm,
bank_id="test-bank",
query="test query",
bank_profile={"name": "Test", "mission": "Testing"},
max_iterations=5,
**mock_functions,
),
timeout=0.1, # Very short timeout to trigger quickly
)
class TestContextOverflowHelpers:
"""Unit tests for context-overflow detection helpers."""
@@ -485,3 +485,206 @@ class TestReflectUsesMentalModels:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelReflectOptions:
"""Tests for fact_types and exclude_mental_models options stored in the trigger field."""
@pytest.mark.asyncio
async def test_trigger_stores_fact_types(self, memory: MemoryEngine, request_context):
"""Trigger field persists fact_types and returns them via get_mental_model."""
bank_id = f"test-mm-trigger-ft-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Observations only",
source_query="Summarize observations",
content="content",
trigger={"refresh_after_consolidation": False, "fact_types": ["observation"]},
request_context=request_context,
)
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
assert fetched["trigger"]["fact_types"] == ["observation"]
assert fetched["trigger"]["refresh_after_consolidation"] is False
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_trigger_stores_exclude_mental_models(self, memory: MemoryEngine, request_context):
"""Trigger field persists exclude_mental_models flag."""
bank_id = f"test-mm-trigger-em-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="No mental models",
source_query="Summarize raw facts",
content="content",
trigger={"refresh_after_consolidation": False, "exclude_mental_models": True},
request_context=request_context,
)
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
assert fetched["trigger"]["exclude_mental_models"] is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_trigger_stores_exclude_mental_model_ids(self, memory: MemoryEngine, request_context):
"""Trigger field persists exclude_mental_model_ids list."""
bank_id = f"test-mm-trigger-eid-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
excluded_ids = ["mm-abc", "mm-xyz"]
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Exclude some models",
source_query="Summarize",
content="content",
trigger={"refresh_after_consolidation": False, "exclude_mental_model_ids": excluded_ids},
request_context=request_context,
)
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
assert fetched["trigger"]["exclude_mental_model_ids"] == excluded_ids
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_trigger_reflect_options(self, memory: MemoryEngine, request_context):
"""update_mental_model persists updated trigger reflect options."""
bank_id = f"test-mm-trigger-upd-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Initially no filter",
source_query="Summarize",
content="content",
trigger={"refresh_after_consolidation": False},
request_context=request_context,
)
updated = await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
trigger={
"refresh_after_consolidation": True,
"fact_types": ["world", "experience"],
"exclude_mental_models": False,
"exclude_mental_model_ids": ["mm-skip"],
},
request_context=request_context,
)
assert updated["trigger"]["refresh_after_consolidation"] is True
assert updated["trigger"]["fact_types"] == ["world", "experience"]
assert updated["trigger"]["exclude_mental_models"] is False
assert updated["trigger"]["exclude_mental_model_ids"] == ["mm-skip"]
await memory.delete_bank(bank_id, request_context=request_context)
class TestReflectFactTypeFiltering:
"""Tests for fact_types and exclude_mental_models filtering in reflect_async."""
@pytest.mark.asyncio
async def test_exclude_mental_models_skips_search_mental_models_tool(
self, memory: MemoryEngine, request_context
):
"""When exclude_mental_models=True, search_mental_models is never called."""
bank_id = f"test-reflect-exmm-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a mental model so the bank has one
await memory.create_mental_model(
bank_id=bank_id,
name="Existing Model",
source_query="Q",
content="Some content about the team",
request_context=request_context,
)
result = await memory.reflect_async(
bank_id=bank_id,
query="Tell me about the team",
request_context=request_context,
exclude_mental_models=True,
)
tool_names = [tc.tool for tc in result.tool_trace]
assert "search_mental_models" not in tool_names, (
f"search_mental_models should be excluded but found in: {tool_names}"
)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_exclude_observations_via_fact_types(self, memory: MemoryEngine, request_context):
"""When fact_types excludes observation, search_observations is never called."""
bank_id = f"test-reflect-exobs-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
result = await memory.reflect_async(
bank_id=bank_id,
query="Tell me something",
request_context=request_context,
fact_types=["world", "experience"],
)
tool_names = [tc.tool for tc in result.tool_trace]
assert "search_observations" not in tool_names, (
f"search_observations should be excluded but found in: {tool_names}"
)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_observation_only_fact_types_skips_recall(self, memory: MemoryEngine, request_context):
"""When fact_types=['observation'], recall is never called."""
bank_id = f"test-reflect-obsonly-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
result = await memory.reflect_async(
bank_id=bank_id,
query="Tell me something",
request_context=request_context,
fact_types=["observation"],
)
tool_names = [tc.tool for tc in result.tool_trace]
assert "recall" not in tool_names, f"recall should be excluded but found in: {tool_names}"
await memory.delete_bank(bank_id, request_context=request_context)
class TestReflectRequestValidation:
"""Tests for ReflectRequest and MentalModelTrigger validation via the HTTP API."""
@pytest.mark.asyncio
async def test_reflect_empty_fact_types_rejected(self, api_client, test_bank_id):
"""Passing fact_types=[] to reflect must return 422."""
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/reflect",
json={"query": "test", "fact_types": []},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_mental_model_empty_fact_types_rejected(self, api_client, test_bank_id):
"""Passing fact_types=[] inside trigger must return 422."""
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/mental-models",
json={
"name": "Test",
"source_query": "Q",
"trigger": {"refresh_after_consolidation": False, "fact_types": []},
},
)
assert response.status_code == 422
@@ -0,0 +1,92 @@
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
import pytest
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
class TestStripCodeFences:
"""Test markdown code fence stripping from LLM responses."""
def test_bare_json_unchanged(self):
"""Bare JSON passes through unchanged."""
content = '{"facts": [{"what": "test"}]}'
assert _strip_code_fences(content) == content
def test_json_fence_stripped(self):
"""```json ... ``` fences are stripped."""
content = '```json\n{"facts": [{"what": "test"}]}\n```'
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
def test_plain_fence_stripped(self):
"""``` ... ``` fences without language tag are stripped."""
content = '```\n{"facts": [{"what": "test"}]}\n```'
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
def test_fence_with_trailing_whitespace(self):
"""Fences with extra whitespace are handled."""
content = '```json\n{"facts": []}\n```\n'
result = _strip_code_fences(content)
assert result == '{"facts": []}'
def test_fence_with_leading_whitespace(self):
"""Content with leading whitespace before fence."""
content = ' ```json\n{"facts": []}\n```'
# The function checks for ``` in content, not startswith
result = _strip_code_fences(content)
assert '{"facts": []}' in result
def test_no_fences_no_change(self):
"""Content without any backticks passes through."""
content = "Just some text without fences"
assert _strip_code_fences(content) == content
def test_empty_string(self):
"""Empty string passes through."""
assert _strip_code_fences("") == ""
def test_multiline_json(self):
"""Multi-line JSON inside fences is preserved."""
content = '```json\n{\n "facts": [\n {"what": "line1"},\n {"what": "line2"}\n ]\n}\n```'
result = _strip_code_fences(content)
assert '"line1"' in result
assert '"line2"' in result
assert "```" not in result
def test_malformed_fence_returns_original(self):
"""Malformed fences (missing closing) return something parseable."""
content = '```json\n{"facts": []}'
result = _strip_code_fences(content)
# Should attempt to strip and return best effort
assert isinstance(result, str)
def test_minimax_style_response(self):
"""Real-world MiniMax response format."""
content = (
"```json\n"
"{\n"
' "facts": [\n'
" {\n"
' "what": "Sebastian switched the Hindsight extraction LLM",\n'
' "when": "2026-03-21",\n'
' "where": "N/A",\n'
' "who": "Sebastian",\n'
' "why": "MiniMax wraps JSON in code fences",\n'
' "fact_kind": "event",\n'
' "fact_type": "world",\n'
' "entities": [{"text": "Sebastian"}, {"text": "Hindsight"}],\n'
' "labels": {"source_type": "stated", "domain": ["infrastructure"]}\n'
" }\n"
" ]\n"
"}\n"
"```"
)
result = _strip_code_fences(content)
assert not result.startswith("```")
assert not result.endswith("```")
# Should be valid JSON
import json
parsed = json.loads(result)
assert len(parsed["facts"]) == 1
assert parsed["facts"][0]["who"] == "Sebastian"
@@ -0,0 +1,269 @@
"""
Unit tests for ValidationResult.accept_with() enrichment (PR #639).
These tests verify:
1. The accept_with() factory creates an accepted result with the correct enrichment fields.
2. The engine applies enrichment to retain contents and recall tags/tag_groups.
3. RecallContext carries tags/tags_match/tag_groups so validators can read filter state.
"""
import pytest
from hindsight_api.extensions import (
OperationValidatorExtension,
RecallContext,
ReflectContext,
RetainContext,
ValidationResult,
)
from hindsight_api.models import RequestContext
# ---------------------------------------------------------------------------
# Pure unit tests for ValidationResult factory methods
# ---------------------------------------------------------------------------
class TestValidationResultAcceptWith:
"""Unit tests for the accept_with() factory — no DB needed."""
def test_accept_is_allowed_with_no_enrichment(self):
result = ValidationResult.accept()
assert result.allowed is True
assert result.contents is None
assert result.tags is None
assert result.tags_match is None
assert result.tag_groups is None
def test_accept_with_contents(self):
contents = [{"content": "enriched text", "tags": ["injected"]}]
result = ValidationResult.accept_with(contents=contents)
assert result.allowed is True
assert result.contents == contents
assert result.tags is None
assert result.tag_groups is None
def test_accept_with_tags(self):
result = ValidationResult.accept_with(tags=["alpha", "beta"])
assert result.allowed is True
assert result.tags == ["alpha", "beta"]
assert result.contents is None
assert result.tag_groups is None
def test_accept_with_tags_match(self):
result = ValidationResult.accept_with(tags=["x"], tags_match="all")
assert result.allowed is True
assert result.tags_match == "all"
def test_accept_with_tag_groups(self):
tag_groups = [{"tags": ["env:prod"], "match": "all"}]
result = ValidationResult.accept_with(tag_groups=tag_groups)
assert result.allowed is True
assert result.tag_groups == tag_groups
def test_accept_with_all_fields(self):
contents = [{"content": "c"}]
tags = ["t1"]
tag_groups = [{"tags": ["g1"]}]
result = ValidationResult.accept_with(
contents=contents,
tags=tags,
tags_match="any",
tag_groups=tag_groups,
)
assert result.allowed is True
assert result.contents == contents
assert result.tags == tags
assert result.tags_match == "any"
assert result.tag_groups == tag_groups
def test_reject_ignores_enrichment_fields(self):
"""reject() always sets allowed=False and leaves enrichment fields at their defaults."""
result = ValidationResult.reject("not allowed", status_code=403)
assert result.allowed is False
assert result.reason == "not allowed"
assert result.status_code == 403
assert result.contents is None
assert result.tags is None
def test_none_fields_mean_no_modification(self):
"""None enrichment fields must not overwrite engine defaults."""
result = ValidationResult.accept_with(tags=None, tag_groups=None)
assert result.tags is None
assert result.tag_groups is None
# Engine should interpret None as "keep original" — we verify the contract here.
# ---------------------------------------------------------------------------
# Integration tests: engine applies enrichment from validator
# ---------------------------------------------------------------------------
class _ContentEnrichingValidator(OperationValidatorExtension):
"""Validator that injects a tag into every retain content item."""
def __init__(self, injected_tag: str):
super().__init__({})
self.injected_tag = injected_tag
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
enriched = []
for item in ctx.contents:
new_item = dict(item)
new_item.setdefault("tags", [])
new_item["tags"] = list(new_item["tags"]) + [self.injected_tag]
enriched.append(new_item)
return ValidationResult.accept_with(contents=enriched)
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
class _TagEnrichingValidator(OperationValidatorExtension):
"""Validator that injects tags into every recall operation."""
def __init__(self, forced_tags: list[str]):
super().__init__({})
self.forced_tags = forced_tags
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept_with(tags=self.forced_tags, tags_match="all")
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
class _RecallContextCapturingValidator(OperationValidatorExtension):
"""Validator that captures the RecallContext for inspection."""
def __init__(self):
super().__init__({})
self.captured: list[RecallContext] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
self.captured.append(ctx)
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
@pytest.fixture
def memory_with_content_enricher(memory):
validator = _ContentEnrichingValidator(injected_tag="validator-injected")
memory._operation_validator = validator
return memory, validator
@pytest.fixture
def memory_with_tag_enricher(memory):
validator = _TagEnrichingValidator(forced_tags=["forced-tag"])
memory._operation_validator = validator
return memory, validator
@pytest.fixture
def memory_with_recall_context_capture(memory):
validator = _RecallContextCapturingValidator()
memory._operation_validator = validator
return memory, validator
class TestRetainContentEnrichment:
"""Engine applies enriched contents returned by validate_retain."""
@pytest.mark.asyncio
async def test_enriched_contents_are_used_for_retain(self, memory_with_content_enricher):
"""When validator returns accept_with(contents=...), engine uses those contents."""
memory, validator = memory_with_content_enricher
bank_id = "test-retain-enrichment"
ctx = RequestContext()
# Retain without any tags — validator should inject "validator-injected"
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice is an engineer."}],
request_context=ctx,
)
# Retrieve facts tagged with the injected tag to confirm enrichment was applied
result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
tags=["validator-injected"],
request_context=ctx,
)
# The fact should be retrievable via the injected tag
assert result is not None
class TestRecallTagEnrichment:
"""Engine applies enriched tags returned by validate_recall."""
@pytest.mark.asyncio
async def test_enriched_tags_filter_recall_results(self, memory_with_tag_enricher):
"""When validator returns accept_with(tags=...), engine filters recall by those tags."""
memory, validator = memory_with_tag_enricher
bank_id = "test-recall-tag-enrichment"
ctx = RequestContext()
# Retain one fact with the forced tag and one without
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Bob is a designer.", "tags": ["forced-tag"]}],
request_context=ctx,
)
# recall is called without tags but validator injects "forced-tag" + match=all
result = await memory.recall_async(
bank_id=bank_id,
query="Bob",
request_context=ctx,
)
# Should still get a result — the injected tag matches the stored fact
assert result is not None
class TestRecallContextContainsTagFields:
"""RecallContext passed to validate_recall carries tag filter state."""
@pytest.mark.asyncio
async def test_recall_context_carries_tags(self, memory_with_recall_context_capture):
"""tags, tags_match, and tag_groups are present in RecallContext."""
memory, validator = memory_with_recall_context_capture
bank_id = "test-recall-ctx-tags"
ctx = RequestContext()
await memory.recall_async(
bank_id=bank_id,
query="test",
tags=["env:prod"],
tags_match="all",
request_context=ctx,
)
assert len(validator.captured) == 1
rc = validator.captured[0]
assert rc.tags == ["env:prod"]
assert rc.tags_match == "all"
@pytest.mark.asyncio
async def test_recall_context_tags_default_to_none(self, memory_with_recall_context_capture):
"""When caller provides no tags, RecallContext.tags is None."""
memory, validator = memory_with_recall_context_capture
bank_id = "test-recall-ctx-no-tags"
ctx = RequestContext()
await memory.recall_async(bank_id=bank_id, query="test", request_context=ctx)
assert len(validator.captured) == 1
rc = validator.captured[0]
assert rc.tags is None
+237 -9
View File
@@ -58,10 +58,14 @@ async def pool(pg0_db_url):
async def clean_operations(pool):
"""Clean up async_operations table before and after tests."""
# Clean before test - covers both 'test-worker-' and 'test_worker_recovery' patterns
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
await pool.execute(
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
)
yield
# Clean after test
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
await pool.execute(
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
)
class TestBrokerTaskBackend:
@@ -387,9 +391,7 @@ class TestWorkerPoller:
"SELECT status, error_message, retry_count FROM async_operations WHERE operation_id = $1",
op_id,
)
assert row["status"] == "failed", (
f"Expected 'failed' for plain exception, got '{row['status']}'"
)
assert row["status"] == "failed", f"Expected 'failed' for plain exception, got '{row['status']}'"
assert row["error_message"] is not None
assert row["retry_count"] == 0 # not incremented; plain exception = immediate fail
@@ -1265,7 +1267,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
for i in range(2):
op_id = uuid.uuid4()
task_ids.append(str(op_id))
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
payload = json.dumps(
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
)
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
@@ -1294,7 +1298,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
for i in range(2):
op_id = uuid.uuid4()
task_ids.append(str(op_id))
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
payload = json.dumps(
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
)
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
@@ -1364,7 +1370,9 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
await _ensure_bank(pool, bank_id)
for i in range(10):
op_id = uuid.uuid4()
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
payload = json.dumps(
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
)
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
@@ -1396,7 +1404,7 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
completed = 0
while completed < 10 and len(tasks_started) < 10:
# Release the next batch
events_to_release = list(task_events.values())[completed:completed+3]
events_to_release = list(task_events.values())[completed : completed + 3]
for event in events_to_release:
event.set()
completed += len(events_to_release)
@@ -1417,3 +1425,223 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
await asyncio.wait_for(poll_task, timeout=1.0)
except asyncio.CancelledError:
pass
class TestMarkFailedParentPropagation:
"""Tests for _mark_failed parent propagation in WorkerPoller.
When a child retain operation fails via an unhandled exception, the memory
engine's transaction is rolled back entirely — including any call to
_maybe_update_parent_operation inside the engine. The poller's fallback
_mark_failed must detect this and finalise the parent batch_retain itself.
"""
async def _insert_op(
self,
pool,
*,
op_id: "uuid.UUID",
bank_id: str,
operation_type: str,
status: str,
result_metadata: dict | None = None,
) -> None:
meta_json = json.dumps(result_metadata if result_metadata is not None else {})
await pool.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, result_metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)
""",
op_id,
bank_id,
operation_type,
status,
meta_json,
)
@pytest.mark.asyncio
async def test_mark_failed_finalises_parent_when_last_sibling_fails(self, pool, clean_operations):
"""When the last pending child fails, parent batch_retain is marked failed."""
from hindsight_api.worker import WorkerPoller
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
parent_id = uuid.uuid4()
child1_id = uuid.uuid4()
child2_id = uuid.uuid4()
# Parent batch_retain still pending
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
# child1 already completed
await self._insert_op(
pool,
op_id=child1_id,
bank_id=bank_id,
operation_type="retain",
status="completed",
result_metadata={"parent_operation_id": str(parent_id)},
)
# child2 still processing — this is the one that will fail
await self._insert_op(
pool,
op_id=child2_id,
bank_id=bank_id,
operation_type="retain",
status="processing",
result_metadata={"parent_operation_id": str(parent_id)},
)
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
await poller._mark_failed(str(child2_id), "DB constraint violation", schema=None)
# child2 must be failed
child2_row = await pool.fetchrow(
"SELECT status, error_message FROM async_operations WHERE operation_id = $1", child2_id
)
assert child2_row["status"] == "failed"
assert "DB constraint violation" in child2_row["error_message"]
# parent must now be failed (all siblings done, at least one failed)
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
assert parent_row["status"] == "failed", (
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
)
@pytest.mark.asyncio
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, clean_operations):
"""When the only child fails, parent batch_retain becomes failed."""
from hindsight_api.worker import WorkerPoller
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
parent_id = uuid.uuid4()
child_id = uuid.uuid4()
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
await self._insert_op(
pool,
op_id=child_id,
bank_id=bank_id,
operation_type="retain",
status="processing",
result_metadata={"parent_operation_id": str(parent_id)},
)
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
await poller._mark_failed(str(child_id), "unexpected error", schema=None)
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
assert parent_row["status"] == "failed"
@pytest.mark.asyncio
async def test_mark_failed_does_not_finalise_parent_when_siblings_still_pending(self, pool, clean_operations):
"""Parent is NOT updated while other siblings are still processing/pending."""
from hindsight_api.worker import WorkerPoller
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
parent_id = uuid.uuid4()
child1_id = uuid.uuid4()
child2_id = uuid.uuid4()
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
# child1 is the one failing
await self._insert_op(
pool,
op_id=child1_id,
bank_id=bank_id,
operation_type="retain",
status="processing",
result_metadata={"parent_operation_id": str(parent_id)},
)
# child2 is still pending — not done yet
await self._insert_op(
pool,
op_id=child2_id,
bank_id=bank_id,
operation_type="retain",
status="pending",
result_metadata={"parent_operation_id": str(parent_id)},
)
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
await poller._mark_failed(str(child1_id), "early failure", schema=None)
# child1 is failed
child1_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child1_id)
assert child1_row["status"] == "failed"
# parent must still be pending (child2 not done)
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
assert parent_row["status"] == "pending", (
f"Parent should remain 'pending' while siblings are outstanding, got '{parent_row['status']}'"
)
@pytest.mark.asyncio
async def test_mark_failed_no_parent_is_safe(self, pool, clean_operations):
"""Operations without a parent (no result_metadata parent_operation_id) fail cleanly."""
from hindsight_api.worker import WorkerPoller
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
op_id = uuid.uuid4()
await self._insert_op(pool, op_id=op_id, bank_id=bank_id, operation_type="retain", status="processing")
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
# Must not raise
await poller._mark_failed(str(op_id), "standalone failure", schema=None)
row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", op_id)
assert row["status"] == "failed"
@pytest.mark.asyncio
async def test_unhandled_exception_via_execute_task_propagates_to_parent(self, pool, clean_operations):
"""End-to-end: executor raises a plain exception, poller calls _mark_failed,
which then resolves the parent batch_retain to failed."""
from hindsight_api.worker import WorkerPoller
from hindsight_api.worker.poller import ClaimedTask
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
parent_id = uuid.uuid4()
child_id = uuid.uuid4()
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
await self._insert_op(
pool,
op_id=child_id,
bank_id=bank_id,
operation_type="retain",
status="processing",
result_metadata={"parent_operation_id": str(parent_id)},
)
async def crashing_executor(task_dict):
raise RuntimeError("Simulated DB constraint violation — transaction rolled back")
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=crashing_executor)
task_dict = {"type": "retain", "operation_id": str(child_id), "bank_id": bank_id}
claimed_task = ClaimedTask(operation_id=str(child_id), task_dict=task_dict, schema=None)
await poller.execute_task(claimed_task)
completed = await poller.wait_for_active_tasks(timeout=5.0)
assert completed, "Task did not complete within timeout"
child_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child_id)
assert child_row["status"] == "failed"
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
assert parent_row["status"] == "failed", (
f"Parent batch_retain should be 'failed' after child fails via unhandled exception, "
f"got '{parent_row['status']}'"
)
+6
View File
@@ -15,5 +15,11 @@ dependencies = [
[tool.uv.sources]
hindsight-api-slim = { workspace = true }
[project.scripts]
hindsight-api = "hindsight_api.main:main"
hindsight-worker = "hindsight_api.worker.main:main"
hindsight-local-mcp = "hindsight_api.mcp_local:main"
hindsight-admin = "hindsight_api.admin.cli:main"
[tool.setuptools]
packages = []
+7
View File
@@ -601,6 +601,13 @@ impl ApiClient {
})
}
pub fn get_mental_model_history(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.get_mental_model_history(bank_id, mental_model_id, None).await?;
Ok(response.into_inner())
})
}
// --- Directive Methods ---
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
+29 -1
View File
@@ -717,6 +717,13 @@ pub fn set_config(
llm_model: Option<String>,
llm_api_key: Option<String>,
llm_base_url: Option<String>,
retain_mission: Option<String>,
retain_extraction_mode: Option<String>,
observations_mission: Option<String>,
reflect_mission: Option<String>,
disposition_skepticism: Option<i64>,
disposition_literalism: Option<i64>,
disposition_empathy: Option<i64>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -736,9 +743,30 @@ pub fn set_config(
if let Some(base_url) = llm_base_url {
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
}
if let Some(mission) = retain_mission {
updates.insert("retain_mission".to_string(), serde_json::Value::String(mission));
}
if let Some(mode) = retain_extraction_mode {
updates.insert("retain_extraction_mode".to_string(), serde_json::Value::String(mode));
}
if let Some(mission) = observations_mission {
updates.insert("observations_mission".to_string(), serde_json::Value::String(mission));
}
if let Some(mission) = reflect_mission {
updates.insert("reflect_mission".to_string(), serde_json::Value::String(mission));
}
if let Some(skepticism) = disposition_skepticism {
updates.insert("disposition_skepticism".to_string(), serde_json::Value::Number(skepticism.into()));
}
if let Some(literalism) = disposition_literalism {
updates.insert("disposition_literalism".to_string(), serde_json::Value::Number(literalism.into()));
}
if let Some(empathy) = disposition_empathy {
updates.insert("disposition_empathy".to_string(), serde_json::Value::Number(empathy.into()));
}
if updates.is_empty() {
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --llm-api-key, or --llm-base-url".to_string()));
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --retain-mission, --observations-mission, or other flags".to_string()));
}
let spinner = if output_format == OutputFormat::Pretty {
+4 -3
View File
@@ -149,11 +149,12 @@ pub fn update(
directive_id: &str,
name: Option<String>,
content: Option<String>,
is_active: Option<bool>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() && content.is_none() {
anyhow::bail!("At least one of --name or --content must be provided");
if name.is_none() && content.is_none() && is_active.is_none() {
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
}
let spinner = if output_format == OutputFormat::Pretty {
@@ -165,7 +166,7 @@ pub fn update(
let request = types::UpdateDirectiveRequest {
name,
content,
is_active: None,
is_active,
priority: None,
tags: None,
};
+3
View File
@@ -363,6 +363,9 @@ impl App {
tags: None,
tags_match: TagsMatch::Any,
tag_groups: None,
fact_types: None,
exclude_mental_models: false,
exclude_mental_model_ids: None,
};
let result = client.reflect(&bank_id, &request, false)
+33 -6
View File
@@ -9,7 +9,7 @@ use crate::output::{self, OutputFormat};
use crate::ui;
// Import types from generated client
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch};
use serde::Deserialize;
use serde_json;
@@ -43,6 +43,16 @@ fn parse_budget(budget: &str) -> Budget {
}
}
// Helper function to parse tags_match string to TagsMatch enum
fn parse_tags_match(tags_match: &Option<String>) -> TagsMatch {
match tags_match.as_deref().unwrap_or("any").to_lowercase().as_str() {
"all" => TagsMatch::All,
"any_strict" => TagsMatch::AnyStrict,
"all_strict" => TagsMatch::AllStrict,
_ => TagsMatch::Any,
}
}
/// List memory units with pagination and optional filters
pub fn list(
client: &ApiClient,
@@ -250,6 +260,8 @@ pub fn recall(
trace: bool,
include_chunks: bool,
chunk_max_tokens: i64,
tags: Vec<String>,
tags_match: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -280,8 +292,8 @@ pub fn recall(
trace,
query_timestamp: None,
include,
tags: None,
tags_match: TagsMatch::Any,
tags: if tags.is_empty() { None } else { Some(tags) },
tags_match: parse_tags_match(&tags_match),
tag_groups: None,
};
@@ -312,6 +324,9 @@ pub fn reflect(
context: Option<String>,
max_tokens: Option<i64>,
schema_path: Option<PathBuf>,
tags: Vec<String>,
tags_match: Option<String>,
include_facts: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -332,16 +347,28 @@ pub fn reflect(
None
};
let include = if include_facts {
Some(ReflectIncludeOptions {
facts: Some(FactsIncludeOptions(serde_json::Map::new())),
tool_calls: None,
})
} else {
None
};
let request = ReflectRequest {
query,
budget: Some(parse_budget(&budget)),
context,
max_tokens: max_tokens.unwrap_or(4096),
include: None,
include,
response_schema,
tags: None,
tags_match: TagsMatch::Any,
tags: if tags.is_empty() { None } else { Some(tags) },
tags_match: parse_tags_match(&tags_match),
tag_groups: None,
fact_types: None,
exclude_mental_models: false,
exclude_mental_model_ids: None,
};
let response = client.reflect(agent_id, &request, verbose);
@@ -272,6 +272,55 @@ pub fn refresh(
}
}
/// Get the change history of a mental model
pub fn history(
client: &ApiClient,
bank_id: &str,
mental_model_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching mental model history..."))
} else {
None
};
let response = client.get_mental_model_history(bank_id, mental_model_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
match response {
Ok(history) => {
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("History: {}", mental_model_id));
if let Some(entries) = history.as_array() {
if entries.is_empty() {
println!(" {}", ui::dim("No history entries found."));
} else {
for entry in entries {
let changed_at = entry.get("changed_at").and_then(|v| v.as_str()).unwrap_or("unknown");
let previous = entry.get("previous_content").and_then(|v| v.as_str()).unwrap_or("(none)");
println!(" {} {}", ui::dim("Changed at:"), changed_at);
let preview: String = previous.chars().take(80).collect();
let ellipsis = if previous.len() > 80 { "..." } else { "" };
println!(" {} {}{}", ui::dim("Previous:"), ui::dim(&preview), ellipsis);
println!();
}
}
}
} else {
output::print_output(&history, output_format)?;
}
Ok(())
}
Err(e) => Err(e),
}
}
// Helper function to print mental model details
fn print_mental_model_detail(mental_model: &types::MentalModelResponse) {
ui::print_section_header(&mental_model.name);
+72 -8
View File
@@ -310,6 +310,34 @@ enum BankCommands {
/// LLM base URL override
#[arg(long)]
llm_base_url: Option<String>,
/// Retain mission: what to focus on during fact extraction
#[arg(long)]
retain_mission: Option<String>,
/// Retain extraction mode (concise, verbose, custom)
#[arg(long)]
retain_extraction_mode: Option<String>,
/// Observations mission: what to synthesize into durable observations
#[arg(long)]
observations_mission: Option<String>,
/// Reflect mission: first-person identity for reflect operations
#[arg(long)]
reflect_mission: Option<String>,
/// Disposition skepticism trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
disposition_skepticism: Option<i64>,
/// Disposition literalism trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
disposition_literalism: Option<i64>,
/// Disposition empathy trait (1-5)
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
disposition_empathy: Option<i64>,
},
/// Reset bank configuration to defaults (remove all overrides)
@@ -387,6 +415,14 @@ enum MemoryCommands {
/// Maximum tokens for chunks (only used with --include-chunks)
#[arg(long, default_value = "8192")]
chunk_max_tokens: i64,
/// Filter by tags (comma-separated, e.g. user:alice,team)
#[arg(long, value_delimiter = ',')]
tags: Vec<String>,
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
#[arg(long)]
tags_match: Option<String>,
},
/// Generate answers using bank identity (reflect/reasoning)
@@ -412,6 +448,18 @@ enum MemoryCommands {
/// Path to JSON schema file for structured output
#[arg(short = 's', long)]
schema: Option<PathBuf>,
/// Filter by tags (comma-separated, e.g. user:alice,team)
#[arg(long, value_delimiter = ',')]
tags: Vec<String>,
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
#[arg(long)]
tags_match: Option<String>,
/// Include source facts (based_on) in the response
#[arg(long)]
include_facts: bool,
},
/// Store (retain) a single memory
@@ -678,6 +726,15 @@ enum MentalModelCommands {
/// Mental model ID
mental_model_id: String,
},
/// Get the change history of a mental model
History {
/// Bank ID
bank_id: String,
/// Mental model ID
mental_model_id: String,
},
}
#[derive(Subcommand)]
@@ -724,6 +781,10 @@ enum DirectiveCommands {
/// New content
#[arg(long)]
content: Option<String>,
/// Enable or disable the directive
#[arg(long)]
is_active: Option<bool>,
},
/// Delete a directive
@@ -821,8 +882,8 @@ fn run() -> Result<()> {
BankCommands::Config { bank_id, overrides_only } => {
commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format)
}
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url } => {
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, verbose, output_format)
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy } => {
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy, verbose, output_format)
}
BankCommands::ResetConfig { bank_id, yes } => {
commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format)
@@ -837,11 +898,11 @@ fn run() -> Result<()> {
MemoryCommands::Get { bank_id, memory_id } => {
commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format)
}
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => {
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format)
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match } => {
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match, verbose, output_format)
}
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema } => {
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, verbose, output_format)
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts } => {
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts, verbose, output_format)
}
MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => {
commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format)
@@ -930,6 +991,9 @@ fn run() -> Result<()> {
MentalModelCommands::Refresh { bank_id, mental_model_id } => {
commands::mental_model::refresh(&client, &bank_id, &mental_model_id, verbose, output_format)
}
MentalModelCommands::History { bank_id, mental_model_id } => {
commands::mental_model::history(&client, &bank_id, &mental_model_id, verbose, output_format)
}
},
// Directive commands
@@ -943,8 +1007,8 @@ fn run() -> Result<()> {
DirectiveCommands::Create { bank_id, name, content } => {
commands::directive::create(&client, &bank_id, &name, &content, verbose, output_format)
}
DirectiveCommands::Update { bank_id, directive_id, name, content } => {
commands::directive::update(&client, &bank_id, &directive_id, name, content, verbose, output_format)
DirectiveCommands::Update { bank_id, directive_id, name, content, is_active } => {
commands::directive::update(&client, &bank_id, &directive_id, name, content, is_active, verbose, output_format)
}
DirectiveCommands::Delete { bank_id, directive_id, yes } => {
commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format)
+68
View File
@@ -4074,6 +4074,13 @@ components:
id: id
trigger:
refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4089,6 +4096,13 @@ components:
id: id
trigger:
refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4115,6 +4129,13 @@ components:
id: id
trigger:
refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4169,6 +4190,13 @@ components:
description: Trigger settings for a mental model.
example:
refresh_after_consolidation: false
fact_types:
- world
- world
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
exclude_mental_models: false
properties:
refresh_after_consolidation:
default: false
@@ -4176,6 +4204,26 @@ components:
\ (real-time mode)"
title: Refresh After Consolidation
type: boolean
fact_types:
items:
enum:
- world
- experience
- observation
type: string
nullable: true
type: array
exclude_mental_models:
default: false
description: "If true, exclude all mental models from the reflect loop (skip\
\ search_mental_models tool)."
title: Exclude Mental Models
type: boolean
exclude_mental_model_ids:
items:
type: string
nullable: true
type: array
title: MentalModelTrigger
OperationResponse:
description: Response model for a single async operation.
@@ -4684,6 +4732,26 @@ components:
$ref: '#/components/schemas/RecallRequest_tag_groups_inner'
nullable: true
type: array
fact_types:
items:
enum:
- world
- experience
- observation
type: string
nullable: true
type: array
exclude_mental_models:
default: false
description: "If true, exclude all mental models from the reflect loop (skip\
\ search_mental_models tool)."
title: Exclude Mental Models
type: boolean
exclude_mental_model_ids:
items:
type: string
nullable: true
type: array
required:
- query
title: ReflectRequest
@@ -21,6 +21,10 @@ var _ MappedNullable = &MentalModelTrigger{}
type MentalModelTrigger struct {
// If true, refresh this mental model after observations consolidation (real-time mode)
RefreshAfterConsolidation *bool `json:"refresh_after_consolidation,omitempty"`
FactTypes []string `json:"fact_types,omitempty"`
// If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
ExcludeMentalModels *bool `json:"exclude_mental_models,omitempty"`
ExcludeMentalModelIds []string `json:"exclude_mental_model_ids,omitempty"`
}
// NewMentalModelTrigger instantiates a new MentalModelTrigger object
@@ -31,6 +35,8 @@ func NewMentalModelTrigger() *MentalModelTrigger {
this := MentalModelTrigger{}
var refreshAfterConsolidation bool = false
this.RefreshAfterConsolidation = &refreshAfterConsolidation
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this
}
@@ -41,6 +47,8 @@ func NewMentalModelTriggerWithDefaults() *MentalModelTrigger {
this := MentalModelTrigger{}
var refreshAfterConsolidation bool = false
this.RefreshAfterConsolidation = &refreshAfterConsolidation
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this
}
@@ -76,6 +84,104 @@ func (o *MentalModelTrigger) SetRefreshAfterConsolidation(v bool) {
o.RefreshAfterConsolidation = &v
}
// GetFactTypes returns the FactTypes field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *MentalModelTrigger) GetFactTypes() []string {
if o == nil {
var ret []string
return ret
}
return o.FactTypes
}
// GetFactTypesOk returns a tuple with the FactTypes 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 *MentalModelTrigger) GetFactTypesOk() ([]string, bool) {
if o == nil || IsNil(o.FactTypes) {
return nil, false
}
return o.FactTypes, true
}
// HasFactTypes returns a boolean if a field has been set.
func (o *MentalModelTrigger) HasFactTypes() bool {
if o != nil && !IsNil(o.FactTypes) {
return true
}
return false
}
// SetFactTypes gets a reference to the given []string and assigns it to the FactTypes field.
func (o *MentalModelTrigger) SetFactTypes(v []string) {
o.FactTypes = v
}
// GetExcludeMentalModels returns the ExcludeMentalModels field value if set, zero value otherwise.
func (o *MentalModelTrigger) GetExcludeMentalModels() bool {
if o == nil || IsNil(o.ExcludeMentalModels) {
var ret bool
return ret
}
return *o.ExcludeMentalModels
}
// GetExcludeMentalModelsOk returns a tuple with the ExcludeMentalModels field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *MentalModelTrigger) GetExcludeMentalModelsOk() (*bool, bool) {
if o == nil || IsNil(o.ExcludeMentalModels) {
return nil, false
}
return o.ExcludeMentalModels, true
}
// HasExcludeMentalModels returns a boolean if a field has been set.
func (o *MentalModelTrigger) HasExcludeMentalModels() bool {
if o != nil && !IsNil(o.ExcludeMentalModels) {
return true
}
return false
}
// SetExcludeMentalModels gets a reference to the given bool and assigns it to the ExcludeMentalModels field.
func (o *MentalModelTrigger) SetExcludeMentalModels(v bool) {
o.ExcludeMentalModels = &v
}
// GetExcludeMentalModelIds returns the ExcludeMentalModelIds field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *MentalModelTrigger) GetExcludeMentalModelIds() []string {
if o == nil {
var ret []string
return ret
}
return o.ExcludeMentalModelIds
}
// GetExcludeMentalModelIdsOk returns a tuple with the ExcludeMentalModelIds 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 *MentalModelTrigger) GetExcludeMentalModelIdsOk() ([]string, bool) {
if o == nil || IsNil(o.ExcludeMentalModelIds) {
return nil, false
}
return o.ExcludeMentalModelIds, true
}
// HasExcludeMentalModelIds returns a boolean if a field has been set.
func (o *MentalModelTrigger) HasExcludeMentalModelIds() bool {
if o != nil && !IsNil(o.ExcludeMentalModelIds) {
return true
}
return false
}
// SetExcludeMentalModelIds gets a reference to the given []string and assigns it to the ExcludeMentalModelIds field.
func (o *MentalModelTrigger) SetExcludeMentalModelIds(v []string) {
o.ExcludeMentalModelIds = v
}
func (o MentalModelTrigger) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -89,6 +195,15 @@ func (o MentalModelTrigger) ToMap() (map[string]interface{}, error) {
if !IsNil(o.RefreshAfterConsolidation) {
toSerialize["refresh_after_consolidation"] = o.RefreshAfterConsolidation
}
if o.FactTypes != nil {
toSerialize["fact_types"] = o.FactTypes
}
if !IsNil(o.ExcludeMentalModels) {
toSerialize["exclude_mental_models"] = o.ExcludeMentalModels
}
if o.ExcludeMentalModelIds != nil {
toSerialize["exclude_mental_model_ids"] = o.ExcludeMentalModelIds
}
return toSerialize, nil
}
@@ -33,6 +33,10 @@ type ReflectRequest struct {
// How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).
TagsMatch *string `json:"tags_match,omitempty"`
TagGroups []RecallRequestTagGroupsInner `json:"tag_groups,omitempty"`
FactTypes []string `json:"fact_types,omitempty"`
// If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
ExcludeMentalModels *bool `json:"exclude_mental_models,omitempty"`
ExcludeMentalModelIds []string `json:"exclude_mental_model_ids,omitempty"`
}
type _ReflectRequest ReflectRequest
@@ -48,6 +52,8 @@ func NewReflectRequest(query string) *ReflectRequest {
this.MaxTokens = &maxTokens
var tagsMatch string = "any"
this.TagsMatch = &tagsMatch
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this
}
@@ -60,6 +66,8 @@ func NewReflectRequestWithDefaults() *ReflectRequest {
this.MaxTokens = &maxTokens
var tagsMatch string = "any"
this.TagsMatch = &tagsMatch
var excludeMentalModels bool = false
this.ExcludeMentalModels = &excludeMentalModels
return &this
}
@@ -356,6 +364,104 @@ func (o *ReflectRequest) SetTagGroups(v []RecallRequestTagGroupsInner) {
o.TagGroups = v
}
// GetFactTypes returns the FactTypes field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *ReflectRequest) GetFactTypes() []string {
if o == nil {
var ret []string
return ret
}
return o.FactTypes
}
// GetFactTypesOk returns a tuple with the FactTypes 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 *ReflectRequest) GetFactTypesOk() ([]string, bool) {
if o == nil || IsNil(o.FactTypes) {
return nil, false
}
return o.FactTypes, true
}
// HasFactTypes returns a boolean if a field has been set.
func (o *ReflectRequest) HasFactTypes() bool {
if o != nil && !IsNil(o.FactTypes) {
return true
}
return false
}
// SetFactTypes gets a reference to the given []string and assigns it to the FactTypes field.
func (o *ReflectRequest) SetFactTypes(v []string) {
o.FactTypes = v
}
// GetExcludeMentalModels returns the ExcludeMentalModels field value if set, zero value otherwise.
func (o *ReflectRequest) GetExcludeMentalModels() bool {
if o == nil || IsNil(o.ExcludeMentalModels) {
var ret bool
return ret
}
return *o.ExcludeMentalModels
}
// GetExcludeMentalModelsOk returns a tuple with the ExcludeMentalModels field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *ReflectRequest) GetExcludeMentalModelsOk() (*bool, bool) {
if o == nil || IsNil(o.ExcludeMentalModels) {
return nil, false
}
return o.ExcludeMentalModels, true
}
// HasExcludeMentalModels returns a boolean if a field has been set.
func (o *ReflectRequest) HasExcludeMentalModels() bool {
if o != nil && !IsNil(o.ExcludeMentalModels) {
return true
}
return false
}
// SetExcludeMentalModels gets a reference to the given bool and assigns it to the ExcludeMentalModels field.
func (o *ReflectRequest) SetExcludeMentalModels(v bool) {
o.ExcludeMentalModels = &v
}
// GetExcludeMentalModelIds returns the ExcludeMentalModelIds field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *ReflectRequest) GetExcludeMentalModelIds() []string {
if o == nil {
var ret []string
return ret
}
return o.ExcludeMentalModelIds
}
// GetExcludeMentalModelIdsOk returns a tuple with the ExcludeMentalModelIds 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 *ReflectRequest) GetExcludeMentalModelIdsOk() ([]string, bool) {
if o == nil || IsNil(o.ExcludeMentalModelIds) {
return nil, false
}
return o.ExcludeMentalModelIds, true
}
// HasExcludeMentalModelIds returns a boolean if a field has been set.
func (o *ReflectRequest) HasExcludeMentalModelIds() bool {
if o != nil && !IsNil(o.ExcludeMentalModelIds) {
return true
}
return false
}
// SetExcludeMentalModelIds gets a reference to the given []string and assigns it to the ExcludeMentalModelIds field.
func (o *ReflectRequest) SetExcludeMentalModelIds(v []string) {
o.ExcludeMentalModelIds = v
}
func (o ReflectRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -391,6 +497,15 @@ func (o ReflectRequest) ToMap() (map[string]interface{}, error) {
if o.TagGroups != nil {
toSerialize["tag_groups"] = o.TagGroups
}
if o.FactTypes != nil {
toSerialize["fact_types"] = o.FactTypes
}
if !IsNil(o.ExcludeMentalModels) {
toSerialize["exclude_mental_models"] = o.ExcludeMentalModels
}
if o.ExcludeMentalModelIds != nil {
toSerialize["exclude_mental_model_ids"] = o.ExcludeMentalModelIds
}
return toSerialize, nil
}
@@ -792,6 +792,7 @@ class Hindsight:
tags: list[str] | None = None,
max_tokens: int | None = None,
trigger: dict[str, Any] | None = None,
id: str | None = None,
):
"""
Create a mental model (runs reflect in background).
@@ -803,6 +804,7 @@ class Hindsight:
tags: Optional tags for filtering during retrieval
max_tokens: Optional maximum tokens for the mental model content
trigger: Optional trigger settings (e.g., {"refresh_after_consolidation": True})
id: Optional custom ID for the mental model (alphanumeric lowercase with hyphens)
Returns:
CreateMentalModelResponse with operation_id
@@ -814,6 +816,7 @@ class Hindsight:
trigger_obj = mental_model_trigger.MentalModelTrigger(**trigger)
request_obj = create_mental_model_request.CreateMentalModelRequest(
id=id,
name=name,
source_query=source_query,
tags=tags,
@@ -17,7 +17,7 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictBool
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional
from typing import Optional, Set
from typing_extensions import Self
@@ -27,7 +27,21 @@ class MentalModelTrigger(BaseModel):
Trigger settings for a mental model.
""" # noqa: E501
refresh_after_consolidation: Optional[StrictBool] = Field(default=False, description="If true, refresh this mental model after observations consolidation (real-time mode)")
__properties: ClassVar[List[str]] = ["refresh_after_consolidation"]
fact_types: Optional[List[StrictStr]] = None
exclude_mental_models: Optional[StrictBool] = Field(default=False, description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).")
exclude_mental_model_ids: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["refresh_after_consolidation", "fact_types", "exclude_mental_models", "exclude_mental_model_ids"]
@field_validator('fact_types')
def fact_types_validate_enum(cls, value):
"""Validates the enum"""
if value is None:
return value
for i in value:
if i not in set(['world', 'experience', 'observation']):
raise ValueError("each list item must be one of ('world', 'experience', 'observation')")
return value
model_config = ConfigDict(
populate_by_name=True,
@@ -68,6 +82,16 @@ class MentalModelTrigger(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# set to None if fact_types (nullable) is None
# and model_fields_set contains the field
if self.fact_types is None and "fact_types" in self.model_fields_set:
_dict['fact_types'] = None
# set to None if exclude_mental_model_ids (nullable) is None
# and model_fields_set contains the field
if self.exclude_mental_model_ids is None and "exclude_mental_model_ids" in self.model_fields_set:
_dict['exclude_mental_model_ids'] = None
return _dict
@classmethod
@@ -80,7 +104,10 @@ class MentalModelTrigger(BaseModel):
return cls.model_validate(obj)
_obj = cls.model_validate({
"refresh_after_consolidation": obj.get("refresh_after_consolidation") if obj.get("refresh_after_consolidation") is not None else False
"refresh_after_consolidation": obj.get("refresh_after_consolidation") if obj.get("refresh_after_consolidation") is not None else False,
"fact_types": obj.get("fact_types"),
"exclude_mental_models": obj.get("exclude_mental_models") if obj.get("exclude_mental_models") is not None else False,
"exclude_mental_model_ids": obj.get("exclude_mental_model_ids")
})
return _obj
@@ -17,7 +17,7 @@ import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.budget import Budget
from hindsight_client_api.models.recall_request_tag_groups_inner import RecallRequestTagGroupsInner
@@ -38,7 +38,10 @@ class ReflectRequest(BaseModel):
tags: Optional[List[StrictStr]] = None
tags_match: Optional[StrictStr] = Field(default='any', description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).")
tag_groups: Optional[List[RecallRequestTagGroupsInner]] = None
__properties: ClassVar[List[str]] = ["query", "budget", "context", "max_tokens", "include", "response_schema", "tags", "tags_match", "tag_groups"]
fact_types: Optional[List[StrictStr]] = None
exclude_mental_models: Optional[StrictBool] = Field(default=False, description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).")
exclude_mental_model_ids: Optional[List[StrictStr]] = None
__properties: ClassVar[List[str]] = ["query", "budget", "context", "max_tokens", "include", "response_schema", "tags", "tags_match", "tag_groups", "fact_types", "exclude_mental_models", "exclude_mental_model_ids"]
@field_validator('tags_match')
def tags_match_validate_enum(cls, value):
@@ -50,6 +53,17 @@ class ReflectRequest(BaseModel):
raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')")
return value
@field_validator('fact_types')
def fact_types_validate_enum(cls, value):
"""Validates the enum"""
if value is None:
return value
for i in value:
if i not in set(['world', 'experience', 'observation']):
raise ValueError("each list item must be one of ('world', 'experience', 'observation')")
return value
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
@@ -119,6 +133,16 @@ class ReflectRequest(BaseModel):
if self.tag_groups is None and "tag_groups" in self.model_fields_set:
_dict['tag_groups'] = None
# set to None if fact_types (nullable) is None
# and model_fields_set contains the field
if self.fact_types is None and "fact_types" in self.model_fields_set:
_dict['fact_types'] = None
# set to None if exclude_mental_model_ids (nullable) is None
# and model_fields_set contains the field
if self.exclude_mental_model_ids is None and "exclude_mental_model_ids" in self.model_fields_set:
_dict['exclude_mental_model_ids'] = None
return _dict
@classmethod
@@ -139,7 +163,10 @@ class ReflectRequest(BaseModel):
"response_schema": obj.get("response_schema"),
"tags": obj.get("tags"),
"tags_match": obj.get("tags_match") if obj.get("tags_match") is not None else 'any',
"tag_groups": [RecallRequestTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None
"tag_groups": [RecallRequestTagGroupsInner.from_dict(_item) for _item in obj["tag_groups"]] if obj.get("tag_groups") is not None else None,
"fact_types": obj.get("fact_types"),
"exclude_mental_models": obj.get("exclude_mental_models") if obj.get("exclude_mental_models") is not None else False,
"exclude_mental_model_ids": obj.get("exclude_mental_model_ids")
})
return _obj
@@ -1331,6 +1331,24 @@ export type MentalModelTrigger = {
* If true, refresh this mental model after observations consolidation (real-time mode)
*/
refresh_after_consolidation?: boolean;
/**
* Fact Types
*
* Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).
*/
fact_types?: Array<"world" | "experience" | "observation"> | null;
/**
* Exclude Mental Models
*
* If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
*/
exclude_mental_models?: boolean;
/**
* Exclude Mental Model Ids
*
* Exclude specific mental models by ID from the reflect loop.
*/
exclude_mental_model_ids?: Array<string> | null;
};
/**
@@ -1825,6 +1843,24 @@ export type ReflectRequest = {
tag_groups?: Array<
TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot
> | null;
/**
* Fact Types
*
* Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).
*/
fact_types?: Array<"world" | "experience" | "observation"> | null;
/**
* Exclude Mental Models
*
* If true, exclude all mental models from the reflect loop (skip search_mental_models tool).
*/
exclude_mental_models?: boolean;
/**
* Exclude Mental Model Ids
*
* Exclude specific mental models by ID from the reflect loop.
*/
exclude_mental_model_ids?: Array<string> | null;
};
/**
+14
View File
@@ -628,6 +628,7 @@ export class HindsightClient {
name: string,
sourceQuery: string,
options?: {
id?: string;
tags?: string[];
maxTokens?: number;
trigger?: { refreshAfterConsolidation?: boolean };
@@ -637,6 +638,7 @@ export class HindsightClient {
client: this.client,
path: { bank_id: bankId },
body: {
id: options?.id,
name,
source_query: sourceQuery,
tags: options?.tags,
@@ -726,6 +728,18 @@ export class HindsightClient {
throw new Error(`deleteMentalModel failed: ${JSON.stringify(response.error)}`);
}
}
/**
* Get the change history of a mental model.
*/
async getMentalModelHistory(bankId: string, mentalModelId: string): Promise<any> {
const response = await sdk.getMentalModelHistory({
client: this.client,
path: { bank_id: bankId, mental_model_id: mentalModelId },
});
return this.validateResponse(response, 'getMentalModelHistory');
}
}
// Re-export types for convenience
+1 -1
View File
@@ -55,7 +55,7 @@
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.1",
"lucide-react": "^0.553.0",
"next": "^16.1.6",
"next": "^16.1.7",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react": "^19.2.0",
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { DATAPLANE_URL } from "@/lib/hindsight-client";
import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function POST(request: NextRequest) {
try {
@@ -25,6 +25,7 @@ export async function POST(request: NextRequest) {
// Forward the form data to the dataplane
const response = await fetch(url, {
method: "POST",
headers: getDataplaneHeaders(),
body: formData,
// Don't set Content-Type - let fetch handle multipart boundary
});
@@ -14,6 +14,9 @@ export async function POST(request: NextRequest) {
tags,
tags_match,
max_tokens,
fact_types,
exclude_mental_models,
exclude_mental_model_ids,
} = body;
const requestBody: any = {
@@ -22,6 +25,9 @@ export async function POST(request: NextRequest) {
tags,
tags_match,
max_tokens: max_tokens || undefined,
fact_types: fact_types || undefined,
exclude_mental_models: exclude_mental_models || undefined,
exclude_mental_model_ids: exclude_mental_model_ids || undefined,
};
// Add include options if specified
@@ -0,0 +1,113 @@
"use client";
import { cn } from "@/lib/utils";
export type FactType = "world" | "experience" | "observation";
export const ALL_FACT_TYPES: FactType[] = ["world", "experience", "observation"];
const FACT_TYPE_CONFIG: Record<
FactType,
{ label: string; active: string; inactive: string; dot: string }
> = {
world: {
label: "World",
active: "bg-blue-500/15 text-blue-700 border-blue-400 dark:text-blue-300 dark:border-blue-500",
inactive:
"border-border text-muted-foreground hover:border-blue-300 hover:text-blue-600 dark:hover:text-blue-400",
dot: "bg-blue-500",
},
experience: {
label: "Experience",
active:
"bg-emerald-500/15 text-emerald-700 border-emerald-400 dark:text-emerald-300 dark:border-emerald-500",
inactive:
"border-border text-muted-foreground hover:border-emerald-300 hover:text-emerald-600 dark:hover:text-emerald-400",
dot: "bg-emerald-500",
},
observation: {
label: "Observation",
active:
"bg-amber-500/15 text-amber-700 border-amber-400 dark:text-amber-300 dark:border-amber-500",
inactive:
"border-border text-muted-foreground hover:border-amber-300 hover:text-amber-600 dark:hover:text-amber-400",
dot: "bg-amber-500",
},
};
function FactTypePill({
ft,
active,
onToggle,
}: {
ft: FactType;
active: boolean;
onToggle: () => void;
}) {
const cfg = FACT_TYPE_CONFIG[ft];
return (
<button
type="button"
onClick={onToggle}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all",
active ? cfg.active : cfg.inactive
)}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", active ? cfg.dot : "bg-muted-foreground/50")}
/>
{cfg.label}
</button>
);
}
/**
* Inline pill-toggle fact-type filter for filter bars.
* An empty selection means "all types included".
*/
export function FactTypeFilter({
value,
onChange,
label = "Fact types:",
}: {
value: FactType[];
onChange: (next: FactType[]) => void;
label?: string;
}) {
const toggle = (ft: FactType) =>
onChange(value.includes(ft) ? value.filter((f) => f !== ft) : [...value, ft]);
return (
<div className="flex items-center gap-2">
{label && <span className="text-sm font-medium text-muted-foreground">{label}</span>}
<div className="flex gap-1.5">
{ALL_FACT_TYPES.map((ft) => (
<FactTypePill key={ft} ft={ft} active={value.includes(ft)} onToggle={() => toggle(ft)} />
))}
</div>
</div>
);
}
/**
* Pill-toggle group for use inside forms/dialogs.
*/
export function FactTypeCheckboxGroup({
value,
onChange,
}: {
value: FactType[];
onChange: (next: FactType[]) => void;
}) {
const toggle = (ft: FactType) =>
onChange(value.includes(ft) ? value.filter((f) => f !== ft) : [...value, ft]);
return (
<div className="flex flex-wrap gap-1.5">
{ALL_FACT_TYPES.map((ft) => (
<FactTypePill key={ft} ft={ft} active={value.includes(ft)} onToggle={() => toggle(ft)} />
))}
</div>
);
}
@@ -8,6 +8,8 @@ import { useBank } from "@/lib/bank-context";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FactType, FactTypeCheckboxGroup } from "@/components/fact-type-filter";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -86,6 +88,9 @@ interface MentalModel {
max_tokens: number;
trigger: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
last_refreshed_at: string;
created_at: string;
@@ -593,6 +598,9 @@ function CreateMentalModelDialog({
maxTokens: "2048",
tags: "",
autoRefresh: false,
factTypes: [] as Array<"world" | "experience" | "observation">,
excludeMentalModels: false,
excludeMentalModelIds: "",
});
const handleCreate = async () => {
@@ -608,13 +616,23 @@ function CreateMentalModelDialog({
const maxTokens = parseInt(form.maxTokens) || 2048;
// Submit mental model creation - content will be generated in background
const excludeIds = form.excludeMentalModelIds
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
await client.createMentalModel(currentBank, {
id: form.id.trim() || undefined,
name: form.name.trim(),
source_query: form.sourceQuery.trim(),
tags: tags.length > 0 ? tags : undefined,
max_tokens: maxTokens,
trigger: { refresh_after_consolidation: form.autoRefresh },
trigger: {
refresh_after_consolidation: form.autoRefresh,
fact_types: form.factTypes.length > 0 ? form.factTypes : undefined,
exclude_mental_models: form.excludeMentalModels || undefined,
exclude_mental_model_ids: excludeIds.length > 0 ? excludeIds : undefined,
},
});
setForm({
@@ -624,6 +642,9 @@ function CreateMentalModelDialog({
maxTokens: "2048",
tags: "",
autoRefresh: false,
factTypes: [],
excludeMentalModels: false,
excludeMentalModelIds: "",
});
onCreated();
} catch (error) {
@@ -645,6 +666,9 @@ function CreateMentalModelDialog({
maxTokens: "2048",
tags: "",
autoRefresh: false,
factTypes: [],
excludeMentalModels: false,
excludeMentalModelIds: "",
});
onClose();
}
@@ -659,80 +683,111 @@ function CreateMentalModelDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
ID <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<Input
value={form.id}
onChange={(e) => setForm({ ...form, id: e.target.value })}
placeholder="e.g., team-communication"
/>
<p className="text-xs text-muted-foreground">
Custom ID for the mental model. If not provided, a UUID will be generated.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Name *</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Team Communication Preferences"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Source Query *</label>
<Input
value={form.sourceQuery}
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
placeholder="e.g., How does the team prefer to communicate?"
/>
<p className="text-xs text-muted-foreground">
This query will be run to generate the initial content, and re-run when you refresh.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Max Tokens</label>
<Input
type="number"
value={form.maxTokens}
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
placeholder="2048"
min="256"
max="8192"
/>
<p className="text-xs text-muted-foreground">
Maximum tokens for the generated response (256-8192).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Tags <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<Input
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
placeholder="e.g., project-x, team-alpha (comma-separated)"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-refresh"
checked={form.autoRefresh}
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
/>
<label
htmlFor="auto-refresh"
className="text-sm font-medium text-foreground cursor-pointer"
>
Auto-refresh after consolidation
</label>
</div>
<p className="text-xs text-muted-foreground -mt-2 ml-6">
Automatically refresh this mental model when memories are consolidated.
</p>
</div>
<Tabs defaultValue="general" className="py-2">
<TabsList className="w-full">
<TabsTrigger value="general" className="flex-1">
General
</TabsTrigger>
<TabsTrigger value="options" className="flex-1">
Options
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">ID</label>
<Input
value={form.id}
onChange={(e) => setForm({ ...form, id: e.target.value })}
placeholder="e.g., team-communication"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Name *</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Team Communication Preferences"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Source Query *</label>
<Input
value={form.sourceQuery}
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
placeholder="e.g., How does the team prefer to communicate?"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Max Tokens</label>
<Input
type="number"
value={form.maxTokens}
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
placeholder="2048"
min="256"
max="8192"
/>
</div>
</TabsContent>
<TabsContent value="options" className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Tags</label>
<Input
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
placeholder="e.g., project-x, team-alpha (comma-separated)"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="auto-refresh"
checked={form.autoRefresh}
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
/>
<label
htmlFor="auto-refresh"
className="text-sm font-medium text-foreground cursor-pointer"
>
Auto-refresh after consolidation
</label>
</div>
<div className="space-y-3">
<label className="text-sm font-medium text-foreground">Fact Types</label>
<FactTypeCheckboxGroup
value={form.factTypes}
onChange={(v) => setForm({ ...form, factTypes: v as FactType[] })}
/>
<p className="text-xs text-muted-foreground">Leave empty to include all types.</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="exclude-mental-models"
checked={form.excludeMentalModels}
onCheckedChange={(checked) =>
setForm({ ...form, excludeMentalModels: checked === true })
}
/>
<label
htmlFor="exclude-mental-models"
className="text-sm font-medium text-foreground cursor-pointer"
>
Exclude all mental models
</label>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Exclude Mental Model IDs
</label>
<Input
value={form.excludeMentalModelIds}
onChange={(e) => setForm({ ...form, excludeMentalModelIds: e.target.value })}
placeholder="e.g., model-a, model-b (comma-separated)"
/>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={creating}>
@@ -776,6 +831,12 @@ function UpdateMentalModelDialog({
maxTokens: String(mentalModel.max_tokens || 2048),
tags: mentalModel.tags.join(", "),
autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false,
factTypes:
(mentalModel.trigger?.fact_types as
| Array<"world" | "experience" | "observation">
| undefined) || [],
excludeMentalModels: mentalModel.trigger?.exclude_mental_models || false,
excludeMentalModelIds: (mentalModel.trigger?.exclude_mental_model_ids || []).join(", "),
});
// Reset form when mental model changes or dialog opens
@@ -787,6 +848,12 @@ function UpdateMentalModelDialog({
maxTokens: String(mentalModel.max_tokens || 2048),
tags: mentalModel.tags.join(", "),
autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false,
factTypes:
(mentalModel.trigger?.fact_types as
| Array<"world" | "experience" | "observation">
| undefined) || [],
excludeMentalModels: mentalModel.trigger?.exclude_mental_models || false,
excludeMentalModelIds: (mentalModel.trigger?.exclude_mental_model_ids || []).join(", "),
});
}
}, [open, mentalModel]);
@@ -803,12 +870,22 @@ function UpdateMentalModelDialog({
const maxTokens = parseInt(form.maxTokens) || 2048;
const excludeIds = form.excludeMentalModelIds
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const updated = await client.updateMentalModel(currentBank, mentalModel.id, {
name: form.name.trim(),
source_query: form.sourceQuery.trim(),
tags: tags.length > 0 ? tags : undefined,
max_tokens: maxTokens,
trigger: { refresh_after_consolidation: form.autoRefresh },
trigger: {
refresh_after_consolidation: form.autoRefresh,
fact_types: form.factTypes.length > 0 ? form.factTypes : undefined,
exclude_mental_models: form.excludeMentalModels || undefined,
exclude_mental_model_ids: excludeIds.length > 0 ? excludeIds : undefined,
},
});
onUpdated(updated);
@@ -830,72 +907,107 @@ function UpdateMentalModelDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">ID</label>
<Input value={mentalModel.id} disabled className="bg-muted" />
<p className="text-xs text-muted-foreground">ID cannot be changed after creation.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Name *</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Team Communication Preferences"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Source Query *</label>
<Input
value={form.sourceQuery}
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
placeholder="e.g., How does the team prefer to communicate?"
/>
<p className="text-xs text-muted-foreground">
This query will be run to generate the initial content, and re-run when you refresh.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Max Tokens</label>
<Input
type="number"
value={form.maxTokens}
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
placeholder="2048"
min="256"
max="8192"
/>
<p className="text-xs text-muted-foreground">
Maximum tokens for the generated response (256-8192).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Tags <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<Input
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
placeholder="e.g., project-x, team-alpha (comma-separated)"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="update-auto-refresh"
checked={form.autoRefresh}
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
/>
<label
htmlFor="update-auto-refresh"
className="text-sm font-medium text-foreground cursor-pointer"
>
Auto-refresh after consolidation
</label>
</div>
<p className="text-xs text-muted-foreground -mt-2 ml-6">
Automatically refresh this mental model when memories are consolidated.
</p>
</div>
<Tabs defaultValue="general" className="py-2">
<TabsList className="w-full">
<TabsTrigger value="general" className="flex-1">
General
</TabsTrigger>
<TabsTrigger value="options" className="flex-1">
Options
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">ID</label>
<Input value={mentalModel.id} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Name *</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Team Communication Preferences"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Source Query *</label>
<Input
value={form.sourceQuery}
onChange={(e) => setForm({ ...form, sourceQuery: e.target.value })}
placeholder="e.g., How does the team prefer to communicate?"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Max Tokens</label>
<Input
type="number"
value={form.maxTokens}
onChange={(e) => setForm({ ...form, maxTokens: e.target.value })}
placeholder="2048"
min="256"
max="8192"
/>
</div>
</TabsContent>
<TabsContent value="options" className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Tags</label>
<Input
value={form.tags}
onChange={(e) => setForm({ ...form, tags: e.target.value })}
placeholder="e.g., project-x, team-alpha (comma-separated)"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="update-auto-refresh"
checked={form.autoRefresh}
onCheckedChange={(checked) => setForm({ ...form, autoRefresh: checked === true })}
/>
<label
htmlFor="update-auto-refresh"
className="text-sm font-medium text-foreground cursor-pointer"
>
Auto-refresh after consolidation
</label>
</div>
<div className="space-y-3">
<label className="text-sm font-medium text-foreground">Fact Types</label>
<FactTypeCheckboxGroup
value={form.factTypes}
onChange={(v) => setForm({ ...form, factTypes: v as FactType[] })}
/>
<p className="text-xs text-muted-foreground">Leave empty to include all types.</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="update-exclude-mental-models"
checked={form.excludeMentalModels}
onCheckedChange={(checked) =>
setForm({ ...form, excludeMentalModels: checked === true })
}
/>
<label
htmlFor="update-exclude-mental-models"
className="text-sm font-medium text-foreground cursor-pointer"
>
Exclude all mental models
</label>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Exclude Mental Model IDs
</label>
<Input
value={form.excludeMentalModelIds}
onChange={(e) => setForm({ ...form, excludeMentalModelIds: e.target.value })}
placeholder="e.g., model-a, model-b (comma-separated)"
/>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={updating}>
@@ -14,6 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { FactType, FactTypeFilter } from "@/components/fact-type-filter";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -33,7 +34,6 @@ import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import { MemoryDetailPanel } from "./memory-detail-panel";
type FactType = "world" | "experience" | "observation";
type Budget = "low" | "mid" | "high";
type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
type ViewMode = "results" | "trace" | "json";
@@ -157,10 +157,6 @@ export function SearchDebugView() {
}
};
const toggleFactType = (ft: FactType) => {
setFactTypes((prev) => (prev.includes(ft) ? prev.filter((t) => t !== ft) : [...prev, ft]));
};
if (!currentBank) {
return (
<Card className="border-dashed">
@@ -197,28 +193,7 @@ export function SearchDebugView() {
{/* Filters */}
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
{/* Fact Types */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-muted-foreground">Types:</span>
<div className="flex gap-3">
{(["world", "experience"] as FactType[]).map((ft) => (
<label key={ft} className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={factTypes.includes(ft)}
onCheckedChange={() => toggleFactType(ft)}
/>
<span className="text-sm capitalize">{ft}</span>
</label>
))}
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={factTypes.includes("observation")}
onCheckedChange={() => toggleFactType("observation")}
/>
<span className="text-sm">Observations</span>
</label>
</div>
</div>
<FactTypeFilter value={factTypes} onChange={setFactTypes} label="Types:" />
<div className="h-6 w-px bg-border" />
@@ -13,6 +13,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { FactType, FactTypeFilter } from "@/components/fact-type-filter";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Sparkles,
@@ -51,6 +52,9 @@ export function ThinkView() {
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState("");
const [tagsMatch, setTagsMatch] = useState<TagsMatch>("any");
const [factTypes, setFactTypes] = useState<FactType[]>([]);
const [excludeMentalModels, setExcludeMentalModels] = useState(false);
const [excludeMentalModelIds, setExcludeMentalModelIds] = useState("");
const [feedback, setFeedback] = useState("");
const [feedbackSubmitting, setFeedbackSubmitting] = useState(false);
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false);
@@ -151,6 +155,11 @@ export function ThinkView() {
.map((t) => t.trim())
.filter((t) => t.length > 0);
const excludeIds = excludeMentalModelIds
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
const data: any = await client.reflect({
bank_id: currentBank,
query,
@@ -159,6 +168,9 @@ export function ThinkView() {
include_facts: includeFacts,
include_tool_calls: includeToolCalls,
...(parsedTags.length > 0 && { tags: parsedTags, tags_match: tagsMatch }),
...(factTypes.length > 0 && { fact_types: factTypes }),
...(excludeMentalModels && { exclude_mental_models: true }),
...(excludeIds.length > 0 && { exclude_mental_model_ids: excludeIds }),
});
setResult(data);
} catch (error) {
@@ -275,6 +287,29 @@ export function ThinkView() {
</SelectContent>
</Select>
</div>
{/* Fact Types & Mental Model Filters */}
<div className="flex flex-wrap items-center gap-6 mt-4 pt-4 border-t">
<FactTypeFilter value={factTypes} onChange={setFactTypes} />
<div className="h-6 w-px bg-border" />
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={excludeMentalModels}
onCheckedChange={(c) => setExcludeMentalModels(c as boolean)}
/>
<span className="text-sm">Exclude mental models</span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Exclude IDs:</span>
<Input
type="text"
value={excludeMentalModelIds}
onChange={(e) => setExcludeMentalModelIds(e.target.value)}
placeholder="model-a, model-b"
className="h-8 w-48"
/>
</div>
</div>
</CardContent>
</Card>
+33 -5
View File
@@ -47,7 +47,12 @@ export interface MentalModel {
content: string;
tags: string[];
max_tokens: number;
trigger: { refresh_after_consolidation: boolean };
trigger: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
last_refreshed_at: string;
created_at: string;
reflect_response?: any;
@@ -183,6 +188,9 @@ export class ControlPlaneClient {
include_tool_calls?: boolean;
tags?: string[];
tags_match?: "any" | "all" | "any_strict" | "all_strict";
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
}) {
return this.fetchApi("/api/reflect", {
method: "POST",
@@ -757,7 +765,12 @@ export class ControlPlaneClient {
content: string;
tags: string[];
max_tokens: number;
trigger: { refresh_after_consolidation: boolean };
trigger: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
last_refreshed_at: string;
created_at: string;
reflect_response?: {
@@ -780,7 +793,12 @@ export class ControlPlaneClient {
source_query: string;
tags?: string[];
max_tokens?: number;
trigger?: { refresh_after_consolidation: boolean };
trigger?: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
}
) {
return this.fetchApi<{
@@ -809,7 +827,12 @@ export class ControlPlaneClient {
source_query?: string;
max_tokens?: number;
tags?: string[];
trigger?: { refresh_after_consolidation: boolean };
trigger?: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
}
) {
return this.fetchApi<{
@@ -820,7 +843,12 @@ export class ControlPlaneClient {
content: string;
tags: string[];
max_tokens: number;
trigger: { refresh_after_consolidation: boolean };
trigger: {
refresh_after_consolidation: boolean;
fact_types?: Array<"world" | "experience" | "observation">;
exclude_mental_models?: boolean;
exclude_mental_model_ids?: string[];
};
last_refreshed_at: string;
created_at: string;
reflect_response?: {
+231 -30
View File
@@ -25,7 +25,10 @@ GITHUB_REPO = "vectorize-io/hindsight"
GITHUB_RELEASES_URL = f"https://github.com/{GITHUB_REPO}/releases"
GITHUB_COMMIT_URL = f"https://github.com/{GITHUB_REPO}/commit"
REPO_PATH = Path(__file__).parent.parent.parent
CHANGELOG_PATH = REPO_PATH / "hindsight-docs" / "src" / "pages" / "changelog.md"
CHANGELOG_PATH = REPO_PATH / "hindsight-docs" / "src" / "pages" / "changelog" / "index.md"
INTEGRATION_CHANGELOG_DIR = REPO_PATH / "hindsight-docs" / "src" / "pages" / "changelog" / "integrations"
VALID_INTEGRATIONS = ["litellm", "pydantic-ai", "crewai", "ai-sdk", "chat", "openclaw", "langgraph", "nemoclaw"]
class ChangelogEntry(BaseModel):
@@ -82,6 +85,31 @@ def get_git_tags() -> list[str]:
return valid_tags
def get_integration_tags(integration: str) -> list[str]:
"""Get all tags for a specific integration, sorted by semver (newest first)."""
prefix = f"integrations/{integration}/v"
result = subprocess.run(
["git", "tag", "-l", f"{prefix}*"],
cwd=REPO_PATH,
capture_output=True,
text=True,
check=True,
)
tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
valid_tags = []
for tag in tags:
version_part = tag.removeprefix(prefix)
try:
parse_semver(version_part)
valid_tags.append(tag)
except ValueError:
continue
valid_tags.sort(key=lambda t: parse_semver(t.removeprefix(prefix)), reverse=True)
return valid_tags
def find_previous_version(new_version: str, existing_tags: list[str]) -> str | None:
"""Find the previous version based on semver rules."""
new_major, new_minor, new_patch = parse_semver(new_version)
@@ -105,13 +133,41 @@ def find_previous_version(new_version: str, existing_tags: list[str]) -> str | N
return candidates[0][0]
def get_commits(from_ref: str | None, to_ref: str) -> list[Commit]:
def find_previous_integration_tag(new_version: str, existing_tags: list[str], integration: str) -> str | None:
"""Find the previous integration tag based on semver rules."""
prefix = f"integrations/{integration}/v"
new_major, new_minor, new_patch = parse_semver(new_version)
candidates = []
for tag in existing_tags:
version_part = tag.removeprefix(prefix)
try:
major, minor, patch = parse_semver(version_part)
except ValueError:
continue
if (major, minor, patch) >= (new_major, new_minor, new_patch):
continue
candidates.append((tag, (major, minor, patch)))
if not candidates:
return None
candidates.sort(key=lambda x: x[1], reverse=True)
return candidates[0][0]
def get_commits(from_ref: str | None, to_ref: str, path_filter: str | None = None) -> list[Commit]:
"""Get commits between two refs as structured data."""
if from_ref:
cmd = ["git", "log", "--format=%h|%s", "--no-merges", f"{from_ref}..{to_ref}"]
else:
cmd = ["git", "log", "--format=%h|%s", "--no-merges", to_ref]
if path_filter:
cmd += ["--", path_filter]
result = subprocess.run(
cmd,
cwd=REPO_PATH,
@@ -131,13 +187,16 @@ def get_commits(from_ref: str | None, to_ref: str) -> list[Commit]:
return commits
def get_detailed_diff(from_ref: str | None, to_ref: str) -> str:
def get_detailed_diff(from_ref: str | None, to_ref: str, path_filter: str | None = None) -> str:
"""Get file change stats between two refs."""
if from_ref:
cmd = ["git", "diff", "--stat", f"{from_ref}..{to_ref}"]
else:
cmd = ["git", "diff", "--stat", f"{to_ref}^..{to_ref}"]
if path_filter:
cmd += ["--", path_filter]
result = subprocess.run(
cmd,
cwd=REPO_PATH,
@@ -153,11 +212,14 @@ def analyze_commits_with_llm(
version: str,
commits: list[Commit],
file_diff: str,
integration: str | None = None,
) -> list[ChangelogEntry]:
"""Use LLM to analyze commits and return structured changelog entries."""
commits_json = json.dumps([{"commit_id": c.hash, "message": c.message} for c in commits], indent=2)
prompt = f"""Analyze the following git commits for release {version} of Hindsight (an AI memory system).
subject = f"the {integration} integration for Hindsight" if integration else f"release {version} of Hindsight"
prompt = f"""Analyze the following git commits for {subject} (an AI memory system).
For each meaningful change, create a changelog entry with:
- category: one of "feature", "improvement", "bugfix", "breaking", "other"
@@ -192,9 +254,14 @@ def build_changelog_markdown(
version: str,
tag: str,
entries: list[ChangelogEntry],
integration: str | None = None,
) -> str:
"""Build markdown changelog from structured entries."""
release_url = f"{GITHUB_RELEASES_URL}/tag/{tag}"
tag_url = (
f"https://github.com/{GITHUB_REPO}/releases/tag/{tag}"
if not integration
else f"https://github.com/{GITHUB_REPO}/tree/{tag}"
)
# Group entries by category
categories = {
@@ -213,7 +280,7 @@ def build_changelog_markdown(
categories["other"][1].append(entry)
# Build markdown
lines = [f"## [{version}]({release_url})", ""]
lines = [f"## [{version}]({tag_url})", ""]
has_entries = False
for cat_key in ["breaking", "feature", "improvement", "bugfix", "other"]:
@@ -234,22 +301,12 @@ def build_changelog_markdown(
return "\n".join(lines)
def read_existing_changelog() -> tuple[str, str]:
def read_existing_changelog(path: Path, default_header: str) -> tuple[str, str]:
"""Read existing changelog and split into header and content."""
if not CHANGELOG_PATH.exists():
header = """---
hide_table_of_contents: true
---
if not path.exists():
return default_header, ""
# Changelog
This changelog highlights user-facing changes only. Internal maintenance, CI/CD, and infrastructure updates are omitted.
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
"""
return header, ""
content = CHANGELOG_PATH.read_text()
content = path.read_text()
match = re.search(r"^## ", content, re.MULTILINE)
if match:
@@ -262,11 +319,11 @@ For full release details, see [GitHub Releases](https://github.com/vectorize-io/
return header, releases
def write_changelog(header: str, new_entry: str, existing_releases: str) -> None:
def write_changelog(path: Path, header: str, new_entry: str, existing_releases: str) -> None:
"""Write changelog with new entry prepended."""
content = header + new_entry + "\n" + existing_releases
CHANGELOG_PATH.parent.mkdir(parents=True, exist_ok=True)
CHANGELOG_PATH.write_text(content.rstrip() + "\n")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content.rstrip() + "\n")
def generate_changelog_entry(
@@ -329,22 +386,154 @@ def generate_changelog_entry(
new_entry = build_changelog_markdown(display_version, tag, entries)
header, existing_releases = read_existing_changelog()
default_header = """---
hide_table_of_contents: true
---
# Changelog
This changelog highlights user-facing changes only. Internal maintenance, CI/CD, and infrastructure updates are omitted.
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
"""
header, existing_releases = read_existing_changelog(CHANGELOG_PATH, default_header)
if f"## [{display_version}]" in existing_releases:
console.print(f"[red]Error: Version {display_version} already exists in changelog[/red]")
sys.exit(1)
write_changelog(header, new_entry, existing_releases)
write_changelog(CHANGELOG_PATH, header, new_entry, existing_releases)
console.print(f"\n[green]Changelog updated: {CHANGELOG_PATH}[/green]")
console.print(f"\n[bold]New entry:[/bold]\n{new_entry}")
def generate_integration_changelog_entry(
integration: str,
version: str,
llm_model: str = "gpt-5.2",
) -> None:
"""Generate changelog entry for a specific integration version."""
if integration not in VALID_INTEGRATIONS:
console.print(f"[red]Error: Unknown integration '{integration}'. Valid: {', '.join(VALID_INTEGRATIONS)}[/red]")
sys.exit(1)
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
console.print("[red]Error: OPENAI_API_KEY environment variable not set[/red]")
sys.exit(1)
client = OpenAI(api_key=api_key)
display_version = version.lstrip("v")
path_filter = f"hindsight-integrations/{integration}/"
changelog_path = INTEGRATION_CHANGELOG_DIR / f"{integration}.md"
console.print(f"[blue]Fetching integration tags for {integration}...[/blue]")
existing_tags = get_integration_tags(integration)
previous_tag = find_previous_integration_tag(display_version, existing_tags, integration)
if previous_tag:
console.print(f"[green]Found previous tag: {previous_tag}[/green]")
else:
console.print("[yellow]No previous tag found, will include all commits touching this integration[/yellow]")
console.print(f"[blue]Getting commits for {path_filter}...[/blue]")
commits = get_commits(previous_tag, "HEAD", path_filter=path_filter)
file_diff = get_detailed_diff(previous_tag, "HEAD", path_filter=path_filter)
if not commits:
console.print("[yellow]Warning: No commits found touching this integration path[/yellow]")
entries = []
else:
console.print(f"[blue]Found {len(commits)} commits[/blue]")
console.print("\n[bold]Commits:[/bold]")
for c in commits:
console.print(f" {c.hash} {c.message}")
console.print("\n[bold]Files changed:[/bold]")
console.print(file_diff[:4000] if len(file_diff) > 4000 else file_diff)
console.print("")
console.print(f"[blue]Analyzing commits with LLM ({llm_model})...[/blue]")
entries = analyze_commits_with_llm(
client, llm_model, display_version, commits, file_diff, integration=integration
)
console.print(f"\n[bold]LLM identified {len(entries)} changelog entries:[/bold]")
for entry in entries:
console.print(f" [{entry.category}] {entry.summary} ({entry.commit_id})")
integration_tag = f"integrations/{integration}/v{display_version}"
new_entry = build_changelog_markdown(display_version, integration_tag, entries, integration=integration)
package_name = _get_package_name(integration)
default_header = f"""---
hide_table_of_contents: true
---
# {_integration_display_name(integration)} Integration Changelog
Changelog for [`{package_name}`]({_package_url(integration, package_name)}).
For the source code, see [`hindsight-integrations/{integration}`](https://github.com/{GITHUB_REPO}/tree/main/hindsight-integrations/{integration}).
[Back to main changelog](/changelog)
"""
header, existing_releases = read_existing_changelog(changelog_path, default_header)
if f"## [{display_version}]" in existing_releases:
console.print(f"[red]Error: Version {display_version} already exists in integration changelog[/red]")
sys.exit(1)
write_changelog(changelog_path, header, new_entry, existing_releases)
console.print(f"\n[green]Integration changelog updated: {changelog_path}[/green]")
console.print(f"\n[bold]New entry:[/bold]\n{new_entry}")
def _get_package_name(integration: str) -> str:
packages = {
"litellm": "hindsight-litellm",
"pydantic-ai": "hindsight-pydantic-ai",
"crewai": "hindsight-crewai",
"ai-sdk": "@vectorize-io/hindsight-ai-sdk",
"chat": "@vectorize-io/hindsight-chat",
"openclaw": "@vectorize-io/hindsight-openclaw",
"langgraph": "hindsight-langgraph",
"nemoclaw": "@vectorize-io/hindsight-nemoclaw",
}
return packages[integration]
def _package_url(integration: str, package_name: str) -> str:
if package_name.startswith("@"):
return f"https://www.npmjs.com/package/{package_name}"
return f"https://pypi.org/project/{package_name}/"
def _integration_display_name(integration: str) -> str:
names = {
"litellm": "LiteLLM",
"pydantic-ai": "Pydantic AI",
"crewai": "CrewAI",
"ai-sdk": "AI SDK",
"chat": "Chat SDK",
"openclaw": "OpenClaw",
"langgraph": "LangGraph",
"nemoclaw": "NemoClaw",
}
return names.get(integration, integration)
def main():
parser = argparse.ArgumentParser(
description="Generate changelog entry for a release",
usage="generate-changelog VERSION [--model MODEL]",
usage="generate-changelog VERSION [--model MODEL] [--integration NAME]",
)
parser.add_argument(
"version",
@@ -355,13 +544,25 @@ def main():
default="gpt-5.2",
help="OpenAI model to use (default: gpt-5.2)",
)
parser.add_argument(
"--integration",
default=None,
help=f"Generate changelog for a specific integration. Valid: {', '.join(VALID_INTEGRATIONS)}",
)
args = parser.parse_args()
generate_changelog_entry(
version=args.version,
llm_model=args.model,
)
if args.integration:
generate_integration_changelog_entry(
integration=args.integration,
version=args.version,
llm_model=args.model,
)
else:
generate_changelog_entry(
version=args.version,
llm_model=args.model,
)
if __name__ == "__main__":
@@ -0,0 +1,251 @@
---
title: "Give the Only Self-Improving AI Agent (Hermes) a Memory Upgrade It Deserves"
authors: [benfrank241]
date: 2026-03-17
tags: [hermes, agents, python, memory, tutorial, plugin]
image: /img/blog/hermes-agent-memory.png
---
![How to Add Persistent Memory to Hermes Agent](/img/blog/hermes-agent-memory.png)
[Hermes Agent](https://github.com/NousResearch/hermes-agent) is a self-improving AI agent with 40+ tools and a plugin system. Its built-in memory saves to local files. `hindsight-hermes` replaces it with structured fact extraction, entity resolution, and multi-strategy retrieval — via one pip install and three environment variables.
<!-- truncate -->
**TL;DR:**
- Hermes Agent's built-in memory is local file-based — no structure, no retrieval intelligence, no cross-machine sync
- `hindsight-hermes` is a pip-installable plugin that registers Hindsight retain/recall/reflect as native Hermes tools
- One `pip install`, three environment variables, disable the built-in `memory` tool, and you're done
- Works with [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) (zero infra) or self-hosted
## The problem: good memory, but it could go further
Hermes Agent has memory built in, and it's a reasonable design. The `memory` tool saves durable facts to `~/.hermes/` as persistent files, and the `session_search` tool lets the agent look back through past conversations. It works — the agent can store preferences, recall context, and carry knowledge across sessions.
But there's room to grow:
- **Structure.** Memories are stored as text. There's no entity resolution (connecting "Alice" with "my coworker Alice from engineering"), no relationship tracking, and no temporal awareness beyond session timestamps.
- **Retrieval.** Search is keyword-based. For simple lookups that's fine, but it struggles with questions that use different terminology than what was stored.
- **Locality.** Memories live on disk. Run Hermes on your laptop and your server — two separate brains with no way to share context.
- **Synthesis.** You can store and retrieve facts, but you can't ask "based on everything you know about this customer, what should we prioritize?" and get a reasoned answer.
Hindsight adds the layer on top: structured fact extraction, entity resolution, a knowledge graph, multi-strategy retrieval with cross-encoder reranking, and a `reflect` operation that synthesizes across all stored memories.
## Architecture: a plugin that registers three tools
```
User ──> Hermes Agent
├── Built-in tools (terminal, browser, files, ...)
└── [hindsight] plugin
├── hindsight_retain ──> Hindsight API ──> fact extraction,
├── hindsight_recall ──> │ entity resolution,
└── hindsight_reflect ──> │ knowledge graph,
└──> PostgreSQL + pgvector
```
`hindsight-hermes` hooks into Hermes's [plugin system](https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/plugins.py). When Hermes starts, it scans for packages with the `hermes_agent.plugins` entry point, finds `hindsight-hermes`, and calls its `register()` function. That registers three tools into Hermes's tool registry.
No forking Hermes. No patching config files. Just `pip install` and environment variables.
## Setting up Hindsight
You have two options: Hindsight Cloud (no setup) or self-hosted.
**Option A: Hindsight Cloud**
1. [Sign up at Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)
2. Create a memory bank in the dashboard and copy your API key
3. Your base URL is `https://api.hindsight.vectorize.io`
**Option B: Self-hosted with Docker**
```bash
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=YOUR_OPENAI_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
Wait for the health check:
```bash
curl http://localhost:8888/health
# {"status":"healthy","database":"connected"}
```
The `-v` flag persists data across container restarts. Port 8888 is the API; port 9999 is the admin UI for browsing memories.
**Option C: Self-hosted with pip**
```bash
pip install hindsight-all
export HINDSIGHT_API_LLM_API_KEY=YOUR_OPENAI_KEY
hindsight-api
```
## Install hindsight-hermes
One pip install. The package auto-registers as a Hermes plugin via Python entry points — no config files, no manual plugin setup. The only requirement is that it's installed in the **same Python environment** as Hermes.
```bash
uv pip install hindsight-hermes --python $HOME/.hermes/hermes-agent/venv/bin/python
```
That's it. When Hermes starts, it discovers the package automatically and registers the three memory tools.
You can verify it's registered:
```bash
python -c "
import importlib.metadata
eps = importlib.metadata.entry_points(group='hermes_agent.plugins')
for ep in eps:
print(f'{ep.name}: {ep.value}')
"
# Expected: hindsight: hindsight_hermes
```
## Configuration
Set these environment variables before launching Hermes:
```bash
# Required — where Hindsight is running
export HINDSIGHT_API_URL=http://localhost:8888
# Required — the memory bank (an isolated "brain" for this agent)
export HINDSIGHT_BANK_ID=my-agent
# Optional — only needed for Hindsight Cloud (https://api.hindsight.vectorize.io)
export HINDSIGHT_API_KEY=hsk_your-key-here
# Optional — recall budget: low (fast), mid (default), high (thorough)
export HINDSIGHT_BUDGET=mid
```
If neither `HINDSIGHT_API_URL` nor `HINDSIGHT_API_KEY` is set, the plugin silently skips registration — Hermes starts normally without the Hindsight tools.
### Disable Hermes's built-in memory
This is the step people miss. Hermes has its own `memory` tool that saves to `~/.hermes/`. If both are active, **the LLM defaults to the built-in one** — it's a single tool it already recognizes. Your Hindsight tools will sit unused.
```bash
hermes tools disable memory
```
This persists across sessions. Re-enable later with `hermes tools enable memory`.
## Using the memory tools
Launch Hermes:
```bash
hermes
```
Type `/tools` to verify. You should see the `[hindsight]` toolset:
```
[hindsight]
* hindsight_recall - Search long-term memory for relevant information.
* hindsight_reflect - Synthesize a thoughtful answer from long-term memories.
* hindsight_retain - Store information to long-term memory for later retrieval.
```
### Retain — store memories
Tell Hermes something to remember:
```
● Remember that my favourite programming language is Rust and I prefer dark mode.
```
You should see `⚡ hindsight` in the response — that confirms it called `hindsight_retain`, not the built-in memory tool.
Under the hood, Hindsight extracts structured facts ("User's favourite programming language is Rust"), resolves entities, generates embeddings, and indexes everything. You don't manage any of that.
### Recall — search memories
```
● What do you know about my programming preferences?
```
Recall runs four retrieval strategies in parallel — semantic search, BM25 keyword matching, entity graph traversal, and temporal filtering — then reranks results with a cross-encoder. This is what makes it work better than string matching over flat files.
### Reflect — synthesize across memories
```
● Based on what you know about me, suggest a colour scheme for my IDE.
```
Reflect doesn't return raw facts. It traverses the knowledge graph, reasons across everything in the bank, and produces a synthesized answer. Slower than recall, but far more useful for open-ended questions.
### Verify via the API
Confirm memories are stored by querying Hindsight directly:
```bash
curl -s http://localhost:8888/v1/default/banks/my-agent/memories/recall \
-H "Content-Type: application/json" \
-d '{"query": "programming preferences", "budget": "low"}' | python3 -m json.tool
```
```json
{
"results": [
{
"text": "User's favourite programming language is Rust.",
"type": "world",
"entities": ["user"]
},
{
"text": "User prefers dark mode in all editors.",
"type": "world",
"entities": ["user"]
}
]
}
```
## Pitfalls and edge cases
1. **Plugin not in `/tools`.** The most common cause: `hindsight-hermes` is installed in a different Python environment than Hermes. Entry points are per-environment. Run `python -c "import importlib.metadata; print(list(importlib.metadata.entry_points(group='hermes_agent.plugins')))"` from the Hermes venv to verify.
2. **LLM picks built-in memory.** Even with the plugin loaded, if both `memory` and `hindsight_retain` exist, the LLM chooses `memory`. Run `hermes tools disable memory`.
3. **Retain is asynchronous.** The API returns immediately; fact extraction happens in the background. If you retain and immediately recall in the same turn, the new facts may not be indexed yet. Design so recall happens on subsequent turns.
4. **Env vars are read once at startup.** Changing `HINDSIGHT_API_URL` or `HINDSIGHT_BANK_ID` after launch has no effect. Restart Hermes to pick up changes.
## Tradeoffs: Hindsight plugin vs. alternatives
| | **Hindsight plugin** | **Built-in memory** |
|---|---|---|
| **Storage** | PostgreSQL + pgvector | Local files (~/.hermes/) |
| **Structure** | Facts, entities, relationships | Raw text |
| **Retrieval** | Semantic + BM25 + graph, reranked | Basic search |
| **Synthesis** | reflect tool | None |
| **Cross-machine** | Yes | No |
| **Setup** | pip install + env vars | Built-in |
**Use the built-in memory** when you want zero setup and basic persistence.
**Use the Hindsight plugin** when you want structured retrieval, entity resolution, and memory that persists across machines.
## Recap
`hindsight-hermes` gives Hermes Agent persistent, structured long-term memory via a pip-installable plugin. No code changes, no config patches.
Hermes's plugin system uses standard Python entry points, so any pip package can register tools. `hindsight-hermes` injects retain, recall, and reflect — backed by Hindsight's multi-strategy retrieval, entity resolution, and knowledge graph.
The key practical detail: disable Hermes's built-in `memory` tool. Otherwise the LLM prefers it and your Hindsight tools go unused.
## Next steps
- **Build up memory over time.** Use Hermes normally — it will retain what matters and recall it when relevant.
- **Try reflect for synthesis.** Ask open-ended questions: "Based on everything you know about me, what kind of projects would I enjoy?"
- **Use per-user banks.** Set `HINDSIGHT_BANK_ID` per user for isolated memory per person.
- **Explore the MCP alternative.** Hermes supports MCP servers natively. You can connect Hindsight's MCP server directly (`http://localhost:8888/mcp`) instead of the plugin — no `hindsight-hermes` package needed. The tradeoff is that the plugin registers tools with Hermes-native schemas, while MCP tools need discovery.
- **Use manual registration for more control.** If you want to set tags, recall filters, or skip the plugin system, `hindsight-hermes` exposes `register_tools()` and `memory_instructions()` functions for direct use.
- **Read the docs.** Full integration reference at [hindsight.vectorize.io/sdks/integrations/hermes](https://hindsight.vectorize.io/sdks/integrations/hermes).
@@ -0,0 +1,285 @@
---
slug: sandboxed-agent-persistent-memory-nemoclaw
title: "Give NemoClaw the Best Agent Memory Available In One Command"
description: Add persistent memory to a NemoClaw sandboxed AI agent without changing code. One command, one network policy, memories survive across sessions.
authors: [hindsight]
date: 2026-03-19
image: /img/blog/2026-03-19/nemoclaw-memory.png
hide_table_of_contents: true
---
## TL;DR
- [NemoClaw](https://nemoclaw.ai) sandboxes isolate AI agents — controlled filesystem, processes, and network. That isolation makes persistent memory harder.
- We connected the `hindsight-openclaw` plugin to a live NemoClaw sandbox using [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup). No code changes — one command.
- External API mode is the natural fit: the plugin becomes a thin HTTP client, and the sandbox only needs one egress rule.
- Memories captured in one session are recalled in the next. The sandbox didn't interfere.
- The pattern generalizes: sandbox controls what the agent can *do*, memory controls what it *knows*. They compose cleanly.
## The Problem: Sandboxed Agents Have No Persistent Memory
AI agents running inside sandboxes present an interesting memory problem. The sandbox is designed to isolate the agent — it controls which files it can read, which processes it can spawn, and which network endpoints it can reach. That isolation is the point. But it creates a question: if every session starts in a clean, constrained environment, where does persistent memory live?
We set out to answer that with [NemoClaw](https://nemoclaw.ai), NVIDIA's sandboxed agent runtime built on OpenShell. The goal was simple: connect the `hindsight-openclaw` plugin to a live NemoClaw sandbox and verify that memories captured in one session are recalled in the next. No code changes allowed — if we needed to modify the plugin to make it work, we'd learned something important about the architecture.
We didn't need to change a line.
<!-- truncate -->
## The Approach: External API Mode for Sandbox Memory
[NemoClaw](https://nemoclaw.ai) runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox. The sandbox enforces a filesystem policy (what paths the agent can read and write), a process policy (what it runs as), and a network egress policy (which outbound endpoints are permitted).
By default, the sandbox ships with policies for the services it needs: the LLM provider, GitHub, npm, the OpenClaw API. Everything else is blocked. That's a good default — an agent that can call arbitrary endpoints is harder to trust.
[Hindsight](https://hindsight.vectorize.io) operates as an external API. The plugin makes HTTPS calls to `api.hindsight.vectorize.io` to [retain and recall memories](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory). From the sandbox's perspective, that's just another outbound endpoint — one that needs to be explicitly permitted.
The full stack looks like this:
```
┌─────────────────────────────────────────────┐
│ NemoClaw Sandbox (OpenShell) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ OpenClaw Gateway │ │
│ │ + hindsight-openclaw plugin │ │
│ │ ↓ before_agent_start: recall │ │
│ │ ↓ agent_end: retain │ │
│ └──────────────────────────────────────┘ │
│ │
│ Network egress policy: │
│ ✓ api.anthropic.com │
│ ✓ integrate.api.nvidia.com │
│ ✓ api.hindsight.vectorize.io ← added │
└─────────────────────────────────────────────┘
```
When the plugin retains a conversation, Hindsight doesn't just store raw text. It extracts structured facts, resolves entities, builds a [knowledge graph](https://hindsight.vectorize.io/blog/2026/03/12/spreading-activation-memory-graphs), and indexes everything for multi-strategy retrieval — semantic search, BM25 keyword matching, graph traversal, and temporal filtering with [cross-encoder reranking](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory). That's what makes recall useful even when the agent's question doesn't match the exact wording of what was stored.
The plugin has two modes. In **local daemon mode**, it spawns a local `hindsight-embed` process and communicates with it over a local port. In **external API mode**, it skips the daemon entirely and makes HTTP calls directly to a Hindsight Cloud endpoint.
Inside a sandbox, local daemon mode is awkward. The sandbox controls which processes can be spawned, and a background daemon that launches `uvx` subprocesses is friction we don't need. External API mode is the natural fit: the plugin becomes a thin HTTP client, and the only infrastructure requirement is a network egress rule.
For background on the OpenClaw plugin itself — how it hooks into the gateway lifecycle, auto-injects memory into context, and prevents feedback loops — see [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight).
## Implementation: One Command
The `hindsight-nemoclaw` package automates the entire setup — installing the plugin, configuring external API mode, reading your current sandbox policy, merging the Hindsight egress rule, and restarting the gateway:
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
That's it. You'll see output like:
```
[0] Preflight checks...
✓ openshell found
✓ openclaw found
[1] Installing @vectorize-io/hindsight-openclaw plugin...
✓ Plugin installed
[2] Configuring plugin in ~/.openclaw/openclaw.json...
✓ Plugin config written (bank: my-sandbox-openclaw)
[3] Applying Hindsight network policy to sandbox "my-assistant"...
✓ Policy version 2 submitted
✓ Policy version 2 loaded (active version: 2)
[4] Restarting OpenClaw gateway...
✓ Gateway restarted
✓ Setup complete!
```
Use `--dry-run` to preview all changes before applying. Use `--skip-policy` if you manage sandbox policies manually.
## Verifying It Works
After setup, the gateway logs confirm the plugin is running:
```
[Hindsight] Plugin loaded successfully
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
[Hindsight] External API health: {"status":"healthy","database":"connected"}
[Hindsight] Default bank: my-sandbox-openclaw
[Hindsight] ✓ Ready (external API mode)
```
Send a message to the agent:
```bash
openclaw agent --agent main --session-id session-1 \
-m "My name is Ben and I work on Hindsight. I prefer detailed commit messages."
```
The gateway logs show the hooks firing:
```
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
```
Open a fresh session and ask what the agent remembers:
```bash
openclaw agent --agent main --session-id session-2 \
-m "What do you remember about me?"
```
```
Right now I've just got the basics: your name is Ben, you're working on
Hindsight, and you like commit messages to be detailed. If there's anything
else you want me to keep in mind, let me know.
```
The memory survived the session boundary. The sandbox didn't interfere with it.
## What the Setup Command Does (Manual Alternative)
If you prefer to apply the steps yourself, here's what `hindsight-nemoclaw setup` does under the hood.
**Install the plugin:**
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
**Configure `~/.openclaw/openclaw.json`:**
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
**Add the Hindsight block to your sandbox network policy** (note: `openshell policy set` replaces the full document — include all existing policies):
```yaml
network_policies:
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
```bash
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
openclaw gateway restart
```
## Pitfalls & Edge Cases
### 1. Policy replacement is full-document
`openshell policy set` replaces the entire policy document, not just the section you're adding. The `hindsight-nemoclaw setup` command handles this automatically — it reads the current policy, merges the Hindsight block, and re-applies the full document. If you're applying manually, make sure your YAML includes all existing network policies.
### 2. LaunchAgent can't follow symlinks on macOS
On macOS, the OpenClaw gateway runs as a LaunchAgent with a restricted security context that can't access `~/Documents` or other user directories. `openclaw plugins install --link` creates a symlink that the LaunchAgent can't follow — install as a copy instead:
```bash
# This works — copies files to ~/.openclaw/extensions/
openclaw plugins install @vectorize-io/hindsight-openclaw
```
If you see `EPERM: operation not permitted, scandir` in your gateway logs, this is what's happening.
### 3. Memory retention is asynchronous
When the plugin calls `retain` at the end of a session, [fact extraction and entity resolution](https://hindsight.vectorize.io/blog/2026/03/12/spreading-activation-memory-graphs) happen in the background on Hindsight's side. If you open a new session immediately, the most recent memories may not be indexed yet. In practice this is a few seconds — but it's worth knowing if you're testing back-to-back.
### 4. Binary-scoped egress is strict
The `binaries` field in the network policy means *only* the specified executable can reach the endpoint. If you update OpenClaw and the binary path changes, the egress rule silently stops working. Check your binary path after upgrades.
## Tradeoffs: External API vs. Local Daemon in a Sandbox
| | **External API mode** | **Local daemon mode** |
|---|---|---|
| **Setup** | One command | Process spawning permissions |
| **Dependencies** | HTTPS egress only | `uvx`, Python, local PostgreSQL |
| **Data location** | Hindsight Cloud | Local to sandbox |
| **Multi-sandbox sharing** | Same bank from anywhere | Per-sandbox only |
| **Sandbox compatibility** | Clean fit | Fights the process policy |
**Use external API mode** when you're in a sandbox, want shared memory across instances, or don't want to manage a local database.
**Use local daemon mode** when data must stay on the machine, network egress is completely locked down, or you're running outside a sandbox where process spawning is unrestricted.
For background on the local daemon approach, see [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight).
## What This Pattern Means for Sandboxed Agent Memory
The pattern here is worth naming. A sandboxed agent isn't a limitation on persistent memory — it's a different trust boundary:
- **Sandbox** controls what the agent can *do* — filesystem access, process spawning, network calls.
- **Memory** controls what the agent *knows* — facts, entities, context from prior sessions.
Those are orthogonal concerns, and they compose cleanly.
By keeping memory in an external service and making the network policy explicit, you get both: an agent that's constrained in what it can affect, and one that builds durable knowledge across sessions. The policy file is a readable record of every external dependency the agent has. That transparency is useful.
There's also an interesting property of `dynamicBankId`:
- **Enabled** (`true`): each user gets an isolated memory bank. Memories from one user's sessions can't bleed into another's. Use this for multi-tenant deployments.
- **Disabled** (`false`): a shared bank accumulates context from all sessions. Use this for single-user sandboxes like a personal coding assistant.
> **Want to skip self-hosting?** [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) is what we used in this walkthrough — no Docker, no infrastructure. Sign up, grab an API key, and run `npx @vectorize-io/hindsight-nemoclaw setup`.
## Recap
Persistent memory in a sandboxed AI agent is one command: `npx @vectorize-io/hindsight-nemoclaw setup`. It installs the plugin, applies the network egress rule, and configures external API mode — everything the sandbox needs to let Hindsight through.
The key insight: sandbox isolation and persistent memory are orthogonal concerns. The sandbox controls what the agent can affect; memory controls what the agent knows. One network policy rule bridges them without compromising either.
## Next Steps
- **Run the setup**: `npx @vectorize-io/hindsight-nemoclaw setup --help` to get started.
- **Try per-user memory banks**: Enable `dynamicBankId: true` to give each user isolated memory in multi-tenant deployments.
- **Explore the OpenClaw plugin in depth**: See [The Memory Upgrade Every OpenClaw User Needs](https://hindsight.vectorize.io/blog/2026/03/06/adding-memory-to-openclaw-with-hindsight) for how the plugin hooks into gateway lifecycle events.
- **Connect other agents to the same memory**: Hindsight works with [Hermes Agent](https://hindsight.vectorize.io/blog/2026/03/17/hermes-agent-memory), [Streamlit chatbots](https://hindsight.vectorize.io/blog/2026/03/17/python-chatbot-memory-streamlit), and [any MCP client](https://hindsight.vectorize.io/blog/2026/03/04/mcp-agent-memory).
- **Check out the docs**: Full API reference and SDK guides at [docs.hindsight.vectorize.io](https://docs.hindsight.vectorize.io/recall/).
---
**Resources:**
- [hindsight-nemoclaw on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-nemoclaw)
- [hindsight-openclaw on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-openclaw)
- [OpenClaw plugin documentation](https://vectorize.io/hindsight/sdks/integrations/openclaw)
- [Hindsight Cloud](https://ui.hindsight.vectorize.io)
@@ -13,6 +13,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
{/* Import raw source files */}
import documentsPy from '!!raw-loader!@site/examples/api/documents.py';
import documentsMjs from '!!raw-loader!@site/examples/api/documents.mjs';
import documentsGo from '!!raw-loader!@site/examples/api/documents.go';
:::tip Prerequisites
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
@@ -60,6 +61,9 @@ hindsight memory retain my-bank "Meeting notes content..." --doc-id notes-2024-0
hindsight memory retain-files my-bank docs/
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-retain" language="go" />
</TabItem>
</Tabs>
@@ -84,6 +88,9 @@ hindsight memory retain my-bank "Project deadline: March 31" --doc-id project-pl
hindsight memory retain my-bank "Project deadline: April 15 (extended)" --doc-id project-plan
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-update" language="go" />
</TabItem>
</Tabs>
@@ -104,6 +111,9 @@ Retrieve a document's original text and metadata. This is useful for expanding d
hindsight document get my-bank meeting-2024-03-15
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-get" language="go" />
</TabItem>
</Tabs>
@@ -128,6 +138,9 @@ hindsight document update-tags my-bank meeting-2024-03-15 --tags team-a --tags t
hindsight document update-tags my-bank meeting-2024-03-15
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-update" language="go" />
</TabItem>
</Tabs>
@@ -152,6 +165,9 @@ Remove a document and all its associated memories:
hindsight document delete my-bank meeting-2024-03-15
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-delete" language="go" />
</TabItem>
</Tabs>
@@ -183,6 +199,9 @@ hindsight document list my-bank --q report
hindsight document list my-bank --tags team-a --tags team-b
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={documentsGo} section="document-list" language="go" />
</TabItem>
</Tabs>
@@ -13,6 +13,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
{/* Import raw source files */}
import mainMethodsPy from '!!raw-loader!@site/examples/api/main-methods.py';
import mainMethodsMjs from '!!raw-loader!@site/examples/api/main-methods.mjs';
import mainMethodsGo from '!!raw-loader!@site/examples/api/main-methods.go';
:::tip Prerequisites
Make sure you've [installed Hindsight](../installation) and completed the [Quick Start](./quickstart).
@@ -33,15 +34,18 @@ Store conversations, documents, and facts into a memory bank.
```bash
# Store a single fact
hindsight retain my-bank "Alice joined Google in March 2024 as a Senior ML Engineer"
hindsight memory retain my-bank "Alice joined Google in March 2024 as a Senior ML Engineer"
# Store from a file
hindsight retain my-bank --file conversation.txt --context "Daily standup"
hindsight memory retain-files my-bank conversation.txt --context "Daily standup"
# Store multiple files
hindsight retain my-bank --files docs/*.md
hindsight memory retain-files my-bank docs/
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mainMethodsGo} section="main-retain" language="go" />
</TabItem>
</Tabs>
@@ -66,18 +70,21 @@ Search for relevant memories using multi-strategy retrieval.
```bash
# Basic search
hindsight recall my-bank "What does Alice do at Google?"
hindsight memory recall my-bank "What does Alice do at Google?"
# Search with options
hindsight recall my-bank "What happened last spring?" \
hindsight memory recall my-bank "What happened last spring?" \
--budget high \
--max-tokens 8192 \
--fact-type world
--fact-type world,experience
# Verbose output (shows weights and sources)
hindsight recall my-bank "Tell me about Alice" -v
# Verbose output
hindsight memory recall my-bank "Tell me about Alice" -v
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mainMethodsGo} section="main-recall" language="go" />
</TabItem>
</Tabs>
@@ -102,15 +109,15 @@ Generate disposition-aware responses using memories and observations.
```bash
# Basic reflect
hindsight reflect my-bank "Should we adopt TypeScript for our backend?"
# Verbose output (shows sources and observations)
hindsight reflect my-bank "What are Alice's strengths for the team lead role?" -v
hindsight memory reflect my-bank "Should we adopt TypeScript for our backend?"
# With higher reasoning budget
hindsight reflect my-bank "Analyze our tech stack" --budget high
hindsight memory reflect my-bank "Analyze our tech stack" --budget high
```
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mainMethodsGo} section="main-reflect" language="go" />
</TabItem>
</Tabs>
@@ -13,8 +13,12 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
{/* Import raw source files */}
import memoryBanksPy from '!!raw-loader!@site/examples/api/memory-banks.py';
import memoryBanksMjs from '!!raw-loader!@site/examples/api/memory-banks.mjs';
import memoryBanksSh from '!!raw-loader!@site/examples/api/memory-banks.sh';
import memoryBanksGo from '!!raw-loader!@site/examples/api/memory-banks.go';
import directivesPy from '!!raw-loader!@site/examples/api/directives.py';
import directivesMjs from '!!raw-loader!@site/examples/api/directives.mjs';
import directivesSh from '!!raw-loader!@site/examples/api/directives.sh';
import directivesGo from '!!raw-loader!@site/examples/api/directives.go';
## What is a Memory Bank?
@@ -44,11 +48,10 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
<CodeSnippet code={memoryBanksMjs} section="create-bank" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
hindsight bank create my-bank
```
<CodeSnippet code={memoryBanksSh} section="create-bank" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="create-bank" language="go" />
</TabItem>
</Tabs>
@@ -205,6 +208,12 @@ How skeptical vs trusting the bank is when evaluating claims during `reflect`. S
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="bank-with-disposition" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="bank-with-disposition" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="bank-with-disposition" language="go" />
</TabItem>
</Tabs>
| Value | Behaviour |
@@ -275,6 +284,12 @@ Bank configuration fields (retain mission, extraction mode, observations mission
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="update-bank-config" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="update-bank-config" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="update-bank-config" language="go" />
</TabItem>
</Tabs>
You can update any subset of fields — only the keys you provide are changed.
@@ -288,6 +303,12 @@ You can update any subset of fields — only the keys you provide are changed.
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="get-bank-config" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="get-bank-config" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="get-bank-config" language="go" />
</TabItem>
</Tabs>
The response distinguishes:
@@ -303,6 +324,12 @@ The response distinguishes:
<TabItem value="node" label="Node.js">
<CodeSnippet code={memoryBanksMjs} section="reset-bank-config" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={memoryBanksSh} section="reset-bank-config" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={memoryBanksGo} section="reset-bank-config" language="go" />
</TabItem>
</Tabs>
This removes all bank-level overrides. The bank reverts to server-wide defaults (set via environment variables).
@@ -337,6 +364,12 @@ Use directives for rules that must never be violated:
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="create-directive" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="create-directive" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="create-directive" language="go" />
</TabItem>
</Tabs>
### Listing Directives
@@ -348,6 +381,12 @@ Use directives for rules that must never be violated:
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="list-directives" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="list-directives" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="list-directives" language="go" />
</TabItem>
</Tabs>
### Updating Directives
@@ -359,6 +398,12 @@ Use directives for rules that must never be violated:
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="update-directive" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="update-directive" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="update-directive" language="go" />
</TabItem>
</Tabs>
### Deleting Directives
@@ -370,6 +415,12 @@ Use directives for rules that must never be violated:
<TabItem value="node" label="Node.js">
<CodeSnippet code={directivesMjs} section="delete-directive" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={directivesSh} section="delete-directive" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={directivesGo} section="delete-directive" language="go" />
</TabItem>
</Tabs>
### Directives vs Disposition
@@ -12,6 +12,9 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
{/* Import raw source files */}
import mentalModelsPy from '!!raw-loader!@site/examples/api/mental-models.py';
import mentalModelsMjs from '!!raw-loader!@site/examples/api/mental-models.mjs';
import mentalModelsSh from '!!raw-loader!@site/examples/api/mental-models.sh';
import mentalModelsGo from '!!raw-loader!@site/examples/api/mental-models.go';
## What Are Mental Models?
@@ -56,22 +59,14 @@ Creating a mental model runs a reflect operation in the background and saves the
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="create-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="create-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Create a mental model (async operation)
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
-H "Content-Type: application/json" \
-d '{
"name": "Team Communication Preferences",
"source_query": "How does the team prefer to communicate?",
"tags": ["team"]
}'
# Response: {"operation_id": "op-123"}
# Use the operations endpoint to check completion
```
<CodeSnippet code={mentalModelsSh} section="create-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="create-mental-model" language="go" />
</TabItem>
</Tabs>
@@ -81,12 +76,38 @@ curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
|-----------|------|----------|-------------|
| `name` | string | Yes | Human-readable name for the mental model |
| `source_query` | string | Yes | The query to run to generate content |
| `id` | string | No | Custom ID for the mental model (alphanumeric lowercase with hyphens). Auto-generated if omitted. |
| `tags` | list | No | Tags for filtering during retrieval |
| `max_tokens` | int | No | Maximum tokens for the mental model content |
| `trigger` | object | No | Trigger settings (see [Automatic Refresh](#automatic-refresh)) |
---
## Create with Custom ID
Assign a stable, human-readable ID to a mental model so you can retrieve or update it by name instead of relying on the auto-generated UUID:
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="create-mental-model-with-id" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="create-mental-model-with-id" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="create-mental-model-with-id" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="create-mental-model-with-id" language="go" />
</TabItem>
</Tabs>
:::tip
Custom IDs must be lowercase alphanumeric and may contain hyphens (e.g. `team-policies`, `q4-status`). If a mental model with that ID already exists, the request is rejected.
:::
---
## Automatic Refresh
Mental models can be configured to **automatically refresh** when observations are updated. This keeps them in sync with the latest knowledge without manual intervention.
@@ -103,19 +124,14 @@ When `refresh_after_consolidation` is enabled, the mental model will be re-gener
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="create-mental-model-with-trigger" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="create-mental-model-with-trigger" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
# Create a mental model with automatic refresh enabled
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
-H "Content-Type: application/json" \
-d '{
"name": "Project Status",
"source_query": "What is the current project status?",
"trigger": {"refresh_after_consolidation": true}
}'
```
<CodeSnippet code={mentalModelsSh} section="create-mental-model-with-trigger" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="create-mental-model-with-trigger" language="go" />
</TabItem>
</Tabs>
@@ -140,12 +156,14 @@ Enable automatic refresh for mental models that need to stay current. Disable it
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="list-mental-models" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="list-mental-models" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models"
```
<CodeSnippet code={mentalModelsSh} section="list-mental-models" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="list-mental-models" language="go" />
</TabItem>
</Tabs>
@@ -157,12 +175,14 @@ curl "http://localhost:8888/v1/default/banks/my-bank/mental-models"
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="get-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="get-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
```
<CodeSnippet code={mentalModelsSh} section="get-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="get-mental-model" language="go" />
</TabItem>
</Tabs>
@@ -190,12 +210,14 @@ Re-run the source query to update the mental model with current knowledge:
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="refresh-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="refresh-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}/refresh"
```
<CodeSnippet code={mentalModelsSh} section="refresh-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="refresh-mental-model" language="go" />
</TabItem>
</Tabs>
@@ -214,14 +236,14 @@ Update the mental model's name:
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="update-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="update-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
curl -X PATCH "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}" \
-H "Content-Type: application/json" \
-d '{"name": "Updated Team Communication Preferences"}'
```
<CodeSnippet code={mentalModelsSh} section="update-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="update-mental-model" language="go" />
</TabItem>
</Tabs>
@@ -233,12 +255,14 @@ curl -X PATCH "http://localhost:8888/v1/default/banks/my-bank/mental-models/{men
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="delete-mental-model" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="delete-mental-model" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
```bash
curl -X DELETE "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
```
<CodeSnippet code={mentalModelsSh} section="delete-mental-model" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="delete-mental-model" language="go" />
</TabItem>
</Tabs>
@@ -280,6 +304,15 @@ Every time a mental model's content changes (via refresh or manual update), the
<TabItem value="python" label="Python">
<CodeSnippet code={mentalModelsPy} section="get-mental-model-history" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={mentalModelsMjs} section="get-mental-model-history" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={mentalModelsSh} section="get-mental-model-history" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={mentalModelsGo} section="get-mental-model-history" language="go" />
</TabItem>
</Tabs>
### Response
@@ -9,12 +9,13 @@ Get up and running with Hindsight in 60 seconds.
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CodeSnippet from '@site/src/components/CodeSnippet';
import {ClientsGrid, IntegrationsGrid} from '@site/src/components/SupportedGrids';
import {ClientsGrid} from '@site/src/components/SupportedGrids';
{/* Import raw source files */}
import quickstartPy from '!!raw-loader!@site/examples/api/quickstart.py';
import quickstartMjs from '!!raw-loader!@site/examples/api/quickstart.mjs';
import quickstartSh from '!!raw-loader!@site/examples/api/quickstart.sh';
import quickstartGo from '!!raw-loader!@site/examples/api/quickstart.go';
## Clients
@@ -90,6 +91,15 @@ curl -fsSL https://hindsight.vectorize.io/get-cli | bash
<CodeSnippet code={quickstartSh} section="quickstart-full" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
```bash
go get github.com/vectorize-io/hindsight/hindsight-clients/go
```
<CodeSnippet code={quickstartGo} section="quickstart-full" language="go" />
</TabItem>
</Tabs>
@@ -107,7 +117,7 @@ curl -fsSL https://hindsight.vectorize.io/get-cli | bash
## Integrations
<IntegrationsGrid />
Browse all supported integrations in the [Integrations Hub](/integrations).
## Next Steps
@@ -16,6 +16,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
import recallPy from '!!raw-loader!@site/examples/api/recall.py';
import recallMjs from '!!raw-loader!@site/examples/api/recall.mjs';
import recallSh from '!!raw-loader!@site/examples/api/recall.sh';
import recallGo from '!!raw-loader!@site/examples/api/recall.go';
:::info How Recall Works
Learn about the four retrieval strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
@@ -37,6 +38,9 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-basic" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-basic" language="go" />
</TabItem>
</Tabs>
---
@@ -59,9 +63,19 @@ Each type runs the full four-strategy retrieval pipeline independently, so narro
<CodeSnippet code={recallPy} section="recall-experience-only" language="python" />
<CodeSnippet code={recallPy} section="recall-observations-only" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-world-only" language="javascript" />
<CodeSnippet code={recallMjs} section="recall-experience-only" language="javascript" />
<CodeSnippet code={recallMjs} section="recall-observations-only" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-fact-type" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-world-only" language="go" />
<CodeSnippet code={recallGo} section="recall-experience-only" language="go" />
<CodeSnippet code={recallGo} section="recall-observations-only" language="go" />
</TabItem>
</Tabs>
:::tip About Observations
@@ -79,6 +93,12 @@ Controls retrieval depth and breadth. Accepted values are `low`, `mid` (default)
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-budget-levels" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-budget-levels" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-budget-levels" language="go" />
</TabItem>
</Tabs>
### max_tokens
@@ -89,6 +109,15 @@ The maximum number of tokens the returned facts can collectively occupy. Default
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-token-budget" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-token-budget" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-token-budget" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-token-budget" language="go" />
</TabItem>
</Tabs>
### query_timestamp
@@ -118,6 +147,12 @@ When enabled and `types` includes `observation`, each observation result is acco
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-source-facts" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-source-facts" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-source-facts" language="go" />
</TabItem>
</Tabs>
#### entities
@@ -152,7 +187,20 @@ Consider a bank with these four memories:
Returns memories that have **at least one** matching tag, plus untagged memories.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-any" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-any" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-any" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-with-tags" language="go" />
</TabItem>
</Tabs>
Use this for **shared global knowledge + user-specific** patterns, where untagged memories represent information everyone should see.
@@ -160,7 +208,20 @@ Use this for **shared global knowledge + user-specific** patterns, where untagge
Same as `any` but untagged memories are excluded.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-any-strict" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-any-strict" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-any-strict" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-strict" language="go" />
</TabItem>
</Tabs>
Use this when memories are **fully partitioned by tags** and untagged memories should never be visible.
@@ -168,7 +229,20 @@ Use this when memories are **fully partitioned by tags** and untagged memories s
Returns memories that have **every** specified tag, plus untagged memories.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-all-mode" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-all-mode" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-all-mode" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-all-mode" language="go" />
</TabItem>
</Tabs>
Use this when memories must belong to a **specific intersection** of scopes (e.g., only memories relevant to both a user and a project), while still surfacing shared global knowledge.
@@ -176,7 +250,20 @@ Use this when memories must belong to a **specific intersection** of scopes (e.g
Returns memories that have **every** specified tag, and excludes untagged memories.
<Tabs>
<TabItem value="python" label="Python">
<CodeSnippet code={recallPy} section="recall-tags-all-strict" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={recallMjs} section="recall-tags-all-strict" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={recallSh} section="recall-tags-all-strict" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={recallGo} section="recall-tags-all" language="go" />
</TabItem>
</Tabs>
Use this for strict scope enforcement where a memory must explicitly belong to **all** specified contexts.
@@ -16,6 +16,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
import reflectPy from '!!raw-loader!@site/examples/api/reflect.py';
import reflectMjs from '!!raw-loader!@site/examples/api/reflect.mjs';
import reflectSh from '!!raw-loader!@site/examples/api/reflect.sh';
import reflectGo from '!!raw-loader!@site/examples/api/reflect.go';
:::info How Reflect Works
Learn about disposition-driven reasoning in the [Reflect Architecture](/developer/reflect) guide.
@@ -37,6 +38,9 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-basic" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-basic" language="go" />
</TabItem>
</Tabs>
---
@@ -58,6 +62,12 @@ Controls how thoroughly the agent explores the memory bank before answering. Acc
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-with-params" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-with-params" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-with-params" language="go" />
</TabItem>
</Tabs>
### max_tokens
@@ -78,6 +88,9 @@ An optional JSON Schema object. When provided, the LLM generates a response that
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-structured-output" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-structured-output" language="go" />
</TabItem>
</Tabs>
### tags
@@ -88,6 +101,15 @@ Filters which memories the agent can access during reflection. Works identically
<TabItem value="python" label="Python">
<CodeSnippet code={reflectPy} section="reflect-with-tags" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-with-tags" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-with-tags" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-with-tags" language="go" />
</TabItem>
</Tabs>
### include
@@ -102,6 +124,15 @@ When enabled, the response includes a `based_on` object listing the memories, me
<TabItem value="python" label="Python">
<CodeSnippet code={reflectPy} section="reflect-sources" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={reflectMjs} section="reflect-sources" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={reflectSh} section="reflect-sources" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={reflectGo} section="reflect-sources" language="go" />
</TabItem>
</Tabs>
#### include.tool_calls
+40 -6
View File
@@ -16,6 +16,7 @@ import CodeSnippet from '@site/src/components/CodeSnippet';
import retainPy from '!!raw-loader!@site/examples/api/retain.py';
import retainMjs from '!!raw-loader!@site/examples/api/retain.mjs';
import retainSh from '!!raw-loader!@site/examples/api/retain.sh';
import retainGo from '!!raw-loader!@site/examples/api/retain.go';
:::info How Retain Works
Learn about fact extraction, entity resolution, and graph construction in the [Retain Architecture](/developer/retain) guide.
@@ -39,6 +40,9 @@ A single retain call accepts one or more **items**. Each item is a piece of raw
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-basic" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-basic" language="go" />
</TabItem>
</Tabs>
### Retaining a Conversation
@@ -52,6 +56,12 @@ A full conversation should be retained as a single item. The LLM can parse any f
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-conversation" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-conversation" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-conversation" language="go" />
</TabItem>
</Tabs>
When the conversation grows — a new message arrives — just retain again with the full updated content and the same `document_id`. Hindsight will delete the previous version and reprocess from scratch, so memories always reflect the latest state of the conversation.
@@ -92,6 +102,9 @@ Providing context consistently is one of the highest-leverage things you can do
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-with-context" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-with-context" language="go" />
</TabItem>
</Tabs>
### metadata
@@ -203,6 +216,12 @@ Multiple items can be submitted in a single request. Batch ingestion is the reco
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-batch" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-batch" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-batch" language="go" />
</TabItem>
</Tabs>
@@ -215,18 +234,18 @@ Upload files directly — Hindsight converts them to text and extracts memories
**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, PNG, GIF, etc. — OCR), audio (MP3, WAV, FLAC, etc. — transcription), HTML, and plain text formats (TXT, MD, CSV, JSON, YAML, etc.)
<Tabs>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
</TabItem>
<TabItem value="curl" label="HTTP">
<CodeSnippet code={retainSh} section="retain-files-curl" language="bash" />
</TabItem>
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-files" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-files" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-files" language="go" />
</TabItem>
</Tabs>
The file retain endpoint always returns asynchronously. The response contains `operation_ids` — one per uploaded file — which you can poll via `GET /v1/default/banks/{bank_id}/operations` to track progress.
@@ -237,6 +256,15 @@ Upload up to 10 files per request (max 100 MB total). Each file becomes a separa
<TabItem value="python" label="Python">
<CodeSnippet code={retainPy} section="retain-files-batch" language="python" />
</TabItem>
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-files-batch" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-files" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-files" language="go" />
</TabItem>
</Tabs>
:::info File Storage
@@ -256,6 +284,12 @@ For large batches, use async ingestion to avoid blocking your application:
<TabItem value="node" label="Node.js">
<CodeSnippet code={retainMjs} section="retain-async" language="javascript" />
</TabItem>
<TabItem value="cli" label="CLI">
<CodeSnippet code={retainSh} section="retain-async" language="bash" />
</TabItem>
<TabItem value="go" label="Go">
<CodeSnippet code={retainGo} section="retain-async" language="go" />
</TabItem>
</Tabs>
When `async: true`, the call returns immediately with an `operation_id`. Processing runs in the background via the worker service. No `usage` metrics are returned for async operations.
@@ -892,6 +892,7 @@ export HINDSIGHT_API_OBSERVATIONS_MISSION="Observations are recurring patterns i
|----------|-------------|---------|
| `HINDSIGHT_API_REFLECT_MAX_ITERATIONS` | Max tool call iterations before forcing a response | `10` |
| `HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS` | Max accumulated context tokens in the reflect loop before forcing final synthesis. Prevents `context_length_exceeded` errors on large banks. Lower this if your LLM has a context window smaller than 128K. | `100000` |
| `HINDSIGHT_API_REFLECT_WALL_TIMEOUT` | Wall-clock timeout in seconds for the entire reflect operation. If exceeded, the request returns HTTP 504. | `300` |
| `HINDSIGHT_API_REFLECT_MISSION` | Global reflect mission (identity and reasoning framing). Overridden per bank via config API. | - |
#### Disposition
+2 -2
View File
@@ -3,7 +3,7 @@ sidebar_position: 1
slug: /
---
import {ClientsGrid, IntegrationsGrid} from '@site/src/components/SupportedGrids';
import {ClientsGrid} from '@site/src/components/SupportedGrids';
# Overview
@@ -122,7 +122,7 @@ These settings only affect the `reflect` operation, not `recall`.
## Integrations
<IntegrationsGrid />
Browse all supported integrations in the [Integrations Hub](/integrations).
## Next Steps
@@ -6,6 +6,8 @@ sidebar_position: 4
The `@vectorize-io/hindsight-ai-sdk` package integrates [Hindsight](https://hindsight.vectorize.io) memory with the [Vercel AI SDK](https://ai-sdk.dev). It provides five ready-to-use tools for retaining, recalling, and reflecting on long-term memories.
[View Changelog →](/changelog/integrations/ai-sdk)
import CodeSnippet from '@site/src/components/CodeSnippet';
import aiSdkTs from '!!raw-loader!@site/examples/integrations/ai-sdk.ts';
@@ -6,6 +6,8 @@ sidebar_position: 5
We built `@vectorize-io/hindsight-chat` to give [Vercel Chat SDK](https://github.com/vercel/chat) bots persistent, per-user memory with a single handler wrapper. The integration works across Slack, Discord, Teams, Google Chat, GitHub, and Linear — no custom plumbing required.
[View Changelog →](/changelog/integrations/chat)
## Installation
```bash
@@ -6,6 +6,8 @@ sidebar_position: 5
Persistent memory for AI agent crews via [CrewAI](https://github.com/crewAIInc/crewAI). Give your crews long-term memory with fact extraction, entity tracking, and temporal awareness.
[View Changelog →](/changelog/integrations/crewai)
## Features
- **Drop-in Storage Backend** - Implements CrewAI's `Storage` interface for `ExternalMemory`
@@ -0,0 +1,317 @@
---
sidebar_position: 7
---
# LangGraph / LangChain
Persistent long-term memory for [LangGraph](https://langchain-ai.github.io/langgraph/) and [LangChain](https://python.langchain.com/) agents via Hindsight. Three integration patterns at different abstraction levels — the tools pattern works with both LangChain and LangGraph, while nodes and the BaseStore adapter are LangGraph-specific.
[View Changelog →](/changelog/integrations/langgraph)
## Features
- **Memory Tools** — retain, recall, and reflect as LangChain `@tool` functions compatible with `bind_tools()` and `ToolNode`. Works with **both LangChain and LangGraph** — no LangGraph dependency required for this pattern.
- **Graph Nodes** *(LangGraph)* — Pre-built nodes that auto-inject memories before LLM calls and auto-store after responses
- **BaseStore Adapter** *(LangGraph)* — Drop-in `BaseStore` implementation backed by Hindsight, for LangGraph's native memory patterns
- **Dynamic Banks** — Resolve bank IDs per-request from `RunnableConfig` for per-user memory
- **Async-Native** — Uses `aretain`, `arecall`, `areflect` directly — no thread-pool workarounds
## Installation
```bash
pip install hindsight-langgraph
```
## Quick Start: Tools (LangChain & LangGraph)
The tools pattern creates standard LangChain `@tool` functions that work with any LangChain-compatible model via `bind_tools()`. You can use them with a LangGraph agent or with plain LangChain — no LangGraph required.
**With LangGraph (recommended):**
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
agent = create_react_agent(ChatOpenAI(model="gpt-4o"), tools=tools)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Remember that I prefer dark mode"}]}
)
```
**With plain LangChain:**
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_hindsight_tools
from langchain_openai import ChatOpenAI
client = Hindsight(base_url="http://localhost:8888")
tools = create_hindsight_tools(client=client, bank_id="user-123")
model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
response = await model.ainvoke("Remember that I prefer dark mode")
```
When using plain LangChain, you handle the tool execution loop yourself — call the model, check for `tool_calls`, execute them, and feed results back. LangGraph automates this loop for you.
The agent gets three tools it can call:
- **`hindsight_retain`** — Store information to long-term memory
- **`hindsight_recall`** — Search long-term memory for relevant facts
- **`hindsight_reflect`** — Synthesize a reasoned answer from memories
## Quick Start: Memory Nodes (LangGraph)
Add recall and retain nodes to your graph for automatic memory injection and storage.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import create_recall_node, create_retain_node
from langgraph.graph import StateGraph, MessagesState, START, END
client = Hindsight(base_url="http://localhost:8888")
recall = create_recall_node(client=client, bank_id="user-123")
retain = create_retain_node(client=client, bank_id="user-123")
builder = StateGraph(MessagesState)
builder.add_node("recall", recall)
builder.add_node("agent", agent_node) # your LLM node
builder.add_node("retain", retain)
builder.add_edge(START, "recall")
builder.add_edge("recall", "agent")
builder.add_edge("agent", "retain")
builder.add_edge("retain", END)
graph = builder.compile()
```
The recall node extracts the latest user message, searches Hindsight, and injects matching memories as a `SystemMessage`. The retain node stores human messages (optionally AI messages too) after the response.
## Quick Start: BaseStore (LangGraph)
Use Hindsight as a LangGraph `BaseStore` for cross-thread persistent memory with semantic search.
```python
from hindsight_client import Hindsight
from hindsight_langgraph import HindsightStore
client = Hindsight(base_url="http://localhost:8888")
store = HindsightStore(client=client)
graph = builder.compile(checkpointer=checkpointer, store=store)
# Store and search via the store API
await store.aput(("user", "123", "prefs"), "theme", {"value": "dark mode"})
results = await store.asearch(("user", "123", "prefs"), query="theme preference")
```
Namespace tuples are mapped to Hindsight bank IDs with `.` as separator (e.g., `("user", "123")` becomes bank `user.123`). Banks are auto-created on first access.
## Dynamic Bank IDs
Both nodes and the store support per-user bank resolution from `RunnableConfig`:
```python
recall = create_recall_node(client=client, bank_id_from_config="user_id")
retain = create_retain_node(client=client, bank_id_from_config="user_id")
# Bank ID resolved at runtime from config
result = await graph.ainvoke(
{"messages": [{"role": "user", "content": "hello"}]},
config={"configurable": {"user_id": "user-456"}},
)
```
## Selecting Tools
Include only the tools you need:
```python
tools = create_hindsight_tools(
client=client,
bank_id="user-123",
include_retain=True,
include_recall=True,
include_reflect=False, # Omit reflect
)
```
## Global Configuration
Instead of passing a client to every call, configure once:
```python
from hindsight_langgraph import configure, create_hindsight_tools
configure(
hindsight_api_url="http://localhost:8888",
api_key="your-api-key", # Or set HINDSIGHT_API_KEY env var
budget="mid", # Recall budget: low/mid/high
max_tokens=4096, # Max tokens for recall results
tags=["env:prod"], # Tags for stored memories
recall_tags=["scope:global"], # Tags to filter recall
recall_tags_match="any", # Tag match mode: any/all/any_strict/all_strict
)
# Now create tools without passing client — uses global config
tools = create_hindsight_tools(bank_id="user-123")
```
## Retain Node Options
```python
retain = create_retain_node(
client=client,
bank_id="user-123",
retain_human=True, # Store human messages (default: True)
retain_ai=False, # Store AI responses (default: False)
tags=["source:chat"], # Tags applied to stored memories
)
```
## Recall Node Options
```python
recall = create_recall_node(
client=client,
bank_id="user-123",
budget="low", # Recall budget: low/mid/high
max_results=10, # Max memories injected
max_tokens=4096, # Max tokens for recall
tags=["scope:user"], # Filter by tags
tags_match="all", # Tag match mode
)
```
### Using `output_key` for Prompt Control
By default, the recall node appends a `SystemMessage` to `messages`. Use `output_key` to write memory text to a custom state field instead, giving you full control over prompt ordering:
```python
from typing import Optional
from langgraph.graph import MessagesState
class AgentState(MessagesState):
memory_context: Optional[str] = None
recall = create_recall_node(
client=client,
bank_id="user-123",
output_key="memory_context",
)
# In your agent node, read state["memory_context"] and prepend it
# to the system prompt before calling the model.
```
## Limitations and Notes
### HindsightStore
- **Async-only.** All sync methods (`batch`, `get`, `put`, `delete`, `search`, `list_namespaces`) raise `NotImplementedError`. Use the async variants (`abatch`, `aget`, `aput`, `adelete`, `asearch`, `alist_namespaces`) instead.
- **`get()` relies on recall.** There is no direct key lookup — the key is used as a recall query and only exact `document_id` matches are returned. Items that do not rank in the top recall results may appear missing.
- **`list_namespaces` is session-scoped.** It only tracks namespaces that have been written to via `aput()` during the current process. After a restart, `list_namespaces` returns empty even though data still exists in Hindsight.
- **`delete` is a no-op.** Calling `adelete()` logs a debug message but does not remove data from Hindsight. Hindsight's memory model is append-oriented; fact superseding is handled automatically during retain.
### Memory Nodes
- **SystemMessage ordering.** The recall node adds a `SystemMessage` with recalled memories. Because `MessagesState` uses `add_messages` (which appends), this message appears after existing messages rather than at position 0. The message has a stable ID (`hindsight_memory_context`) so it is updated rather than duplicated across invocations. If your LLM provider requires system messages first, sort or filter messages in your agent node before passing them to the model.
### Error Handling
- **Tools** raise `HindsightError` on failure, which surfaces to the agent as a tool error.
- **Nodes** silently log errors and return empty messages, so a Hindsight outage does not crash your graph.
## API Reference
### `create_hindsight_tools()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | *required* | Hindsight memory bank ID |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
| `max_tokens` | `4096` | Maximum tokens for recall results |
| `tags` | `None` | Tags applied when storing memories |
| `recall_tags` | `None` | Tags to filter when searching |
| `recall_tags_match` | `"any"` | Tag matching mode (any/all/any\_strict/all\_strict) |
| `retain_metadata` | `None` | Default metadata dict for retain operations |
| `retain_document_id` | `None` | Default document\_id for retain (groups/upserts memories) |
| `recall_types` | `None` | Fact types to filter (world, experience, opinion, observation) |
| `recall_include_entities` | `False` | Include entity information in recall results |
| `reflect_context` | `None` | Additional context for reflect operations |
| `reflect_max_tokens` | `None` | Max tokens for reflect results (defaults to `max_tokens`) |
| `reflect_response_schema` | `None` | JSON schema to constrain reflect output format |
| `reflect_tags` | `None` | Tags to filter memories used in reflect (defaults to `recall_tags`) |
| `reflect_tags_match` | `None` | Tag matching for reflect (defaults to `recall_tags_match`) |
| `include_retain` | `True` | Include the retain (store) tool |
| `include_recall` | `True` | Include the recall (search) tool |
| `include_reflect` | `True` | Include the reflect (synthesize) tool |
### `create_recall_node()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `budget` | `"mid"` | Recall budget level |
| `max_tokens` | `4096` | Max tokens for recall results |
| `max_results` | `10` | Max memories to inject |
| `tags` | `None` | Tags to filter recall results |
| `tags_match` | `"any"` | Tag matching mode |
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
| `output_key` | `None` | If set, write memory text to this state key instead of appending a SystemMessage to `messages` |
### `create_retain_node()`
| Parameter | Default | Description |
|---|---|---|
| `bank_id` | `None` | Static bank ID (or use `bank_id_from_config`) |
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `tags` | `None` | Tags applied to stored memories |
| `bank_id_from_config` | `"user_id"` | Config key to resolve bank ID at runtime |
| `retain_human` | `True` | Store human messages |
| `retain_ai` | `False` | Store AI responses |
### `HindsightStore()`
| Parameter | Default | Description |
|---|---|---|
| `client` | `None` | Pre-configured Hindsight client |
| `hindsight_api_url` | `None` | API URL (used if no client provided) |
| `api_key` | `None` | API key (used if no client provided) |
| `tags` | `None` | Tags applied to all retain operations |
### `configure()`
| Parameter | Default | Description |
|---|---|---|
| `hindsight_api_url` | Production API | Hindsight API URL |
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
| `budget` | `"mid"` | Default recall budget level |
| `max_tokens` | `4096` | Default max tokens for recall |
| `tags` | `None` | Default tags for retain operations |
| `recall_tags` | `None` | Default tags to filter recall |
| `recall_tags_match` | `"any"` | Default tag matching mode |
| `verbose` | `False` | Enable verbose logging |
## Requirements
- Python >= 3.10
- langchain-core >= 0.3.0
- hindsight-client >= 0.4.0
- langgraph >= 0.3.0 *(only for nodes and store patterns — install with `pip install hindsight-langgraph[langgraph]`)*
@@ -6,6 +6,8 @@ sidebar_position: 1
Universal LLM memory integration via [LiteLLM](https://github.com/BerriAI/litellm). Add persistent memory to any LLM application with just a few lines of code.
[View Changelog →](/changelog/integrations/litellm)
## Features
- **Universal LLM Support** - Works with 100+ LLM providers via LiteLLM (OpenAI, Anthropic, Groq, Azure, AWS Bedrock, Google Vertex AI, and more)
@@ -0,0 +1,248 @@
---
sidebar_position: 5
---
# NemoClaw
Persistent memory for [NemoClaw](https://nemoclaw.ai) sandboxed agents using [Hindsight](https://hindsight.vectorize.io).
NemoClaw runs [OpenClaw](https://openclaw.ai) inside an OpenShell sandbox with controlled filesystem, process, and network egress policies. The `hindsight-nemoclaw` package automates adding Hindsight memory to a sandbox in one command — no code changes required.
[View Changelog →](/changelog/integrations/nemoclaw)
## Quick Start
```bash
npx @vectorize-io/hindsight-nemoclaw setup \
--sandbox my-assistant \
--api-url https://api.hindsight.vectorize.io \
--api-token <your-api-key> \
--bank-prefix my-sandbox
```
Get an API key at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup).
You'll see output like:
```
[0] Preflight checks...
✓ openshell found
✓ openclaw found
[1] Installing @vectorize-io/hindsight-openclaw plugin...
✓ Plugin installed
[2] Configuring plugin in ~/.openclaw/openclaw.json...
✓ Plugin config written (bank: my-sandbox-openclaw)
[3] Applying Hindsight network policy to sandbox "my-assistant"...
✓ Policy version 2 submitted
✓ Policy version 2 loaded (active version: 2)
[4] Restarting OpenClaw gateway...
✓ Gateway restarted
✓ Setup complete!
```
## How It Works
### The sandbox problem
OpenShell enforces strict network egress — every outbound endpoint must be explicitly permitted in the sandbox policy. By default, the Hindsight API (`api.hindsight.vectorize.io`) is not in that list.
The `hindsight-openclaw` plugin supports **external API mode**, where it skips the local daemon entirely and makes direct HTTPS calls to Hindsight Cloud. This is the natural fit for sandboxed environments: the plugin becomes a thin HTTP client, and the only sandbox change needed is one egress rule.
### What the setup command does
1. **Preflight** — verifies `openshell` and `openclaw` are installed
2. **Install plugin** — runs `openclaw plugins install @vectorize-io/hindsight-openclaw`
3. **Configure plugin** — writes external API mode config to `~/.openclaw/openclaw.json`
4. **Apply policy** — reads the current sandbox policy, merges the Hindsight egress block, and re-applies via `openshell policy set`
5. **Restart gateway** — runs `openclaw gateway restart`
### Memory flow
Once set up, the `hindsight-openclaw` plugin hooks into the OpenClaw gateway lifecycle:
- **`before_agent_start`** — recalls relevant memories from past sessions and injects them into context
- **`agent_end`** — retains the conversation to the Hindsight memory bank
The sandbox doesn't interfere with either step — it sees the Hindsight calls as normal HTTPS egress to a permitted endpoint.
## CLI Reference
```
hindsight-nemoclaw setup [options]
Options:
--sandbox <name> NemoClaw sandbox name (required)
--api-url <url> Hindsight API URL (required)
--api-token <token> Hindsight API token (required)
--bank-prefix <prefix> Memory bank prefix (default: "nemoclaw")
--skip-policy Skip sandbox network policy update
--skip-plugin-install Skip openclaw plugin installation
--dry-run Preview changes without applying
--help Show help
```
Use `--dry-run` to preview all changes before applying anything. Use `--skip-policy` if you manage sandbox policies manually.
## Manual Setup
If you prefer to apply the steps yourself instead of using the CLI:
### 1. Install the plugin
```bash
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### 2. Configure `~/.openclaw/openclaw.json`
```json
{
"plugins": {
"entries": {
"hindsight-openclaw": {
"enabled": true,
"config": {
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "<your-api-key>",
"llmProvider": "claude-code",
"dynamicBankId": false,
"bankIdPrefix": "my-sandbox"
}
}
}
}
}
```
`llmProvider: "claude-code"` uses the Claude Code process already present in the sandbox — no additional API key needed.
### 3. Add the Hindsight network policy
`openshell policy set` replaces the entire policy document. Export your current policy first, add the Hindsight block, then re-apply:
```yaml
network_policies:
hindsight:
name: hindsight
endpoints:
- host: api.hindsight.vectorize.io
port: 443
protocol: rest
tls: terminate
enforcement: enforce
rules:
- allow:
method: GET
path: /**
- allow:
method: POST
path: /**
- allow:
method: PUT
path: /**
binaries:
- path: /usr/local/bin/openclaw
```
```bash
openshell policy set my-sandbox --policy /path/to/full-policy.yaml --wait
openclaw gateway restart
```
## Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
| `hindsightApiUrl` | string | — | Hindsight API base URL |
| `hindsightApiToken` | string | — | API token for authentication |
| `llmProvider` | string | auto-detect | LLM provider for memory extraction |
| `dynamicBankId` | boolean | `false` | Isolate memory per user (`true`) or share across sessions (`false`) |
| `bankIdPrefix` | string | `"nemoclaw"` | Prefix for the memory bank name |
### Bank naming
When `dynamicBankId: false`, all sessions write to a single bank named `{bankIdPrefix}-openclaw`. When `dynamicBankId: true`, each user gets an isolated bank — useful for multi-tenant deployments.
## Verifying It Works
After setup, check the gateway logs:
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
```
On startup you should see:
```
[Hindsight] Plugin loaded successfully
[Hindsight] ✓ Using external API: https://api.hindsight.vectorize.io
[Hindsight] External API health: {"status":"healthy","database":"connected"}
[Hindsight] Default bank: my-sandbox-openclaw
[Hindsight] ✓ Ready (external API mode)
```
After a conversation:
```
[Hindsight] before_agent_start - bank: my-sandbox-openclaw, channel: undefined/webchat
[Hindsight Hook] agent_end triggered - bank: my-sandbox-openclaw
[Hindsight] Retained 6 messages to bank my-sandbox-openclaw for session agent:main:...
```
## Pitfalls
### Policy replacement is full-document
`openshell policy set` replaces the entire policy document. The `hindsight-nemoclaw setup` command handles this automatically. If you're applying manually, export the current policy first so existing rules aren't lost.
### LaunchAgent can't follow symlinks on macOS
On macOS, the OpenClaw gateway runs as a LaunchAgent under a restricted security context. `openclaw plugins install --link` creates a symlink the LaunchAgent can't follow — the setup command installs as a copy instead. If you see `EPERM: operation not permitted, scandir` in gateway logs, this is the cause.
### Memory retention is asynchronous
Fact extraction and entity resolution happen in the background after `retain`. If you open a new session immediately after closing one, the most recent memories may not be indexed yet — typically a few seconds.
### Binary-scoped egress
The `binaries` field in the network policy restricts the egress rule to a specific executable path. If OpenClaw updates and the binary path changes, the rule silently stops working. Check your binary path after upgrades.
## Troubleshooting
### Plugin not loading
```bash
openclaw plugins list | grep hindsight
# Should show: ✓ enabled │ Hindsight Memory │ ...
# Reinstall
openclaw plugins install @vectorize-io/hindsight-openclaw
```
### Egress blocked
If calls to `api.hindsight.vectorize.io` are being blocked, check the active sandbox policy:
```bash
openshell sandbox get my-assistant
```
Verify the `hindsight` block is present and the `binaries` path matches your OpenClaw binary:
```bash
which openclaw
```
### External API not connecting
```bash
tail -f /tmp/openclaw/openclaw-*.log | grep Hindsight
# If you see daemon startup messages instead of "Using external API",
# the plugin config isn't being read — check ~/.openclaw/openclaw.json
```
@@ -8,6 +8,8 @@ Local, long term memory for [OpenClaw](https://openclaw.ai) agents using [Hindsi
This plugin integrates [hindsight-embed](https://vectorize.io/hindsight/cli), a standalone daemon that bundles Hindsight's memory engine (API + PostgreSQL) into a single command. Everything runs locally on your machine, reuses the LLM you're already paying for, and costs nothing extra.
[View Changelog →](/changelog/integrations/openclaw)
## Quick Start
**Step 1: Set up LLM for memory extraction**
@@ -6,6 +6,8 @@ sidebar_position: 6
Persistent memory tools for [Pydantic AI](https://ai.pydantic.dev/) agents via Hindsight. Give your agents long-term memory with retain, recall, and reflect — all async-native with no thread-pool hacks.
[View Changelog →](/changelog/integrations/pydantic-ai)
## Features
- **Async-Native Tools** — Uses Pydantic AI's async tool interface directly (`aretain`, `arecall`, `areflect`)
+3 -3
View File
@@ -209,10 +209,10 @@ const config: Config = {
className: 'navbar-item-developer',
},
{
to: '/faq',
to: '/integrations',
position: 'left',
label: 'FAQ',
className: 'navbar-item-faq',
label: 'Integrations',
className: 'navbar-item-integrations',
},
{
to: '/changelog',
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
const bankID = "directives-example-bank"
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// =============================================================================
// Setup (not shown in docs)
// =============================================================================
client.BanksAPI.CreateOrUpdateBank(ctx, bankID).
CreateBankRequest(hindsight.CreateBankRequest{
Name: *hindsight.NewNullableString(hindsight.PtrString("Test Bank")),
}).Execute()
// =============================================================================
// Doc Examples
// =============================================================================
// [docs:create-directive]
// Create a directive (hard rule for reflect)
directive, _, _ := client.DirectivesAPI.CreateDirective(ctx, bankID).
CreateDirectiveRequest(hindsight.CreateDirectiveRequest{
Name: "Formal Language",
Content: "Always respond in formal English, avoiding slang and colloquialisms.",
}).Execute()
fmt.Printf("Created directive: %s\n", directive.GetId())
// [/docs:create-directive]
directiveID := directive.GetId()
// [docs:list-directives]
// List all directives in a bank
directives, _, _ := client.DirectivesAPI.ListDirectives(ctx, bankID).Execute()
for _, d := range directives.GetItems() {
content := d.GetContent()
if len(content) > 50 {
content = content[:50]
}
fmt.Printf("- %s: %s...\n", d.GetName(), content)
}
// [/docs:list-directives]
// [docs:update-directive]
// Update a directive (e.g., disable without deleting)
isActiveFalse := false
updated, _, _ := client.DirectivesAPI.UpdateDirective(ctx, bankID, directiveID).
UpdateDirectiveRequest(hindsight.UpdateDirectiveRequest{
IsActive: *hindsight.NewNullableBool(&isActiveFalse),
}).Execute()
fmt.Printf("Directive active: %v\n", updated.GetIsActive())
// [/docs:update-directive]
// [docs:delete-directive]
// Delete a directive
client.DirectivesAPI.DeleteDirective(ctx, bankID, directiveID).Execute()
// [/docs:delete-directive]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, bankID), nil)
http.DefaultClient.Do(req)
fmt.Println("directives.go: All examples passed")
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Directives API examples for Hindsight CLI
# Run: bash examples/api/directives.sh
set -e
HINDSIGHT_URL="${HINDSIGHT_API_URL:-http://localhost:8888}"
BANK_ID="directives-example-bank"
# =============================================================================
# Setup (not shown in docs)
# =============================================================================
hindsight bank create "$BANK_ID" --name "Test Bank"
# =============================================================================
# Doc Examples
# =============================================================================
# [docs:create-directive]
# Create a directive (hard rule for reflect)
hindsight directive create "$BANK_ID" \
"Formal Language" \
"Always respond in formal English, avoiding slang and colloquialisms."
# [/docs:create-directive]
# Get the directive ID for subsequent operations
DIRECTIVE_ID=$(hindsight directive list "$BANK_ID" -o json | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null || echo "")
# [docs:list-directives]
# List all directives in a bank
hindsight directive list "$BANK_ID"
# [/docs:list-directives]
if [ -n "$DIRECTIVE_ID" ]; then
# [docs:update-directive]
# Update a directive (e.g., disable without deleting)
hindsight directive update "$BANK_ID" "$DIRECTIVE_ID" --is-active false
# [/docs:update-directive]
# [docs:delete-directive]
# Delete a directive
hindsight directive delete "$BANK_ID" "$DIRECTIVE_ID" -y
# [/docs:delete-directive]
fi
# =============================================================================
# Cleanup (not shown in docs)
# =============================================================================
hindsight bank delete "$BANK_ID" -y
echo "directives.sh: All examples passed"
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// [docs:document-retain]
// Retain with document ID
docID := "meeting-2024-03-15"
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{
Content: "Alice presented the Q4 roadmap...",
DocumentId: *hindsight.NewNullableString(&docID),
},
},
}).Execute()
// [/docs:document-retain]
// [docs:document-update]
// Original
planDoc := "project-plan"
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{
Content: "Project deadline: March 31",
DocumentId: *hindsight.NewNullableString(&planDoc),
},
},
}).Execute()
// Update (deletes old facts, creates new ones)
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{
Content: "Project deadline: April 15 (extended)",
DocumentId: *hindsight.NewNullableString(&planDoc),
},
},
}).Execute()
// [/docs:document-update]
// [docs:document-get]
doc, _, err := client.DocumentsAPI.GetDocument(ctx, "my-bank", "meeting-2024-03-15").Execute()
if err != nil {
log.Fatalf("Failed to get document: %v", err)
}
fmt.Printf("Document ID: %s\n", doc.GetId())
fmt.Printf("Memory units: %d\n", doc.GetMemoryUnitCount())
// [/docs:document-get]
// [docs:document-delete]
client.DocumentsAPI.DeleteDocument(ctx, "my-bank", "meeting-2024-03-15").Execute()
// [/docs:document-delete]
// [docs:document-list]
// List all documents
docs, _, err := client.DocumentsAPI.ListDocuments(ctx, "my-bank").Execute()
if err != nil {
log.Fatalf("Failed to list documents: %v", err)
}
for _, d := range docs.Items {
id, _ := d["id"].(string)
memCount, _ := d["memory_unit_count"].(float64)
fmt.Printf("%s: %d memories\n", id, int(memCount))
}
// [/docs:document-list]
// Cleanup (not shown in docs)
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
http.DefaultClient.Do(req)
fmt.Println("documents.go: All examples passed")
}
@@ -0,0 +1,60 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// [docs:main-retain]
// Store a fact or conversation into a memory bank
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{Content: "Alice joined Google in March 2024 as a Senior ML Engineer"},
},
}).Execute()
// [/docs:main-retain]
// [docs:main-recall]
// Search for memories using a natural language query
resp, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What does Alice do at Google?",
}).Execute()
for _, r := range resp.Results {
fmt.Println(r.Text)
}
// [/docs:main-recall]
// [docs:main-reflect]
// Generate a reasoned response using memories and bank disposition
answer, _, _ := client.MemoryAPI.Reflect(ctx, "my-bank").
ReflectRequest(hindsight.ReflectRequest{
Query: "Should we adopt TypeScript for our backend?",
}).Execute()
fmt.Println(answer.GetText())
// [/docs:main-reflect]
// Cleanup (not shown in docs)
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
http.DefaultClient.Do(req)
fmt.Println("main-methods.go: All examples passed")
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// =============================================================================
// Doc Examples
// =============================================================================
// [docs:create-bank]
client.BanksAPI.CreateOrUpdateBank(ctx, "my-bank").
CreateBankRequest(hindsight.CreateBankRequest{}).Execute()
// [/docs:create-bank]
// [docs:bank-with-disposition]
client.BanksAPI.CreateOrUpdateBank(ctx, "architect-bank").
CreateBankRequest(hindsight.CreateBankRequest{
ReflectMission: *hindsight.NewNullableString(hindsight.PtrString(
"You're a senior software architect - keep track of system designs, " +
"technology decisions, and architectural patterns. Prefer simplicity over cutting-edge.",
)),
DispositionSkepticism: *hindsight.NewNullableInt32(hindsight.PtrInt32(4)),
DispositionLiteralism: *hindsight.NewNullableInt32(hindsight.PtrInt32(4)),
DispositionEmpathy: *hindsight.NewNullableInt32(hindsight.PtrInt32(2)),
}).Execute()
// [/docs:bank-with-disposition]
// [docs:bank-background]
client.BanksAPI.CreateOrUpdateBank(ctx, "my-bank").
CreateBankRequest(hindsight.CreateBankRequest{
ReflectMission: *hindsight.NewNullableString(hindsight.PtrString(
"I am a research assistant specializing in machine learning.",
)),
}).Execute()
// [/docs:bank-background]
// [docs:bank-mission]
client.BanksAPI.CreateOrUpdateBank(ctx, "my-bank").
CreateBankRequest(hindsight.CreateBankRequest{
ReflectMission: *hindsight.NewNullableString(hindsight.PtrString(
"You're a senior software architect - keep track of system designs, " +
"technology decisions, and architectural patterns.",
)),
}).Execute()
// [/docs:bank-mission]
// [docs:bank-support-agent]
client.BanksAPI.CreateOrUpdateBank(ctx, "support-bank").
CreateBankRequest(hindsight.CreateBankRequest{}).Execute()
client.BanksAPI.UpdateBankConfig(ctx, "support-bank").
BankConfigUpdate(hindsight.BankConfigUpdate{
Updates: map[string]interface{}{
"observations_mission": "I am a customer support agent. Track customer preferences, " +
"recurring issues, and resolution history to provide consistent, personalized support.",
},
}).Execute()
// [/docs:bank-support-agent]
// [docs:update-bank-config]
client.BanksAPI.UpdateBankConfig(ctx, "my-bank").
BankConfigUpdate(hindsight.BankConfigUpdate{
Updates: map[string]interface{}{
"retain_mission": "Always include technical decisions, API design choices, and architectural trade-offs. " +
"Ignore meeting logistics and social exchanges.",
"retain_extraction_mode": "verbose",
"observations_mission": "Observations are stable facts about people and projects. " +
"Always include preferences, skills, and recurring patterns. Ignore one-off events.",
"disposition_skepticism": 4,
"disposition_literalism": 4,
"disposition_empathy": 2,
},
}).Execute()
// [/docs:update-bank-config]
// [docs:get-bank-config]
// Returns resolved config (server defaults merged with bank overrides) and the raw overrides
result, _, _ := client.BanksAPI.GetBankConfig(ctx, "my-bank").Execute()
// result.Config — full resolved configuration
// result.Overrides — only fields overridden at the bank level
fmt.Println("Config keys:", len(result.GetConfig()))
// [/docs:get-bank-config]
// [docs:reset-bank-config]
// Remove all bank-level overrides, reverting to server defaults
client.BanksAPI.ResetBankConfig(ctx, "my-bank").Execute()
// [/docs:reset-bank-config]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
for _, bankID := range []string{"my-bank", "architect-bank", "support-bank"} {
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, bankID), nil)
http.DefaultClient.Do(req)
}
fmt.Println("memory-banks.go: All examples passed")
}
@@ -0,0 +1,71 @@
#!/bin/bash
# Memory Banks API examples for Hindsight CLI
# Run: bash examples/api/memory-banks.sh
set -e
HINDSIGHT_URL="${HINDSIGHT_API_URL:-http://localhost:8888}"
# =============================================================================
# Doc Examples
# =============================================================================
# [docs:create-bank]
hindsight bank create my-bank
# [/docs:create-bank]
# [docs:bank-with-disposition]
hindsight bank create architect-bank \
--mission "You're a senior software architect - keep track of system designs, technology decisions, and architectural patterns. Prefer simplicity over cutting-edge." \
--skepticism 4 \
--literalism 4 \
--empathy 2
# [/docs:bank-with-disposition]
# [docs:bank-background]
hindsight bank create my-bank \
--mission "I am a research assistant specializing in machine learning."
# [/docs:bank-background]
# [docs:bank-mission]
hindsight bank create my-bank \
--mission "You're a senior software architect - keep track of system designs, technology decisions, and architectural patterns."
# [/docs:bank-mission]
# [docs:bank-support-agent]
hindsight bank create support-bank
hindsight bank set-config support-bank \
--observations-mission "I am a customer support agent. Track customer preferences, recurring issues, and resolution history."
# [/docs:bank-support-agent]
# [docs:update-bank-config]
hindsight bank set-config my-bank \
--retain-mission "Always include technical decisions, API design choices, and architectural trade-offs. Ignore meeting logistics and social exchanges." \
--retain-extraction-mode verbose \
--observations-mission "Observations are stable facts about people and projects. Always include preferences, skills, and recurring patterns. Ignore one-off events." \
--disposition-skepticism 4 \
--disposition-literalism 4 \
--disposition-empathy 2
# [/docs:update-bank-config]
# [docs:get-bank-config]
# Returns resolved config (server defaults merged with bank overrides)
hindsight bank config my-bank
# Show only bank-specific overrides
hindsight bank config my-bank --overrides-only
# [/docs:get-bank-config]
# [docs:reset-bank-config]
# Remove all bank-level overrides, reverting to server defaults
hindsight bank reset-config my-bank -y
# [/docs:reset-bank-config]
# =============================================================================
# Cleanup (not shown in docs)
# =============================================================================
for bank_id in my-bank architect-bank support-bank; do
curl -s -X DELETE "${HINDSIGHT_URL}/v1/default/banks/${bank_id}" > /dev/null
done
echo "memory-banks.sh: All examples passed"
@@ -0,0 +1,173 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"time"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
const mmBankID = "mental-models-demo-bank"
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// =============================================================================
// Setup (not shown in docs)
// =============================================================================
client.BanksAPI.CreateOrUpdateBank(ctx, mmBankID).
CreateBankRequest(hindsight.CreateBankRequest{
Name: *hindsight.NewNullableString(hindsight.PtrString("Mental Models Demo")),
}).Execute()
for _, content := range []string{
"The team prefers async communication via Slack",
"For urgent issues, use the #incidents channel",
"Weekly syncs happen every Monday at 10am",
} {
client.MemoryAPI.RetainMemories(ctx, mmBankID).
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{{Content: content}},
}).Execute()
}
time.Sleep(2 * time.Second)
// =============================================================================
// Doc Examples
// =============================================================================
// [docs:create-mental-model]
// Create a mental model (runs reflect in background)
result, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
Name: "Team Communication Preferences",
SourceQuery: "How does the team prefer to communicate?",
Tags: []string{"team", "communication"},
}).Execute()
// Returns an operation_id — check operations endpoint for completion
fmt.Printf("Operation ID: %s\n", result.GetOperationId())
// [/docs:create-mental-model]
// [docs:create-mental-model-with-id]
// Create a mental model with a specific custom ID
mmID := "communication-policy"
resultWithID, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
Id: *hindsight.NewNullableString(&mmID),
Name: "Communication Policy",
SourceQuery: "What are the team's communication guidelines?",
}).Execute()
fmt.Printf("Created with custom ID: %s\n", resultWithID.GetOperationId())
// [/docs:create-mental-model-with-id]
time.Sleep(5 * time.Second)
// [docs:create-mental-model-with-trigger]
// Create a mental model with automatic refresh enabled
refreshTrue := true
result2, _, _ := client.MentalModelsAPI.CreateMentalModel(ctx, mmBankID).
CreateMentalModelRequest(hindsight.CreateMentalModelRequest{
Name: "Project Status",
SourceQuery: "What is the current project status?",
Trigger: &hindsight.MentalModelTrigger{
RefreshAfterConsolidation: &refreshTrue,
},
}).Execute()
// This mental model will automatically refresh when observations are updated
fmt.Printf("Operation ID: %s\n", result2.GetOperationId())
// [/docs:create-mental-model-with-trigger]
time.Sleep(5 * time.Second)
// [docs:list-mental-models]
// List all mental models in a bank
mentalModels, _, _ := client.MentalModelsAPI.ListMentalModels(ctx, mmBankID).Execute()
for _, mm := range mentalModels.GetItems() {
fmt.Printf("- %s: %s\n", mm.GetName(), mm.GetSourceQuery())
}
// [/docs:list-mental-models]
if len(mentalModels.GetItems()) == 0 {
fmt.Println("mental-models.go: All examples passed (no mental models created yet)")
cleanupMentalModels(client, ctx, apiURL)
return
}
mentalModelID := mentalModels.GetItems()[0].GetId()
// [docs:get-mental-model]
// Get a specific mental model
mentalModel, _, _ := client.MentalModelsAPI.GetMentalModel(ctx, mmBankID, mentalModelID).Execute()
fmt.Printf("Name: %s\n", mentalModel.GetName())
fmt.Printf("Content: %s\n", mentalModel.GetContent())
fmt.Printf("Last refreshed: %s\n", mentalModel.GetLastRefreshedAt())
// [/docs:get-mental-model]
// [docs:refresh-mental-model]
// Refresh a mental model to update with current knowledge
refreshResult, _, _ := client.MentalModelsAPI.RefreshMentalModel(ctx, mmBankID, mentalModelID).Execute()
fmt.Printf("Refresh operation ID: %s\n", refreshResult.GetOperationId())
// [/docs:refresh-mental-model]
// [docs:update-mental-model]
// Update a mental model's metadata
newName := "Updated Team Communication Preferences"
refreshAfter := true
updated, _, _ := client.MentalModelsAPI.UpdateMentalModel(ctx, mmBankID, mentalModelID).
UpdateMentalModelRequest(hindsight.UpdateMentalModelRequest{
Name: *hindsight.NewNullableString(&newName),
Trigger: *hindsight.NewNullableMentalModelTrigger(&hindsight.MentalModelTrigger{
RefreshAfterConsolidation: &refreshAfter,
}),
}).Execute()
fmt.Printf("Updated name: %s\n", updated.GetName())
// [/docs:update-mental-model]
// [docs:get-mental-model-history]
// Get the change history of a mental model
history, _, _ := client.MentalModelsAPI.GetMentalModelHistory(ctx, mmBankID, mentalModelID).Execute()
if entries, ok := history.([]interface{}); ok {
for _, entry := range entries {
if e, ok := entry.(map[string]interface{}); ok {
fmt.Printf("Changed at: %v\n", e["changed_at"])
fmt.Printf("Previous content: %v\n", e["previous_content"])
}
}
}
// [/docs:get-mental-model-history]
// [docs:delete-mental-model]
// Delete a mental model
client.MentalModelsAPI.DeleteMentalModel(ctx, mmBankID, mentalModelID).Execute()
// [/docs:delete-mental-model]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
cleanupMentalModels(client, ctx, apiURL)
fmt.Println("mental-models.go: All examples passed")
}
func cleanupMentalModels(client *hindsight.APIClient, ctx context.Context, apiURL string) {
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, mmBankID), nil)
http.DefaultClient.Do(req)
}
@@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* Mental Models API examples for Hindsight (Node.js)
* Run: node examples/api/mental-models.mjs
*/
import { HindsightClient } from '@vectorize-io/hindsight-client';
const HINDSIGHT_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888';
const BANK_ID = 'mental-models-demo-bank';
// =============================================================================
// Setup (not shown in docs)
// =============================================================================
const client = new HindsightClient({ baseUrl: HINDSIGHT_URL });
await client.createBank(BANK_ID, { name: 'Mental Models Demo' });
await client.retain(BANK_ID, 'The team prefers async communication via Slack');
await client.retain(BANK_ID, 'For urgent issues, use the #incidents channel');
await client.retain(BANK_ID, 'Weekly syncs happen every Monday at 10am');
await new Promise(r => setTimeout(r, 2000));
// =============================================================================
// Doc Examples
// =============================================================================
// [docs:create-mental-model]
// Create a mental model (runs reflect in background)
const result = await client.createMentalModel(
BANK_ID,
'Team Communication Preferences',
'How does the team prefer to communicate?',
{ tags: ['team', 'communication'] },
);
// Returns an operation_id — check operations endpoint for completion
console.log(`Operation ID: ${result.operation_id}`);
// [/docs:create-mental-model]
// [docs:create-mental-model-with-id]
// Create a mental model with a specific custom ID
const resultWithId = await client.createMentalModel(
BANK_ID,
'Communication Policy',
"What are the team's communication guidelines?",
{ id: 'communication-policy' },
);
console.log(`Created with custom ID: ${resultWithId.operation_id}`);
// [/docs:create-mental-model-with-id]
await new Promise(r => setTimeout(r, 5000));
// [docs:create-mental-model-with-trigger]
// Create a mental model with automatic refresh enabled
const result2 = await client.createMentalModel(
BANK_ID,
'Project Status',
'What is the current project status?',
{ trigger: { refreshAfterConsolidation: true } },
);
// This mental model will automatically refresh when observations are updated
console.log(`Operation ID: ${result2.operation_id}`);
// [/docs:create-mental-model-with-trigger]
await new Promise(r => setTimeout(r, 5000));
// [docs:list-mental-models]
// List all mental models in a bank
const mentalModels = await client.listMentalModels(BANK_ID);
for (const mm of mentalModels.items) {
console.log(`- ${mm.name}: ${mm.source_query}`);
}
// [/docs:list-mental-models]
const mentalModelId = mentalModels.items[0]?.id;
if (!mentalModelId) {
console.log('mental-models.mjs: All examples passed (no mental models created yet)');
await fetch(`${HINDSIGHT_URL}/v1/default/banks/${BANK_ID}`, { method: 'DELETE' });
process.exit(0);
}
// [docs:get-mental-model]
// Get a specific mental model
const mentalModel = await client.getMentalModel(BANK_ID, mentalModelId);
console.log(`Name: ${mentalModel.name}`);
console.log(`Content: ${mentalModel.content}`);
console.log(`Last refreshed: ${mentalModel.last_refreshed_at}`);
// [/docs:get-mental-model]
// [docs:refresh-mental-model]
// Refresh a mental model to update with current knowledge
const refreshResult = await client.refreshMentalModel(BANK_ID, mentalModelId);
console.log(`Refresh operation ID: ${refreshResult.operation_id}`);
// [/docs:refresh-mental-model]
// [docs:update-mental-model]
// Update a mental model's metadata
const updated = await client.updateMentalModel(BANK_ID, mentalModelId, {
name: 'Updated Team Communication Preferences',
trigger: { refresh_after_consolidation: true },
});
console.log(`Updated name: ${updated.name}`);
// [/docs:update-mental-model]
// [docs:get-mental-model-history]
// Get the change history of a mental model
const history = await client.getMentalModelHistory(BANK_ID, mentalModelId);
for (const entry of history) {
console.log(`Changed at: ${entry.changed_at}`);
console.log(`Previous content: ${entry.previous_content}`);
}
// [/docs:get-mental-model-history]
// [docs:delete-mental-model]
// Delete a mental model
await client.deleteMentalModel(BANK_ID, mentalModelId);
// [/docs:delete-mental-model]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
await client.deleteBank(BANK_ID);
console.log('mental-models.mjs: All examples passed');
@@ -42,6 +42,18 @@ result = client.create_mental_model(
print(f"Operation ID: {result.operation_id}")
# [/docs:create-mental-model]
# [docs:create-mental-model-with-id]
# Create a mental model with a specific custom ID
result_with_id = client.create_mental_model(
bank_id=BANK_ID,
name="Communication Policy",
source_query="What are the team's communication guidelines?",
id="communication-policy"
)
print(f"Created with custom ID: {result_with_id.operation_id}")
# [/docs:create-mental-model-with-id]
# Wait for the mental model to be created
time.sleep(5)
@@ -0,0 +1,90 @@
#!/bin/bash
# Mental Models API examples for Hindsight CLI
# Run: bash examples/api/mental-models.sh
set -e
HINDSIGHT_URL="${HINDSIGHT_API_URL:-http://localhost:8888}"
BANK_ID="mental-models-demo-bank"
# =============================================================================
# Setup (not shown in docs)
# =============================================================================
hindsight bank create "$BANK_ID" --name "Mental Models Demo"
hindsight memory retain "$BANK_ID" "The team prefers async communication via Slack"
hindsight memory retain "$BANK_ID" "For urgent issues, use the #incidents channel"
hindsight memory retain "$BANK_ID" "Weekly syncs happen every Monday at 10am"
sleep 2
# =============================================================================
# Doc Examples
# =============================================================================
# [docs:create-mental-model]
# Create a mental model (runs reflect in background)
hindsight mental-model create "$BANK_ID" \
"Team Communication Preferences" \
"How does the team prefer to communicate?"
# [/docs:create-mental-model]
# [docs:create-mental-model-with-id]
# Create a mental model with a specific custom ID
hindsight mental-model create "$BANK_ID" \
"Communication Policy" \
"What are the team's communication guidelines?" \
--id communication-policy
# [/docs:create-mental-model-with-id]
sleep 5
# [docs:create-mental-model-with-trigger]
# Create a mental model and get its ID for subsequent operations
hindsight mental-model create "$BANK_ID" \
"Project Status" \
"What is the current project status?"
# [/docs:create-mental-model-with-trigger]
sleep 5
# [docs:list-mental-models]
# List all mental models in a bank
hindsight mental-model list "$BANK_ID"
# [/docs:list-mental-models]
# Get the first mental model ID for subsequent examples
MENTAL_MODEL_ID=$(hindsight mental-model list "$BANK_ID" -o json | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null || echo "")
if [ -n "$MENTAL_MODEL_ID" ]; then
# [docs:get-mental-model]
# Get a specific mental model
hindsight mental-model get "$BANK_ID" "$MENTAL_MODEL_ID"
# [/docs:get-mental-model]
# [docs:refresh-mental-model]
# Refresh a mental model to update with current knowledge
hindsight mental-model refresh "$BANK_ID" "$MENTAL_MODEL_ID"
# [/docs:refresh-mental-model]
# [docs:update-mental-model]
# Update a mental model's metadata
hindsight mental-model update "$BANK_ID" "$MENTAL_MODEL_ID" \
--name "Updated Team Communication Preferences"
# [/docs:update-mental-model]
# [docs:get-mental-model-history]
# Get the change history of a mental model
hindsight mental-model history "$BANK_ID" "$MENTAL_MODEL_ID"
# [/docs:get-mental-model-history]
# [docs:delete-mental-model]
# Delete a mental model
hindsight mental-model delete "$BANK_ID" "$MENTAL_MODEL_ID" -y
# [/docs:delete-mental-model]
fi
# =============================================================================
# Cleanup (not shown in docs)
# =============================================================================
curl -s -X DELETE "${HINDSIGHT_URL}/v1/default/banks/${BANK_ID}" > /dev/null
echo "mental-models.sh: All examples passed"
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// =============================================================================
// Setup (not shown in docs)
// =============================================================================
for _, content := range []string{
"Alice works at Google as a software engineer",
"Alice loves hiking on weekends",
"Bob is a data scientist who works with Alice",
} {
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{{Content: content}},
}).Execute()
}
// =============================================================================
// Doc Examples
// =============================================================================
// [docs:recall-basic]
response, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What does Alice do?",
}).Execute()
// response.Results is a slice of RecallResult, each with:
// - Id: fact ID
// - Text: the extracted fact
// - Type: "world", "experience", or "observation"
// - Context: context label set during retain
// - Tags: []string of tags
// - Entities: []string of entity names linked to this fact
// - OccurredStart: ISO datetime of when the event started
// - OccurredEnd: ISO datetime of when the event ended
// - MentionedAt: ISO datetime of when the fact was retained
// - DocumentId: document this fact belongs to
for _, r := range response.GetResults() {
fmt.Println(r.GetText())
}
// [/docs:recall-basic]
// [docs:recall-with-options]
budgetHigh := hindsight.HIGH
maxTokens := int32(8000)
traceTrue := true
detailedResponse, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What does Alice do?",
Types: []string{"world", "experience"},
Budget: &budgetHigh,
MaxTokens: &maxTokens,
Trace: &traceTrue,
}).Execute()
for _, r := range detailedResponse.GetResults() {
fmt.Println("-", r.GetText())
}
// [/docs:recall-with-options]
// [docs:recall-world-only]
// Only world facts (objective information)
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "Where does Alice work?",
Types: []string{"world"},
}).Execute()
// [/docs:recall-world-only]
// [docs:recall-experience-only]
// Only experience (conversations and events)
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What have I recommended?",
Types: []string{"experience"},
}).Execute()
// [/docs:recall-experience-only]
// [docs:recall-observations-only]
// Only observations (consolidated knowledge)
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What patterns have I learned?",
Types: []string{"observation"},
}).Execute()
// [/docs:recall-observations-only]
// [docs:recall-source-facts]
// Recall observations and include their source facts
maxSFTokens := int32(4096)
sfOpts := hindsight.SourceFactsIncludeOptions{MaxTokens: &maxSFTokens}
obsResponse, _, _ := client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What patterns have I learned about Alice?",
Types: []string{"observation"},
Include: &hindsight.IncludeOptions{
SourceFacts: *hindsight.NewNullableSourceFactsIncludeOptions(&sfOpts),
},
}).Execute()
for _, obs := range obsResponse.GetResults() {
fmt.Printf("Observation: %s\n", obs.GetText())
for _, factID := range obs.GetSourceFactIds() {
if fact, ok := obsResponse.GetSourceFacts()[factID]; ok {
fmt.Printf(" - [%s] %s\n", fact.GetType(), fact.GetText())
}
}
}
// [/docs:recall-source-facts]
// [docs:recall-budget-levels]
budgetLow := hindsight.LOW
// Quick lookup
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "Alice's email",
Budget: &budgetLow,
}).Execute()
// Deep exploration
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "How are Alice and Bob connected?",
Budget: &budgetHigh,
}).Execute()
// [/docs:recall-budget-levels]
// [docs:recall-token-budget]
// Fill up to 4K tokens of context with relevant memories
mt4k := int32(4096)
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What do I know about Alice?",
MaxTokens: &mt4k,
}).Execute()
// Smaller budget for quick lookups
mt500 := int32(500)
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "Alice's email",
MaxTokens: &mt500,
}).Execute()
// [/docs:recall-token-budget]
// [docs:recall-with-tags]
// Filter recall to only memories tagged for a specific user
tagsMatch := "any"
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What feedback did the user give?",
Tags: []string{"user:alice"},
TagsMatch: &tagsMatch,
}).Execute()
// [/docs:recall-with-tags]
// [docs:recall-tags-strict]
// Strict mode: only return memories that have matching tags (exclude untagged)
tagsMatchStrict := "any_strict"
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What did the user say?",
Tags: []string{"user:alice"},
TagsMatch: &tagsMatchStrict,
}).Execute()
// [/docs:recall-tags-strict]
// [docs:recall-tags-all]
// AND matching: require ALL specified tags to be present
tagsMatchAll := "all_strict"
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "What bugs were reported?",
Tags: []string{"user:alice", "bug-report"},
TagsMatch: &tagsMatchAll,
}).Execute()
// [/docs:recall-tags-all]
// [docs:recall-tags-all-mode]
// AND matching, includes untagged memories
tagsMatchAllMode := "all"
client.MemoryAPI.RecallMemories(ctx, "my-bank").
RecallRequest(hindsight.RecallRequest{
Query: "communication tools",
Tags: []string{"user:alice", "team"},
TagsMatch: &tagsMatchAllMode,
}).Execute()
// [/docs:recall-tags-all-mode]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
http.DefaultClient.Do(req)
fmt.Println("recall.go: All examples passed")
}
+82
View File
@@ -61,6 +61,88 @@ for (const r of detailedResponse.results) {
// [/docs:recall-with-options]
// [docs:recall-world-only]
await client.recall('my-bank', 'query', { types: ['world'] });
// [/docs:recall-world-only]
// [docs:recall-experience-only]
await client.recall('my-bank', 'query', { types: ['experience'] });
// [/docs:recall-experience-only]
// [docs:recall-observations-only]
await client.recall('my-bank', 'query', { types: ['observation'] });
// [/docs:recall-observations-only]
// [docs:recall-token-budget]
// Fill up to 4K tokens of context with relevant memories
await client.recall('my-bank', 'What do I know about Alice?', { maxTokens: 4096 });
// Smaller budget for quick lookups
await client.recall('my-bank', "Alice's email", { maxTokens: 500 });
// [/docs:recall-token-budget]
// [docs:recall-with-tags]
// Filter recall to only memories tagged for a specific user
await client.recall('my-bank', 'What feedback did the user give?', {
tags: ['user:alice']
});
// [/docs:recall-with-tags]
// [docs:recall-tags-strict]
// Strict: only memories that have matching tags (excludes untagged)
await client.recall('my-bank', 'What did the user say?', {
tags: ['user:alice'],
tagsMatch: 'any_strict'
});
// [/docs:recall-tags-strict]
// [docs:recall-tags-all]
// AND matching: require ALL specified tags to be present
await client.recall('my-bank', 'What bugs were reported?', {
tags: ['user:alice', 'bug-report'],
tagsMatch: 'all_strict'
});
// [/docs:recall-tags-all]
// [docs:recall-tags-any]
await client.recall('my-bank', 'communication preferences', {
tags: ['user:alice'],
tagsMatch: 'any'
});
// [/docs:recall-tags-any]
// [docs:recall-tags-any-strict]
await client.recall('my-bank', 'communication preferences', {
tags: ['user:alice'],
tagsMatch: 'any_strict'
});
// [/docs:recall-tags-any-strict]
// [docs:recall-tags-all-mode]
await client.recall('my-bank', 'communication tools', {
tags: ['user:alice', 'team'],
tagsMatch: 'all'
});
// [/docs:recall-tags-all-mode]
// [docs:recall-tags-all-strict]
await client.recall('my-bank', 'communication tools', {
tags: ['user:alice', 'team'],
tagsMatch: 'all_strict'
});
// [/docs:recall-tags-all-strict]
// [docs:recall-source-facts]
// Recall observations and include their source facts
const obsResponse = await client.recall('my-bank', 'What patterns have I learned about Alice?', {
+70
View File
@@ -38,6 +38,76 @@ hindsight memory recall my-bank "query" --trace
# [/docs:recall-trace]
# [docs:recall-budget-levels]
# Quick lookup
hindsight memory recall my-bank "Alice's email" --budget low
# Deep exploration
hindsight memory recall my-bank "How are Alice and Bob connected?" --budget high
# [/docs:recall-budget-levels]
# [docs:recall-token-budget]
# Fill up to 4K tokens of context with relevant memories
hindsight memory recall my-bank "What do I know about Alice?" --max-tokens 4096
# Smaller budget for quick lookups
hindsight memory recall my-bank "Alice's email" --max-tokens 500
# [/docs:recall-token-budget]
# [docs:recall-source-facts]
# Recall observations with source facts
hindsight memory recall my-bank "What patterns have I learned about Alice?" \
--fact-type observation
# [/docs:recall-source-facts]
# [docs:recall-with-tags]
# Filter recall to only memories tagged for a specific user
hindsight memory recall my-bank "What feedback did the user give?" \
--tags "user:alice"
# [/docs:recall-with-tags]
# [docs:recall-tags-strict]
# Strict: only memories that have matching tags (excludes untagged)
hindsight memory recall my-bank "What did the user say?" \
--tags "user:alice" --tags-match any_strict
# [/docs:recall-tags-strict]
# [docs:recall-tags-all]
# AND matching: require ALL specified tags to be present
hindsight memory recall my-bank "What bugs were reported?" \
--tags "user:alice,bug-report" --tags-match all_strict
# [/docs:recall-tags-all]
# [docs:recall-tags-any]
hindsight memory recall my-bank "communication preferences" \
--tags "user:alice" --tags-match any
# [/docs:recall-tags-any]
# [docs:recall-tags-any-strict]
hindsight memory recall my-bank "communication preferences" \
--tags "user:alice" --tags-match any_strict
# [/docs:recall-tags-any-strict]
# [docs:recall-tags-all-mode]
hindsight memory recall my-bank "communication tools" \
--tags "user:alice,team" --tags-match all
# [/docs:recall-tags-all-mode]
# [docs:recall-tags-all-strict]
hindsight memory recall my-bank "communication tools" \
--tags "user:alice,team" --tags-match all_strict
# [/docs:recall-tags-all-strict]
# =============================================================================
# Cleanup (not shown in docs)
# =============================================================================
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// =============================================================================
// Setup (not shown in docs)
// =============================================================================
for _, content := range []string{
"Alice works at Google as a software engineer",
"Alice has been working there for 5 years",
"Alice recently got promoted to senior engineer",
} {
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{{Content: content}},
}).Execute()
}
// =============================================================================
// Doc Examples
// =============================================================================
// [docs:reflect-basic]
client.MemoryAPI.Reflect(ctx, "my-bank").
ReflectRequest(hindsight.ReflectRequest{
Query: "What should I know about Alice?",
}).Execute()
// [/docs:reflect-basic]
// [docs:reflect-with-params]
budgetMid := hindsight.MID
client.MemoryAPI.Reflect(ctx, "my-bank").
ReflectRequest(hindsight.ReflectRequest{
Query: "We're considering a hybrid work policy. What do you think about remote work?",
Budget: &budgetMid,
}).Execute()
// [/docs:reflect-with-params]
// [docs:reflect-with-context]
// Context is passed to the LLM to help it understand the situation
ctxText := "We're in a budget review meeting discussing Q4 spending"
client.MemoryAPI.Reflect(ctx, "my-bank").
ReflectRequest(hindsight.ReflectRequest{
Query: "What do you think about the proposal?",
Context: *hindsight.NewNullableString(&ctxText),
}).Execute()
// [/docs:reflect-with-context]
// [docs:reflect-disposition]
// Create a bank with specific disposition
skepticism := int32(5)
literalism := int32(4)
empathy := int32(2)
mission := "I am a risk-aware financial advisor"
client.BanksAPI.CreateOrUpdateBank(ctx, "cautious-advisor").
CreateBankRequest(hindsight.CreateBankRequest{
Name: *hindsight.NewNullableString(hindsight.PtrString("Cautious Advisor")),
ReflectMission: *hindsight.NewNullableString(&mission),
DispositionSkepticism: *hindsight.NewNullableInt32(&skepticism),
DispositionLiteralism: *hindsight.NewNullableInt32(&literalism),
DispositionEmpathy: *hindsight.NewNullableInt32(&empathy),
}).Execute()
// Reflect responses will reflect this disposition
client.MemoryAPI.Reflect(ctx, "cautious-advisor").
ReflectRequest(hindsight.ReflectRequest{
Query: "Should I invest in crypto?",
}).Execute()
// Response will likely emphasize risks and caution
// [/docs:reflect-disposition]
// [docs:reflect-sources]
// include.facts enables the based_on field in the response
sourcesResponse, _, _ := client.MemoryAPI.Reflect(ctx, "my-bank").
ReflectRequest(hindsight.ReflectRequest{
Query: "Tell me about Alice",
Include: &hindsight.ReflectIncludeOptions{
Facts: map[string]interface{}{}, // empty map enables fact inclusion
},
}).Execute()
fmt.Println("Response:", sourcesResponse.GetText())
fmt.Println("\nBased on:")
if basedOn := sourcesResponse.GetBasedOn(); basedOn.Memories != nil {
for _, fact := range basedOn.GetMemories() {
fmt.Printf(" - [%s] %s\n", fact.GetType(), fact.GetText())
}
}
// [/docs:reflect-sources]
// [docs:reflect-with-tags]
// Filter reflection to only consider memories for a specific user
tagsMatch := "any_strict"
client.MemoryAPI.Reflect(ctx, "my-bank").
ReflectRequest(hindsight.ReflectRequest{
Query: "What does this user think about our product?",
Tags: []string{"user:alice"},
TagsMatch: &tagsMatch,
}).Execute()
// [/docs:reflect-with-tags]
// [docs:reflect-structured-output]
// Define JSON schema for structured output
responseSchema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"recommendation": map[string]interface{}{"type": "string"},
"confidence": map[string]interface{}{"type": "string", "enum": []string{"low", "medium", "high"}},
"key_factors": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"risks": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
},
"required": []string{"recommendation", "confidence", "key_factors"},
}
structuredResponse, _, _ := client.MemoryAPI.Reflect(ctx, "my-bank").
ReflectRequest(hindsight.ReflectRequest{
Query: "Should we hire Alice for the ML team lead position?",
ResponseSchema: responseSchema,
}).Execute()
// Access structured output
if out := structuredResponse.GetStructuredOutput(); out != nil {
fmt.Println("Recommendation:", out["recommendation"])
fmt.Println("Key factors:", out["key_factors"])
}
// [/docs:reflect-structured-output]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
for _, bankID := range []string{"my-bank", "cautious-advisor"} {
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/%s", apiURL, bankID), nil)
http.DefaultClient.Do(req)
}
fmt.Println("reflect.go: All examples passed")
}
+13 -2
View File
@@ -60,16 +60,27 @@ const advisorResponse = await client.reflect('cautious-advisor', 'Should I inves
// [docs:reflect-sources]
const sourcesResponse = await client.reflect('my-bank', 'Tell me about Alice');
const sourcesResponse = await client.reflect('my-bank', 'Tell me about Alice', {
includeFacts: true
});
console.log('Response:', sourcesResponse.text);
console.log('\nBased on:');
for (const fact of sourcesResponse.based_on || []) {
for (const fact of (sourcesResponse.based_on?.memories || [])) {
console.log(` - [${fact.type}] ${fact.text}`);
}
// [/docs:reflect-sources]
// [docs:reflect-with-tags]
// Filter reflect to only use memories tagged for a specific user
await client.reflect('my-bank', 'What feedback did the user give?', {
tags: ['user:alice'],
tagsMatch: 'any_strict'
});
// [/docs:reflect-with-tags]
// [docs:reflect-structured-output]
// Define JSON schema directly
const responseSchema = {
+23 -3
View File
@@ -26,9 +26,29 @@ hindsight memory reflect my-bank "Should I learn Python?" --context "career advi
# [/docs:reflect-with-context]
# [docs:reflect-high-budget]
hindsight memory reflect my-bank "Summarize my week" --budget high
# [/docs:reflect-high-budget]
# [docs:reflect-with-params]
hindsight memory reflect my-bank "Summarize my week" --budget high --max-tokens 8192
# [/docs:reflect-with-params]
# [docs:reflect-disposition]
hindsight bank set-config my-bank \
--disposition-skepticism 5 \
--disposition-literalism 4 \
--disposition-empathy 2
hindsight memory reflect my-bank "Should I invest in crypto?"
# [/docs:reflect-disposition]
# [docs:reflect-sources]
hindsight memory reflect my-bank "Tell me about Alice" --include-facts
# [/docs:reflect-sources]
# [docs:reflect-with-tags]
hindsight memory reflect my-bank "What feedback did the user give?" \
--tags "user:alice" --tags-match any_strict
# [/docs:reflect-with-tags]
# [docs:reflect-structured-output]
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
hindsight "github.com/vectorize-io/hindsight/hindsight-clients/go"
)
func main() {
apiURL := os.Getenv("HINDSIGHT_API_URL")
if apiURL == "" {
apiURL = "http://localhost:8888"
}
cfg := hindsight.NewConfiguration()
cfg.Servers = hindsight.ServerConfigurations{{URL: apiURL}}
client := hindsight.NewAPIClient(cfg)
ctx := context.Background()
// =============================================================================
// Doc Examples
// =============================================================================
// [docs:retain-basic]
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{Content: "Alice works at Google as a software engineer"},
},
}).Execute()
// [/docs:retain-basic]
// [docs:retain-conversation]
// Retain an entire conversation as a single document.
conversation := "Alice (2024-03-15T09:00:00Z): Hi Bob! Did you end up going to the doctor last week?\n" +
"Bob (2024-03-15T09:01:00Z): Yes, finally. Turns out I have a mild peanut allergy.\n" +
"Alice (2024-03-15T09:02:00Z): Oh no! Are you okay?\n" +
"Bob (2024-03-15T09:03:00Z): Yeah, nothing serious. Just need to carry an antihistamine.\n" +
"Alice (2024-03-15T09:04:00Z): Good to know. We'll avoid peanuts at the team lunch."
docID := "chat-2024-03-15-alice-bob"
context_ := "team chat"
ts := "2024-03-15T09:04:00Z"
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{
Content: conversation,
Context: *hindsight.NewNullableString(&context_),
DocumentId: *hindsight.NewNullableString(&docID),
Timestamp: *hindsight.NewNullableTimestamp(&hindsight.Timestamp{
String: &ts,
}),
},
},
}).Execute()
// [/docs:retain-conversation]
// [docs:retain-with-context]
ctxLabel := "career update"
ts2 := "2024-03-15T10:00:00Z"
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{
Content: "Alice got promoted to senior engineer",
Context: *hindsight.NewNullableString(&ctxLabel),
Timestamp: *hindsight.NewNullableTimestamp(&hindsight.Timestamp{
String: &ts2,
}),
},
},
}).Execute()
// [/docs:retain-with-context]
// [docs:retain-batch]
doc1 := "conversation_001_msg_1"
doc2 := "conversation_001_msg_2"
doc3 := "conversation_001_msg_3"
ctx1 := "career"
ctx2 := "relationship"
client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{Content: "Alice works at Google", Context: *hindsight.NewNullableString(&ctx1), DocumentId: *hindsight.NewNullableString(&doc1)},
{Content: "Bob is a data scientist at Meta", Context: *hindsight.NewNullableString(&ctx1), DocumentId: *hindsight.NewNullableString(&doc2)},
{Content: "Alice and Bob are friends", Context: *hindsight.NewNullableString(&ctx2), DocumentId: *hindsight.NewNullableString(&doc3)},
},
}).Execute()
// [/docs:retain-batch]
// [docs:retain-async]
// Start async ingestion (returns immediately)
asyncTrue := true
largeDoc1 := "large-doc-1"
largeDoc2 := "large-doc-2"
retainResp, _, _ := client.MemoryAPI.RetainMemories(ctx, "my-bank").
RetainRequest(hindsight.RetainRequest{
Items: []hindsight.MemoryItem{
{Content: "Large batch item 1", DocumentId: *hindsight.NewNullableString(&largeDoc1)},
{Content: "Large batch item 2", DocumentId: *hindsight.NewNullableString(&largeDoc2)},
},
Async: &asyncTrue,
}).Execute()
// Check if it was processed asynchronously
fmt.Println("Async:", retainResp.GetAsync())
// [/docs:retain-async]
// [docs:retain-files]
// Open a file and upload it — Hindsight converts it to text and extracts memories.
// Supports: PDF, DOCX, PPTX, XLSX, images (OCR), audio (transcription), and text formats.
f, err := os.Open("../../hindsight-docs/examples/api/sample.pdf")
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
defer f.Close()
fileResp, _, _ := client.FilesAPI.FileRetain(ctx, "my-bank").
Files([]*os.File{f}).
Request(`{"files_metadata": [{"context": "quarterly report"}]}`).
Execute()
fmt.Println("Operation IDs:", fileResp.GetOperationIds()) // Track processing via the operations endpoint
// [/docs:retain-files]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/v1/default/banks/my-bank", apiURL), nil)
http.DefaultClient.Do(req)
fmt.Println("retain.go: All examples passed")
}
+15
View File
@@ -85,6 +85,21 @@ console.log(result.operation_ids); // Track processing via the operations endpo
// [/docs:retain-files]
// [docs:retain-files-batch]
// Upload multiple files with per-file metadata (up to 10 files per request)
const batchResult = await client.retainFiles('my-bank', [
new File([pdfBytes], 'report.pdf'),
new File([pdfBytes], 'notes.pdf'),
], {
filesMetadata: [
{ context: 'quarterly report', document_id: 'q1-report', tags: ['project:alpha'] },
{ context: 'meeting notes', document_id: 'q1-notes', tags: ['project:alpha'] },
]
});
console.log(batchResult.operation_ids); // One operation ID per file
// [/docs:retain-files-batch]
// =============================================================================
// Cleanup (not shown in docs)
// =============================================================================
+25
View File
@@ -25,12 +25,37 @@ hindsight memory retain my-bank "Alice works at Google as a software engineer"
# [/docs:retain-basic]
# [docs:retain-conversation]
# Retain an entire conversation as a single document.
CONVERSATION="Alice (2024-03-15T09:00:00Z): Hi Bob! Did you end up going to the doctor last week?
Bob (2024-03-15T09:01:00Z): Yes, finally. Turns out I have a mild peanut allergy.
Alice (2024-03-15T09:02:00Z): Oh no! Are you okay?
Bob (2024-03-15T09:03:00Z): Yeah, nothing serious. Just need to carry an antihistamine.
Alice (2024-03-15T09:04:00Z): Good to know. We'll avoid peanuts at the team lunch."
hindsight memory retain my-bank "$CONVERSATION" \
--context "team chat" \
--doc-id "chat-2024-03-15-alice-bob"
# [/docs:retain-conversation]
# [docs:retain-with-context]
hindsight memory retain my-bank "Alice got promoted" \
--context "career update"
# [/docs:retain-with-context]
# [docs:retain-batch]
# Batch ingestion via individual retain calls (CLI processes items one at a time)
hindsight memory retain my-bank "Alice works at Google" \
--context "career" --doc-id "conversation_001_msg_1"
hindsight memory retain my-bank "Bob is a data scientist at Meta" \
--context "career" --doc-id "conversation_001_msg_2"
hindsight memory retain my-bank "Alice and Bob are friends" \
--context "relationship" --doc-id "conversation_001_msg_3"
# [/docs:retain-batch]
# [docs:retain-async]
hindsight memory retain my-bank "Meeting notes" --async
# [/docs:retain-async]
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"build": "node scripts/check-code-parity.mjs && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Validates that every "language" Tabs block in MDX docs has all 4 required variants:
* Python, Node.js, CLI, Go.
*
* A Tabs block is considered a "language" block if it contains at least one TabItem
* with value "python", "node", "cli", or "go".
*
* Run: node scripts/check-code-parity.mjs
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const docsRoot = join(__dirname, '..');
const REQUIRED_TABS = new Set(['python', 'node', 'cli', 'go']);
const IGNORED_PATHS = [
'node_modules',
'build',
'.docusaurus',
'versioned_docs', // skip versioned docs
];
/**
* Recursively find all .mdx files under a directory.
*/
function findMdxFiles(dir) {
const results = [];
for (const entry of readdirSync(dir)) {
if (IGNORED_PATHS.includes(entry)) continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
results.push(...findMdxFiles(full));
} else if (entry.endsWith('.mdx') || entry.endsWith('.md')) {
results.push(full);
}
}
return results;
}
/**
* Parse a single MDX file and return all violations.
* A violation is a Tabs block that has at least one language tab but is missing
* one or more of the 4 required language variants.
*/
function checkFile(filePath) {
const content = readFileSync(filePath, 'utf8');
const violations = [];
// Split content into Tabs blocks.
// Strategy: find <Tabs> ... </Tabs> sections and scan for TabItem values.
// We use a simple line-by-line state machine.
const lines = content.split('\n');
let inTabs = false;
let tabsStartLine = -1;
let currentTabValues = new Set();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!inTabs) {
// Look for opening <Tabs> tag (not <TabItem>)
if (/^\s*<Tabs[\s>]/.test(line) && !/<\/Tabs/.test(line)) {
inTabs = true;
tabsStartLine = i + 1; // 1-indexed
currentTabValues = new Set();
}
} else {
// Inside a Tabs block — look for </Tabs> or nested TabItem values
if (/^\s*<\/Tabs\s*>/.test(line)) {
// End of Tabs block — check if it's a language block
const hasLanguageTab = [...currentTabValues].some(v => REQUIRED_TABS.has(v));
if (hasLanguageTab) {
const missing = [...REQUIRED_TABS].filter(t => !currentTabValues.has(t));
if (missing.length > 0) {
violations.push({
line: tabsStartLine,
found: [...currentTabValues].filter(v => REQUIRED_TABS.has(v)),
missing,
});
}
}
inTabs = false;
currentTabValues = new Set();
} else {
// Look for TabItem value attributes
// Matches: <TabItem value="python" or <TabItem value='cli'
const match = line.match(/TabItem[^>]*value=["']([^"']+)["']/);
if (match) {
currentTabValues.add(match[1]);
}
}
}
}
return violations;
}
// ─── Main ────────────────────────────────────────────────────────────────────
const mdxFiles = findMdxFiles(docsRoot);
let totalViolations = 0;
for (const filePath of mdxFiles) {
const violations = checkFile(filePath);
if (violations.length > 0) {
const rel = relative(docsRoot, filePath);
for (const v of violations) {
console.error(
`[code-parity] ${rel}:${v.line} — Tabs block missing language tabs: ${v.missing.join(', ')} (found: ${v.found.join(', ')})`
);
}
totalViolations += violations.length;
}
}
if (totalViolations > 0) {
console.error(`\n[code-parity] ❌ Found ${totalViolations} Tabs block(s) missing required language variants.`);
console.error('[code-parity] Every Tabs block with language tabs must include: python, node, cli, go');
process.exit(1);
} else {
console.log(`[code-parity] ✅ All ${mdxFiles.length} docs files pass 4-tab parity check.`);
}
+12
View File
@@ -226,6 +226,18 @@ const sidebars: SidebarsConfig = {
label: 'Hermes Agent',
customProps: { icon: '/img/icons/hermes.png' },
},
{
type: 'doc',
id: 'sdks/integrations/langgraph',
label: 'LangGraph / LangChain',
customProps: { icon: '/img/icons/langgraph.png' },
},
{
type: 'doc',
id: 'sdks/integrations/nemoclaw',
label: 'NemoClaw',
customProps: { icon: '/img/icons/nemoclaw.png' },
},
{
type: 'doc',
id: 'sdks/integrations/skills',

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