Compare commits

..
Author SHA1 Message Date
Nicolò Boschi 5df013bc1e fix(python-client): async=true was silently ignored on retain calls
The hand-written client wrapper passed `async_=retain_async` to
RetainRequest, but the generated Pydantic model uses `var_async` as the
Python field name (with `alias="async"`). The `async_` kwarg didn't
match either the field name or the alias, so Pydantic silently ignored
it — every retain call ran synchronously regardless of the flag.

This has been broken since the client was first introduced (6073ac4f),
not a regression.

Also adds unit tests that verify the async field serializes correctly
in the request JSON, preventing future regressions.
2026-03-26 15:13:25 +01:00
Nicolò Boschi c0201cc7e0 refactor(claude-code): remove recallTopK setting
Unused client-side cap — Hindsight server already controls result
count via recallBudget and recallMaxTokens.
2026-03-26 13:57:33 +01:00
Nicolò Boschi aff4c90d8f docs(claude-code): tidy configuration reference and sync README
Add missing settings (retainMode, retainToolCalls, retainTags,
retainMetadata, embedPackagePath, llmApiKeyEnv, agentName, and
several recall options) that existed in code but not in docs.
Restructure config tables with prose introductions, clearer
descriptions, and consistent layout across both files.
2026-03-26 13:51:40 +01:00
Nicolò Boschi b6a4f17cbe fix: resolve all Dependabot security alerts (#702)
- requests: bump minimum to >=2.33.0 (CVE temp file reuse)
- streamlit: bump minimum to >=1.54.0 (SSRF/NTLM exposure)
- picomatch: add npm override for >=2.3.2/<3 || >=4.0.4 (ReDoS + method injection)
- flatted: tighten override to >=3.4.2 (prototype pollution)
- yaml: add npm override for >=1.10.3 (stack overflow)
- rustls-webpki: cargo update to 0.103.10 (CRL distribution point)
- Also fix pre-existing ty lint error in metrics.py (type: ignore for Windows resource import)
- Pygments: no patch available (<=2.19.2 vulnerable, no fix released)
2026-03-26 13:15:36 +01:00
Nicolò Boschi ffc96bec97 release(claude-code): v0.3.0 2026-03-26 12:58:25 +01:00
Nicolò Boschi 8cb8b9128e feat(claude-code): retain tool calls as structured JSON (#704)
When retainToolCalls is enabled (new default), the retention transcript
is output as JSON with full message structure including tool_use blocks
(Edit, Read, Bash, Grep, etc.) and their complete input dicts, plus
tool_result blocks (truncated at 2k chars). This preserves the context
of what actions the assistant actually took, not just its narration.

Hindsight MCP tools (recall/retain/reflect) are excluded to prevent
feedback loops. Channel message tools still get their text extracted
inline. Setting retainToolCalls=false falls back to the legacy text
format.
2026-03-26 12:58:09 +01:00
Nicolò Boschi 64d96a9c53 release(claude-code): v0.2.0 2026-03-26 12:11:28 +01:00
Nicolò Boschi 9dedac1dbd chore: add claude-code package name and display name to changelog generator 2026-03-26 12:10:42 +01:00
Nicolò Boschi 413ddbb45d chore: add claude-code to changelog generation valid integrations 2026-03-26 12:09:32 +01:00
Nicolò Boschi 246912f596 chore: add claude-code to release-integration script
Support plugin.json version bumping for Claude Code plugin releases.
2026-03-26 12:08:23 +01:00
Nicolò Boschi 2d31b67d0c feat(claude-code): full-session retain with document upsert and configurable tags (#695)
* feat(claude-code): full-session retain mode with document upsert and configurable tags

Switch default retain behavior from per-turn chunks to full-session upsert.
Each session is now retained as a single document (document_id = session_id)
that gets updated on every Stop event, instead of creating fragmented
documents with timestamp-suffixed IDs.

New config options:
- retainMode: "full-session" (default) or "chunked" (legacy)
- retainTags: list with template variable support ({session_id}, {bank_id}, {timestamp})
- retainMetadata: extra metadata dict merged with built-in fields, supports templates

* fix(claude-code): respect retainEveryNTurns in full-session mode

The turn-count gating was only applied in chunked mode, meaning
full-session mode would re-ingest the entire transcript on every
single Stop event. Now retainEveryNTurns gates both modes.

Also fix test isolation: resolve ~/.hindsight/claude-code.json at
call time (not module load) so HOME override in tests works correctly.

* fix(claude-code): fix config tests after USER_CONFIG_PATH removal

Update tests to use HOME env var override instead of monkeypatching
the removed USER_CONFIG_PATH constant. Add autouse fixture to
TestLoadConfig to isolate all config tests from real user config
and HINDSIGHT_* env vars.
2026-03-26 12:06:30 +01:00
Nicolò Boschi 349c112c61 docs: add supported platforms and Windows installation guide (#700)
* docs: add supported platforms section and Windows installation guide

Adds a platform compatibility table (Linux, macOS, Windows) and a
dedicated Windows setup section with step-by-step instructions for
installing PostgreSQL + pgvector and running Hindsight natively.
Follows up on #699 which added Windows native support.

Also fixes a ty type-check error in metrics.py for the conditional
resource module import.

* chore: sync generated clients and lock file after #699

Regenerate client SDKs to pick up ValidationError model changes
and update uv.lock with platform-specific uvloop/winloop deps.

* docs: update Windows section — pg0 now supports Windows

pg0 v0.12.0 added Windows support, so embedded DB works everywhere.
Restructure Windows section to show simple install-and-run first,
with external PostgreSQL as an optional alternative.

* chore: sync generated docs skill and openapi references
2026-03-26 12:01:30 +01:00
Mr. Khachaturov 939cb40a73 fix: include Pydantic v2 fields in ValidationError OpenAPI schema (#697)
FastAPI generates the ValidationError schema with only loc, msg, and
type, but Pydantic v2 actually returns input, ctx, and url as well.
Generated clients with strict JSON decoding (Go's DisallowUnknownFields)
cannot parse real 422 responses — the actual validation message gets
replaced by a confusing JSON decoding error.

- Patch the OpenAPI schema in create_app() to add input, ctx, url
- Regenerate spec and Go client
2026-03-26 11:25:46 +01:00
grimmjoww578andClaude Opus 4.6 c5700ff5b4 feat: Windows native support — run Hindsight without Docker (#699)
* feat: Windows native support — run Hindsight without Docker on Windows

Four compatibility fixes that allow Hindsight to run natively on Windows
with an external PostgreSQL + pgvector installation:

1. **pyproject.toml**: Conditional event loop dependency
   - `winloop` on Windows (sys_platform == 'win32')
   - `uvloop` on Linux/macOS (sys_platform != 'win32')

2. **main.py**: winloop integration via `winloop.install()`
   - Patches asyncio event loop policy globally before uvicorn starts
   - uvicorn sees "asyncio" but runs winloop underneath (same perf as uvloop)
   - Falls back to default asyncio if winloop unavailable

3. **metrics.py**: Guard `resource` module import
   - `resource` is Unix-only (getrusage, getrlimit)
   - Conditional import with None fallback
   - Skip process metrics collection on Windows

4. **fact_storage.py**: Cross-platform strftime
   - `%-d` (no-padding day) is glibc-only, fails on Windows
   - Replaced with `%d` + `.replace(" 0", " ")` for same output

## Windows Setup Guide

### Prerequisites
- Python 3.11+
- PostgreSQL 17 with pgvector extension
- Ollama (for local embeddings) or external embedding provider

### Install PostgreSQL + pgvector on Windows
```bash
winget install PostgreSQL.PostgreSQL.17

# Build pgvector from source (requires Visual Studio Build Tools)
git clone https://github.com/pgvector/pgvector.git
# In x64 Native Tools Command Prompt:
set PGROOT=C:\Program Files\PostgreSQL\17
nmake /F Makefile.win
nmake /F Makefile.win install

# Enable extension
psql -U postgres -d hindsight -c "CREATE EXTENSION IF NOT EXISTS vector;"
```

### Install and Run Hindsight
```bash
pip install -e ".[embedded-db]"

# Set environment variables
set HINDSIGHT_API_LLM_PROVIDER=openai
set HINDSIGHT_API_LLM_API_KEY=your-api-key
set HINDSIGHT_API_LLM_BASE_URL=https://your-llm-endpoint/v1
set HINDSIGHT_API_LLM_MODEL=your-model
set HINDSIGHT_API_DATABASE_URL=postgresql://postgres@localhost:5432/hindsight
set HINDSIGHT_API_EMBEDDING_PROVIDER=ollama
set HINDSIGHT_API_PORT=8889

hindsight-api
```

Data persists in PostgreSQL on your local disk — survives reboots,
updates, and anything that would wipe a Docker volume.

Tested on Windows 11 with PostgreSQL 17.9, pgvector 0.8.2,
Python 3.11, RTX 5080 (CUDA embeddings + reranking).

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

* fix: handle strftime ValueError on Windows in fact_storage

The strftime call on occurred_start/occurred_end can raise ValueError
on Windows when the datetime object has unexpected format properties.
Wrap in try/except to gracefully skip date signal rather than crash
the entire retain batch.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 11:23:03 +01:00
Nicolò Boschi 6bb83f4600 fix: control plane UI fixes for recall and data view (#693)
* fix: control plane UI fixes for recall and data view

- Sanitize NaN cross-encoder scores to 0.0 in reranking pipeline
  (Pydantic serializes NaN as JSON null, breaking UI score display)
- Add null-coalesce for score in search debug view to prevent crash
- Switch data view text filter from debounced onChange to Enter key
  (avoids slow ILIKE queries on every keystroke for large banks)
- Show loading spinner in search icon during filter requests
- Preserve search/tag filters when clicking "Load more"

* chore: sync generated files after rebase
2026-03-25 18:42:57 +01:00
Ben a94a90ea3f fix(claude-code): make fcntl import conditional for Windows compatibility (#694)
fcntl is a Unix-only module — importing it unconditionally causes an
ImportError on Windows, breaking the entire plugin. Guard the import with a
sys.platform check and fall back to a no-op lock path in
increment_turn_count() so Windows users get correct behaviour without
crashing.
2026-03-25 18:20:27 +01:00
Nicolò Boschi 9e5a066d26 feat: add 'none' LLM provider for chunk-only storage mode (#691)
Adds a proper 'none' provider option so users can run Hindsight as a
chunk store with semantic search but without any LLM dependency, replacing
the hacky workaround of setting provider to 'mock'.

When HINDSIGHT_API_LLM_PROVIDER=none:
- Retain automatically uses chunks mode (no fact extraction)
- Recall works normally (semantic search, BM25, graph retrieval)
- Reflect returns HTTP 400 with clear error message
- Consolidation/observations are disabled
- Mental model refresh returns HTTP 400
- No API key required
2026-03-25 18:01:20 +01:00
Nicolò Boschi 5095d5e36f feat(reflect): make source facts in search_observations configurable (#688)
* feat(reflect): make source facts in search_observations configurable

The recent fix (#669) hardcoded include_source_facts=False in
search_observations to prevent context overflow. This makes it
configurable via HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS
(env/tenant/bank), defaulting to -1 (disabled).

- -1: source facts disabled (current behavior, default)
- 0: source facts enabled with no token limit
- >0: source facts enabled with a token budget

* docs: add reflect_source_facts_max_tokens to configuration reference

* fix: update configurable fields count in tests and regenerate docs skill
2026-03-25 17:54:49 +01:00
Ben 22ca6a8d73 fix: add setup_hooks.py and hindsight:setup skill for hook registration (#690)
Claude Code's plugin installer does not merge hooks.json into settings.json
automatically. This adds a setup script and skill that users can run once
after installing the plugin to register the hooks manually.
2026-03-25 16:59:25 +01:00
Nicolò Boschi 0ff36548e0 feat(hermes): file-based config + updated docs (#686)
* feat(hermes): file-based config + updated docs

Replace the old dataclass/configure() singleton with a plain dict
config loaded from ~/.hindsight/hermes.json — same field names and
conventions as the openclaw and claude-code integrations.

Loading order: defaults → config file → env var overrides.

- config.py: rewritten with load_config() returning a plain dict,
  DEFAULTS matching openclaw/claude-code fields, ENV_OVERRIDES with
  typed casting
- tools.py: register() uses load_config() instead of raw env vars
- __init__.py: clean exports (removed configure/get_config/reset_config)
- README.md: full rewrite with config file examples, tables by category
- docs/hermes.md: full rewrite with quick start, architecture, all
  config tables, gateway section, troubleshooting
- tests: updated for new config pattern, 46 tests pass

* ci: add test job for hermes integration

* chore: regenerate docs skill for hermes integration
2026-03-25 16:11:38 +01:00
Ben d344ef26da blog: Your AWS Strands Agent Forgets Everything Between Runs. Here's the Fix. (#685)
* blog: add Strands persistent memory post
2026-03-25 10:37:37 -04:00
Nicolò Boschi 4fed005662 ci: skip unrelated jobs based on changed paths (#687)
Add a detect-changes job using dorny/paths-filter to determine which
parts of the monorepo changed, then gate each CI job with appropriate
conditions. This avoids running all ~30 jobs for docs-only or
integration-only changes.

Key behaviors:
- Docs/README-only changes only run build-docs and test-doc-examples
- Integration package changes only run their specific test job
- Client SDK changes only run their build/test + dependent jobs
- Core API changes run all API-dependent jobs
- CI config changes (.github/**) run everything as a safety net
- workflow_dispatch (manual) always runs everything
- verify-generated-files always runs unconditionally
2026-03-25 15:34:02 +01:00
Nicolò Boschi b42b35bf93 feat(embed): add programmatic UI (control plane) management (#683)
* feat(embed): add programmatic UI (control plane) management

Add ability to start/stop the web UI from hindsight-embed, with
configurable port (default: daemon_port + 10000) and hostname
(default: 0.0.0.0). Uses npx to run the published control plane
package, or node directly in dev mode.

New CLI commands:
  hindsight-embed ui start [--port PORT] [--hostname HOST]
  hindsight-embed ui stop [--port PORT]
  hindsight-embed ui status [--port PORT]
  hindsight-embed ui logs [-f] [-n N]

New programmatic API:
  daemon_client.start_ui(profile, ui_port, hostname)
  daemon_client.stop_ui(profile, ui_port)
  daemon_client.is_ui_running(profile, ui_port)
  daemon_client.get_ui_url(profile, ui_port)

* feat(embed): expose UI management on HindsightEmbedded

Add start_ui(), stop_ui(), is_ui_running(), and ui_url property
to HindsightEmbedded so the UI can be started programmatically:

  client = HindsightEmbedded(profile="myapp", ...)
  client.start_ui()  # starts daemon + UI
  print(client.ui_url)
2026-03-25 14:38:32 +01:00
Nicolò Boschi db70fdbe5e feat: add LiteLLM LLM provider for Bedrock and 100+ providers (#679)
* feat: add LiteLLM LLM provider for Bedrock and 100+ providers

Add a new `litellm` LLM provider that uses the LiteLLM SDK for chat
completions and tool calling, enabling AWS Bedrock and 100+ other
providers for Hindsight's core engine (retain, recall, reflect).

- New LiteLLMLLM provider in engine/providers/litellm_llm.py
- Registered in factory, valid providers list, and no-api-key set
- Refactored API key validation to use requires_api_key() helper
- Added boto3 dependency for Bedrock auth
- Updated docs: configuration, models, monitoring, providers grid

* feat: add bedrock as first-class LLM provider alias

Add `bedrock` as a dedicated provider name that auto-prepends the
`bedrock/` prefix to model names and delegates to LiteLLMLLM under
the hood. This makes Bedrock support more discoverable — users set
`HINDSIGHT_API_LLM_PROVIDER=bedrock` with plain Bedrock model IDs.

* test: add Bedrock to CI provider tests

- Add bedrock/us.amazon.nova-lite-v1:0 to MODEL_MATRIX in test_llm_provider.py
- Add AWS credential check in should_skip_provider()
- Pass AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION_NAME secrets to test-api job
- Update default bedrock model to amazon.nova-2-lite-v1:0

* fix: regenerate docs skill files and bump memory test timeout

- Regenerate skills/hindsight-docs references after docs changes
- Bump test_llm_provider_memory_operations timeout to 600s for slower
  providers like Bedrock via LiteLLM

* test: skip bedrock lite models in memory operations test

Nova Lite has a 10K output token limit which is too low for fact
extraction (requires 64K). The api_methods test (completion, tools,
structured output) already validates the provider works correctly.

* test: use Nova Pro for bedrock CI tests to cover full memory pipeline

Nova Lite only supports 10K output tokens, too low for fact extraction.
Switch to Nova Pro which supports the full 64K output needed for
retain/reflect operations. This ensures bedrock is tested on all
Hindsight functionalities, not just basic API methods.

* test: switch bedrock CI to Nova 2 Lite (supports 64K output tokens)

Nova v1 models (Pro, Lite) have a 10K output token limit which is
too low for fact extraction. Nova 2 Lite supports 64K+ output tokens,
enabling full memory pipeline testing (retain + reflect).
2026-03-25 14:17:38 +01:00
Philipp OppolzerandPhilipp c5273f5fd4 fix: coerce JSON-string tags to list in MemoryItem and MCP tools (#682)
MCP tool bridges sometimes serialize JSON arrays as strings during
transport, e.g. '["a", "b"]' arrives as the literal string '["a", "b"]'
instead of a native JSON array. This causes Pydantic to reject the
input with a validation error.

Add defensive coercion at two layers:

1. HTTP API (http.py): Pydantic field_validator on MemoryItem.tags
   with mode="before" that parses JSON strings back into lists.
2. MCP tools (mcp_tools.py): Same coercion in build_content_dict
   before tags reach the Pydantic model.

A plain non-JSON string is wrapped in a single-element list.
Correctly-formatted input is passed through unchanged.

Co-authored-by: Philipp <[email protected]>
2026-03-25 14:16:47 +01:00
Philipp OppolzerandPhilipp 4285e94406 feat(mcp): add strategy parameter to retain tool (#684)
Expose the named retain strategy on the MCP retain tool, matching the
HTTP API's per-item strategy support. This allows MCP clients (Claude
Code, Claude Desktop, etc.) to specify extraction behavior per memory:

  strategy: "exact"   → verbatim storage, no LLM processing
  strategy: "verbose" → detailed extraction
  strategy: "concise" → default compressed extraction

Strategies are defined in bank config under retain_strategies.
Unknown strategy names are logged and ignored (bank default applies).

Changes:
- Add strategy param to both retain function signatures (with/without bank_id)
- Add strategy to build_content_dict
- Strategy is set in the content dict, which the engine already handles per-item

Co-authored-by: Philipp <[email protected]>
2026-03-25 14:16:24 +01:00
Nicolò Boschi 35dfd3aa0c fix(hermes): use async client methods to prevent event loop deadlock (#677) (#681)
Tool handlers and lifecycle hooks now use the native async client API
(aretain, arecall, areflect, acreate_bank) instead of sync wrappers
that call loop.run_until_complete(), which deadlocks in async contexts
like Discord/Telegram gateways.
2026-03-25 11:25:06 +01:00
Nicolò Boschi 0bcbf8491b fix: return metadata in recall responses (#680)
* fix: return metadata in recall responses (#674)

Metadata stored during retain was never retrieved during recall.
Add metadata to all SQL SELECT queries, the RetrievalResult dataclass,
ScoredResult.to_dict(), and MemoryFact construction in the recall pipeline.

* test: add metadata round-trip test for retain→recall

Replace placeholder metadata test with one that actually passes
metadata via retain_batch_async and asserts it is returned on recall.

* fix: parse metadata JSON string from database in MemoryFact

asyncpg may return JSONB columns as strings. Add a field_validator
to MemoryFact.metadata to handle JSON string deserialization.
2026-03-25 11:24:18 +01:00
Nicolò Boschi f0f0d554f2 security: exclude litellm 1.82.8 (supply chain compromise) (#673)
* security: exclude litellm 1.82.8 (supply chain compromise)

litellm 1.82.8 on PyPI contains a malicious .pth file that
automatically steals credentials on Python startup (no import needed).
See: https://github.com/BerriAI/litellm/issues/24512

Our Docker images ship 1.82.6 and are unaffected, but the open version
constraints (>=1.0.0, >=1.40.0) would allow resolving to 1.82.8 on
fresh installs or lockfile refreshes.

* security: cap litellm at <=1.82.6 (1.82.7 also compromised)

* chore: regenerate uv.lock and openapi spec

* fix: update test to match claude-haiku-4-5 default model name and regenerate docs skill

* chore: fix ruff formatting in generate_changelog.py
2026-03-25 10:21:02 +01:00
Ben 0ad6ee3156 Blog: Adding Long-Term Memory to LangGraph and LangChain Agents (#637)
* Add blog post: Adding Long-Term Memory to LangGraph and LangChain Agents

* blog: update langgraph post date to 2026-03-24 and add cover image

* blog: fix claude-code-telegram filename to match frontmatter date (2026-03-25)

* blog: set claude-code-telegram date to 2026-03-23

* blog: fix date timezone offset by adding T12:00 to all post dates

* ci: trigger fresh CI run

* blog: fix broken docs link (routeBasePath is /)
2026-03-24 13:51:27 -04:00
Nicolò Boschi 39bf6820d6 release(strands): v0.1.1 2026-03-24 17:42:52 +01:00
Nicolò Boschi 8ef9c48a62 fix: add strands to changelog generator valid integrations 2026-03-24 17:42:41 +01:00
Ben 7fe773c0ee feat: add Strands Agents SDK integration with Hindsight memory tools (#659)
* feat: add Strands Agents SDK integration with Hindsight memory tools

* fix: add strands docs to versioned docs so build link check passes

* fix(strands): run hindsight client calls in thread pool to avoid event loop conflict with Strands
2026-03-24 17:21:30 +01:00
Nicolò Boschi 58e68f3e4a feat: remove hardcoded default models from integrations (#670)
* feat(openclaw): remove hardcoded default models, rely on Hindsight API defaults

* feat(claude-code): remove hardcoded default models, rely on Hindsight API defaults

* feat(claude-code,docs): remove hardcoded default models from claude-code integration and docs

* feat: use claude-haiku-4-5 as default Anthropic model
2026-03-24 17:20:15 +01:00
Nicolò Boschi 4f533dde94 docs: 0.4.20 release blog post and changelog (#671)
* docs: add 0.4.20 release blog post and changelog

Add release notes blog post covering Claude Code integration, LangGraph
integration, NemoClaw integration, independent integration versioning,
and reflect improvements. Auto-generated changelog entry included.

* docs: add 0.4.20 release blog cover image
2026-03-24 10:03:33 +01:00
Nicolò Boschi 08d2c78ae7 Release v0.4.20
- Update version to 0.4.20 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.4
2026-03-24 09:19:14 +01:00
KaguraandKagura Chen 8e2e2d5bf2 fix(reflect): disable source facts in search_observations to prevent context overflow (#669)
search_observations in the reflect agent hardcoded include_source_facts=True
with max_source_facts_tokens=-1 (unlimited). For banks with many observations
backed by thousands of facts, a single tool call could produce 300K+ tokens,
exceeding the default 100K context budget and causing forced synthesis with
an empty 'Retrieved Data' section.

The reflect agent synthesizes from observations, not raw backing facts.
Disable source facts to keep payloads proportional to observation count
(~6K vs ~310K in the reporter's case).

The consolidation path already has configurable source fact limits (PR #509,
v0.4.17). The reflect path was not updated.

Fixes #668

Co-authored-by: Kagura Chen <[email protected]>
2026-03-24 09:12:54 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4a55068db7 chore(deps): bump actions/setup-python from 5 to 6 (#654)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 07:48:44 +01:00
Ben e1f539c612 blog: add cover images to AMB, Claude Code Telegram, and NemoClaw posts (#667)
* blog: add cover images to AMB, Claude Code Telegram, and NemoClaw posts

* blog: remove redundant landing image from AMB post
2026-03-23 16:23:02 -04:00
Nicolò Boschi 742f212b2f docs: update blog 2026-03-23 18:14:04 +01:00
Nicolò Boschi f2b0ff7d38 Update author in agent memory benchmark blog post 2026-03-23 17:57:13 +01:00
Nicolò Boschi 8ae3ae13a6 Update 2026-03-23-agent-memory-benchmark.mdx 2026-03-23 17:56:44 +01:00
Nicolò Boschi 546d595c9f feat(blog): Agent Memory Benchmark launch post (#657)
* feat(blog): launch Agent Memory Benchmark post and ImageCarousel component

* feat(blog): remove RAG terminology, add agentic eval framing
2026-03-23 17:51:51 +01:00
Nicolò Boschi 26944e25bc fix(claude-code): pre-start daemon in background on SessionStart hook (#663)
Daemon cold start takes ~25s but hooks have short timeouts, causing
retain to time out on first use. Fix by firing daemon startup as a
detached background process in SessionStart so it warms up before the
first recall/retain hook fires.

Also bumps the daemon start timeout in _ensure_daemon_running from 10s
to 30s as a fallback for when retain fires before pre-start completes.
2026-03-23 16:11:57 +01:00
Nicolò Boschi e6333719ee fix(entity_resolver): prevent _pending_stats/_pending_cooccurrences memory leak (#662)
* fix(entity_resolver): prevent _pending_stats/_pending_cooccurrences memory leak

Add discard_pending_stats() to EntityResolver to clean up both pending dicts
for the current task key. Call it at the start of each _run_db_work attempt so
that exceptions between accumulation and flush_pending_stats() — including
deadlock retries — never leave stale entries keyed by recycled task IDs.

Fixes #660

* test(entity_resolver): add unit tests for discard_pending_stats()

Covers: clears both dicts for current task, is idempotent when empty,
and does not touch entries belonging to other task keys.
No database required — purely in-memory logic.
2026-03-23 16:06:04 +01:00
Nicolò BoschiandBen d886d3acb9 doc: Claude Code + Telegram + Hindsight blog post (#656)
* doc: add Claude Code + Telegram + Hindsight blog post

* doc: add fabioscarsi to blog authors

* doc: update fabioscarsi title to Contributor

* doc: remove horizontal rule dividers from blog post

* doc: update cover image and add image frontmatter for claude-code-telegram blog post

* doc: remove horizontal rule dividers

* doc: align Hindsight setup steps with PR #661 README

* fix: move marketplace.json to repo root and update source path

* doc: add Claude Code integration page, sidebar, and integrations hub entry

* doc: update versioned docs to 0.4.19

---------

Co-authored-by: Ben <[email protected]>
2026-03-23 15:44:07 +01:00
Nicolò Boschi 35b2cbb6ed fix(claude-code): fix plugin installation, config UX, and release workflow (#661)
* 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

* remove install.sh — users install via claude plugin commands directly

* test(claude-code): add 116 unit tests for plugin hooks and lib modules

* 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

* 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.

* docs(claude-code): add ToS hint for claude-code LLM provider option

* fix(claude-code): set author to Hindsight Team in plugin.json

* ci: add test-claude-code-integration job to run plugin unit tests
2026-03-23 15:15:16 +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
597 changed files with 42352 additions and 3993 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
},
"plugins": [
{
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight",
"source": "./hindsight-integrations/claude-code"
}
]
}
+14 -2
View File
@@ -31,8 +31,10 @@ jobs:
run: |
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
echo "type=python" >> $GITHUB_OUTPUT
else
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) ──────────────────
@@ -45,7 +47,7 @@ jobs:
- name: Set up Python
if: steps.type.outputs.type == 'python'
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
@@ -63,6 +65,16 @@ jobs:
# ── 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
+320
View File
@@ -10,7 +10,103 @@ concurrency:
cancel-in-progress: true
jobs:
detect-changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
core: ${{ steps.filter.outputs.core }}
clients-ts: ${{ steps.filter.outputs.clients-ts }}
clients-python: ${{ steps.filter.outputs.clients-python }}
clients-rust: ${{ steps.filter.outputs.clients-rust }}
clients-go: ${{ steps.filter.outputs.clients-go }}
control-plane: ${{ steps.filter.outputs.control-plane }}
cli: ${{ steps.filter.outputs.cli }}
docker: ${{ steps.filter.outputs.docker }}
helm: ${{ steps.filter.outputs.helm }}
docs: ${{ steps.filter.outputs.docs }}
embed: ${{ steps.filter.outputs.embed }}
hindsight-all: ${{ steps.filter.outputs.hindsight-all }}
integration-tests: ${{ steps.filter.outputs.integration-tests }}
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
core:
- 'hindsight-api-slim/**'
- 'hindsight-api/**'
- 'scripts/**'
- '.python-version'
- '.env.example'
clients-ts:
- 'hindsight-clients/typescript/**'
- 'package.json'
- 'package-lock.json'
clients-python:
- 'hindsight-clients/python/**'
clients-rust:
- 'hindsight-clients/rust/**'
clients-go:
- 'hindsight-clients/go/**'
control-plane:
- 'hindsight-control-plane/**'
- 'package.json'
- 'package-lock.json'
cli:
- 'hindsight-cli/**'
docker:
- 'docker/**'
helm:
- 'helm/**'
docs:
- 'hindsight-docs/**'
- '*.md'
embed:
- 'hindsight-embed/**'
hindsight-all:
- 'hindsight-all/**'
integration-tests:
- 'hindsight-integration-tests/**'
integrations-openclaw:
- 'hindsight-integrations/openclaw/**'
integrations-ai-sdk:
- 'hindsight-integrations/ai-sdk/**'
integrations-chat:
- 'hindsight-integrations/chat/**'
integrations-claude-code:
- 'hindsight-integrations/claude-code/**'
integrations-crewai:
- 'hindsight-integrations/crewai/**'
integrations-litellm:
- 'hindsight-integrations/litellm/**'
integrations-pydantic-ai:
- 'hindsight-integrations/pydantic-ai/**'
integrations-hermes:
- 'hindsight-integrations/hermes/**'
dev:
- 'hindsight-dev/**'
ci:
- '.github/**'
build-api-python-versions:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
@@ -34,6 +130,11 @@ jobs:
run: uv build
build-typescript-client:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -53,6 +154,11 @@ jobs:
run: npm run build --workspace=hindsight-clients/typescript
build-openclaw-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -75,7 +181,35 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: npm run build
test-claude-code-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-claude-code == 'true' ||
needs.detect-changes.outputs.ci == 'true'
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:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -99,6 +233,11 @@ jobs:
run: npm run build
test-ai-sdk-integration-deno:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -123,6 +262,11 @@ jobs:
run: npm run test:deno
build-chat-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-chat == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -146,6 +290,12 @@ jobs:
run: npm run build
build-control-plane:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -198,6 +348,11 @@ jobs:
fi
build-docs:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.docs == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -217,6 +372,12 @@ jobs:
run: npm run build --workspace=hindsight-docs
test-rust-cli:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -337,6 +498,11 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
lint-helm-chart:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.helm == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -351,6 +517,13 @@ jobs:
run: helm lint helm/hindsight
build-docker-images:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.docker == 'true' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.ci == 'true'
name: Build Docker (${{ matrix.name }})
runs-on: ubuntu-latest
strategy:
@@ -434,6 +607,11 @@ jobs:
run: ./docker/test-image.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
test-api:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -443,6 +621,9 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION_NAME: ${{ secrets.AWS_REGION_NAME }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
@@ -499,6 +680,12 @@ jobs:
run: uv run pytest tests -v
test-python-client:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -601,6 +788,12 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
test-typescript-client:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -708,6 +901,12 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
test-typescript-client-deno:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -821,6 +1020,11 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
build-rust-cli-arm64:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-24.04-arm
steps:
@@ -845,6 +1049,12 @@ jobs:
run: cargo build --release --target aarch64-unknown-linux-gnu
test-rust-client:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-rust == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -951,6 +1161,12 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
test-go-client:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-go == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -1055,6 +1271,13 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
test-openclaw-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -1162,6 +1385,12 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
test-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.integration-tests == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -1259,6 +1488,11 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
test-crewai-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-crewai == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -1288,6 +1522,11 @@ jobs:
run: uv run pytest tests -v
test-litellm-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-litellm == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -1317,6 +1556,11 @@ jobs:
run: uv run pytest tests -v
test-pydantic-ai-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-pydantic-ai == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
@@ -1345,7 +1589,46 @@ jobs:
working-directory: ./hindsight-integrations/pydantic-ai
run: uv run pytest tests -v
test-hermes-integration:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-hermes == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build hermes integration
working-directory: ./hindsight-integrations/hermes
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/hermes
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/hermes
run: uv run pytest tests -v
test-pip-slim:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -1406,6 +1689,12 @@ jobs:
cat /tmp/slim-api-server.log 2>/dev/null || true
test-embed:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -1459,6 +1748,12 @@ jobs:
run: ./test.sh
test-hindsight-all:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.hindsight-all == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -1512,6 +1807,16 @@ jobs:
run: uv run pytest tests/ -v
test-doc-examples:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.clients-go == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.docs == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -1651,6 +1956,12 @@ jobs:
cat /tmp/api-server.log || echo "No API server log found"
test-upgrade:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.dev == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
@@ -1775,6 +2086,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 +2103,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
@@ -1797,6 +2112,11 @@ jobs:
echo "✓ All generated files are up to date"
check-openapi-compatibility:
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
+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/>
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.19
appVersion: "0.4.19"
version: 0.4.20
appVersion: "0.4.20"
keywords:
- ai
- memory
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.4.19"
version = "0.4.20"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+48 -3
View File
@@ -34,7 +34,6 @@ Using context manager:
"""
import logging
import os
import threading
from typing import Optional
@@ -140,13 +139,17 @@ class HindsightEmbedded:
return
if self._closed:
raise RuntimeError("Cannot use HindsightEmbedded after it has been closed")
raise RuntimeError(
"Cannot use HindsightEmbedded after it has been closed"
)
# Use embed manager interface for daemon management
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
success = self._manager.ensure_running(self.config, self.profile)
if not success:
raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'")
raise RuntimeError(
f"Failed to start daemon for profile '{self.profile}'"
)
# Get daemon URL and create client
daemon_url = self._manager.get_url(self.profile)
@@ -375,3 +378,45 @@ class HindsightEmbedded:
def is_running(self) -> bool:
"""Check if the client is initialized."""
return self._started and not self._closed and self._client is not None
def start_ui(self, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
"""Start the control plane web UI.
The daemon is started automatically if not already running.
Args:
ui_port: Port for the UI. Defaults to daemon_port + 10000.
hostname: Hostname to bind to. Defaults to 0.0.0.0.
Returns:
True if UI started successfully.
"""
self._ensure_started()
return self._manager.start_ui(self.profile, ui_port, hostname)
def stop_ui(self, ui_port: int | None = None) -> bool:
"""Stop the control plane web UI.
Args:
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
Returns:
True if stopped successfully.
"""
return self._manager.stop_ui(self.profile, ui_port)
def is_ui_running(self, ui_port: int | None = None) -> bool:
"""Check if the control plane web UI is running.
Args:
ui_port: Port to check. Defaults to daemon_port + 10000.
Returns:
True if UI is running and responsive.
"""
return self._manager.is_ui_running(self.profile, ui_port)
@property
def ui_url(self) -> str:
"""Get the UI URL for this profile."""
return self._manager.get_ui_url(self.profile)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.4.19"
version = "0.4.20"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.19"
__version__ = "0.4.20"
@@ -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
@@ -72,6 +72,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
from hindsight_api.config import get_config
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
@@ -169,6 +170,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:
@@ -415,6 +425,27 @@ class MemoryItem(BaseModel):
default=None,
description="Optional tags for visibility scoping. Memories with tags can be filtered during recall.",
)
@field_validator("tags", mode="before")
@classmethod
def coerce_tags(cls, v):
"""Coerce JSON-string tags to list.
MCP tool bridges sometimes serialize JSON arrays as strings during
transport, e.g. '["a", "b"]' instead of ["a", "b"]. This validator
parses such strings back into lists so the retain call succeeds.
A plain non-JSON string is wrapped in a single-element list.
"""
if isinstance(v, str):
try:
parsed = json.loads(v)
if isinstance(parsed, list):
return parsed
except (json.JSONDecodeError, TypeError):
pass
return [v]
return v
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field(
default=None,
title="ObservationScopes",
@@ -669,6 +700,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 +1485,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):
@@ -1995,6 +2064,26 @@ def create_app(
# This is required for mounted sub-applications where lifespan may not fire
app.state.memory = memory
# ---------------------------------------------------------------------------
# Patch OpenAPI schema: align ValidationError with Pydantic v2 error format
# ---------------------------------------------------------------------------
# FastAPI auto-generates ValidationError with only loc/msg/type, but Pydantic
# v2 actually returns additional fields: input (the rejected value), ctx (extra
# context dict), and url (link to error docs). Without these in the spec,
# generated clients using strict JSON decoding break on real 422 responses.
_original_openapi = app.openapi
def _patched_openapi() -> dict[str, Any]:
schema = _original_openapi()
ve = schema.get("components", {}).get("schemas", {}).get("ValidationError")
if ve and "input" not in ve.get("properties", {}):
ve["properties"]["input"] = {"title": "Input"}
ve["properties"]["ctx"] = {"title": "Context", "type": "object"}
ve["properties"]["url"] = {"title": "URL", "type": "string"}
return schema
app.openapi = _patched_openapi # type: ignore[assignment]
# Add HTTP metrics middleware
@app.middleware("http")
async def http_metrics_middleware(request, call_next):
@@ -2505,6 +2594,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 +2672,14 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except LLMNotAvailableError as e:
raise HTTPException(status_code=400, detail=str(e))
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
@@ -2940,6 +3040,8 @@ def _register_routes(app: FastAPI):
request_context=request_context,
)
return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued")
except LLMNotAvailableError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except (AuthenticationError, HTTPException):
+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)
+26 -2
View File
@@ -338,7 +338,9 @@ 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"
ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
# Disposition settings
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
@@ -353,7 +355,7 @@ DEFAULT_LLM_PROVIDER = "openai"
# Provider-specific default models
PROVIDER_DEFAULT_MODELS = {
"openai": "gpt-4o-mini",
"anthropic": "claude-haiku-4-5-20251001",
"anthropic": "claude-haiku-4-5",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
@@ -363,6 +365,9 @@ PROVIDER_DEFAULT_MODELS = {
"openai-codex": "gpt-5.2-codex",
"claude-code": "claude-sonnet-4-5-20250929",
"mock": "mock-model",
"none": "none",
"litellm": "gpt-4o-mini",
"bedrock": "us.amazon.nova-2-lite-v1:0",
}
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
DEFAULT_LLM_MAX_CONCURRENT = 32
@@ -499,6 +504,8 @@ 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)
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS = -1 # Token budget for source facts in search_observations (-1 = disabled)
# Disposition defaults (None = not set, fall back to bank DB value or 3)
DEFAULT_DISPOSITION_SKEPTICISM = None
@@ -770,6 +777,7 @@ class HindsightConfig:
# Reflect agent settings
reflect_mission: str | None
reflect_source_facts_max_tokens: int
# Disposition settings (hierarchical - can be overridden per bank; None = fall back to DB)
disposition_skepticism: int | None
@@ -801,6 +809,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
@@ -867,6 +876,7 @@ class HindsightConfig:
"observations_mission",
# Reflect settings
"reflect_mission",
"reflect_source_facts_max_tokens",
# Disposition settings
"disposition_skepticism",
"disposition_literalism",
@@ -949,9 +959,19 @@ class HindsightConfig:
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
)
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
self.enable_observations = False
logger.info(
"LLM provider set to 'none': forcing retain_extraction_mode='chunks', "
"disabling observations/consolidation. Reflect will return HTTP 400."
)
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks
if self.retain_max_completion_tokens <= self.retain_chunk_size:
# (not applicable when provider is "none" since no LLM calls are made)
if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than "
@@ -1274,7 +1294,11 @@ 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,
reflect_source_facts_max_tokens=int(
os.getenv(ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS))
),
# Disposition settings (None = fall back to DB value)
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
if 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]] = {}
@@ -85,6 +86,19 @@ class EntityResolver:
task = asyncio.current_task()
return id(task) if task is not None else 0
def discard_pending_stats(self) -> None:
"""
Discard accumulated entity stats and co-occurrence counts for the current task.
Call this on any exception path between resolve_entities_batch /
link_units_to_entities_batch and flush_pending_stats() to prevent the
per-task dicts from growing unbounded when tasks fail before flushing.
Safe to call even if no entries exist for the current task.
"""
key = self._task_key()
self._pending_stats.pop(key, None)
self._pending_cooccurrences.pop(key, None)
async def flush_pending_stats(self) -> None:
"""
Flush accumulated entity stats and co-occurrence counts for the current task.
@@ -202,6 +216,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 +505,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,
@@ -125,7 +125,10 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"openai-codex",
"claude-code",
"mock",
"none",
"vertexai",
"litellm",
"bedrock",
}
)
@@ -172,7 +175,9 @@ def create_llm_provider(
ClaudeCodeLLM,
CodexLLM,
GeminiLLM,
LiteLLMLLM,
MockLLM,
NoneLLM,
OpenAICompatibleLLM,
)
@@ -205,6 +210,15 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower == "none":
return NoneLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
)
elif provider_lower in ("gemini", "vertexai"):
return GeminiLLM(
provider=provider,
@@ -227,6 +241,26 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower == "litellm":
return LiteLLMLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
)
elif provider_lower == "bedrock":
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
return LiteLLMLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=bedrock_model,
reasoning_effort=reasoning_effort,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax"):
return OpenAICompatibleLLM(
provider=provider,
@@ -296,7 +330,10 @@ class LLMProvider:
"openai-codex",
"claude-code",
"mock",
"none",
"minimax",
"litellm",
"bedrock",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -675,10 +712,13 @@ class LLMProvider:
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY", "")
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
# ollama (local), vertexai (uses GCP service account credentials),
# or litellm (uses provider-specific auth, e.g. AWS credentials for Bedrock)
if not api_key and not requires_api_key(provider):
pass # Provider handles its own auth
elif not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex or claude-code)"
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex, claude-code, or litellm)"
)
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
@@ -692,12 +732,13 @@ class LLMProvider:
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
# API key not needed for providers with their own auth mechanisms
if not api_key and not requires_api_key(provider):
pass # Provider handles its own auth
elif not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required "
"(unless using openai-codex or claude-code)"
"(unless using openai-codex, claude-code, or litellm)"
)
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
@@ -711,12 +752,13 @@ class LLMProvider:
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY", ""))
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
# ollama (local), or vertexai (uses GCP service account credentials)
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
# API key not needed for providers with their own auth mechanisms
if not api_key and not requires_api_key(provider):
pass # Provider handles its own auth
elif not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required "
"(unless using openai-codex or claude-code)"
"(unless using openai-codex, claude-code, or litellm)"
)
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
@@ -23,7 +23,7 @@ import asyncpg
import httpx
import tiktoken
from ..config import get_config
from ..config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS, get_config
from ..metrics import get_metrics_collector
from ..tracing import create_operation_span
from ..utils import mask_network_location
@@ -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(
[
@@ -321,6 +328,10 @@ class MemoryEngine(MemoryEngineInterface):
# Apply defaults from config
db_url = db_url or config.database_url
memory_llm_provider = memory_llm_provider or config.llm_provider
# Force skip LLM verification when provider is "none" (no LLM to verify)
if memory_llm_provider == "none":
self._skip_llm_verification = True
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
if not memory_llm_api_key and requires_api_key(memory_llm_provider):
raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
@@ -492,24 +503,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 +553,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)
@@ -803,6 +824,11 @@ class MemoryEngine(MemoryEngineInterface):
if not bank_id:
raise ValueError("bank_id is required for consolidation task")
# Skip consolidation when LLM provider is "none"
if self._llm_config.provider == "none":
logger.info(f"[CONSOLIDATION] Skipping consolidation for bank {bank_id}: LLM provider is 'none'")
return {"memories_processed": 0, "skipped": True}
from hindsight_api.models import RequestContext
from .consolidation import run_consolidation_job
@@ -868,14 +894,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 +2065,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:
@@ -2226,6 +2263,11 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Force chunks mode when LLM provider is "none" (no LLM available for fact extraction)
if self._llm_config.provider == "none":
resolved_config.retain_extraction_mode = "chunks"
resolved_config.enable_observations = False
# Apply strategy overrides: explicit strategy > bank default strategy
from hindsight_api.config_resolver import apply_strategy
@@ -2397,8 +2439,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}
@@ -3196,7 +3248,7 @@ class MemoryEngine(MemoryEngineInterface):
source_rows = await sf_conn.fetch(
f"""
SELECT id, text, fact_type, context, occurred_start, occurred_end,
mentioned_at, document_id, chunk_id, tags
mentioned_at, document_id, chunk_id, tags, metadata
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
@@ -3217,6 +3269,7 @@ class MemoryEngine(MemoryEngineInterface):
occurred_end=r["occurred_end"].isoformat() if r["occurred_end"] else None,
mentioned_at=r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
document_id=r["document_id"],
metadata=r["metadata"],
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
tags=r["tags"] or None,
)
@@ -3295,6 +3348,7 @@ class MemoryEngine(MemoryEngineInterface):
occurred_end=result_dict.get("occurred_end"),
mentioned_at=result_dict.get("mentioned_at"),
document_id=result_dict.get("document_id"),
metadata=result_dict.get("metadata"),
chunk_id=result_dict.get("chunk_id"),
tags=result_dict.get("tags"),
source_fact_ids=source_fact_ids_by_obs.get(result_id) if include_source_facts else None,
@@ -5113,6 +5167,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:
"""
@@ -5150,6 +5206,15 @@ class MemoryEngine(MemoryEngineInterface):
if self._reflect_llm_config is None:
raise ValueError("Memory LLM API key not set. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
# Block reflect when LLM provider is "none"
if self._llm_config.provider == "none":
from .providers.none_llm import LLMNotAvailableError
raise LLMNotAvailableError(
"Reflect requires an LLM provider. Current provider is set to 'none'. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
# Authenticate tenant and set schema in context (for fq_table())
await self._authenticate_tenant(request_context)
@@ -5188,6 +5253,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)
@@ -5219,6 +5285,12 @@ class MemoryEngine(MemoryEngineInterface):
pending_consolidation=pending_consolidation,
)
# Get reflect source facts config (hierarchical: env → tenant → bank)
config_dict = await self._config_resolver.get_bank_config(bank_id, request_context)
reflect_source_facts_max_tokens = config_dict.get(
"reflect_source_facts_max_tokens", DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
)
async def search_observations_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
return await tool_search_observations(
self,
@@ -5231,8 +5303,14 @@ class MemoryEngine(MemoryEngineInterface):
tag_groups=tag_groups,
last_consolidated_at=last_consolidated_at,
pending_consolidation=pending_consolidation,
source_facts_max_tokens=reflect_source_facts_max_tokens,
)
# 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 +5322,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 +5345,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 +5365,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 +6534,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 +6548,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 +7519,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 +7530,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 +7581,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
@@ -7758,6 +7879,15 @@ class MemoryEngine(MemoryEngineInterface):
Returns:
Dict with operation_id
"""
# Block mental model refresh when LLM provider is "none"
if self._llm_config.provider == "none":
from .providers.none_llm import LLMNotAvailableError
raise LLMNotAvailableError(
"Mental model refresh requires an LLM provider. Current provider is set to 'none'. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
await self._authenticate_tenant(request_context)
# Pre-operation validation (credit check)
@@ -8,7 +8,18 @@ from .anthropic_llm import AnthropicLLM
from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .litellm_llm import LiteLLMLLM
from .mock_llm import MockLLM
from .none_llm import NoneLLM
from .openai_compatible_llm import OpenAICompatibleLLM
__all__ = ["AnthropicLLM", "ClaudeCodeLLM", "CodexLLM", "GeminiLLM", "MockLLM", "OpenAICompatibleLLM"]
__all__ = [
"AnthropicLLM",
"ClaudeCodeLLM",
"CodexLLM",
"GeminiLLM",
"LiteLLMLLM",
"MockLLM",
"NoneLLM",
"OpenAICompatibleLLM",
]
@@ -0,0 +1,380 @@
"""
LiteLLM LLM provider for universal model support.
This provider enables using 100+ LLM providers via the LiteLLM SDK, including:
- AWS Bedrock (bedrock/anthropic.claude-3-5-sonnet-...)
- Azure OpenAI (azure/gpt-4o)
- Together AI (together_ai/meta-llama/...)
- Any other LiteLLM-supported provider
Uses litellm.acompletion() for async chat completions.
Authentication for cloud providers (e.g., AWS Bedrock via boto3 credential chain)
is handled automatically by LiteLLM.
"""
import asyncio
import json
import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
class LiteLLMLLM(LLMInterface):
"""
LLM provider using the LiteLLM SDK for universal model support.
Supports any model accessible via litellm.acompletion(), including AWS Bedrock,
Azure OpenAI, Together AI, Fireworks AI, and more.
Model names follow LiteLLM conventions with provider prefixes:
- bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0
- azure/gpt-4o
- together_ai/meta-llama/Llama-3-70b-chat-hf
- fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self.timeout = timeout
self._litellm: Any = None
try:
import litellm
self._litellm = litellm
# Suppress LiteLLM's verbose logging
litellm.suppress_debug_info = True # type: ignore[assignment]
# Drop unsupported params instead of raising errors (e.g. tool_choice on some Bedrock models)
litellm.drop_params = True # type: ignore[assignment]
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
logger.info(f"LiteLLM SDK initialized for model: {self.model}")
except ImportError as e:
raise RuntimeError("LiteLLM SDK not installed. Run: uv add litellm or pip install litellm") from e
async def verify_connection(self) -> None:
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=50,
temperature=0.0,
scope="verification",
max_retries=0,
)
logger.info("LiteLLM connection verified successfully")
except OutputTooLongError:
# Truncation is fine for verification — it means the connection works
logger.info("LiteLLM connection verified successfully (response truncated)")
except Exception as e:
logger.error(f"LiteLLM connection verification failed: {e}")
raise RuntimeError(f"Failed to verify LiteLLM connection: {e}") from e
def _build_common_kwargs(
self,
messages: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
) -> dict[str, Any]:
"""Build common kwargs for litellm calls."""
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"timeout": self.timeout,
}
if self.api_key:
kwargs["api_key"] = self.api_key
if self.base_url:
kwargs["api_base"] = self.base_url
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
start_time = time.time()
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
# Add JSON schema response format if provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
call_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": response_format.__name__ if hasattr(response_format, "__name__") else "response",
"schema": schema,
"strict": strict_schema,
},
}
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._litellm.acompletion(**call_kwargs)
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
# Check for length-limited output
if finish_reason == "length":
raise OutputTooLongError("LiteLLM response was truncated due to token limit")
if response_format is not None:
# Strip markdown code fences if present
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:
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Extract usage
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
total_tokens = input_tokens + output_tokens
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return result, token_usage
return result
except OutputTooLongError:
raise
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning("LiteLLM returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts")
raise
except Exception as e:
error_str = str(e).lower()
# Fast fail on auth errors
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
logger.error(f"LiteLLM auth error, not retrying: {e}")
raise
last_exception = e
if attempt < max_retries:
# Retry on rate limits, connection errors, server errors
is_retryable = any(
keyword in error_str
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
)
if is_retryable:
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
continue
logger.error(f"LiteLLM API error after {attempt + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("LiteLLM call failed after all retries")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
start_time = time.time()
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
call_kwargs["tools"] = tools
call_kwargs["tool_choice"] = tool_choice
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._litellm.acompletion(**call_kwargs)
message = response.choices[0].message
content = message.content
finish_reason = response.choices[0].finish_reason
# Extract tool calls
tool_calls: list[LLMToolCall] = []
if message.tool_calls:
for tc in message.tool_calls:
arguments = tc.function.arguments
if isinstance(arguments, str):
arguments = json.loads(arguments)
tool_calls.append(
LLMToolCall(
id=tc.id,
name=tc.function.name,
arguments=arguments,
)
)
# Extract usage
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason or ("tool_calls" if tool_calls else "stop"),
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
raise
last_exception = e
if attempt < max_retries:
is_retryable = any(
keyword in error_str
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
)
if is_retryable:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
logger.error(f"LiteLLM tool call error after {attempt + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("LiteLLM tool call failed after all retries")
async def cleanup(self) -> None:
"""Clean up resources."""
pass
@@ -0,0 +1,78 @@
"""
No-op LLM provider for chunk-only storage mode.
When the LLM provider is set to "none", the system operates without any LLM dependency.
Retain uses chunks mode (no fact extraction), and reflect/consolidation are disabled.
This provider acts as a safety net — if any code path unexpectedly tries to call the LLM,
it raises a clear error instead of a confusing connection failure.
"""
import logging
from typing import Any
from ..llm_interface import LLMInterface
from ..response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
class LLMNotAvailableError(Exception):
"""Raised when an operation requires an LLM but the provider is set to 'none'."""
pass
class NoneLLM(LLMInterface):
"""
No-op LLM provider that rejects all LLM calls.
Used when HINDSIGHT_API_LLM_PROVIDER=none to run Hindsight as a chunk store
with semantic search but without LLM-based features (fact extraction, reflect,
consolidation).
"""
async def verify_connection(self) -> None:
"""No-op — no LLM connection to verify."""
logger.debug("NoneLLM: no LLM connection to verify (provider=none)")
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Raise LLMNotAvailableError — no LLM is configured."""
raise LLMNotAvailableError(
"LLM provider is set to 'none'. This operation requires an LLM. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""Raise LLMNotAvailableError — no LLM is configured."""
raise LLMNotAvailableError(
"LLM provider is set to 'none'. This operation requires an LLM. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
async def cleanup(self) -> None:
"""No-op — nothing to clean up."""
pass
@@ -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:
@@ -134,9 +134,10 @@ async def tool_search_observations(
tag_groups: "list | None" = None,
last_consolidated_at: datetime | None = None,
pending_consolidation: int = 0,
source_facts_max_tokens: int = -1,
) -> dict[str, Any]:
"""
Search consolidated observations using recall with include_source_facts.
Search consolidated observations using recall.
Observations are auto-generated from memories. Returns freshness info
so the agent knows if it should also verify with recall().
@@ -151,10 +152,16 @@ async def tool_search_observations(
tags_match: How to match tags - "any" (OR), "all" (AND)
last_consolidated_at: When consolidation last ran (for staleness check)
pending_consolidation: Number of memories waiting to be consolidated
source_facts_max_tokens: Token budget for source facts (-1 = disabled, 0+ = enabled with limit)
Returns:
Dict with matching observations including freshness info and source memories
"""
include_source_facts = source_facts_max_tokens != -1
recall_kwargs: dict[str, Any] = {}
if include_source_facts and source_facts_max_tokens > 0:
recall_kwargs["max_source_facts_tokens"] = source_facts_max_tokens
result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
@@ -165,10 +172,10 @@ async def tool_search_observations(
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
include_source_facts=True,
max_source_facts_tokens=-1, # No token limit — include all source facts
include_source_facts=include_source_facts,
_connection_budget=1,
_quiet=True,
**recall_kwargs,
)
is_stale = pending_consolidation > 0
@@ -200,6 +207,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 +225,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:
@@ -8,7 +8,7 @@ API stability even if internal models change.
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
# Valid fact types for recall operations (excludes 'opinion' which is deprecated)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "observation"])
@@ -159,6 +159,19 @@ class MemoryFact(BaseModel):
mentioned_at: str | None = Field(None, description="ISO format date when the fact was mentioned/learned")
document_id: str | None = Field(None, description="ID of the document this memory belongs to")
metadata: dict[str, str] | None = Field(None, description="User-defined metadata")
@field_validator("metadata", mode="before")
@classmethod
def parse_metadata(cls, v: Any) -> dict[str, str] | None:
"""Parse metadata from JSON string if needed (asyncpg may return JSONB as str)."""
if v is None:
return None
if isinstance(v, str):
import json
return json.loads(v)
return v
chunk_id: str | None = Field(
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
)
@@ -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)
@@ -81,9 +81,15 @@ async def insert_facts_batch(
if fact.entities:
signal_parts.extend(e.name for e in fact.entities)
if fact.occurred_start:
signal_parts.append(fact.occurred_start.strftime("%B %-d %Y"))
try:
signal_parts.append(fact.occurred_start.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
except (ValueError, AttributeError):
pass
if fact.occurred_end and fact.occurred_end != fact.occurred_start:
signal_parts.append(fact.occurred_end.strftime("%B %-d %Y"))
try:
signal_parts.append(fact.occurred_end.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
except (ValueError, AttributeError):
pass
text_signals_list.append(" ".join(signal_parts) if signal_parts else None)
# Batch insert all facts
@@ -292,6 +292,10 @@ async def retain_batch(
pf.document_id = None
pf.chunk_id = None
# Discard any leftover pending stats from a previous failed attempt so
# retries don't double-count or accumulate unbounded state.
entity_resolver.discard_pending_stats()
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# Handle document tracking for all documents
@@ -231,7 +231,7 @@ class BFSGraphRetriever(GraphRetriever):
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
mu.mentioned_at, mu.fact_type,
mu.document_id, mu.chunk_id, mu.tags,
mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
ml.weight, ml.link_type, ml.from_unit_id
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
@@ -449,7 +449,7 @@ async def fetch_memory_units_by_ids(
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags
mentioned_at, fact_type, document_id, chunk_id, tags, metadata
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND fact_type = $2
@@ -153,6 +153,8 @@ class CrossEncoderReranker:
# Normalize scores using sigmoid to [0, 1] range
# Cross-encoder returns logits which can be negative
import math
import numpy as np
def sigmoid(x):
@@ -163,11 +165,20 @@ class CrossEncoderReranker:
# Create ScoredResult objects with cross-encoder scores
scored_results = []
for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores):
# Sanitize NaN scores (cross-encoder can return NaN for certain inputs).
# NaN propagates through all downstream scoring and Pydantic serializes
# NaN as JSON null, which breaks clients expecting numeric values.
raw = float(raw_score)
norm = float(norm_score)
if math.isnan(raw):
raw = 0.0
if math.isnan(norm):
norm = 0.0
scored_result = ScoredResult(
candidate=candidate,
cross_encoder_score=float(raw_score),
cross_encoder_score_normalized=float(norm_score),
weight=float(norm_score), # Initial weight is just cross-encoder score
cross_encoder_score=raw,
cross_encoder_score_normalized=norm,
weight=norm, # Initial weight is just cross-encoder score
)
scored_results.append(scored_result)
@@ -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,30 +139,31 @@ 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)
cols = (
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
"fact_type, document_id, chunk_id, tags"
"fact_type, document_id, chunk_id, tags, metadata"
)
table = fq_table("memory_units")
# --- 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)
@@ -331,7 +343,7 @@ async def retrieve_temporal_combined(
{groups_clause}
),
sim_ranked AS (
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
1 - (mu.embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
FROM date_ranked dr
@@ -339,7 +351,7 @@ async def retrieve_temporal_combined(
WHERE dr.rn <= 50
AND (1 - (mu.embedding <=> $1::vector)) >= $6
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, similarity
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, metadata, similarity
FROM sim_ranked
WHERE sim_rn <= 10
""",
@@ -437,7 +449,7 @@ async def retrieve_temporal_combined(
# bank_id on memory_units lets the planner use idx_memory_units_bank_fact_type.
neighbors = await conn.fetch(
f"""
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
l.weight, l.link_type,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM unnest($2::uuid[]) AS src(from_unit_id)
@@ -47,6 +47,7 @@ class RetrievalResult:
document_id: str | None = None
chunk_id: str | None = None
tags: list[str] | None = None # Visibility scope tags
metadata: dict[str, str] | None = None # User-provided metadata
# Retrieval-specific scores (only one will be set depending on retrieval method)
similarity: float | None = None # Semantic retrieval
@@ -70,6 +71,7 @@ class RetrievalResult:
document_id=row.get("document_id"),
chunk_id=row.get("chunk_id"),
tags=row.get("tags"),
metadata=row.get("metadata"),
similarity=row.get("similarity"),
bm25_score=row.get("bm25_score"),
activation=row.get("activation"),
@@ -153,6 +155,7 @@ class ScoredResult:
"document_id": self.retrieval.document_id,
"chunk_id": self.retrieval.chunk_id,
"tags": self.retrieval.tags,
"metadata": self.retrieval.metadata,
"semantic_similarity": self.retrieval.similarity,
"bm25_score": self.retrieval.bm25_score,
}
@@ -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
+22 -180
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()
@@ -381,15 +211,27 @@ def main():
# Prepare uvicorn config
# When using workers or reload, we must use import string so each worker can import the app
use_import_string = args.workers > 1 or args.reload
# Check for uvloop availability
try:
import uvloop # noqa: F401
# Check for uvloop/winloop availability
import sys
loop_impl = "uvloop"
print("uvloop available, will use for event loop")
except ImportError:
loop_impl = "asyncio"
print("uvloop not installed, using default asyncio event loop")
loop_impl = "asyncio"
if sys.platform == "win32":
try:
import winloop
winloop.install() # Patches asyncio globally — uvicorn uses "asyncio" but gets winloop
loop_impl = "asyncio" # Tell uvicorn "asyncio" — it's now winloop underneath
print("winloop installed as asyncio event loop policy (Windows uvloop port)")
except ImportError:
print("winloop not installed, using default asyncio event loop")
else:
try:
import uvloop # noqa: F401
loop_impl = "uvloop"
print("uvloop available, will use for event loop")
except ImportError:
print("uvloop not installed, using default asyncio event loop")
uvicorn_config = {
"app": "hindsight_api.server:app" if use_import_string else app,
+30 -3
View File
@@ -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:
@@ -95,6 +101,7 @@ def build_content_dict(
tags: list[str] | None = None,
metadata: dict[str, str] | None = None,
document_id: str | None = None,
strategy: str | None = None,
) -> tuple[dict[str, Any], str | None]:
"""Build a content dict for retain operations.
@@ -105,10 +112,24 @@ def build_content_dict(
tags: Optional tags for scoped visibility filtering
metadata: Optional key-value metadata to attach to the memory
document_id: Optional document ID to associate the memory with
strategy: Optional named retain strategy override (e.g., 'exact', 'verbose')
Returns:
Tuple of (content_dict, error_message). error_message is None if successful.
"""
# Coerce tags from JSON string to list if needed.
# MCP tool bridges sometimes serialize JSON arrays as strings during
# transport, e.g. '["a", "b"]' instead of ["a", "b"].
if isinstance(tags, str):
try:
parsed = json.loads(tags)
if isinstance(parsed, list):
tags = parsed
except (json.JSONDecodeError, TypeError):
pass
if isinstance(tags, str):
tags = [tags]
content_dict: dict[str, Any] = {"content": content, "context": context}
if timestamp:
@@ -124,6 +145,8 @@ def build_content_dict(
content_dict["metadata"] = metadata
if document_id is not None:
content_dict["document_id"] = document_id
if strategy is not None:
content_dict["strategy"] = strategy
return content_dict, None
@@ -353,6 +376,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
metadata: dict[str, str] | None = None,
document_id: str | None = None,
bank_id: str | None = None,
strategy: str | None = None,
) -> dict:
"""
Args:
@@ -363,12 +387,13 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
document_id: Optional document ID to associate this memory with
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
"""
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id)
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
if error:
return {"status": "error", "message": error}
@@ -402,6 +427,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
tags: list[str] | None = None,
metadata: dict[str, str] | None = None,
document_id: str | None = None,
strategy: str | None = None,
) -> dict:
"""
Args:
@@ -411,12 +437,13 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123'])
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
document_id: Optional document ID to associate this memory with
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
"""
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id)
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
if error:
return {"status": "error", "message": error}
+8 -1
View File
@@ -13,7 +13,12 @@ This module provides metrics for:
import logging
import os
import resource
import types
try:
import resource
except ImportError:
resource: types.ModuleType | None = None # Windows doesn't have resource module
import threading
import time
from contextlib import contextmanager
@@ -455,6 +460,8 @@ class MetricsCollector(MetricsCollectorBase):
def _setup_process_metrics(self):
"""Set up observable gauges for process metrics."""
if resource is None:
return # Skip process metrics on Windows
def get_cpu_times(_options):
"""Get process CPU times."""
@@ -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."""
+11 -6
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.4.19"
version = "0.4.20"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -40,12 +40,13 @@ dependencies = [
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
"litellm>=1.0.0",
"litellm>=1.0.0,<=1.82.6", # 1.82.7+ contains a supply chain attack (malicious .pth credential stealer)
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
"uvloop>=0.22.1",
"winloop>=0.1.0; sys_platform == 'win32'",
"uvloop>=0.22.1; sys_platform != 'win32'",
# 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 +54,13 @@ 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",
"boto3>=1.42.74",
]
[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,114 @@
"""
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
# ---------------------------------------------------------------------------
# Unit tests for discard_pending_stats() — no database required
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_discard_pending_stats_clears_both_dicts():
"""discard_pending_stats() must remove entries for the current task from
both _pending_stats and _pending_cooccurrences."""
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
key = resolver._task_key()
resolver._pending_stats[key] = [object()] # type: ignore[list-item]
resolver._pending_cooccurrences[key] = [object()] # type: ignore[list-item]
resolver.discard_pending_stats()
assert key not in resolver._pending_stats
assert key not in resolver._pending_cooccurrences
@pytest.mark.asyncio
async def test_discard_pending_stats_is_idempotent():
"""Calling discard_pending_stats() when nothing is pending must not raise."""
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
resolver.discard_pending_stats()
resolver.discard_pending_stats() # second call — still safe
@pytest.mark.asyncio
async def test_discard_pending_stats_does_not_affect_other_task_keys():
"""discard_pending_stats() must only remove the current task's entries,
leaving entries keyed under other task IDs untouched."""
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
other_key = -1 # A fake key that can never be a real task id
resolver._pending_stats[other_key] = [object()] # type: ignore[list-item]
resolver._pending_cooccurrences[other_key] = [object()] # type: ignore[list-item]
resolver.discard_pending_stats() # discards current task's key only
assert other_key in resolver._pending_stats, "other task's stats must be preserved"
assert other_key in resolver._pending_cooccurrences, "other task's cooccurrences must be preserved"
@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'."""
@@ -89,7 +89,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 19
assert len(configurable) == 20
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
@@ -402,7 +402,7 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory
assert field in config, f"Expected configurable field '{field}' missing from config"
# Should have a small number of configurable fields (not hundreds)
assert len(config) < 20, f"Too many fields returned: {len(config)}"
assert len(config) < 25, f"Too many fields returned: {len(config)}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -43,6 +43,8 @@ MODEL_MATRIX = [
("claude-code", "claude-sonnet-4-20250514"),
# OpenAI Codex (uses MCP with Codex-specific models)
("openai-codex", "gpt-5.2-codex"),
# Bedrock models (via LiteLLM)
("bedrock", "us.amazon.nova-2-lite-v1:0"),
# Mock provider (for testing)
("mock", "mock"),
]
@@ -78,6 +80,12 @@ def should_skip_provider(provider: str, model: str = "") -> tuple[bool, str]:
if provider == "ollama" and "gemma" in model.lower():
return True, f"Ollama {model} does not support tool calling"
# Bedrock needs AWS credentials
if provider == "bedrock":
if not os.getenv("AWS_ACCESS_KEY_ID"):
return True, "No AWS credentials available (set AWS_ACCESS_KEY_ID)"
return False, ""
# Other providers need an API key
if provider not in ("ollama", "claude-code", "openai-codex", "mock"):
api_key = get_api_key_for_provider(provider)
@@ -227,7 +235,7 @@ async def test_llm_provider_api_methods(provider: str, model: str):
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
@pytest.mark.timeout(300)
@pytest.mark.timeout(600) # 600s: some providers (e.g., bedrock via litellm) need extra time for fact extraction
async def test_llm_provider_memory_operations(provider: str, model: str):
"""
Test LLM provider with actual memory operations: fact extraction and reflect.
@@ -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
@@ -0,0 +1,232 @@
"""
Tests for the 'none' LLM provider mode.
Verifies that when HINDSIGHT_API_LLM_PROVIDER=none:
- Retain defaults to chunks mode (no LLM calls)
- Reflect returns 400
- Mental model refresh returns 400
- Consolidation is skipped
- NoneLLM.call() raises LLMNotAvailableError
"""
import os
from datetime import datetime, timezone
import httpx
import pytest
import pytest_asyncio
from hindsight_api import LLMConfig, LocalSTEmbeddings, MemoryEngine, RequestContext
from hindsight_api.api import create_app
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError, NoneLLM
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.engine.task_backend import SyncTaskBackend
@pytest.fixture(scope="function")
def request_context():
return RequestContext()
@pytest_asyncio.fixture(scope="function")
async def none_memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""MemoryEngine with provider=none."""
mem = MemoryEngine(
db_url=pg0_db_url,
memory_llm_provider="none",
memory_llm_api_key=None,
memory_llm_model="none",
embeddings=embeddings,
cross_encoder=cross_encoder,
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=5,
run_migrations=False,
task_backend=SyncTaskBackend(),
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
@pytest_asyncio.fixture
async def none_api_client(none_memory):
"""HTTP test client backed by a none-provider MemoryEngine."""
app = create_app(none_memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# -- Unit tests for NoneLLM ---------------------------------------------------
@pytest.mark.asyncio
async def test_none_llm_call_raises():
"""NoneLLM.call() should raise LLMNotAvailableError."""
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
with pytest.raises(LLMNotAvailableError):
await llm.call(messages=[{"role": "user", "content": "hello"}])
@pytest.mark.asyncio
async def test_none_llm_call_with_tools_raises():
"""NoneLLM.call_with_tools() should raise LLMNotAvailableError."""
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
with pytest.raises(LLMNotAvailableError):
await llm.call_with_tools(
messages=[{"role": "user", "content": "hello"}],
tools=[{"type": "function", "function": {"name": "test", "parameters": {}}}],
)
@pytest.mark.asyncio
async def test_none_llm_verify_connection_succeeds():
"""NoneLLM.verify_connection() should be a no-op."""
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
await llm.verify_connection() # Should not raise
# -- Config validation tests ---------------------------------------------------
def test_config_forces_chunks_mode():
"""When provider is 'none', config.validate() forces retain_extraction_mode='chunks'."""
from hindsight_api.config import HindsightConfig
config = HindsightConfig.from_env()
# Override to none for test
config.llm_provider = "none"
config.retain_extraction_mode = "facts"
config.enable_observations = True
config.validate()
assert config.retain_extraction_mode == "chunks"
assert config.enable_observations is False
# -- Integration tests (require database) -------------------------------------
@pytest.mark.asyncio
async def test_retain_works_with_none_provider(none_memory, request_context):
"""Retain should work with provider=none, storing chunks without LLM calls."""
bank_id = f"test_none_retain_{datetime.now(timezone.utc).timestamp()}"
unit_ids = await none_memory.retain_async(
bank_id=bank_id,
content="Alice is a software engineer. She works at TechCorp and loves Python.",
context="team info",
request_context=request_context,
)
assert len(unit_ids) > 0, "Should store chunks even without an LLM"
@pytest.mark.asyncio
async def test_recall_works_with_none_provider(none_memory, request_context):
"""Recall should work with provider=none (uses embeddings, not LLM)."""
bank_id = f"test_none_recall_{datetime.now(timezone.utc).timestamp()}"
await none_memory.retain_async(
bank_id=bank_id,
content="Alice is a software engineer at TechCorp.",
context="team info",
request_context=request_context,
)
result = await none_memory.recall_async(
bank_id=bank_id,
query="Who is Alice?",
budget=Budget.LOW,
request_context=request_context,
)
assert len(result.results) > 0, "Should find results via semantic search"
@pytest.mark.asyncio
async def test_reflect_raises_with_none_provider(none_memory, request_context):
"""Reflect should raise LLMNotAvailableError with provider=none."""
bank_id = f"test_none_reflect_{datetime.now(timezone.utc).timestamp()}"
with pytest.raises(LLMNotAvailableError):
await none_memory.reflect_async(
bank_id=bank_id,
query="What do you know?",
request_context=request_context,
)
@pytest.mark.asyncio
async def test_consolidation_skipped_with_none_provider(none_memory, request_context):
"""Consolidation handler should skip when provider=none."""
result = await none_memory._handle_consolidation({"bank_id": "test_bank"})
assert result["skipped"] is True
assert result["memories_processed"] == 0
@pytest.mark.asyncio
async def test_mental_model_refresh_raises_with_none_provider(none_memory, request_context):
"""Mental model refresh should raise LLMNotAvailableError with provider=none."""
bank_id = f"test_none_mm_{datetime.now(timezone.utc).timestamp()}"
with pytest.raises(LLMNotAvailableError):
await none_memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id="fake-id",
request_context=request_context,
)
# -- HTTP API tests -----------------------------------------------------------
@pytest.mark.asyncio
async def test_http_reflect_returns_400(none_api_client):
"""Reflect endpoint should return 400 when LLM provider is none."""
bank_id = f"test_none_http_{datetime.now(timezone.utc).timestamp()}"
response = await none_api_client.post(
f"/v1/default/banks/{bank_id}/reflect",
json={"query": "What do you know?"},
)
assert response.status_code == 400
assert "none" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_http_retain_works(none_api_client):
"""Retain endpoint should work with provider=none (chunks mode)."""
bank_id = f"test_none_http_retain_{datetime.now(timezone.utc).timestamp()}"
response = await none_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "Hello world", "context": "test"}]},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_http_recall_works(none_api_client):
"""Recall endpoint should work with provider=none."""
bank_id = f"test_none_http_recall_{datetime.now(timezone.utc).timestamp()}"
# Retain first
await none_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "Alice is an engineer.", "context": "test"}]},
)
# Recall
response = await none_api_client.post(
f"/v1/default/banks/{bank_id}/memories/recall",
json={"query": "Alice"},
)
assert response.status_code == 200
@@ -97,8 +97,8 @@ def test_per_operation_provider_default_model():
# Retain should use Anthropic default
assert (
config.retain_llm_model == "claude-haiku-4-5-20251001"
), f"Expected claude-haiku-4-5-20251001, got {config.retain_llm_model}"
config.retain_llm_model == "claude-haiku-4-5"
), f"Expected claude-haiku-4-5, got {config.retain_llm_model}"
finally:
clear_config_cache()
@@ -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."""
@@ -0,0 +1,129 @@
"""
Tests for reflect search_observations source_facts_max_tokens configuration.
Verifies that the source_facts_max_tokens parameter correctly controls
whether source facts are included in search_observations recall calls.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api.engine.reflect.tools import tool_search_observations
from hindsight_api.engine.response_models import RecallResult
def _make_mock_engine(recall_result=None):
"""Create a mock memory engine with a recall_async method."""
if recall_result is None:
recall_result = RecallResult(results=[], source_facts={})
engine = MagicMock()
engine.recall_async = AsyncMock(return_value=recall_result)
return engine
@pytest.fixture
def mock_request_context():
return MagicMock()
class TestSearchObservationsSourceFacts:
"""Test source_facts_max_tokens parameter in tool_search_observations."""
@pytest.mark.asyncio
async def test_default_disables_source_facts(self, mock_request_context):
"""Default source_facts_max_tokens=-1 should disable source facts."""
engine = _make_mock_engine()
await tool_search_observations(
engine, "bank-1", "test query", mock_request_context
)
engine.recall_async.assert_called_once()
call_kwargs = engine.recall_async.call_args.kwargs
assert call_kwargs["include_source_facts"] is False
assert "max_source_facts_tokens" not in call_kwargs
@pytest.mark.asyncio
async def test_zero_enables_source_facts_unlimited(self, mock_request_context):
"""source_facts_max_tokens=0 should enable source facts with no token limit."""
engine = _make_mock_engine()
await tool_search_observations(
engine, "bank-1", "test query", mock_request_context,
source_facts_max_tokens=0,
)
engine.recall_async.assert_called_once()
call_kwargs = engine.recall_async.call_args.kwargs
assert call_kwargs["include_source_facts"] is True
assert "max_source_facts_tokens" not in call_kwargs
@pytest.mark.asyncio
async def test_positive_enables_source_facts_with_limit(self, mock_request_context):
"""source_facts_max_tokens>0 should enable source facts with a token budget."""
engine = _make_mock_engine()
await tool_search_observations(
engine, "bank-1", "test query", mock_request_context,
source_facts_max_tokens=5000,
)
engine.recall_async.assert_called_once()
call_kwargs = engine.recall_async.call_args.kwargs
assert call_kwargs["include_source_facts"] is True
assert call_kwargs["max_source_facts_tokens"] == 5000
@pytest.mark.asyncio
async def test_negative_one_disables_source_facts(self, mock_request_context):
"""Explicit -1 should disable source facts (same as default)."""
engine = _make_mock_engine()
await tool_search_observations(
engine, "bank-1", "test query", mock_request_context,
source_facts_max_tokens=-1,
)
engine.recall_async.assert_called_once()
call_kwargs = engine.recall_async.call_args.kwargs
assert call_kwargs["include_source_facts"] is False
assert "max_source_facts_tokens" not in call_kwargs
class TestReflectSourceFactsConfig:
"""Test that reflect_source_facts_max_tokens is properly wired in HindsightConfig."""
def test_config_field_exists(self):
"""reflect_source_facts_max_tokens should be a valid config field."""
from hindsight_api.config import HindsightConfig
import dataclasses
field_names = {f.name for f in dataclasses.fields(HindsightConfig)}
assert "reflect_source_facts_max_tokens" in field_names
def test_config_is_configurable(self):
"""reflect_source_facts_max_tokens should be a configurable (per-bank) field."""
from hindsight_api.config import HindsightConfig
assert "reflect_source_facts_max_tokens" in HindsightConfig.get_configurable_fields()
def test_default_value_is_disabled(self):
"""Default should be -1 (disabled)."""
from hindsight_api.config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
assert DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS == -1
def test_env_var_constant_exists(self):
"""Env var constant should be defined."""
from hindsight_api.config import ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS
assert ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS == "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
@patch.dict("os.environ", {"HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS": "8000"})
def test_from_env_reads_value(self):
"""from_env should parse the env var."""
from hindsight_api.config import HindsightConfig
config = HindsightConfig.from_env()
assert config.reflect_source_facts_max_tokens == 8000
@@ -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
+20 -14
View File
@@ -814,34 +814,36 @@ async def test_context_with_batch(memory, request_context):
@pytest.mark.asyncio
async def test_metadata_storage_and_retrieval(memory, request_context):
"""
Test that user-defined metadata is preserved.
Test that user-defined metadata passed during retain is returned on recall.
Metadata allows arbitrary key-value data to be stored with facts.
"""
bank_id = f"test_metadata_{datetime.now(timezone.utc).timestamp()}"
try:
# Store content with custom metadata
custom_metadata = {
"source": "slack",
"channel": "engineering",
"importance": "high",
"tags": "product,launch"
}
# Note: retain_async doesn't directly support metadata parameter
# Metadata would need to be supported in the API layer
# For now, we test that the system handles content without errors
unit_ids = await memory.retain_async(
# Use retain_batch_async which supports the metadata parameter
unit_ids_list = await memory.retain_batch_async(
bank_id=bank_id,
content="The product launch is scheduled for March 1st.",
context="planning meeting",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
contents=[
{
"content": "The product launch is scheduled for March 1st.",
"context": "planning meeting",
"event_date": datetime(2024, 1, 15, tzinfo=timezone.utc),
"metadata": custom_metadata,
}
],
request_context=request_context,
)
assert len(unit_ids) > 0, "Should create memory units"
assert len(unit_ids_list) > 0, "Should create memory units"
assert len(unit_ids_list[0]) > 0, "Should have at least one unit ID"
# Recall to verify storage worked
# Recall and verify metadata is returned
result = await memory.recall_async(
bank_id=bank_id,
query="When is the product launch?",
@@ -853,8 +855,12 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
assert len(result.results) > 0, "Should recall stored facts"
print("✓ Successfully stored and retrieved facts")
print(" (Note: Metadata support depends on API implementation)")
# Verify metadata is present on recalled facts
fact = result.results[0]
assert fact.metadata is not None, "Metadata should not be null on recall"
assert fact.metadata.get("source") == "slack"
assert fact.metadata.get("channel") == "engineering"
assert fact.metadata.get("importance") == "high"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -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']}'"
)
+7 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.4.19"
version = "0.4.20"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -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 = []
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.19"
version = "0.4.20"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+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)
+85 -1
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.4.19
version: 0.4.20
servers:
- url: /
paths:
@@ -3884,12 +3884,18 @@ components:
loc:
- ValidationError_loc_inner
- ValidationError_loc_inner
input: ""
ctx: "{}"
type: type
url: url
- msg: msg
loc:
- ValidationError_loc_inner
- ValidationError_loc_inner
input: ""
ctx: "{}"
type: type
url: url
properties:
detail:
items:
@@ -4074,6 +4080,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 +4102,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 +4135,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 +4196,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 +4210,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 +4738,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
@@ -5134,7 +5208,10 @@ components:
loc:
- ValidationError_loc_inner
- ValidationError_loc_inner
input: ""
ctx: "{}"
type: type
url: url
properties:
loc:
items:
@@ -5146,6 +5223,13 @@ components:
type:
title: Error Type
type: string
input: {}
ctx:
title: Context
type: object
url:
title: URL
type: string
required:
- loc
- msg
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
)
// APIClient manages communication with the Hindsight HTTP API API v0.4.19
// APIClient manages communication with the Hindsight HTTP API API v0.4.20
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.19
API version: 0.4.20
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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