Compare commits

...
71 Commits
Author SHA1 Message Date
Nicolò Boschi f0c5153861 chore: apply ruff formatting to generate_changelog.py 2026-03-18 17:51:45 +01:00
Nicolò Boschi ef24828356 fix: add agno and hermes integration docs to version-0.4 for production build 2026-03-18 17:45:26 +01:00
Nicolò Boschi db7a0ad3a5 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
2026-03-18 17:36:52 +01:00
Nicolò Boschi c10c9c89e9 docs: add 0.4.19 release blog post, Agno and Hermes integration pages (#608) 2026-03-18 17:35:54 +01:00
OctopusandPR Bot 1f1462a5f6 feat: upgrade MiniMax default model from M2.5 to M2.7 (#606)
* feat: upgrade MiniMax default model from M2.5 to M2.7

MiniMax has released MiniMax-M2.7, their latest model with a 1M context
window (up from 204K). This updates the default model across config,
docs, and examples. M2.5 remains fully compatible for users who prefer it.

- Update PROVIDER_DEFAULT_MODELS to MiniMax-M2.7
- Update .env.example and documentation references
- Add test_minimax_provider.py with M2.7 and backward compat tests

* chore: remove test file per review feedback

---------

Co-authored-by: PR Bot <[email protected]>
2026-03-18 17:15:20 +01:00
Nicolò Boschi 0727f2d069 Release v0.4.19
- Update version to 0.4.19 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-hermes, hindsight-agno, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-18 14:29:15 +01:00
Nicolò Boschi 72c25c97e3 feat(typescript-client): Deno compatibility (#607)
* feat(typescript-client): add Deno compatibility

- Switch build from tsc to tsup for dual CJS + ESM output with proper exports field
- Add deno_setup.ts preload that injects Jest-compatible globals (describe/test/expect) via @std/testing/bdd and @std/expect
- Fix generated client.gen.ts: exclude hey-api internal `client` field from RequestInit spread to avoid conflict with Deno.HttpClient
- Add test:deno npm script using --unstable-sloppy-imports and --preload
- Add test-typescript-client-deno CI job using denoland/setup-deno@v2 (v2.x)
- Update docs: rename page to TypeScript / JavaScript Client, add Deno installation section

* feat: add Deno compatibility to ai-sdk and chat integrations

- Switch ai-sdk and chat builds from tsc to tsup (ESM bundle, eliminates
  extension-less import issues in Deno)
- Add deno.json import map to ai-sdk redirecting 'vitest' to a custom
  vitest-compat.ts shim and bare npm specifiers to npm: URLs
- Add vitest-compat.ts shim implementing vi.fn()/vi.spyOn()/vi.mocked()
  using @std/expect's Symbol.for("@MOCK") interface so toHaveBeenCalledWith
  and other mock matchers work under Deno
- Add test:deno script to ai-sdk (all 30 tests pass under Deno)

* ci: add Deno test job for ai-sdk integration

Adds a new test-ai-sdk-integration-deno CI job that runs the ai-sdk
unit tests under Deno LTS, verifying Deno compatibility of the package.

* fix: remove broken link to non-existent n8n blog post in streamlit post

* fix: patch client.gen.ts for Deno compatibility during generation

Add a post-generation patch step to generate-clients.sh that removes
the hey-api internal 'client' field from the RequestInit spread in
client.gen.ts. Deno's Request constructor rejects 'client' because it
conflicts with the Deno.HttpClient option name.
2026-03-18 14:25:35 +01:00
BenandClaude Opus 4.6 8c378b981a feat: add Agno integration with Hindsight memory toolkit (#596)
* feat: add Agno integration with Hindsight memory toolkit

Add hindsight-agno package providing Hindsight memory tools (retain,
recall, reflect) as an Agno Toolkit, following the same pattern as
Agno's Mem0Tools. Includes per-user bank isolation, global config,
bank auto-creation, and memory_instructions() for system prompt
injection.

Also adds cookbook documentation page with architecture diagrams,
quick start examples, and configuration reference.

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

* chore: remove n8n blog post, add Agno icon, bind to release process

- Remove n8n blog post from the agno integration branch
- Add Agno logo icon and map hindsight-agno SDK tag in CookbookGrid
- Add hindsight-agno to release.sh PYTHON_PACKAGES array
- Add build, publish, artifact upload, and release asset steps in release.yml

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

* chore: remove cookbook page (moved to hindsight-cookbook repo)

The Agno cookbook application now lives in
vectorize-io/hindsight-cookbook/applications/agno-memory.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-18 11:17:23 +01:00
Ben e2b19d3b38 blog: fix internal links in streamlit post (#605) 2026-03-17 15:33:08 -04:00
Ben 210a40665d blog: fix streamlit post slug and add cover image (#604) 2026-03-17 15:16:29 -04:00
Nicolò Boschi 28dac7c7f8 fix: prevent silent memory loss on consolidation LLM failure (#601)
* fix: prevent silent memory loss on consolidation LLM failure

When all LLM retries are exhausted during consolidation, memories were
being marked consolidated_at unconditionally, permanently excluding them
from future consolidation runs without producing any observations.

Fix with two complementary mechanisms:
- Adaptive batch splitting: on LLM failure, the batch is halved and
  retried recursively down to batch_size=1, recovering most transient
  failures (rate limits, Pydantic validation on long prompts) without
  operator intervention
- consolidation_failed_at column: only single-memory batches that still
  fail after all retries are marked here instead of consolidated_at, so
  they remain visible and retryable
- New API endpoint POST /v1/default/banks/{bank_id}/consolidation/retry-failed
  resets these memories for the next consolidation run

* chore: regenerate OpenAPI spec

* fix: rename consolidation endpoint from /retry-failed to /recover

* fix: add consolidation_failed_at column, adaptive batch splitting, and recovery API

- Migration a3b4c5d6e7f8: add consolidation_failed_at TIMESTAMPTZ column to
  memory_units with an index for efficient failure queries; properly chains off
  g7h8i9j0k1l2 (backsweep_orphan_observations)
- Consolidator: filter pending memories with consolidation_failed_at IS NULL
  so failed memories are not re-fetched in an infinite loop
- Consolidator: adaptive batch splitting — when a batch exhausts all 3 LLM
  retries, halve it and retry sub-batches recursively; only single-memory
  batches that also exhaust all retries get consolidation_failed_at set
- New tests (9 total) covering: adaptive splitting recovers all memories,
  larger batch splitting, single-memory permanent failure, exclusion from
  next run, partial batch failure, recover resets columns, recover returns
  0 when none failed, recover-then-consolidate succeeds, HTTP endpoint

* chore: regenerate Go, Python, TypeScript clients with recover consolidation endpoint

* feat: add Recover Consolidation action to bank Actions dropdown

* style: apply ruff formatting to http.py and config.py

* fix: handle consolidation scope in large batch test mock LLM

The mock LLM was returning {"facts": ...} for ALL calls including consolidation.
Consolidation doesn't use skip_validation=True so it expects a _ConsolidationBatchResponse
instance, not a raw dict. Before this PR consolidation silently swallowed the AttributeError
(failed=False was returned); now failed=True triggers adaptive splitting and timeouts.

Fix: return _ConsolidationBatchResponse() when scope=="consolidation".

* fix: restrict claude-agent-sdk to macOS platform only (no Linux wheel available)

Also fix pre-existing type errors: use setattr for XLM-RoBERTa monkey-patch
and add missing reranker_local_fp16/bucket_batching/batch_size fields to main.py config constructor.

* fix: add UV_INDEX_STRATEGY=unsafe-best-match to fix markupsafe cp314 wheel conflict

PyTorch CPU index serves markupsafe==3.0.3 with only cp314 wheels.
uv's default first-index strategy stops at the first index with any version
even if no compatible wheel exists. unsafe-best-match searches all indices
for the best compatible wheel, falling back to PyPI for markupsafe.

* fix: use explicit pytorch index to prevent markupsafe wheel conflict

Configure the pytorch CPU index as explicit=true in pyproject.toml so it is
ONLY used for torch (via [tool.uv.sources]). All other packages (including
markupsafe) are resolved exclusively from PyPI, preventing the pytorch index
from serving incompatible cp314-only wheels for non-pytorch packages.

Remove UV_INDEX and UV_INDEX_STRATEGY from CI workflow (no longer needed
since the index is now configured in pyproject.toml).

* ci: trigger CI run

* ci: retry trigger

* ci: trigger after remote URL fix

* ci: add workflow_dispatch to unblock manual trigger

* fix: remove empty env blocks left after UV_INDEX removal

* fix: add type: ignore for optional claude_agent_sdk imports (macOS-only)

* fix: correct type: ignore rules for claude_agent_sdk and fix utcnow deprecation
2026-03-17 20:15:33 +01:00
Ben f88f0a3b26 blog: Streamlit chatbot with persistent memory (#602)
* blog: add Streamlit chatbot with persistent memory post

* fix
2026-03-17 14:45:54 -04:00
Nicolò Boschi e4f8a157c2 feat(retain): verbatim, chunks modes and named retain strategies (#593)
* feat(retain): add verbatim extraction mode

Adds retain_extraction_mode="verbatim" that stores each chunk as-is
without LLM summarization. The LLM still runs to extract entities,
temporal info, and location for full indexability — only the fact text
is replaced with the original chunk content (one memory per chunk).

Useful for RAG-style indexing and benchmarks where original text
must be preserved in memory.

- Add "verbatim" to RETAIN_EXTRACTION_MODES in config.py
- Add VERBATIM_FACT_EXTRACTION_PROMPT with instructions to preserve text
- Add _collapse_to_verbatim() post-processing to enforce 1 fact/chunk
- Expose in bank config UI dropdown with updated description
- Update configuration.md docs with verbatim mode description
- Add unit test for _collapse_to_verbatim and integration test via LLM
- Fix pre-existing main.py CLI override missing new reranker fields
- Fix pre-existing cross_encoder.py ty type error via setattr

* refactor(retain): verbatim mode skips 'what' field entirely

Instead of asking the LLM to echo the chunk text back into 'what' and
then discarding it, verbatim mode now uses a dedicated schema
(VerbatimExtractedFact) that omits the 'what' field altogether.
The LLM only returns metadata (entities, temporal info, location, who),
saving output tokens and avoiding any risk of paraphrasing before the
backfill.

- Add VerbatimExtractedFact / VerbatimFactExtractionResponse models
- Verbatim mode skips causal-relations section (nothing to relate causally)
- _extract_facts_from_chunk: allow missing 'what' in verbatim mode,
  set combined_text="" (backfilled by _collapse_to_verbatim)
- Update verbatim prompt to say DO NOT include 'what'

* feat(retain): add index_only extraction mode

Zero-LLM retain mode: chunks are stored as-is with no LLM call, no
entity extraction, and no temporal indexing. Embeddings still run for
semantic search. User-provided entities via RetainContent.entities
are the sole source of entity data.

Early return placed before the batch-API check so no LLM queue or
concurrency locks are acquired.

- Add "index_only" to RETAIN_EXTRACTION_MODES
- Add _extract_facts_index_only() with pure Python chunking path
- Add to UI dropdown and update description
- Update configuration.md with index_only docs and table entry
- Add unit test asserting zero token usage and exact text preservation

* feat(retain): add named retain strategies

Allows mixing extraction modes in a single bank via named strategies.
Each strategy is a set of hierarchical config overrides (extraction_mode,
chunk_size, entity_labels, entities_allow_free_form, etc.) applied on
top of the resolved bank config at retain time.

- retain_strategies: dict of strategy_name → config overrides (bank config)
- retain_default_strategy: default strategy when none specified (bank config)
- strategy field on /retain request: per-call override
- apply_strategy() in config_resolver applies overrides via dataclasses.replace()
- strategy propagates through retain_batch_async → _retain_batch_async_internal
  and through the async worker task payload
- Any hierarchical field is overridable per strategy, including entity_labels
  and entities_allow_free_form
- Docs updated with strategy configuration example and RRF fairness note
- Unit test for apply_strategy covering overrides, unknown strategy, and
  non-hierarchical field filtering

* feat(retain): add per-item strategy and strategy tests

- Add `strategy` field to `MemoryItem` so individual items in a retain
  request can override the request-level strategy
- Add `strategy` field to `FileRetainMetadata` for per-file strategy
  override in file retain requests
- Group memory items by effective strategy in `api_retain`; each group
  is processed as a separate batch, results are aggregated
- Thread strategy through `submit_async_file_retain` →
  `_handle_file_convert_retain` → retain task payload
- Add `operation_ids` to `RetainResponse` for async requests with
  mixed per-item strategies
- Add `test_strategy_overrides_extraction_mode_for_index_only`: unit
  test verifying a named strategy with index_only bypasses the LLM
- Add `test_retain_request_per_item_strategy_field`: unit test for
  per-item strategy grouping logic

* feat(ui): add retain strategies and default strategy to bank config UI

- Add StrategiesEditor component: per-strategy cards with name input and
  JSON overrides textarea; supports add/remove; validates JSON inline
- Add Default Strategy text input (retain_default_strategy)
- Update RetainEdits type and retainSlice() to include both new fields
- Regenerate OpenAPI spec (retain_strategies, retain_default_strategy,
  per-item strategy on MemoryItem/FileRetainMetadata, operation_ids on
  RetainResponse)

* refactor(ui): move retain strategies into its own dedicated config section

* feat(ui): improve retain strategies UX and add strategy to document dialog

- Strategy form now includes entity section (free form toggle + entity labels editor)
- Default strategy selector moved outside tab panel, above strategy chips
- Strategy tabs redesigned with underline indicator style for clarity
- Remove strategy confirms with AlertDialog
- Fix tab re-render bug when typing strategy name (skipSyncRef)
- Add strategy field to Add New Document dialog (text + per-file for uploads)
- File upload collapsible uses same Document/Tags/Source tabbed layout
- API: validate empty strategy names in config_resolver
- api.ts: add strategy field to retain and uploadFiles types

* fix: forward strategy through HTTP layer and SDK; add integration test

- route.ts: extract and forward `strategy` from request body to retainBatch
- TypeScript SDK: accept and forward `strategy` in retainBatch options and per-item
- config_resolver.py: validate empty strategy name keys on update
- bank-config-view.tsx: merge entity fields into RetainStrategyForm, redesign strategy tabs with underline style, add confirmation dialog for removal, fix tab-reset-on-typing with skipSyncRef, move default strategy selector outside panel
- bank-selector.tsx: add strategy field to Add Document dialog (per-file in tabbed collapsible)
- test_retain.py: add end-to-end integration test verifying named strategy application (index_only = 0 LLM tokens)

* fix: regenerate TypeScript client with strategy field in RetainRequest/MemoryItem

- Regenerate OpenAPI spec to include strategy field in RetainRequest and MemoryItem
- Regenerate TypeScript client from updated spec
- Add strategy to MemoryItemInput interface
- Remove (item as any) cast now that strategy is properly typed

* rename: index_only extraction mode → chunks

* remove top-level strategy from RetainRequest; strategy is per-item only

* fix(clients): update Go and Python generated clients with strategy/operation_ids fields

* fix(ci): update hierarchical field count, add strategy to Rust MemoryItem initializers

* fix(go-client): minimal targeted YAML updates for strategy/operation_ids fields
2026-03-17 18:08:25 +01:00
BenandClaude Opus 4.6 ef90842f87 feat: hindsight-hermes integration for Hermes Agent (#600)
* feat: add hindsight-hermes integration for Hermes Agent

* chore: add Hermes docs page, icon, and release process bindings

- Add cookbook page for Hermes integration (synced with README)
- Add Hermes icon and map hindsight-hermes SDK tag in CookbookGrid
- Add cookbook entry to index.mdx
- Add hindsight-hermes to release.sh PYTHON_PACKAGES array
- Add build, publish, artifact upload, and release asset steps in release.yml

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-17 18:06:55 +01:00
Nicolò Boschi f68e2e2851 docs: add Best Practices unversioned page (#598)
* docs: revamp sidebar with icon grid components and language support

- Merge Clients and Integrations sections into the developer sidebar
  (removed top-level SDKs navbar item)
- Reorder sidebar: Architecture → API → Clients → Integrations → Hosting
- Unify icon system using react-icons (LuXxx/SiXxx) via customProps.icon
- Add uppercase section titles with increased spacing and reduced indentation
- Rename Node.js → "JavaScript / TypeScript" with TypeScript icon
- Add reusable IconGrid and SupportedGrids components (ClientsGrid,
  IntegrationsGrid, LLMProvidersGrid)
- Use grids in FAQ, Models, Overview, and Quick Start pages
- Convert developer/index.md, models.md, faq.md to MDX for JSX support

* docs: add Best Practices page as unversioned standalone page

- Add src/pages/best-practices.mdx covering core concepts (memory banks,
  taxonomy, memory types), bank configuration (missions, dispositions,
  entity labels), retain (formats, context, document_id, tags, observation
  scopes), recall (budget, tag filtering, include options), reflect
  (recall vs reflect decision, response_schema, auditing), mental models,
  and anti-patterns
- Add Resources section to sidebar with Best Practices and FAQ links
- Update generate-docs-skill.sh to include standalone pages (best-practices,
  faq) from src/pages/ into the agent skill references
- SKILL.md now surfaces best-practices.md as the recommended starting point

* fix: remove leftover merge conflict markers in DocSidebarItem Link

* fix: add missing lu-star, lu-circle-help, lu-file-text icons to sidebar map

* fix: remove duplicate LuFileText import

* fix: add Best Practices and FAQ to Resources navbar dropdown

* docs: hide right TOC and add manual TOC to best practices page

* docs: hide right TOC and add manual TOC to FAQ page

* fix: add lu-star icon to navbar item icon map

* fix: correct broken anchor in best practices TOC
2026-03-17 14:03:38 +01:00
BenandClaude Opus 4.6 61b01cc040 blog: add n8n persistent memory workflows post (#585)
* blog: add n8n persistent memory workflows post

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

* blog: add cover image for n8n memory workflows post

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

* blog: update n8n cover image

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

* blog: remove broken screenshot references from n8n post

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

* blog: add Hindsight Cloud option and n8n Cloud guidance

- Add Cloud vs self-hosted setup paths in Step 1
- Show both Cloud and self-hosted URLs for retain/recall/reflect nodes
- Note that Cloud eliminates the localhost IP gotcha
- Mention n8n Cloud compatibility (requires Hindsight Cloud or public endpoint)

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

* blog: update n8n post date to 2026-03-16

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

* blog: update n8n post with optimized content and fix accuracy

- Use optimized version of the blog post
- Fix blog cross-links to use date-prefixed URLs
- Fix retain response to match actual API (success, bank_id, items_count, async)
- Fix recall response to match actual API (text, type, entities — not confidence/source)
- Update title to "How to Add Persistent Memory to n8n Workflows"

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

* blog: update n8n post title

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-16 14:56:56 -04:00
Nicolò Boschi bbcfe2f5ab docs(skills): encourage rich context over pre-summarized strings in retain (#594)
* docs: add config vars for local reranker FP16 and bucket batching (#588)

* fix: add missing reranker local fields to CLI config override and fix ty type error

- Add reranker_local_fp16, reranker_local_bucket_batching, reranker_local_batch_size
  to the manual HindsightConfig() constructor call in main.py (CLI override block)
- Replace direct module attribute assignment with setattr() in the transformers 5.x
  monkey-patch so ty can resolve it without raising unresolved-attribute

* docs(skills): encourage rich context over pre-summarized strings in retain

The previous guidance told agents to distill content before calling
retain (e.g. "Be specific: store X not Y"). This misrepresents the
actual architecture: the server runs a full extraction pipeline (fact
extraction, entity linking, embeddings) on whatever is passed in.

- Add "How Hindsight Works" section explaining the server-side pipeline
- Update retain examples to pass full-context observations
- Replace "Be specific" with "Pass rich context"
- Clarify that --context is metadata labeling, not a content filter

Closes #592

* docs(skills): add raw conversation transcript example for retain
2026-03-16 18:37:12 +01:00
Nicolò Boschi d2bfa84bca docs: add config vars for local reranker FP16 and bucket batching (#589)
* docs: add config vars for local reranker FP16 and bucket batching (#588)

* fix: add missing reranker local fields to CLI config override and fix ty type error

- Add reranker_local_fp16, reranker_local_bucket_batching, reranker_local_batch_size
  to the manual HindsightConfig() constructor call in main.py (CLI override block)
- Replace direct module attribute assignment with setattr() in the transformers 5.x
  monkey-patch so ty can resolve it without raising unresolved-attribute
2026-03-16 17:35:09 +01:00
abix5andSisyphus 8a64dc8db6 fix(docker): honor HINDSIGHT_CP_HOSTNAME for control-plane startup (#590)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <[email protected]>
2026-03-16 16:19:40 +01:00
Fabio Scarsi e7da7d0e4f feat: local reranker FP16, bucket batching, and transformers 5.x compatibility (#588)
Three independent, cumulative improvements to LocalSTCrossEncoder:

1. transformers 5.x compatibility patch for XLM-RoBERTa models (Jina v2)
2. FP16 inference (opt-in via HINDSIGHT_API_RERANKER_LOCAL_FP16)
3. Length-sorted bucket batching (opt-in via HINDSIGHT_API_RERANKER_LOCAL_BUCKET_BATCHING)

All behind .env switches with conservative defaults preserving current behavior.

Fixes #586, Closes #587
2026-03-16 15:38:33 +01:00
Nicolò Boschi f09ad9deac fix(migration): backsweep orphaned observation memory units (#584)
* fix(migration): backsweep orphaned observation memory units

Delete observation rows whose every source_memory_id points to a
deleted memory unit, left behind before PR #580 fixed the chunk FK
cascade and before delete_document() called
_delete_stale_observations_for_memories.

Closes #572 (data cleanup for pre-existing installs).

* fix(migration): broaden backsweep to cover all fact types and bank-level orphans

- Pass 1: delete any memory_units row (all fact_types) whose bank_id no
  longer exists in banks — catches orphans from bank deletions that
  predate a FK cascade between the two tables.
- Pass 2: delete observation rows whose every source_memory_id points to
  a deleted memory unit, regardless of document_id/chunk_id anchors.

* test(migration): verify backsweep removes orphans and preserves legit rows

Adds a focused migration test that:
- Starts a fresh pg0 instance at revision f6g7h8i9j0k1
- Seeds orphaned rows for both backsweep passes (ghost-bank + all-dead-sources)
- Seeds legitimate rows that must survive
- Applies the backsweep migration to head
- Asserts the expected rows are deleted/preserved
2026-03-16 14:06:33 +01:00
jnMetaCode f27bd95382 fix: change chunk FK to CASCADE so doc deletion removes linked memory units (#580)
The foreign key from memory_units.chunk_id to chunks.chunk_id used
ON DELETE SET NULL, which left ghost memory_units rows (chunk_id nulled
out, no parent document) after a document was deleted.  Switching to
ON DELETE CASCADE lets the existing document -> chunks -> memory_units
cascade clean up everything in one pass.

Closes #572

Signed-off-by: JiangNan <[email protected]>
2026-03-16 12:24:22 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7eabe5e168 chore(deps): bump actions/checkout from 4 to 6 (#581)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-16 12:01:07 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 33565a8236 chore(deps): bump actions/download-artifact from 4 to 8 (#582)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  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-16 12:00:56 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 51f05365a1 chore(deps): bump actions/setup-python from 5 to 6 (#583)
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-16 12:00:46 +01:00
Salman Chishti 1e6cb15e99 Upgrade GitHub Actions to latest versions (#576)
Signed-off-by: Salman Muin Kayser Chishti <[email protected]>
2026-03-14 12:22:05 +01:00
BenandClaude Opus 4.6 bd6348aa08 blog: add disposition-aware agents post (#566)
* blog: add disposition-aware agents post

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-13 17:53:27 -04:00
DK09876andClaude Opus 4.6 836fd81e19 fix: inject Accept header in MCP middleware to prevent 406 errors (#571)
Some MCP clients (e.g., Claude Code) don't send an Accept header,
causing the MCP SDK to reject requests with 406 Not Acceptable. The
middleware now ensures Accept includes application/json and
text/event-stream when missing.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-13 21:33:30 +01:00
陈家名and陈家名 32b00cea4f docs: improve type hints and documentation in client_wrapper (#570)
- Add comprehensive docstrings to all API namespace classes
- Add return type annotations (Any) to all methods
- Add detailed Args and Returns sections to method docstrings
- Improve HindsightClient class docstring with Attributes section
- Add type annotations to __init__ parameters

Co-authored-by: 陈家名 <[email protected]>
2026-03-13 17:42:54 +01:00
Nicolò Boschi 21f9f46ca3 fix: support gemini-3.1-flash-lite-preview by preserving thought_signature in tool calls (#568)
Gemini 3.1+ thinking models include a thought_signature field in functionCall
parts. When reconstructing conversation history for subsequent turns, this
signature must be preserved or the API returns 400 INVALID_ARGUMENT.

- Add optional thought_signature field to LLMToolCall
- Capture thought_signature from Gemini response parts
- Pass thought_signature back when reconstructing multi-turn history
- Add gemini-3.1-flash-lite-preview to the LLM provider test matrix
2026-03-13 16:43:01 +01:00
Nicolò Boschi c7db770281 doc: add 0.4.18 release blog post (#567)
* doc: add 0.4.18 release blog post

* doc: include changelog and blog image for 0.4.18
2026-03-13 16:00:59 +01:00
Nicolò Boschi 5fdb0e863f Release v0.4.18
- Update version to 0.4.18 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-13 15:21:03 +01:00
Nicolò Boschi 4a69a422a0 doc: fix build 2026-03-13 15:19:56 +01:00
Nicolò Boschi 26472df166 doc: improve link icons and structure 2026-03-13 15:09:13 +01:00
Nicolò Boschi 5de793eec7 feat: compound tag filtering via tag_groups (#562)
* feat: add compound tag filtering via tag_groups

Adds tag_groups to RecallRequest and ReflectRequest to express arbitrary
boolean tag predicates: leaf {tags, match}, and/or/not compounds.
Top-level groups are AND-ed. Existing tags/tags_match unchanged.

Examples:
  Step filter AND user scope:
    tag_groups: [{tags: ["step:5","step:8"], match: "any_strict"},
                 {tags: ["user:alice"], match: "all_strict"}]
  Exclusion:
    tag_groups: [{tags: ["user:alice"], match: "all_strict"},
                 {not: {tags: ["archived"], match: "any_strict"}}]

- Recursive SQL builder (build_tag_groups_where_clause) threads through
  all 4 retrieval strategies (semantic/BM25, temporal, graph, MPFP)
- Python-side filter (filter_results_by_tag_groups) for post-traversal
- 22 new unit tests
- OpenAPI spec + all clients regenerated (Rust, Python, TypeScript, Go)

* fix: add tag_groups: None to Rust CLI struct initializers

* fix: add tag_groups: None to Rust client test RecallRequest initializer

* feat: reject tags+tag_groups together, add tag_groups integration tests

- Add model_validator to RecallRequest and ReflectRequest that returns 422
  when both `tags` and `tag_groups` are set (mutually exclusive)
- Add 5 integration tests for tag_groups compound filtering:
  * validation: 422 when both fields are set
  * AND filter: two leaf groups (step scope AND user scope)
  * OR compound: user:alice OR user:bob
  * NOT compound: user:alice AND NOT archived
  * Nested: user:alice AND (step:5 OR step:8)

* ci: trigger CI run
2026-03-13 14:30:11 +01:00
Nicolò Boschi 06200f1752 docs: revamp sidebar with icon grids and language support (#563)
* docs: revamp sidebar with icon grid components and language support

- Merge Clients and Integrations sections into the developer sidebar
  (removed top-level SDKs navbar item)
- Reorder sidebar: Architecture → API → Clients → Integrations → Hosting
- Unify icon system using react-icons (LuXxx/SiXxx) via customProps.icon
- Add uppercase section titles with increased spacing and reduced indentation
- Rename Node.js → "JavaScript / TypeScript" with TypeScript icon
- Add reusable IconGrid and SupportedGrids components (ClientsGrid,
  IntegrationsGrid, LLMProvidersGrid)
- Use grids in FAQ, Models, Overview, and Quick Start pages
- Convert developer/index.md, models.md, faq.md to MDX for JSX support

* fix: use inline style for label color to prevent link color inheritance

* fix: label visibility and rename JavaScript/TypeScript to TypeScript

* feat: add HTTP client to grid and OpenAI Compatible to LLM providers grid
2026-03-13 14:12:42 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> d4131f88fa chore(deps): bump actions/setup-node from 4 to 6 (#557)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  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-13 14:10:39 +01:00
Nicolò Boschi 94598fbd25 fix: remove broken minimax test and enhance slim smoke test with retain/recall (#564)
- Delete test_minimax_provider.py which imports non-existent `create_llm`
  function (should be `create_llm_provider`), causing pytest collection errors
- Add scripts/smoke-test-slim.sh: shared retain + recall validation script
  used by both Docker slim and pip slim CI jobs
- Update docker/test-image.sh to run retain/recall after health check for
  all API targets
- Update test-pip-slim CI job to run the shared smoke test script
2026-03-13 14:10:32 +01:00
Nicolò Boschi 15ea23d5d6 feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 720d42c576 chore(deps): bump actions/checkout from 4 to 6 (#556)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-13 13:47:18 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8cbad0ef7a chore(deps): bump actions/upload-artifact from 4 to 7 (#555)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  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-13 13:47:09 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3d2c62ef09 chore(deps): bump actions/setup-go from 5 to 6 (#558)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  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-13 13:47:00 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 56462a30cd chore(deps): bump actions/cache from 4 to 5 (#559)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  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-13 13:46:52 +01:00
Nicolò Boschi 067acf1ba5 chore: add dependabot config for GitHub Actions updates (#554) 2026-03-13 10:32:29 +01:00
Salman Chishti 4eaa2f3566 Upgrade GitHub Actions to latest versions (#553)
Signed-off-by: Salman Muin Kayser Chishti <[email protected]>
2026-03-13 10:32:22 +01:00
Nicolò Boschi eeb938fc65 fix: truncate documents exceeding LiteLLM reranker context limit (#549)
* fix: register embedded profiles in CLI metadata on daemon start

When HindsightEmbedded(profile="myapp") starts a daemon, the profile
was never written to metadata.json or given a .env file, making it
invisible to `hindsight-embed profile list` and other CLI commands.

Add _register_profile() to DaemonEmbedManager which saves HINDSIGHT_API_*
config to ~/.hindsight/profiles/{name}.env and registers the port in
metadata.json. Called after a successful new daemon start and when the
daemon is already running, so orphaned profiles also get registered on
next use.

* fix: truncate documents exceeding LiteLLM reranker context limit

Add HINDSIGHT_API_RERANKER_LITELLM_MAX_TOKENS_PER_DOC env var for both
litellm and litellm-sdk reranker providers. When set, documents are
truncated to the configured token limit using tiktoken (cl100k_base)
before being sent to the reranker, preventing BadRequestError for
models with small context windows (e.g. 1024-token limit).

* refactor: use shared _tiktoken_encoder for doc truncation in LiteLLM reranker

* refactor: use _get_tiktoken_encoding() consistently, remove eager module-level encoder instance

* doc: add HINDSIGHT_API_RERANKER_LITELLM_MAX_TOKENS_PER_DOC to configuration reference
2026-03-13 10:18:18 +01:00
Ethan Clarkeandocto-patch 2344484f77 feat: add MiniMax LLM provider support (#550)
Add MiniMax as a supported LLM provider via the OpenAI-compatible interface.

- Register MiniMax in the provider factory and valid providers list
- Set default base URL to https://api.minimax.io/v1
- Set default model to MiniMax-M2.5 in PROVIDER_DEFAULT_MODELS
- Add temperature clamping for MiniMax (must be >0, ≤1.0)
- Add API key validation (MiniMax requires an API key)
- Add MiniMax configuration example to .env.example
- Update documentation (models.md, configuration.md, embed.md, CLAUDE.md, README.md)
- Add unit and integration tests for MiniMax provider

Co-authored-by: octo-patch <[email protected]>
2026-03-13 10:17:55 +01:00
Ben a01bb18bc4 blog: Time-Aware Spreading Activation for Memory Graphs (#547)
doc: add blog post on time-aware spreading activation for memory graphs
2026-03-12 12:55:14 -04:00
Stable GeniusandStable Genius b17f338e17 fix(openclaw): inject recalled memories as system context (#548)
Co-authored-by: Stable Genius <[email protected]>
2026-03-12 17:09:13 +01:00
Nicolò Boschi e210953d05 add trending badge HTML in README.md 2026-03-12 16:26:17 +01:00
Nicolò Boschi 06b0f74a48 fix: register embedded profiles in CLI metadata on daemon start (#546)
When HindsightEmbedded(profile="myapp") starts a daemon, the profile
was never written to metadata.json or given a .env file, making it
invisible to `hindsight-embed profile list` and other CLI commands.

Add _register_profile() to DaemonEmbedManager which saves HINDSIGHT_API_*
config to ~/.hindsight/profiles/{name}.env and registers the port in
metadata.json. Called after a successful new daemon start and when the
daemon is already running, so orphaned profiles also get registered on
next use.
2026-03-12 09:39:47 +01:00
Nicolò Boschi 0560f6260d fix: cancel in-flight async ops when bank is deleted (#545)
* fix: cancel async ops on bank delete via CASCADE FK + heartbeat checkpoints

- Add migration e5f6g7h8i9j0: FK ON DELETE CASCADE from async_operations
  and webhooks to banks, so deleting a bank auto-removes all its ops/webhooks
- Add _check_op_alive() helper: returns False if op row was deleted (cascade)
- Add consolidation checkpoint: after each LLM batch commit, abort early if
  op was deleted mid-run (returns status='cancelled')
- Add retain checkpoint: between sub-batches, abort early if op was deleted
- _mark_operation_completed/failed/completed_and_fire_webhook: gracefully
  handle missing row (UPDATE 0) with log instead of silent error
- Thread operation_id into run_consolidation_job() for checkpoint access
- Fix y0t1u2v3w4x5 and a1b2c3d4e5f6 migrations: add IF NOT EXISTS to prevent
  failure on idempotent re-runs
- Add 10 tests covering cascade delete, _check_op_alive, graceful mark methods,
  consolidation checkpoint, and retain checkpoint

* refactor: use RETURNING + fetchrow instead of execute + string comparison

* fix: add bank upsert before async_operations FK inserts and update tests

- memory_engine.py: upsert bank in submit_async_retain before async_operations INSERT
- http.py: upsert bank in api_create_webhook before webhooks INSERT
- test_worker.py, test_async_batch_retain.py, test_webhooks.py: add _ensure_bank
  helper calls before direct async_operations/webhooks inserts to satisfy FK constraint

* fix: mock bank_utils.get_bank_profile in unit test with mocked pool
2026-03-12 09:39:31 +01:00
BenandClaude Opus 4.6 220851e6f4 doc: What's New in Hindsight Cloud — Programmatic API Key Management (#543)
* doc: What's New in Hindsight Cloud — Programmatic API Key Management

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-11 11:41:21 -04:00
Ben 5b360c83d2 doc: Run Hindsight with Ollama: Local AI Memory, No API Keys Needed (#536)
* doc: add run-hindsight-with-ollama blog post
2026-03-11 11:39:59 -04:00
Nicolò Boschi 1caf5ec9ee feat: add jina-mlx reranker provider for Apple Silicon (#542)
* feat: add JinaMLXCrossEncoder for native Apple Silicon reranking

Adds a new `jina-mlx` reranker provider backed by jinaai/jina-reranker-v3-mlx,
a 0.6B multilingual listwise reranker running via the MLX framework on Apple Silicon.
The model is downloaded automatically from HuggingFace Hub on first use.

Benchmarked latencies (Apple Silicon): 1 doc→32ms, 5→45ms, 10→60ms, 20→94ms.
Sub-linear scaling because all docs are ranked in a single forward pass.

- Embeds the MLX reranker implementation (_MLXReranker / _MLPProjector) directly
  in cross_encoder.py with no transformers/PyTorch dependency
- Adds `mlx`, `mlx-lm`, `safetensors` to pyproject.toml optional deps (uv add)
- Updates configuration.md with provider docs and benchmark table

* refactor: import MLXReranker from repo rerank.py instead of duplicating code

Use importlib to load MLXReranker directly from the model repo's own rerank.py
(downloaded via snapshot_download). Also pin exact minimum versions for
mlx>=0.31.0, mlx-lm>=0.31.1, safetensors>=0.6.2 (verified against installed versions).

* refactor: move MLX reranker impl to dedicated jina_mlx_reranker.py

Replaces the importlib hack with a proper module. jina_mlx_reranker.py is
adapted from jinaai/jina-reranker-v3-mlx/rerank.py (CC BY-NC 4.0) with the
source clearly documented at the top of the file.

* docs: simplify jina-mlx reranker docs

* fix: disable GIN fastupdate on source_memory_ids index to prevent deadlocks

GIN fastupdate buffers inserts in a pending list and flushes it with
AccessExclusiveLock when full. Under concurrent test load (8 xdist workers
all running retain_async), two workers can trigger a flush simultaneously
and deadlock. Recreating the index with fastupdate=off eliminates the
flush/lock cycle at the cost of slightly slower individual inserts.

* fix: drop per-bank HNSW indexes after transaction to avoid AccessExclusiveLock deadlock

When deleting a bank, the previous code dropped HNSW indexes inside the
same transaction as the DELETE FROM memory_units. Since DROP INDEX needs
AccessExclusiveLock on the parent table and DELETE holds RowExclusiveLock,
two concurrent bank deletions deadlocked on the same table lock.

Fix: capture internal_id inside the transaction, commit, then drop the
indexes outside the transaction so no row-level locks are held.
2026-03-11 15:15:58 +01:00
Nicolò Boschi 66dedb8d41 feat: make recall max query tokens configurable via env var (#544)
* doc: add 0.4.17 release blog post

* feat: make recall max query tokens configurable via env var

Add HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS env var (default: 500) to
replace the hardcoded MAX_QUERY_TOKENS constant in http.py.
2026-03-11 14:56:59 +01:00
Nicolò Boschi 43b3efc494 perf: replace window-function retrieval with UNION ALL + per-bank HNSW indexes (#541)
* perf: replace window-function retrieval with UNION ALL + per-bank HNSW indexes

The previous retrieve_semantic_bm25_combined() used ROW_NUMBER() OVER (PARTITION
BY fact_type ...) which forced a full sequential scan — pgvector cannot use HNSW
indexes when a window function partitions on the same column as the ORDER BY.

Changes:
- retrieval.py: rewrite to UNION ALL of per-fact_type subqueries; each arm has
  its own ORDER BY embedding <=> $1 LIMIT n, enabling partial HNSW index scans.
  Semantic arms over-fetch 5x (min 100) for HNSW approximation; trimmed in Python.
- memory_engine.py: set hnsw.ef_search=200 at pool init (persistent per-connection,
  no per-query SET/RESET overhead).
- bank_utils.py: add create_bank_hnsw_indexes / drop_bank_hnsw_indexes for
  per-(bank_id, fact_type) partial HNSW index lifecycle management.
- fact_storage.py / bank_utils.py: create per-bank indexes on fresh bank insert.
- memory_engine.py delete_bank: drop per-bank indexes via DELETE...RETURNING to
  avoid a separate round-trip.
- Migration a3b4c5d6e7f8: add interim fact_type-only partial indexes.
- Migration d5e6f7a8b9c0: add internal_id UUID UNIQUE to banks, replace
  fact_type-only indexes with per-(bank, fact_type) partial HNSW indexes, drop
  the global idx_memory_units_embedding that competed with them.

Why per-(bank, fact_type) not just per-fact_type:
The idx_memory_units_bank_id B-tree index always wins over fact_type-only partial
indexes when bank_id appears in the WHERE clause. Including bank_id in the partial
index predicate removes the B-tree from consideration and lets the planner choose
HNSW. The global HNSW index must also be dropped to avoid competing for the larger
fact_type partitions (world, observation).

* refactor: collapse two HNSW migrations into one

* refactor: generate bank internal_id in Python before insert

Instead of relying on DEFAULT gen_random_uuid() and RETURNING internal_id,
generate the UUID in application code before the INSERT. This means we
always know the value upfront and can call create_bank_hnsw_indexes
immediately without needing a DB round-trip to retrieve the assigned ID.

Also adds tests for HNSW index lifecycle and retrieve_semantic_bm25_combined.

* fix: correct migration and prevent global HNSW index recreation

Migration fixes:
- Add text() wrappers for raw SQL in d5e6f7a8b9c0 (SQLAlchemy 2.0 compat)
- Drop stale fact_type-only partial indexes (idx_mu_emb_world/observation/experience)
  that may exist from prior migrations on the same DB

migrations.py fix:
- Skip global HNSW index creation when per-bank partial HNSW indexes already
  exist on memory_units (idx_mu_emb_* pattern). Without this, the post-migration
  vector index check detects no %embedding% named index and recreates the global
  idx_memory_units_embedding, which defeats the per-bank index strategy.

Verified with EXPLAIN ANALYZE on 66K-row bank: all three fact_type arms use
their per-bank HNSW index scan (idx_mu_emb_worl/expr/obsv_<uid16>).

* fix: use correct embeddings.encode() in test
2026-03-11 12:09:50 +01:00
Nicolò Boschi 00ac3d8834 doc: add 0.4.17 release blog post (#538) 2026-03-10 17:40:10 +01:00
Nicolò Boschi 2191654b1f Release v0.4.17
- Update version to 0.4.17 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-10 17:18:35 +01:00
Nicolò Boschi dcaacbe407 feat: add manual retry for failed async operations (#537)
- API: POST /v1/default/banks/{bank_id}/operations/{operation_id}/retry
  resets status to pending so the worker re-executes the task
- UI: Retry button on failed operations in the operations view
- Control plane proxy route + ControlPlaneClient.retryOperation()
- Updated OpenAPI spec, all generated clients, and operations docs
2026-03-10 17:16:11 +01:00
And#ocean 32a4882a10 fix: resolve remaining webhook schema issues in multi-tenant retain (#533)
Follow-up to #499 which fixed the worker path and http.py but missed
two code paths in memory_engine.py:

1. `_retain_batch_async_internal` (line ~2185) still passed
   `request_context.tenant_id` which is always None for HTTP requests
   (tenant_id is never populated by the HTTP layer — the schema is
   stored in the _current_schema contextvar by _authenticate_tenant).

2. `_build_retain_outbox_callback._callback` captured the `schema`
   parameter at closure creation time. In the HTTP path, http.py builds
   the callback *before* calling retain_batch_async, but _current_schema
   is only set inside retain_batch_async by _authenticate_tenant — so
   the captured schema is always None. Fixed by resolving schema at
   callback invocation time via `schema or _current_schema.get()`.

Both issues cause `relation "webhooks" does not exist` errors that
abort the entire retain transaction in multi-tenant deployments,
silently rolling back all inserted memory data.
2026-03-10 16:44:04 +01:00
Nicolò Boschi cd3a6a227b fix: strip null bytes from parsed file content before retain (#535)
* doc: split blog index into Hindsight and Hindsight Cloud sections

- Tag the document upload post with `hindsight-cloud`
- BlogListPage renders two sections, capping Cloud at 3 posts with a "View all →" link
- Swizzle BlogTagsPostsPage so /blog/tags/hindsight-cloud uses the custom grid layout

* doc: attribute blog posts to Nicolò Boschi with GitHub profile image

Replace the generic "Hindsight Team" author with the real author entry
(nicoloboschi) across all 15 blog posts. GitHub profile image is loaded
from https://github.com/nicoloboschi.png.

* doc: add Hindsight Team title to nicoloboschi author

* doc: assign blog posts to correct authors based on git blame

- Add benfrank241 (Ben Bartholomew) and chrislatimer (Chris Latimer) to authors.yml
- Assign 7 posts to Ben, 1 post to Chris, remainder stay with Nicolò

* fix: strip null bytes from parsed file content before retain

* test: add tests for sanitize_llm_output

* fix: retry retain DB transaction on deadlock during parallel document processing
2026-03-10 16:24:11 +01:00
Nicolò Boschi 28308a14d6 doc: split blog index into Hindsight and Hindsight Cloud sections (#534)
* doc: split blog index into Hindsight and Hindsight Cloud sections

- Tag the document upload post with `hindsight-cloud`
- BlogListPage renders two sections, capping Cloud at 3 posts with a "View all →" link
- Swizzle BlogTagsPostsPage so /blog/tags/hindsight-cloud uses the custom grid layout

* doc: attribute blog posts to Nicolò Boschi with GitHub profile image

Replace the generic "Hindsight Team" author with the real author entry
(nicoloboschi) across all 15 blog posts. GitHub profile image is loaded
from https://github.com/nicoloboschi.png.

* doc: add Hindsight Team title to nicoloboschi author

* doc: assign blog posts to correct authors based on git blame

- Add benfrank241 (Ben Bartholomew) and chrislatimer (Chris Latimer) to authors.yml
- Assign 7 posts to Ben, 1 post to Chris, remainder stay with Nicolò
2026-03-10 13:32:43 +01:00
BenandClaude Opus 4.6 fc71664b5f doc: What's New in Hindsight — Document File Upload (#532)
* doc: add Hindsight document file upload blog post

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

* doc: clarify document upload is a Hindsight Cloud feature

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

* doc: fix Iris billing claim to be more accurate

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 10:11:10 +01:00
Chris Bartholomew 9a694f64b8 Fix run-db-migration for all-tenant upgrades (#530)
* Add release-scoped migration admin command

* Fix run-db-migration for all-tenant upgrades
2026-03-10 10:10:03 +01:00
BenandClaude Opus 4.6 7bcf26097c doc: Your Pydantic AI Agent Forgets You After Every Run. Fix It in 5 Lines. (#531)
* doc: add pydantic-ai-persistent-memory blog post

* doc: update Pydantic AI blog cover image

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

* doc: SEO-optimized rewrite of Pydantic AI blog post

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-09 16:39:20 -04:00
Nicolò Boschi 1cdfb7c2e2 fix: normalize named tool_choice to required + filtered tools for OpenAI-compatible providers (#528)
LM Studio (and Ollama) reject the named tool_choice dict format
{"type": "function", "function": {"name": "..."}} with HTTP 400.

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

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

Closes #520
2026-03-09 15:47:51 +01:00
Nicolò Boschi 3e967add78 docs: add FAQ entry for conversation retain format (#529)
Addresses common questions from community discussions on the recommended
format and flow for retaining conversations (JSON array vs plain text,
upsert pattern, avoiding pre-summarization).
2026-03-09 15:47:25 +01:00
Nicolò Boschi 00ccf0b218 fix(consolidation): respect bank mission over ephemeral-state heuristic (#525)
* Add Hindsight as git subtree + BCGU noise filtering tests

Adds hindsight server source as a subtree under hindsight-api/ so we
can iterate on server-side fixes directly.

test_bcgu_noise_filtering.py proves that a well-crafted
retain_custom_instructions (BCGU_RETAIN_MISSION) can suppress
talking-head noise at fact extraction time — eliminating the need for
client-side --filter-vision-noise preprocessing.

Tests cover:
- Default mode extracts 3 noise facts from talking-head frame (problem documented)
- BCGU mission produces 0 noise facts from same talking-head frame
- BCGU mission still extracts 2 high-value ChatGPT screen facts correctly
- Mixed doc (2 talking-head + 2 screen): 0% noise ratio with BCGU mission
- Pure talking-head doc: 0 facts extracted

All 5 tests pass in ~32s using gpt-4o-mini.

* fix(consolidation): respect mission context over ephemeral-state heuristic

Two related fixes for the consolidation engine when a bank mission is
configured:

1. **Mission override for ephemeral-state filter** (`prompts.py`):
   The system prompt previously instructed the LLM to discard any fact
   that looked like "ephemeral state" (e.g. current position, transient
   actions).  When a mission is active the mission itself defines what is
   valuable — timestamped screen actions, session events, tool interactions
   may all be mission-critical even though they look ephemeral.  Added a
   MISSION OVERRIDE block that explicitly tells the LLM the mission takes
   priority over the generic ephemeral-state guidance.

2. **Remove contradictory durable-knowledge nudge** (`consolidator.py`):
   The user-prompt builder was injecting "Focus on DURABLE knowledge that
   serves this mission, not ephemeral state" alongside the mission text.
   This phrasing contradicted missions that intentionally capture
   timestamped events.  Replaced with a neutral directive that simply
   signals the mission overrides general rules.

3. **JSON control-character sanitisation** (`consolidator.py`):
   LLMs occasionally embed literal ASCII control characters (0x00–0x1f)
   inside JSON string values, causing `json.loads` to raise a
   JSONDecodeError.  Added a try/except that strips control characters
   and retries the parse before re-raising, preventing spurious failures.

* refactor(consolidation): move sanitize_llm_output to llm_wrapper, reuse in consolidator

- Add `sanitize_llm_output()` to `llm_wrapper.py` as the single canonical
  function for stripping characters that break downstream systems
  (ASCII control chars 0x00-0x08/0x0B-0x0C/0x0E-0x1F/0x7F and Unicode
  surrogates). Tab, newline, and carriage-return are preserved.
- Reduce `_sanitize_text()` in `fact_extraction.py` to a thin wrapper
  that delegates to `sanitize_llm_output()`.
- Update `consolidator.py` to import and call `sanitize_llm_output()`
  directly instead of reimplementing the logic inline.
- Remove test_bcgu_noise_filtering.py (should not have been committed).

* fix(consolidation): apply sanitize_llm_output to observation text fields

sanitize_llm_output was imported but unused after the old _call_llm_once
path was removed. The batch flow uses structured Pydantic output so
there's no raw json.loads call — instead, apply sanitization via
field_validator on _CreateAction.text and _UpdateAction.text so control
characters are stripped before observation text reaches the database.

* fix(entity-resolver): correct mention_count for new entities in batch retain

When the same entity (e.g. "Bob") appears across N items in a single batch
retain, _resolve_entities_batch_impl deduplicates them into one name group
before inserting, then queued only ONE _EntityStat regardless of N. The
flush therefore always incremented mention_count by 1 beyond the INSERT
value — giving 2 for any number of mentions.

Two-part fix:
- INSERT with mention_count=0 so the post-transaction flush is the single
  source of truth for the count (avoids an off-by-one for N=1 as well).
- Append one _EntityStat per original mention (len(g.indices)) instead of
  one per unique name, so flush_pending_stats() adds the correct total N.

This makes the batch path consistent with the single-entity path, which
already accumulates one stat per mention via entities_to_update.
2026-03-09 15:04:36 +01:00
Nicolò Boschi f7a60f898d feat: filter operations by type + fix stale auto-refresh closure (#522) (#527)
* feat: filter operations by type + fix stale closure in auto-refresh

- Add `type` query param to GET /operations endpoint and engine layer
- Add operation type dropdown filter in Background Operations UI
- Fix auto-refresh interval using stale statusFilter/offset closure by
  adding filter state to useEffect deps and wrapping loadOperations in
  useCallback (fixes #522)
- Regenerate OpenAPI spec and all SDK clients

* fix: update Rust CLI list_operations call with new type parameter
2026-03-09 13:17:00 +01:00
Nicolò Boschi 7accac94b2 fix: migrate mental_models.embedding dimension alongside memory_units (#526)
ensure_embedding_dimension() now also checks and migrates mental_models.embedding,
fixing silent failures when changing embedding model dimensions. Extracted shared
per-table logic into _migrate_table_embedding_dimension() to avoid duplication.
Adds test coverage for the mental_models dimension migration path.

Fixes #523
2026-03-09 12:23:50 +01:00
687 changed files with 29536 additions and 3757 deletions
+6 -1
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -20,6 +20,11 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
# Example: MiniMax configuration (1M context window)
# HINDSIGHT_API_LLM_PROVIDER=minimax
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.7
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+4 -4
View File
@@ -21,20 +21,20 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- uses: astral-sh/setup-uv@v4
- uses: astral-sh/setup-uv@v7
- run: npm ci --workspace=hindsight-docs
- run: uv run generate-llms-full
- run: npm run build --workspace=hindsight-docs
env:
UMAMI_URL: https://analytics.hindsight.vectorize.io
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }}
- uses: actions/upload-pages-artifact@v3
- uses: actions/upload-pages-artifact@v4
with:
path: hindsight-docs/build
deploy:
+99
View File
@@ -0,0 +1,99 @@
name: Release Integration
on:
push:
tags:
- 'integrations/**'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
steps:
- uses: actions/checkout@v6
- name: Extract integration info
id: info
run: |
# refs/tags/integrations/litellm/v0.1.0 → integration=litellm, version=0.1.0
TAG="${GITHUB_REF#refs/tags/}"
INTEGRATION=$(echo "$TAG" | cut -d'/' -f2)
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
echo "integration=$INTEGRATION" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Integration: $INTEGRATION, Version: $VERSION"
- name: Detect integration type
id: type
run: |
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
echo "type=python" >> $GITHUB_OUTPUT
else
echo "type=typescript" >> $GITHUB_OUTPUT
fi
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
- name: Install uv
if: steps.type.outputs.type == 'python'
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
if: steps.type.outputs.type == 'python'
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build Python package
if: steps.type.outputs.type == 'python'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: uv build --out-dir dist
- name: Publish Python package to PyPI
if: steps.type.outputs.type == 'python'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/${{ steps.info.outputs.integration }}/dist
skip-existing: true
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
- name: Set up Node.js
if: steps.type.outputs.type == 'typescript'
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm ci
- name: Build TypeScript package
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+53 -235
View File
@@ -13,15 +13,15 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
@@ -30,37 +30,39 @@ jobs:
working-directory: ./hindsight-clients/python
run: uv build --out-dir dist
- name: Build hindsight-api-slim
working-directory: ./hindsight-api-slim
run: uv build --out-dir dist
- name: Build hindsight-api
working-directory: ./hindsight-api
run: uv build --out-dir dist
- name: Build hindsight-all
working-directory: ./hindsight
working-directory: ./hindsight-all
run: uv build --out-dir dist
- name: Build hindsight-litellm
working-directory: ./hindsight-integrations/litellm
- name: Build hindsight-all-slim
working-directory: ./hindsight-all-slim
run: uv build --out-dir dist
- name: Build hindsight-embed
working-directory: ./hindsight-embed
run: uv build --out-dir dist
- name: Build hindsight-crewai
working-directory: ./hindsight-integrations/crewai
run: uv build --out-dir dist
- name: Build hindsight-pydantic-ai
working-directory: ./hindsight-integrations/pydantic-ai
run: uv build --out-dir dist
# Publish in order (client and api first, then hindsight-all which depends on them)
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-clients/python/dist
skip-existing: true
- name: Publish hindsight-api-slim to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-api-slim/dist
skip-existing: true
- name: Publish hindsight-api to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
@@ -70,13 +72,13 @@ jobs:
- name: Publish hindsight-all to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight/dist
packages-dir: ./hindsight-all/dist
skip-existing: true
- name: Publish hindsight-litellm to PyPI
- name: Publish hindsight-all-slim to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/litellm/dist
packages-dir: ./hindsight-all-slim/dist
skip-existing: true
- name: Publish hindsight-embed to PyPI
@@ -85,31 +87,18 @@ jobs:
packages-dir: ./hindsight-embed/dist
skip-existing: true
- name: Publish hindsight-crewai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/crewai/dist
skip-existing: true
- name: Publish hindsight-pydantic-ai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/pydantic-ai/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: python-packages
path: |
hindsight-clients/python/dist/*
hindsight-api-slim/dist/*
hindsight-api/dist/*
hindsight/dist/*
hindsight-integrations/litellm/dist/*
hindsight-all/dist/*
hindsight-all-slim/dist/*
hindsight-embed/dist/*
hindsight-integrations/crewai/dist/*
hindsight-integrations/pydantic-ai/dist/*
retention-days: 1
release-typescript-client:
@@ -117,10 +106,10 @@ jobs:
environment: npm
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
@@ -155,168 +144,21 @@ jobs:
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: typescript-client
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-openclaw-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/openclaw
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/openclaw
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: openclaw-integration
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/ai-sdk
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/ai-sdk
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ai-sdk-integration
path: hindsight-integrations/ai-sdk/*.tgz
retention-days: 1
release-chat-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/chat
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/chat
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/chat
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-integrations/chat
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: chat-integration
path: hindsight-integrations/chat/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
@@ -364,7 +206,7 @@ jobs:
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: control-plane
path: hindsight-control-plane/*.tgz
@@ -393,7 +235,7 @@ jobs:
asset_name: hindsight-linux-arm64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -411,7 +253,7 @@ jobs:
chmod +x artifacts/${{ matrix.asset_name }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: rust-cli-${{ matrix.asset_name }}
path: artifacts/${{ matrix.asset_name }}
@@ -452,7 +294,7 @@ jobs:
PRELOAD_ML_MODELS=false
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
@@ -466,13 +308,13 @@ jobs:
swap-storage: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -484,7 +326,7 @@ jobs:
- name: Extract metadata for release tags
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
flavor: |
@@ -500,7 +342,7 @@ jobs:
# # Step 1: Build for local testing (single platform, no push)
# # This creates an identical image to what will be released, just for one platform
# - name: Build image for testing
# uses: docker/build-push-action@v6
# uses: docker/build-push-action@v7
# with:
# context: .
# file: docker/standalone/Dockerfile
@@ -519,7 +361,7 @@ jobs:
# Build multi-platform and push to release tags
- name: Build and push release images
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: docker/standalone/Dockerfile
@@ -537,7 +379,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Helm
uses: azure/setup-helm@v4
@@ -557,7 +399,7 @@ jobs:
run: helm push helm-packages/*.tgz oci://ghcr.io/${{ github.repository_owner }}/charts
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: helm-chart
path: helm-packages/*.tgz
@@ -565,73 +407,55 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Extract version from tag
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Download Python packages
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: python-packages
path: ./artifacts/python-packages
- name: Download TypeScript client
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClaw Integration
uses: actions/download-artifact@v4
with:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Chat Integration
uses: actions/download-artifact@v4
with:
name: chat-integration
path: ./artifacts/chat-integration
- name: Download Control Plane
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: control-plane
path: ./artifacts/control-plane
- name: Download Rust CLI (Linux)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-linux-amd64
path: ./artifacts/rust-cli-linux
- name: Download Rust CLI (macOS Intel)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-darwin-amd64
path: ./artifacts/rust-cli-darwin-amd64
- name: Download Rust CLI (macOS ARM)
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: rust-cli-hindsight-darwin-arm64
path: ./artifacts/rust-cli-darwin-arm64
- name: Download Helm chart
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: helm-chart
path: ./artifacts/helm-chart
@@ -641,19 +465,13 @@ jobs:
mkdir -p release-assets
# Python packages
cp artifacts/python-packages/hindsight-clients/python/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-api-slim/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-all-slim/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Chat Integration
cp artifacts/chat-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+360 -176
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -17,20 +17,20 @@ Hindsight is an agent memory system that provides long-term memory for AI agents
./scripts/dev/start-api.sh
# Run all tests (parallelized with pytest-xdist)
cd hindsight-api && uv run pytest tests/
cd hindsight-api-slim && uv run pytest tests/
# Run specific test file
cd hindsight-api && uv run pytest tests/test_http_api_integration.py -v
cd hindsight-api-slim && uv run pytest tests/test_http_api_integration.py -v
# Run single test function
cd hindsight-api && uv run pytest tests/test_retain.py::test_retain_simple -v
cd hindsight-api-slim && uv run pytest tests/test_retain.py::test_retain_simple -v
# Lint and format
cd hindsight-api && uv run ruff check .
cd hindsight-api && uv run ruff format .
cd hindsight-api-slim && uv run ruff check .
cd hindsight-api-slim && uv run ruff format .
# Type checking (uses ty - extremely fast type checker from Astral)
cd hindsight-api && uv run ty check hindsight_api/
cd hindsight-api-slim && uv run ty check hindsight_api/
```
### Control Plane (Next.js)
@@ -72,7 +72,7 @@ cd hindsight-control-plane && npm run dev
## Architecture
### Monorepo Structure
- **hindsight-api/**: Core FastAPI server with memory engine (Python, uv)
- **hindsight-api-slim/**: Core FastAPI server with memory engine (Python, uv)
- **hindsight/**: Embedded Python bundle (hindsight-all package)
- **hindsight-control-plane/**: Admin UI (Next.js, npm)
- **hindsight-cli/**: CLI tool (Rust, cargo, uses progenitor for API client)
@@ -81,9 +81,9 @@ cd hindsight-control-plane && npm run dev
- **hindsight-integrations/**: Framework integrations (LiteLLM, OpenAI)
- **hindsight-dev/**: Development tools and benchmarks
### Core Engine (hindsight-api/hindsight_api/engine/)
### Core Engine (hindsight-api-slim/hindsight_api/engine/)
- `memory_engine.py`: Main orchestrator (~170KB) for retain/recall/reflect operations
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, Groq, Ollama, LM Studio
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, Groq, MiniMax, Ollama, LM Studio
- `embeddings.py`: Embedding generation (local sentence-transformers or TEI)
- `cross_encoder.py`: Reranking (local or TEI)
- `entity_resolver.py`: Entity extraction and normalization
@@ -101,7 +101,7 @@ cd hindsight-control-plane && npm run dev
- `fusion.py`: Reciprocal rank fusion for combining results
- `reranking.py`: Cross-encoder reranking
### API Layer (hindsight-api/hindsight_api/api/)
### API Layer (hindsight-api-slim/hindsight_api/api/)
- `http.py`: FastAPI HTTP routers (~80KB) for all REST endpoints
- `mcp.py`: Model Context Protocol server implementation
@@ -111,13 +111,13 @@ Main operations:
- **Reflect**: Disposition-aware reasoning using memories and mental models.
### Database
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api-slim/hindsight_api/alembic/`. Migrations run automatically on API startup.
Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
### Adding Database Migrations
1. **Create a new migration file** in `hindsight-api/hindsight_api/alembic/versions/`:
1. **Create a new migration file** in `hindsight-api-slim/hindsight_api/alembic/versions/`:
- File name format: `<revision_id>_<description>.py` (e.g., `f1a2b3c4d5e6_add_new_index.py`)
- Use a unique hex revision ID (12 chars)
- Set `down_revision` to the previous migration's revision ID
@@ -154,7 +154,7 @@ Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
3. **Run migrations locally**:
```bash
# Set database URL and run migrations
# Set database URL and run migrations for the base schema plus all tenants
uv run hindsight-admin run-db-migration
# Run on a specific tenant schema
@@ -251,7 +251,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
#### Adding a New Configuration Field
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
1. **config.py** (`hindsight-api-slim/hindsight_api/config.py`):
- Add `ENV_*` constant for the environment variable name (e.g., `ENV_MY_SETTING = "HINDSIGHT_API_MY_SETTING"`)
- Add `DEFAULT_*` constant for the default value
- Add field to `HindsightConfig` dataclass with type annotation
@@ -268,7 +268,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
# Static field - just don't add to _HIERARCHICAL_FIELDS
```
2. **main.py** (`hindsight-api/hindsight_api/main.py`):
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
3. **Use hierarchical config in MemoryEngine**:
@@ -308,14 +308,14 @@ cp .env.example .env
# Edit .env with LLM API key
# Python deps
uv sync --directory hindsight-api/
uv sync --directory hindsight-api-slim/
# Node deps (uses npm workspaces)
npm install
```
Required env vars:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, ollama, lmstudio
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
+3 -2
View File
@@ -9,8 +9,9 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![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/>
<a href="https://trendshift.io/repositories/15603" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15603" alt="vectorize-io%2Fhindsight | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
---
@@ -69,7 +70,7 @@ docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
>API: http://localhost:8888
>UI: http://localhost:9999
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, and `lmstudio`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, and `minimax`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
Generated
+139
View File
@@ -0,0 +1,139 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.17": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/expect@*": "1.0.18",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/testing@*": "1.0.17"
},
"jsr": {
"@std/[email protected]": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "8566eab35200466f8609eb7e7aed062ed0db314e9a258d5d201b1b8997ce801a",
"dependencies": [
"jsr:@std/assert@^1.0.19",
"jsr:@std/internal",
"jsr:@std/path"
]
},
"@std/[email protected]": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/[email protected]": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/[email protected]": {
"integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681",
"dependencies": [
"jsr:@std/assert@^1.0.17",
"jsr:@std/internal"
]
}
},
"workspace": {
"members": {
"hindsight-clients/typescript": {
"packageJson": {
"dependencies": [
"npm:@hey-api/[email protected]",
"npm:@types/jest@29",
"npm:@types/node@20",
"npm:jest@29",
"npm:ts-jest@29",
"npm:tsup@^8.5.1",
"npm:typescript@5"
]
}
},
"hindsight-control-plane": {
"packageJson": {
"dependencies": [
"npm:@eslint/eslintrc@^3.3.3",
"npm:@eslint/js@^9.39.2",
"npm:@radix-ui/react-alert-dialog@^1.1.15",
"npm:@radix-ui/react-checkbox@^1.3.3",
"npm:@radix-ui/react-dialog@^1.1.15",
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
"npm:@radix-ui/react-switch@^1.2.6",
"npm:@radix-ui/react-tabs@^1.1.13",
"npm:@radix-ui/react-tooltip@^1.2.8",
"npm:@tailwindcss/postcss@^4.1.17",
"npm:@tailwindcss/typography@~0.5.19",
"npm:@types/cytoscape@^3.21.9",
"npm:@types/node@^24.10.0",
"npm:@types/react-dom@^19.2.2",
"npm:@types/react@^19.2.2",
"npm:autoprefixer@^10.4.21",
"npm:class-variance-authority@~0.7.1",
"npm:clsx@^2.1.1",
"npm:cmdk@^1.1.1",
"npm:cytoscape-fcose@^2.2.0",
"npm:cytoscape@^3.33.1",
"npm:eslint-config-next@^16.0.1",
"npm:eslint-plugin-react-hooks@^7.0.1",
"npm:eslint-plugin-react@^7.37.5",
"npm:eslint@^9.39.1",
"npm:[email protected]",
"npm:next-themes@~0.4.6",
"npm:next@^16.1.6",
"npm:postcss@^8.5.6",
"npm:prettier@^3.7.4",
"npm:react-chrono@^2.9.1",
"npm:react-dom@^19.2.0",
"npm:react-markdown@^10.1.0",
"npm:react18-json-view@~0.2.9",
"npm:react@^19.2.0",
"npm:recharts@^3.5.1",
"npm:remark-gfm@^4.0.1",
"npm:sonner@^2.0.7",
"npm:tailwind-merge@^3.4.0",
"npm:tailwindcss-animate@^1.0.7",
"npm:tailwindcss@^4.1.17",
"npm:[email protected]",
"npm:typescript-eslint@^8.50.0",
"npm:typescript@^5.9.3"
]
}
},
"hindsight-docs": {
"packageJson": {
"dependencies": [
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/theme-common@^3.9.2",
"npm:@docusaurus/theme-mermaid@^3.9.2",
"npm:@docusaurus/[email protected]",
"npm:@docusaurus/[email protected]",
"npm:@easyops-cn/docusaurus-search-local@~0.52.2",
"npm:@mdx-js/react@3",
"npm:clsx@2",
"npm:prism-react-renderer@^2.3.0",
"npm:raw-loader@^4.0.2",
"npm:react-dom@19",
"npm:react-icons@^5.6.0",
"npm:react@19",
"npm:redocusaurus@^2.5.0",
"npm:typescript@~5.6.2"
]
}
}
}
}
}
+10 -13
View File
@@ -42,25 +42,22 @@ RUN apt-get update && apt-get install -y \
&& pip install --no-cache-dir uv
# Copy dependency files and README (required by pyproject.toml)
COPY hindsight-api/pyproject.toml ./api/
COPY hindsight-api/README.md ./api/
COPY hindsight-api-slim/pyproject.toml ./api/
COPY hindsight-api-slim/README.md ./api/
WORKDIR /app/api
# Remove local ML model dependencies if INCLUDE_LOCAL_MODELS=false
# This creates a smaller image when using external providers (TEI, OpenAI, Cohere)
RUN if [ "$INCLUDE_LOCAL_MODELS" != "true" ]; then \
echo "Removing local-models dependencies (sentence-transformers, torch, transformers)..." && \
sed -i '/"sentence-transformers/d' pyproject.toml && \
sed -i '/"transformers/d' pyproject.toml && \
sed -i '/"torch/d' pyproject.toml; \
# Sync dependencies using appropriate extras based on INCLUDE_LOCAL_MODELS
# local-ml: torch, sentence-transformers, transformers, einops, flashrank, mlx (optional)
# embedded-db: pg0-embedded (always included for embedded PostgreSQL support)
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --extra local-ml --extra embedded-db; \
else \
uv sync --extra embedded-db; \
fi
# Sync dependencies (will create lock file if needed)
RUN uv sync
# Copy source code (alembic migrations are inside hindsight_api/)
COPY hindsight-api/hindsight_api ./hindsight_api
COPY hindsight-api-slim/hindsight_api ./hindsight_api
# Install the local package (uv sync only installed dependencies, not the package itself)
RUN uv pip install -e .
+17 -2
View File
@@ -77,18 +77,32 @@ PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
API_HEALTH_URL="${HINDSIGHT_API_HEALTH_URL:-http://localhost:8888/health}"
API_STARTUP_WAIT_SECONDS="${HINDSIGHT_API_STARTUP_WAIT_SECONDS:-300}"
# Run API directly - Python's PYTHONUNBUFFERED=1 handles output buffering
hindsight-api &
API_PID=$!
PIDS+=($API_PID)
# Wait for API to be ready
for i in {1..60}; do
if curl -sf http://localhost:8888/health &>/dev/null; then
api_ready=false
for ((i=1; i<=API_STARTUP_WAIT_SECONDS; i++)); do
if ! kill -0 "$API_PID" 2>/dev/null; then
wait "$API_PID"
exit $?
fi
if curl -sf "$API_HEALTH_URL" &>/dev/null; then
api_ready=true
break
fi
sleep 1
done
if [ "$api_ready" != "true" ]; then
echo "❌ API did not become healthy within ${API_STARTUP_WAIT_SECONDS}s"
exit 1
fi
else
echo "API disabled (HINDSIGHT_ENABLE_API=false)"
fi
@@ -97,6 +111,7 @@ fi
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
export HOSTNAME="${HINDSIGHT_CP_HOSTNAME:-0.0.0.0}"
PORT="${HINDSIGHT_CP_PORT:-9999}" node server.js &
CP_PID=$!
PIDS+=($CP_PID)
+18
View File
@@ -49,6 +49,9 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -178,6 +181,21 @@ for i in $(seq 1 "$TIMEOUT"); do
echo "=== Health Response ==="
curl -s "http://localhost:${HEALTH_PORT}${HEALTH_PATH}" | python3 -m json.tool 2>/dev/null || curl -s "http://localhost:${HEALTH_PORT}${HEALTH_PATH}"
echo ""
# Run retain/recall smoke test for API targets
if [ "$TARGET" != "cp-only" ]; then
echo ""
echo "=== Retain/Recall Smoke Test ==="
if ! "$REPO_ROOT/scripts/smoke-test-slim.sh" "http://localhost:${HEALTH_PORT}"; then
echo ""
echo "=== Container Logs (last 50 lines) ==="
docker logs "$CONTAINER_NAME" 2>&1 | tail -50
echo ""
echo -e "${RED}Smoke test FAILED${NC}"
exit 1
fi
fi
echo ""
echo "=== Container Logs (last 50 lines) ==="
docker logs "$CONTAINER_NAME" 2>&1 | tail -50
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.16
appVersion: "0.4.16"
version: 0.4.19
appVersion: "0.4.19"
keywords:
- ai
- memory
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim>=0.4.17",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
[tool.uv.sources]
hindsight-api-slim = { workspace = true }
hindsight-client = { workspace = true }
hindsight-embed = { workspace = true }
[project.optional-dependencies]
test = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
]
[tool.setuptools]
packages = []
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
+48
View File
@@ -0,0 +1,48 @@
# hindsight-all
All-in-one package for Hindsight - Agent Memory That Works Like Human Memory
## Quick Start
```python
from hindsight import start_server, HindsightClient
# Start server with embedded PostgreSQL
server = start_server(
llm_provider="groq",
llm_api_key="your-api-key",
llm_model="openai/gpt-oss-120b"
)
# Create client
client = HindsightClient(base_url=server.url)
# Store memories
client.put(agent_id="assistant", content="User prefers Python for data analysis")
# Search memories
results = client.search(agent_id="assistant", query="programming preferences")
# Generate contextual response
response = client.think(agent_id="assistant", query="What languages should I recommend?")
# Stop server when done
server.stop()
```
## Using Context Manager
```python
from hindsight import HindsightServer, HindsightClient
with HindsightServer(llm_provider="groq", llm_api_key="...") as server:
client = HindsightClient(base_url=server.url)
# ... use client ...
# Server automatically stops
```
## Installation
```bash
pip install hindsight-all
```
+423
View File
@@ -0,0 +1,423 @@
"""
Wrapper for Hindsight client that adds API namespaces.
Provides organized access to different parts of the Hindsight API through
namespaces like .banks, .mental_models, etc.
"""
from __future__ import annotations
from typing import Any
from hindsight_client import Hindsight
class BanksAPI:
"""Namespace for bank-related operations.
Provides methods to create, delete, and manage memory banks.
"""
def __init__(self, client: Hindsight):
self._client = client
def create(
self,
bank_id: str,
name: str | None = None,
mission: str | None = None,
disposition: dict[str, Any] | None = None,
) -> Any:
"""Create a new bank.
Args:
bank_id: Unique identifier for the bank.
name: Optional display name for the bank.
mission: Optional mission statement for the bank.
disposition: Optional disposition configuration dict.
Returns:
Bank creation response from the API.
"""
return self._client.create_bank(
bank_id=bank_id,
name=name,
mission=mission,
disposition=disposition,
)
def delete(self, bank_id: str) -> Any:
"""Delete a bank.
Args:
bank_id: The ID of the bank to delete.
Returns:
Deletion response from the API.
"""
return self._client.delete_bank(bank_id=bank_id)
def set_mission(self, bank_id: str, mission: str) -> Any:
"""Set or update the mission for a bank.
Args:
bank_id: The ID of the bank.
mission: The mission statement to set.
Returns:
API response confirming the update.
"""
return self._client.set_mission(bank_id=bank_id, mission=mission)
def set_disposition(self, bank_id: str, disposition: dict[str, Any]) -> Any:
"""Set or update the disposition for a bank.
Args:
bank_id: The ID of the bank.
disposition: The disposition configuration dict.
Returns:
API response confirming the update.
"""
return self._client.set_disposition(bank_id=bank_id, disposition=disposition)
def list(self) -> Any:
"""List all banks.
Returns:
List of banks from the API.
"""
from hindsight_client.hindsight_client import _run_async
return _run_async(self._client._banks_api.list_banks())
class MentalModelsAPI:
"""Namespace for mental model operations.
Mental models are reusable knowledge structures that guide agent behavior.
"""
def __init__(self, client: Hindsight):
self._client = client
def create(
self,
bank_id: str,
name: str,
content: str,
tags: list[str] | None = None,
) -> Any:
"""Create a new mental model.
Args:
bank_id: The ID of the bank to add the model to.
name: Name for the mental model.
content: The content/instructions for the mental model.
tags: Optional list of tags for categorization.
Returns:
Creation response from the API.
"""
return self._client.create_mental_model(
bank_id=bank_id,
name=name,
content=content,
tags=tags,
)
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
"""List all mental models for a bank.
Args:
bank_id: The ID of the bank.
tags: Optional filter by tags.
Returns:
List of mental models.
"""
return self._client.list_mental_models(bank_id=bank_id, tags=tags)
def get(self, bank_id: str, mental_model_id: str) -> Any:
"""Get a specific mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model.
Returns:
The mental model details.
"""
return self._client.get_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
def refresh(self, bank_id: str, mental_model_id: str) -> Any:
"""Refresh a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to refresh.
Returns:
Refresh response from the API.
"""
return self._client.refresh_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
def update(
self,
bank_id: str,
mental_model_id: str,
name: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
) -> Any:
"""Update a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to update.
name: Optional new name.
content: Optional new content.
tags: Optional new tags list.
Returns:
Update response from the API.
"""
return self._client.update_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
name=name,
content=content,
tags=tags,
)
def delete(self, bank_id: str, mental_model_id: str) -> Any:
"""Delete a mental model.
Args:
bank_id: The ID of the bank.
mental_model_id: The ID of the mental model to delete.
Returns:
Deletion response from the API.
"""
return self._client.delete_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
class DirectivesAPI:
"""Namespace for directive operations.
Directives are explicit instructions that guide agent behavior.
"""
def __init__(self, client: Hindsight):
self._client = client
def create(
self,
bank_id: str,
name: str,
content: str,
tags: list[str] | None = None,
) -> Any:
"""Create a new directive.
Args:
bank_id: The ID of the bank to add the directive to.
name: Name for the directive.
content: The directive content/instructions.
tags: Optional list of tags for categorization.
Returns:
Creation response from the API.
"""
return self._client.create_directive(
bank_id=bank_id,
name=name,
content=content,
tags=tags,
)
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
"""List all directives for a bank.
Args:
bank_id: The ID of the bank.
tags: Optional filter by tags.
Returns:
List of directives.
"""
return self._client.list_directives(bank_id=bank_id, tags=tags)
def get(self, bank_id: str, directive_id: str) -> Any:
"""Get a specific directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive.
Returns:
The directive details.
"""
return self._client.get_directive(bank_id=bank_id, directive_id=directive_id)
def update(
self,
bank_id: str,
directive_id: str,
name: str | None = None,
content: str | None = None,
tags: list[str] | None = None,
) -> Any:
"""Update a directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive to update.
name: Optional new name.
content: Optional new content.
tags: Optional new tags list.
Returns:
Update response from the API.
"""
return self._client.update_directive(
bank_id=bank_id,
directive_id=directive_id,
name=name,
content=content,
tags=tags,
)
def delete(self, bank_id: str, directive_id: str) -> Any:
"""Delete a directive.
Args:
bank_id: The ID of the bank.
directive_id: The ID of the directive to delete.
Returns:
Deletion response from the API.
"""
return self._client.delete_directive(bank_id=bank_id, directive_id=directive_id)
class MemoriesAPI:
"""Namespace for memory operations.
Provides methods to query and retrieve stored memories.
"""
def __init__(self, client: Hindsight):
self._client = client
def list(
self,
bank_id: str,
type: str | None = None,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
) -> Any:
"""List memories in a bank.
Args:
bank_id: The ID of the bank to query.
type: Optional filter by memory type.
search_query: Optional search query for filtering.
limit: Maximum number of results to return (default: 100).
offset: Number of results to skip for pagination (default: 0).
Returns:
List of memories matching the criteria.
"""
return self._client.list_memories(
bank_id=bank_id,
type=type,
search_query=search_query,
limit=limit,
offset=offset,
)
class HindsightClient(Hindsight):
"""
Enhanced Hindsight client with organized API namespaces.
This wrapper extends the auto-generated Hindsight client with organized
access to different parts of the API through namespaces.
Example:
```python
from hindsight import HindsightClient
client = HindsightClient(base_url="http://localhost:8888")
# Core operations (inherited from Hindsight)
client.retain(bank_id="test", content="Hello")
results = client.recall(bank_id="test", query="Hello")
# Organized API access through namespaces
client.banks.create(bank_id="test", name="Test Bank")
models = client.mental_models.list(bank_id="test")
directives = client.directives.list(bank_id="test")
memories = client.memories.list(bank_id="test")
```
Attributes:
banks: Namespace for bank management operations.
mental_models: Namespace for mental model operations.
directives: Namespace for directive operations.
memories: Namespace for memory listing operations.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._banks_namespace: BanksAPI | None = None
self._mental_models_namespace: MentalModelsAPI | None = None
self._directives_namespace: DirectivesAPI | None = None
self._memories_namespace: MemoriesAPI | None = None
@property
def banks(self) -> BanksAPI:
"""Access bank management operations.
Returns:
BanksAPI instance for bank operations.
"""
if self._banks_namespace is None:
self._banks_namespace = BanksAPI(self)
return self._banks_namespace
@property
def mental_models(self) -> MentalModelsAPI:
"""Access mental model operations.
Returns:
MentalModelsAPI instance for mental model operations.
"""
if self._mental_models_namespace is None:
self._mental_models_namespace = MentalModelsAPI(self)
return self._mental_models_namespace
@property
def directives(self) -> DirectivesAPI:
"""Access directive operations.
Returns:
DirectivesAPI instance for directive operations.
"""
if self._directives_namespace is None:
self._directives_namespace = DirectivesAPI(self)
return self._directives_namespace
@property
def memories(self) -> MemoriesAPI:
"""Access memory listing operations.
Returns:
MemoriesAPI instance for memory operations.
"""
if self._memories_namespace is None:
self._memories_namespace = MemoriesAPI(self)
return self._memories_namespace
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.4.16"
version = "0.4.19"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api>=0.0.7",
"hindsight-api-slim[all]>=0.4.17",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
[tool.uv.sources]
hindsight-api = { workspace = true }
hindsight-api-slim = { workspace = true }
hindsight-client = { workspace = true }
hindsight-embed = { workspace = true }
+137
View File
@@ -0,0 +1,137 @@
# Hindsight API
**Memory System for AI Agents** — Temporal + Semantic + Entity Memory Architecture using PostgreSQL with pgvector.
Hindsight gives AI agents persistent memory that works like human memory: it stores facts, tracks entities and relationships, handles temporal reasoning ("what happened last spring?"), and forms opinions based on configurable disposition traits.
## Installation
```bash
pip install hindsight-api
```
## Quick Start
### Run the Server
```bash
# Set your LLM provider
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
# Start the server (uses embedded PostgreSQL by default)
hindsight-api
```
The server starts at http://localhost:8888 with:
- REST API for memory operations
- MCP server at `/mcp` for tool-use integration
### Use the Python API
```python
from hindsight_api import MemoryEngine
# Create and initialize the memory engine
memory = MemoryEngine()
await memory.initialize()
# Create a memory bank for your agent
bank = await memory.create_memory_bank(
name="my-assistant",
background="A helpful coding assistant"
)
# Store a memory
await memory.retain(
memory_bank_id=bank.id,
content="The user prefers Python for data science projects"
)
# Recall memories
results = await memory.recall(
memory_bank_id=bank.id,
query="What programming language does the user prefer?"
)
# Reflect with reasoning
response = await memory.reflect(
memory_bank_id=bank.id,
query="Should I recommend Python or R for this ML project?"
)
```
## CLI Options
```bash
hindsight-api --help
# Common options
hindsight-api --port 9000 # Custom port (default: 8888)
hindsight-api --host 127.0.0.1 # Bind to localhost only
hindsight-api --workers 4 # Multiple worker processes
hindsight-api --log-level debug # Verbose logging
```
## Configuration
Configure via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
| `HINDSIGHT_API_LLM_PROVIDER` | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio` | `openai` |
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-4o-mini` |
| `HINDSIGHT_API_HOST` | Server bind address | `0.0.0.0` |
| `HINDSIGHT_API_PORT` | Server port | `8888` |
### Example with External PostgreSQL
```bash
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
hindsight-api
```
## Docker
```bash
docker run --rm -it -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## MCP Server
For local MCP integration without running the full API server:
```bash
hindsight-local-mcp
```
This runs a stdio-based MCP server that can be used directly with MCP-compatible clients.
## Key Features
- **Multi-Strategy Retrieval (TEMPR)** — Semantic, keyword, graph, and temporal search combined with RRF fusion
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
## Documentation
Full documentation: [https://hindsight.vectorize.io](https://hindsight.vectorize.io)
- [Installation Guide](https://hindsight.vectorize.io/developer/installation)
- [Configuration Reference](https://hindsight.vectorize.io/developer/configuration)
- [API Reference](https://hindsight.vectorize.io/api-reference)
- [Python SDK](https://hindsight.vectorize.io/sdks/python)
## License
Apache 2.0
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.16"
__version__ = "0.4.19"
@@ -14,7 +14,8 @@ from typing import Any
import asyncpg
import typer
from ..config import HindsightConfig
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig
from ..extensions import TenantExtension, load_extension
from ..pg0 import parse_pg0_url, resolve_database_url
@@ -214,20 +215,81 @@ def restore(
typer.echo("Restore complete")
async def _run_migration(db_url: str, schema: str = "public") -> None:
"""Resolve database URL and run migrations."""
from ..migrations import run_migrations
async def _run_migration(
db_url: str,
schema: str | None = None,
base_schema: str = DEFAULT_DATABASE_SCHEMA,
embedding_dimension: int | None = None,
) -> list[str]:
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
run_migrations(resolved_url, schema=schema)
config = HindsightConfig.from_env()
if schema:
schemas = [schema]
else:
tenant_extension = load_extension("TENANT", TenantExtension)
schemas = [base_schema or DEFAULT_DATABASE_SCHEMA]
if tenant_extension:
tenants = await tenant_extension.list_tenants()
schemas.extend(tenant.schema for tenant in tenants if tenant.schema)
# Preserve order while removing duplicates.
schemas = list(dict.fromkeys(schemas))
for schema in schemas:
run_migrations(resolved_url, schema=schema)
if embedding_dimension is not None:
for schema in schemas:
ensure_embedding_dimension(
resolved_url,
embedding_dimension,
schema=schema,
vector_extension=config.vector_extension,
)
for schema in schemas:
ensure_vector_extension(
resolved_url,
vector_extension=config.vector_extension,
schema=schema,
)
for schema in schemas:
ensure_text_search_extension(
resolved_url,
text_search_extension=config.text_search_extension,
schema=schema,
)
return schemas
@app.command(name="run-db-migration")
def run_db_migration(
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to run migrations on"),
schema: str | None = typer.Option(
None,
"--schema",
"-s",
help="Database schema to run migrations on. If omitted, migrate the base schema and all discovered tenant schemas.",
),
embedding_dimension: int | None = typer.Option(
None,
"--embedding-dimension",
help="Expected embedding dimension to enforce after migrations. Omit to skip dimension sync.",
),
):
"""Run database migrations to the latest version."""
config = HindsightConfig.from_env()
@@ -237,11 +299,21 @@ def run_db_migration(
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
typer.echo(f"Running database migrations (schema: {schema})...")
if schema:
typer.echo(f"Running database migrations for schema: {schema}...")
else:
typer.echo("Running database migrations for base schema and all discovered tenant schemas...")
asyncio.run(_run_migration(config.database_url, schema))
schemas = asyncio.run(
_run_migration(
config.database_url,
schema=schema,
base_schema=config.database_schema,
embedding_dimension=embedding_dimension,
)
)
typer.echo("Database migrations completed successfully")
typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)")
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
@@ -34,7 +34,7 @@ def upgrade() -> None:
# Create file_storage table (minimal: just key + data)
op.execute(
f"""
CREATE TABLE {schema}file_storage (
CREATE TABLE IF NOT EXISTS {schema}file_storage (
storage_key TEXT PRIMARY KEY,
data BYTEA NOT NULL
)
@@ -0,0 +1,52 @@
"""Add consolidation_failed_at column to memory_units for tracking persistent LLM failures.
When all LLM retries are exhausted on a single-memory batch, the memory is marked
with consolidation_failed_at instead of consolidated_at, so it is not silently lost
and can be retried later via the API.
Revision ID: a3b4c5d6e7f8
Revises: g7h8i9j0k1l2
Create Date: 2026-03-17
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "a3b4c5d6e7f8"
down_revision: str | Sequence[str] | None = "g7h8i9j0k1l2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS consolidation_failed_at TIMESTAMPTZ DEFAULT NULL
"""
)
# Index to efficiently query memories that failed consolidation for a given bank
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_consolidation_failed
ON {schema}memory_units (bank_id, consolidation_failed_at)
WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_consolidation_failed")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidation_failed_at")
@@ -0,0 +1,53 @@
"""Recreate idx_memory_units_source_memory_ids GIN index with fastupdate=off
GIN indexes use a "fastupdate" pending list by default: small writes are
buffered there and flushed to the main GIN tree in bulk. Flushing requires
AccessExclusiveLock on the index. Under high insert concurrency (e.g. 8
parallel pytest-xdist workers all calling retain_async) two transactions can
each trigger a flush simultaneously and deadlock.
Disabling fastupdate makes every insert write directly to the GIN tree
(slightly slower per insert, but no pending-list lock cycles).
Revision ID: d4e5f6g7h8i9
Revises: d5e6f7a8b9c0
Create Date: 2026-03-11
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "d4e5f6g7h8i9"
down_revision: str | Sequence[str] | None = "d5e6f7a8b9c0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# DROP + CREATE CONCURRENTLY must run outside a transaction block.
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_source_memory_ids")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_source_memory_ids "
f"ON {schema}memory_units USING GIN (source_memory_ids) "
f"WITH (fastupdate=off) "
f"WHERE source_memory_ids IS NOT NULL"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_source_memory_ids")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_source_memory_ids "
f"ON {schema}memory_units USING GIN (source_memory_ids) "
f"WHERE source_memory_ids IS NOT NULL"
)
@@ -0,0 +1,131 @@
"""Add internal_id to banks and per-(bank, fact_type) partial HNSW indexes
Revision ID: d5e6f7a8b9c0
Revises: a3b4c5d6e7f8
Create Date: 2026-03-11
This migration:
1. Adds internal_id UUID column to banks (stable identifier for index naming)
2. Drops the global HNSW index (competes with per-bank partial indexes)
3. Creates per-(bank_id, fact_type) partial HNSW indexes for all existing banks
(new banks get indexes created at bank-creation time via bank_utils.create_bank_hnsw_indexes)
Why per-(bank, fact_type) indexes:
- fact_type-only partial indexes are never chosen by the planner when bank_id is in the WHERE
clause, because the idx_memory_units_bank_id B-tree index always wins at planning time.
- Per-(bank, fact_type) partial indexes have both predicates matching → planner selects them.
- The global HNSW index competes for larger partitions (world, observation) and must be dropped.
For large deployments, create indexes CONCURRENTLY before running this migration:
SELECT internal_id, bank_id FROM banks;
-- for each bank and each fact_type in (world, experience, observation):
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_mu_emb_{ft}_{uid16}
ON memory_units USING hnsw (embedding vector_cosine_ops)
WHERE fact_type = '{ft}' AND bank_id = '{bank_id}';
DROP INDEX CONCURRENTLY IF EXISTS idx_memory_units_embedding;
"""
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
revision: str = "d5e6f7a8b9c0"
down_revision: str | Sequence[str] | None = "c3d4e5f6g7h8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_HNSW_FACT_TYPES: dict[str, str] = {
"world": "worl",
"experience": "expr",
"observation": "obsv",
}
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# 1. Add internal_id column to banks
op.execute(
f"ALTER TABLE {schema}banks ADD COLUMN IF NOT EXISTS internal_id UUID DEFAULT gen_random_uuid() NOT NULL"
)
op.execute(f"ALTER TABLE {schema}banks ADD CONSTRAINT banks_internal_id_unique UNIQUE (internal_id)")
# 2. Drop any fact_type-only partial HNSW indexes that may exist from prior migrations
# (bank_id B-tree always wins over them when bank_id is in the WHERE clause)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_world")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_observation")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_experience")
# 4. Drop global HNSW index (competes with per-bank partial indexes)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_embedding")
# 5. Create per-(bank, fact_type) partial HNSW indexes for all existing banks
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
bank_id = row[0]
internal_id = str(row[1]).replace("-", "")[:16]
escaped_bank_id = bank_id.replace("'", "''")
for ft, ft_short in _HNSW_FACT_TYPES.items():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
# Index name is schema-unqualified (indexes live in the schema of their table)
bind.execute(
text(
f"CREATE INDEX IF NOT EXISTS {idx_name} "
f"ON {table_ref} USING hnsw (embedding vector_cosine_ops) "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped_bank_id}'"
)
)
def downgrade() -> None:
schema = _get_schema_prefix()
# Drop per-bank HNSW indexes (iterate existing banks)
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
rows = bind.execute(text(f"SELECT internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
internal_id = str(row[0]).replace("-", "")[:16]
for ft_short in _HNSW_FACT_TYPES.values():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
bind.execute(text(f"DROP INDEX IF EXISTS {schema}{idx_name}"))
# Restore the global HNSW index
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_memory_units_embedding ON {table_ref} USING hnsw (embedding vector_cosine_ops)"
)
# Restore old fact_type-only partial indexes
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_mu_emb_world "
f"ON {table_ref} USING hnsw (embedding vector_cosine_ops) "
f"WHERE fact_type = 'world'"
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_mu_emb_observation "
f"ON {table_ref} USING hnsw (embedding vector_cosine_ops) "
f"WHERE fact_type = 'observation'"
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_mu_emb_experience "
f"ON {table_ref} USING hnsw (embedding vector_cosine_ops) "
f"WHERE fact_type = 'experience'"
)
# Drop internal_id column
op.execute(f"ALTER TABLE {schema}banks DROP CONSTRAINT IF EXISTS banks_internal_id_unique")
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS internal_id")
@@ -0,0 +1,73 @@
"""Add CASCADE DELETE FK from async_operations and webhooks to banks.
When a bank is deleted, all its async_operations and webhooks rows are
automatically deleted by the database. This ensures that any in-flight
worker tasks detect the deletion via _check_op_alive() and abort early.
Revision ID: e5f6g7h8i9j0
Revises: d4e5f6g7h8i9
Create Date: 2026-03-11
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "e5f6g7h8i9j0"
down_revision: str | Sequence[str] | None = "d4e5f6g7h8i9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Remove orphaned async_operations rows whose bank no longer exists
# (can happen because there was no FK before this migration).
op.execute(
f"""
DELETE FROM {schema}async_operations
WHERE bank_id IS NOT NULL
AND bank_id NOT IN (SELECT bank_id FROM {schema}banks)
"""
)
# Remove orphaned webhooks rows whose bank no longer exists.
op.execute(
f"""
DELETE FROM {schema}webhooks
WHERE bank_id IS NOT NULL
AND bank_id NOT IN (SELECT bank_id FROM {schema}banks)
"""
)
# Add FK with ON DELETE CASCADE so that deleting a bank automatically
# cleans up all its pending/processing operations and webhook configs.
op.execute(
f"""
ALTER TABLE {schema}async_operations
ADD CONSTRAINT fk_async_operations_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id)
ON DELETE CASCADE
"""
)
op.execute(
f"""
ALTER TABLE {schema}webhooks
ADD CONSTRAINT fk_webhooks_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id)
ON DELETE CASCADE
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}async_operations DROP CONSTRAINT IF EXISTS fk_async_operations_bank_id")
op.execute(f"ALTER TABLE {schema}webhooks DROP CONSTRAINT IF EXISTS fk_webhooks_bank_id")
@@ -0,0 +1,38 @@
"""chunk_fk_cascade_delete
Revision ID: f6g7h8i9j0k1
Revises: e5f6g7h8i9j0
Create Date: 2026-03-16 00:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f6g7h8i9j0k1"
down_revision: str | Sequence[str] | None = "e5f6g7h8i9j0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Change memory_units.chunk_id FK from SET NULL to CASCADE.
When a document is deleted the CASCADE reaches chunks first; with SET NULL
the memory_units rows survived with chunk_id = NULL, leaving ghost records.
Switching to CASCADE ensures they are removed together with their chunk.
"""
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
op.create_foreign_key(
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="CASCADE"
)
def downgrade() -> None:
"""Revert to SET NULL behaviour."""
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
op.create_foreign_key(
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="SET NULL"
)
@@ -0,0 +1,71 @@
"""backsweep_orphan_memory_units
Two-pass cleanup of memory_units rows that were never removed by earlier bugs:
Pass 1 — any fact_type, bank gone:
memory_units whose bank_id no longer exists in banks. These accumulate when
a bank is deleted without a proper cascade (no FK from memory_units to banks
exists in the schema).
Pass 2 — observations only, all sources gone:
observation rows whose bank still exists but every source_memory_id points
to a deleted memory unit. These were left behind before PR #580 fixed the
chunk FK cascade and before delete_document() called
_delete_stale_observations_for_memories.
Revision ID: g7h8i9j0k1l2
Revises: f6g7h8i9j0k1
Create Date: 2026-03-16
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "g7h8i9j0k1l2"
down_revision: str | Sequence[str] | None = "f6g7h8i9j0k1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
mu = f"{schema}memory_units"
banks = f"{schema}banks"
# Pass 1: delete all memory_units (any fact_type) whose bank no longer exists.
# There is no FK from memory_units to banks, so these never cascade away.
op.execute(
f"""
DELETE FROM {mu}
WHERE NOT EXISTS (
SELECT 1 FROM {banks} b WHERE b.bank_id = {mu}.bank_id
)
"""
)
# Pass 2: delete orphaned observations whose bank still exists but every
# source_memory_id refers to a now-deleted memory unit (or the array is
# empty). Observations with at least one surviving source are left alone.
op.execute(
f"""
DELETE FROM {mu} orphan
WHERE orphan.fact_type = 'observation'
AND NOT EXISTS (
SELECT 1
FROM {mu} src
WHERE src.id = ANY(orphan.source_memory_ids)
AND src.bank_id = orphan.bank_id
)
"""
)
def downgrade() -> None:
# Deleted rows cannot be restored.
pass
@@ -35,7 +35,7 @@ def upgrade() -> None:
# Add GIN index for JSONB containment queries (@> operator)
op.execute(f"""
CREATE INDEX idx_async_operations_result_metadata
CREATE INDEX IF NOT EXISTS idx_async_operations_result_metadata
ON {schema}async_operations
USING gin(result_metadata)
""")
@@ -10,7 +10,7 @@ import json
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Literal
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
@@ -34,7 +34,7 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]:
from typing import Callable
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from hindsight_api import MemoryEngine
@@ -73,15 +73,13 @@ 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.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
class EntityIncludeOptions(BaseModel):
"""Options for including entity observations in recall results."""
@@ -165,6 +163,17 @@ class RecallRequest(BaseModel):
description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), "
"'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).",
)
tag_groups: list[TagGroup] | None = Field(
default=None,
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: ...}.",
)
@model_validator(mode="after")
def validate_tags_exclusive(self) -> "RecallRequest":
if self.tags is not None and self.tag_groups is not None:
raise ValueError("'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering.")
return self
class RecallResult(BaseModel):
@@ -416,6 +425,11 @@ class MemoryItem(BaseModel):
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
),
)
strategy: str | None = Field(
default=None,
description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
@field_validator("timestamp", mode="before")
@classmethod
@@ -482,6 +496,11 @@ class FileRetainMetadata(BaseModel):
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
"E.g. 'iris' or ['iris', 'markitdown'].",
)
strategy: str | None = Field(
default=None,
description="Named retain strategy for this file. Overrides the bank's default strategy. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
class FileRetainRequest(BaseModel):
@@ -535,7 +554,11 @@ class RetainResponse(BaseModel):
)
operation_id: str | None = Field(
default=None,
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true.",
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. When items use different per-item strategies, use operation_ids instead.",
)
operation_ids: list[str] | None = Field(
default=None,
description="Operation IDs when items were submitted as multiple strategy groups (async=true with mixed per-item strategies). operation_id is set to the first entry for backward compatibility.",
)
usage: TokenUsage | None = Field(
default=None,
@@ -641,6 +664,17 @@ class ReflectRequest(BaseModel):
description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), "
"'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).",
)
tag_groups: list[TagGroup] | None = Field(
default=None,
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: ...}.",
)
@model_validator(mode="after")
def validate_tags_exclusive(self) -> "ReflectRequest":
if self.tags is not None and self.tag_groups is not None:
raise ValueError("'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering.")
return self
class ReflectFact(BaseModel):
@@ -1294,6 +1328,14 @@ class ClearMemoryObservationsResponse(BaseModel):
deleted_count: int
class RecoverConsolidationResponse(BaseModel):
"""Response model for recovering failed consolidation."""
model_config = ConfigDict(json_schema_extra={"example": {"retried_count": 42}})
retried_count: int
class BankStatsResponse(BaseModel):
"""Response model for bank statistics endpoint."""
@@ -1558,6 +1600,24 @@ class CancelOperationResponse(BaseModel):
operation_id: str
class RetryOperationResponse(BaseModel):
"""Response model for retry operation endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "Operation 550e8400-e29b-41d4-a716-446655440000 queued for retry",
"operation_id": "550e8400-e29b-41d4-a716-446655440000",
}
}
)
success: bool
message: str
operation_id: str
class ChildOperationStatus(BaseModel):
"""Status of a child operation (for batch operations)."""
@@ -2245,12 +2305,13 @@ def _register_routes(app: FastAPI):
metrics = get_metrics_collector()
# Validate query length to prevent expensive operations on oversized queries
max_query_tokens = get_config().recall_max_query_tokens
encoding = _get_tiktoken_encoding()
query_tokens = len(encoding.encode(request.query))
if query_tokens > MAX_QUERY_TOKENS:
if query_tokens > max_query_tokens:
raise HTTPException(
status_code=400,
detail=f"Query too long: {query_tokens} tokens exceeds maximum of {MAX_QUERY_TOKENS}. Please shorten your query.",
detail=f"Query too long: {query_tokens} tokens exceeds maximum of {max_query_tokens}. Please shorten your query.",
)
try:
@@ -2307,6 +2368,7 @@ def _register_routes(app: FastAPI):
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
tag_groups=request.tag_groups,
)
# Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
@@ -2442,6 +2504,7 @@ def _register_routes(app: FastAPI):
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
tag_groups=request.tag_groups,
)
# Build based_on (memories + mental_models + directives) if facts are requested
@@ -3427,13 +3490,17 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations",
response_model=OperationsListResponse,
summary="List async operations",
description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
description="Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.",
operation_id="list_operations",
tags=["Operations"],
)
async def api_list_operations(
bank_id: str,
status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
type: str | None = Query(
default=None,
description="Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery",
),
limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
request_context: RequestContext = Depends(get_request_context),
@@ -3441,7 +3508,7 @@ def _register_routes(app: FastAPI):
"""List async operations for a memory bank with optional filtering and pagination."""
try:
result = await app.state.memory.list_operations(
bank_id, status=status, limit=limit, offset=offset, request_context=request_context
bank_id, status=status, task_type=type, limit=limit, offset=offset, request_context=request_context
)
return OperationsListResponse(
bank_id=bank_id,
@@ -3528,6 +3595,39 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/operations/{operation_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/operations/{operation_id}/retry",
response_model=RetryOperationResponse,
summary="Retry a failed async operation",
description="Re-queue a failed async operation so the worker picks it up again",
operation_id="retry_operation",
tags=["Operations"],
)
async def api_retry_operation(
bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
):
"""Retry a failed async operation."""
try:
try:
uuid.UUID(operation_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
result = await app.state.memory.retry_operation(bank_id, operation_id, request_context=request_context)
return RetryOperationResponse(**result)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/operations/{operation_id}/retry: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/profile",
response_model=BankProfileResponse,
@@ -3810,6 +3910,34 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/consolidation/recover",
response_model=RecoverConsolidationResponse,
summary="Recover failed consolidation",
description=(
"Reset all memories that were permanently marked as failed during consolidation "
"(after exhausting all LLM retries and adaptive batch splitting) so they are "
"picked up again on the next consolidation run. Does not delete any observations."
),
operation_id="recover_consolidation",
tags=["Banks"],
)
async def api_recover_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Reset consolidation-failed memories for recovery."""
try:
result = await app.state.memory.retry_failed_consolidation(bank_id, request_context=request_context)
return RecoverConsolidationResponse(retried_count=result["retried_count"])
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidation/recover: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/memories/{memory_id}/observations",
response_model=ClearMemoryObservationsResponse,
@@ -4037,9 +4165,13 @@ def _register_routes(app: FastAPI):
try:
pool = await app.state.memory._get_pool()
from hindsight_api.engine.memory_engine import fq_table
from hindsight_api.engine.retain import bank_utils
# Ensure the bank row exists before inserting into webhooks (FK constraint).
await bank_utils.get_bank_profile(pool, bank_id)
webhook_id = uuid.uuid4()
now = datetime.utcnow().isoformat() + "Z"
now = datetime.now(timezone.utc).isoformat()
row = await pool.fetchrow(
f"""
INSERT INTO {fq_table("webhooks")}
@@ -4359,10 +4491,13 @@ def _register_routes(app: FastAPI):
metrics = get_metrics_collector()
try:
# Prepare contents for processing
contents = []
# Group items by strategy
strategy_groups: dict[str | None, list[dict]] = {}
for item in request.items:
content_dict = {"content": item.content}
effective = item.strategy
if effective not in strategy_groups:
strategy_groups[effective] = []
content_dict: dict = {"content": item.content}
if item.timestamp == "unset":
content_dict["event_date"] = None
elif item.timestamp:
@@ -4379,20 +4514,30 @@ def _register_routes(app: FastAPI):
content_dict["tags"] = item.tags
if item.observation_scopes is not None:
content_dict["observation_scopes"] = item.observation_scopes
contents.append(content_dict)
strategy_groups[effective].append(content_dict)
if request.async_:
# Async processing: queue task and return immediately
result = await app.state.memory.submit_async_retain(
bank_id, contents, document_tags=request.document_tags, request_context=request_context
)
# Async processing: one submit per strategy group
all_operation_ids = []
total_items_count = 0
for group_strategy, contents in strategy_groups.items():
result = await app.state.memory.submit_async_retain(
bank_id,
contents,
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
)
all_operation_ids.append(result["operation_id"])
total_items_count += result["items_count"]
return RetainResponse.model_validate(
{
"success": True,
"bank_id": bank_id,
"items_count": result["items_count"],
"items_count": total_items_count,
"async": True,
"operation_id": result["operation_id"],
"operation_id": all_operation_ids[0] if all_operation_ids else None,
"operation_ids": all_operation_ids if len(all_operation_ids) > 1 else None,
}
)
else:
@@ -4411,24 +4556,41 @@ def _register_routes(app: FastAPI):
),
)
# Synchronous processing: wait for completion (record metrics)
# Synchronous processing: one batch per strategy group, aggregate results
total_items_count = 0
total_usage = TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
with metrics.record_operation("retain", bank_id=bank_id, source="api"):
result, usage = await app.state.memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
document_tags=request.document_tags,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
for group_strategy, contents in strategy_groups.items():
result, usage = await app.state.memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
total_items_count += len(contents)
if usage:
total_usage = TokenUsage(
input_tokens=total_usage.input_tokens + usage.input_tokens,
output_tokens=total_usage.output_tokens + usage.output_tokens,
total_tokens=total_usage.total_tokens + usage.total_tokens,
)
return RetainResponse.model_validate(
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False, "usage": usage}
{
"success": True,
"bank_id": bank_id,
"items_count": total_items_count,
"async": False,
"usage": total_usage,
}
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
@@ -4591,6 +4753,7 @@ def _register_routes(app: FastAPI):
"tags": file_meta.tags or [],
"timestamp": file_meta.timestamp,
"parser": parser_chain,
"strategy": file_meta.strategy,
}
file_items.append(item)
@@ -381,6 +381,15 @@ class MCPMiddleware:
# Clear root_path since we're passing directly to the app
new_scope["root_path"] = ""
# Ensure Accept header includes required MIME types for MCP SDK.
# Some clients (e.g., Claude Code) don't send Accept, causing
# the SDK to reject with 406 Not Acceptable.
accept_header = self._get_header(new_scope, "accept")
if not accept_header or "text/event-stream" not in accept_header:
headers = [(k, v) for k, v in new_scope.get("headers", []) if k.lower() != b"accept"]
headers.append((b"accept", b"application/json, text/event-stream"))
new_scope["headers"] = headers
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing.
# Only rewrite SSE (text/event-stream) responses to avoid corrupting tool results
# that might contain the literal string "data: /messages".
@@ -193,6 +193,7 @@ ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
ENV_RERANKER_LITELLM_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_API_BASE"
ENV_RERANKER_LITELLM_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_API_KEY"
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
ENV_RERANKER_LITELLM_MAX_TOKENS_PER_DOC = "HINDSIGHT_API_RERANKER_LITELLM_MAX_TOKENS_PER_DOC"
# LiteLLM SDK configuration (direct API access, no proxy needed)
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY"
@@ -211,6 +212,9 @@ ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
ENV_RERANKER_LOCAL_FP16 = "HINDSIGHT_API_RERANKER_LOCAL_FP16"
ENV_RERANKER_LOCAL_BUCKET_BATCHING = "HINDSIGHT_API_RERANKER_LOCAL_BUCKET_BATCHING"
ENV_RERANKER_LOCAL_BATCH_SIZE = "HINDSIGHT_API_RERANKER_LOCAL_BATCH_SIZE"
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
@@ -238,6 +242,7 @@ ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# OpenTelemetry tracing configuration
@@ -262,6 +267,7 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_DEFAULT_STRATEGY = "HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY"
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
ENV_RETAIN_ENTITY_LOOKUP = "HINDSIGHT_API_RETAIN_ENTITY_LOOKUP"
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
@@ -350,6 +356,7 @@ PROVIDER_DEFAULT_MODELS = {
"anthropic": "claude-haiku-4-5-20251001",
"gemini": "gemini-2.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"ollama": "gemma3:12b",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
@@ -386,6 +393,9 @@ DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound rerankin
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
)
DEFAULT_RERANKER_LOCAL_FP16 = False # FP16 inference: opt-in, faster on MPS/CUDA (not CPU)
DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING = False # Length-sorted bucket batching: opt-in, 36-54% speedup
DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 32 # Batch size for local reranker predict() calls
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
DEFAULT_RERANKER_MAX_CANDIDATES = 300
@@ -407,6 +417,7 @@ DEFAULT_TEXT_SEARCH_EXTENSION = "native" # Options: "native", "vchord", "pg_tex
DEFAULT_LITELLM_API_BASE = "http://localhost:4000"
DEFAULT_EMBEDDINGS_LITELLM_MODEL = "text-embedding-3-small"
DEFAULT_RERANKER_LITELLM_MODEL = "cohere/rerank-english-v3.0"
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC: int | None = None
# LiteLLM SDK defaults
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
@@ -425,6 +436,7 @@ DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp",
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
# Retain settings
@@ -432,9 +444,11 @@ DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction L
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom", "verbatim", "chunks") # Allowed extraction modes
DEFAULT_RETAIN_MISSION = None # Declarative spec of what to retain (injected into any extraction mode)
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_DEFAULT_STRATEGY = None # Default strategy name (None = no strategy override)
DEFAULT_RETAIN_STRATEGIES: dict | None = None # Named retain strategies (dict of name → config overrides)
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
@@ -663,6 +677,9 @@ class HindsightConfig:
reranker_local_force_cpu: bool
reranker_local_max_concurrent: int
reranker_local_trust_remote_code: bool
reranker_local_fp16: bool
reranker_local_bucket_batching: bool
reranker_local_batch_size: int
reranker_tei_url: str | None
reranker_tei_batch_size: int
reranker_tei_max_concurrent: int
@@ -673,6 +690,7 @@ class HindsightConfig:
reranker_litellm_api_base: str
reranker_litellm_api_key: str | None
reranker_litellm_model: str
reranker_litellm_max_tokens_per_doc: int | None
reranker_litellm_sdk_api_key: str | None
reranker_litellm_sdk_model: str
reranker_litellm_sdk_api_base: str | None
@@ -694,6 +712,7 @@ class HindsightConfig:
mpfp_top_k_neighbors: int
recall_max_concurrent: int
recall_connection_budget: int
recall_max_query_tokens: int
mental_model_refresh_concurrency: int
# Retain settings
@@ -703,6 +722,8 @@ class HindsightConfig:
retain_extraction_mode: str
retain_mission: str | None
retain_custom_instructions: str | None
retain_default_strategy: str | None
retain_strategies: dict | None
retain_batch_tokens: int
retain_batch_enabled: bool
retain_batch_poll_interval_seconds: int
@@ -833,6 +854,8 @@ class HindsightConfig:
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
"retain_default_strategy",
"retain_strategies",
# Entity labels (controlled vocabulary for entity classification)
"entity_labels",
"entities_allow_free_form",
@@ -1085,6 +1108,15 @@ class HindsightConfig:
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
reranker_local_fp16=os.getenv(ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)).lower()
in ("true", "1"),
reranker_local_bucket_batching=os.getenv(
ENV_RERANKER_LOCAL_BUCKET_BATCHING, str(DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING)
).lower()
in ("true", "1"),
reranker_local_batch_size=int(
os.getenv(ENV_RERANKER_LOCAL_BATCH_SIZE, str(DEFAULT_RERANKER_LOCAL_BATCH_SIZE))
),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
reranker_tei_max_concurrent=int(
@@ -1100,6 +1132,9 @@ class HindsightConfig:
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
reranker_litellm_api_key=os.getenv(ENV_RERANKER_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
reranker_litellm_model=os.getenv(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL),
reranker_litellm_max_tokens_per_doc=int(v)
if (v := os.getenv(ENV_RERANKER_LITELLM_MAX_TOKENS_PER_DOC))
else DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
# LiteLLM SDK reranker (direct API access)
reranker_litellm_sdk_api_key=os.getenv(ENV_RERANKER_LITELLM_SDK_API_KEY),
reranker_litellm_sdk_model=os.getenv(ENV_RERANKER_LITELLM_SDK_MODEL, DEFAULT_RERANKER_LITELLM_SDK_MODEL),
@@ -1126,6 +1161,7 @@ class HindsightConfig:
recall_connection_budget=int(
os.getenv(ENV_RECALL_CONNECTION_BUDGET, str(DEFAULT_RECALL_CONNECTION_BUDGET))
),
recall_max_query_tokens=int(os.getenv(ENV_RECALL_MAX_QUERY_TOKENS, str(DEFAULT_RECALL_MAX_QUERY_TOKENS))),
mental_model_refresh_concurrency=int(
os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY))
),
@@ -1146,6 +1182,8 @@ class HindsightConfig:
),
retain_mission=os.getenv(ENV_RETAIN_MISSION) or DEFAULT_RETAIN_MISSION,
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_default_strategy=os.getenv(ENV_RETAIN_DEFAULT_STRATEGY) or DEFAULT_RETAIN_DEFAULT_STRATEGY,
retain_strategies=DEFAULT_RETAIN_STRATEGIES,
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
retain_entity_lookup=os.getenv(ENV_RETAIN_ENTITY_LOOKUP, DEFAULT_RETAIN_ENTITY_LOOKUP),
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
@@ -10,7 +10,7 @@ multiple API servers.
import json
import logging
from dataclasses import asdict
from dataclasses import asdict, replace
from typing import Any
import asyncpg
@@ -239,6 +239,14 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate retain_strategies: reject empty string keys
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
if empty_keys:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
@@ -273,3 +281,35 @@ class ConfigResolver:
)
logger.info(f"Reset bank config for {bank_id} to defaults")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
"""
strategies = config.retain_strategies or {}
if strategy_name not in strategies:
logger.warning(f"Unknown retain strategy '{strategy_name}', using resolved config as-is")
return config
overrides = strategies[strategy_name]
if not isinstance(overrides, dict):
logger.warning(f"Retain strategy '{strategy_name}' is not a dict, skipping")
return config
configurable = HindsightConfig.get_configurable_fields()
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)
@@ -24,9 +24,10 @@ from datetime import datetime, timezone
from itertools import combinations
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from ...config import get_config
from ..llm_wrapper import sanitize_llm_output
from ..memory_engine import fq_table
from ..retain import embedding_utils
from .prompts import build_batch_consolidation_prompt
@@ -45,12 +46,22 @@ class _CreateAction(BaseModel):
text: str
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
@field_validator("text", mode="before")
@classmethod
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
class _UpdateAction(BaseModel):
text: str
observation_id: str # UUID of the existing observation to update
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
@field_validator("text", mode="before")
@classmethod
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
class _DeleteAction(BaseModel):
observation_id: str # UUID of the observation to remove
@@ -69,6 +80,7 @@ class _BatchLLMResult:
deletes: list[_DeleteAction] = field(default_factory=list)
obs_count: int = 0
prompt_chars: int = 0
failed: bool = False
@dataclass
@@ -150,6 +162,7 @@ async def run_consolidation_job(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
operation_id: str | None = None,
) -> dict[str, Any]:
"""
Run consolidation job for a bank.
@@ -207,6 +220,7 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
@@ -228,6 +242,7 @@ async def run_consolidation_job(
"observations_deleted": 0,
"actions_executed": 0,
"skipped": 0,
"memories_failed": 0,
}
# Track all unique tags from consolidated memories for mental model refresh filtering
@@ -245,6 +260,7 @@ async def run_consolidation_job(
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
ORDER BY created_at ASC
LIMIT $2
@@ -286,94 +302,148 @@ async def run_consolidation_job(
if memory_tags:
consolidated_tags.update(memory_tags)
async with pool.acquire() as conn:
# Determine observation_scopes for this batch. All memories in a batch share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = llm_batch[0].get("observation_scopes") if llm_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
# Process llm_batch with adaptive splitting: on LLM failure, halve the sub-batch
# and retry, down to batch_size=1. Only if a single-memory batch still fails is
# the memory marked with consolidation_failed_at and excluded from future runs
# until explicitly retried via the API.
all_results: list[dict[str, Any]] = []
all_deleted = 0
succeeded_ids: list[Any] = []
failed_ids: list[Any] = []
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = llm_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
pending: list[list[dict[str, Any]]] = [llm_batch]
while pending:
sub_batch = pending.pop(0)
batch_deleted: int = 0
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
results = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted = await _process_memory_batch(
async with pool.acquire() as conn:
# Determine observation_scopes for this sub-batch. All memories share
# the same tags (enforced by tag_groups), so we only check the first memory.
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
_obs_raw = sub_batch[0].get("observation_scopes") if sub_batch else None
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
if _obs_parsed == "per_tag":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
elif _obs_parsed == "all_combinations":
_memory_tags = sub_batch[0].get("tags") or []
obs_tags_list = (
[
list(combo)
for r in range(1, len(_memory_tags) + 1)
for combo in combinations(_memory_tags, r)
]
if _memory_tags
else None
)
elif _obs_parsed == "combined" or _obs_parsed is None:
obs_tags_list = None # single combined pass (default behaviour)
else:
# explicit list[list[str]]
obs_tags_list = _obs_parsed
sub_deleted: int = 0
sub_llm_failed = False
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
sub_results: list[dict[str, Any]] = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted, pass_failed = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=sub_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
sub_deleted += pass_deleted
sub_llm_failed = sub_llm_failed or pass_failed
# Merge results: prefer non-skipped actions
if not sub_results:
sub_results = pass_results
else:
for i, (existing, new) in enumerate(zip(sub_results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
sub_results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
sub_results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
# Normal single pass using the memory's own tags
sub_results, sub_deleted, sub_llm_failed = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=llm_batch,
memories=sub_batch,
request_context=request_context,
perf=perf,
config=config,
obs_tags_override=obs_tags,
)
batch_deleted += pass_deleted
# Merge results: prefer non-skipped actions
if not results:
results = pass_results
else:
for i, (existing, new) in enumerate(zip(results, pass_results)):
if existing.get("action") == "skipped" and new.get("action") != "skipped":
results[i] = new
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
# Both did something — combine into "multiple"
existing_created = existing.get(
"created", 1 if existing.get("action") == "created" else 0
)
existing_updated = existing.get(
"updated", 1 if existing.get("action") == "updated" else 0
)
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
total = existing_created + existing_updated + new_created + new_updated
results[i] = {
"action": "multiple",
"created": existing_created + new_created,
"updated": existing_updated + new_updated,
"merged": 0,
"total_actions": total,
}
else:
# Normal single pass using the memory's own tags
results, batch_deleted = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
bank_id=bank_id,
memories=llm_batch,
request_context=request_context,
perf=perf,
config=config,
)
stats["observations_deleted"] += batch_deleted
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(m["id"],) for m in llm_batch],
all_deleted += sub_deleted
if sub_llm_failed and len(sub_batch) > 1:
# Split and retry with smaller batches
mid = len(sub_batch) // 2
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for sub-batch of {len(sub_batch)},"
f" splitting into {mid}/{len(sub_batch) - mid}"
)
pending[0:0] = [sub_batch[:mid], sub_batch[mid:]]
elif sub_llm_failed:
# batch_size=1 and still failing — mark as permanently failed for now
failed_ids.append(sub_batch[0]["id"])
all_results.append({"action": "failed"})
logger.warning(
f"[CONSOLIDATION] bank={bank_id} LLM failed for single memory"
f" {sub_batch[0]['id']}, marking consolidation_failed_at"
)
else:
succeeded_ids.extend(m["id"] for m in sub_batch)
all_results.extend(sub_results)
# Commit consolidated_at / consolidation_failed_at in a single DB round-trip
async with pool.acquire() as conn:
if succeeded_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in succeeded_ids],
)
if failed_ids:
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidation_failed_at = NOW() WHERE id = $1",
[(mem_id,) for mem_id in failed_ids],
)
stats["observations_deleted"] += all_deleted
results = all_results
# Checkpoint: abort if the operation (and thus the bank) was deleted mid-run.
if operation_id and not await memory_engine._check_op_alive(operation_id):
logger.info(
f"[CONSOLIDATION] bank={bank_id} operation {operation_id} cancelled (bank deleted), stopping early"
)
return {"status": "cancelled", "bank_id": bank_id, **stats}
for result in results:
stats["memories_processed"] += 1
@@ -394,6 +464,8 @@ async def run_consolidation_job(
stats["actions_executed"] += result.get("total_actions", 0)
elif action == "skipped":
stats["skipped"] += 1
elif action == "failed":
stats["memories_failed"] += 1
# Per-LLM-batch log
llm_batch_time = time.time() - llm_batch_start
@@ -406,6 +478,7 @@ async def run_consolidation_job(
batch_created = stats["observations_created"] - snap_stats["observations_created"]
batch_updated = stats["observations_updated"] - snap_stats["observations_updated"]
batch_skipped = stats["skipped"] - snap_stats["skipped"]
batch_failed = stats["memories_failed"] - snap_stats["memories_failed"]
llm_calls_made = perf.llm_calls - snap_llm_calls
logger.info(
f"[CONSOLIDATION] bank={bank_id} llm_batch #{llm_batch_num}"
@@ -413,7 +486,8 @@ async def run_consolidation_job(
f" | {stats['memories_processed']}/{total_count} processed"
f" | {', '.join(timing_parts)}"
f" | created={batch_created} updated={batch_updated} skipped={batch_skipped}"
f" | input_tokens=~{input_tokens}"
+ (f" failed={batch_failed}" if batch_failed else "")
+ f" | input_tokens=~{input_tokens}"
f" | avg={llm_batch_time / len(llm_batch):.3f}s/memory"
)
@@ -565,7 +639,7 @@ async def _process_memory_batch(
perf: ConsolidationPerfLog | None = None,
config: Any = None,
obs_tags_override: list[str] | None = None,
) -> tuple[list[dict[str, Any]], int]:
) -> tuple[list[dict[str, Any]], int, bool]:
"""
Process a batch of memories in a single LLM call.
@@ -728,7 +802,7 @@ async def _process_memory_batch(
else:
results.append({"action": "skipped", "reason": "no_durable_knowledge"})
return results, deleted_count
return results, deleted_count, llm_result.failed
def _min_date(dates: "Any") -> "datetime | None":
@@ -1062,7 +1136,7 @@ async def _consolidate_batch_with_llm(
logger.error(
f"[CONSOLIDATION] LLM batch call failed after {max_attempts} attempts, skipping batch. Last error: {last_exc}"
)
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt))
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt), failed=True)
async def _create_observation_directly(
@@ -29,7 +29,7 @@ Compare the facts against existing observations:
- Same topic as an existing observation UPDATE it (observation_id + source_fact_ids)
- New topic with durable knowledge CREATE a new observation (source_fact_ids)
- Cross-reference facts within the batch: a later fact may resolve a vague reference in an earlier one
- Purely ephemeral facts omit them (no create/update needed)"""
- Purely ephemeral facts omit them unless the MISSION above explicitly targets such data (e.g. timestamped events, session state, screen content)"""
# Output format — JSON braces escaped as {{ }} so .format() leaves them literal
_BATCH_OUTPUT_FORMAT = """
@@ -20,8 +20,10 @@ from ..config import (
DEFAULT_RERANKER_COHERE_MODEL,
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
@@ -110,6 +112,9 @@ class LocalSTCrossEncoder(CrossEncoderModel):
max_concurrent: int = 4,
force_cpu: bool = False,
trust_remote_code: bool = False,
fp16: bool = False,
bucket_batching: bool = False,
batch_size: int = DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -124,10 +129,20 @@ class LocalSTCrossEncoder(CrossEncoderModel):
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
Default: False (disabled for security)
fp16: Use FP16 (half precision) inference. Faster on MPS and CUDA,
may be slower on CPU. Default: False (opt-in via env var).
bucket_batching: Sort pairs by token length before batching to reduce
padding waste. 36-54% speedup, quality-identical.
Default: False (opt-in via env var).
batch_size: Batch size for predict() calls. Optimal values vary by
hardware and model (MPS: 32, CUDA: 128+). Default: 32.
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self.fp16 = fp16
self.bucket_batching = bucket_batching
self.batch_size = batch_size
self._model = None
LocalSTCrossEncoder._max_concurrent = max_concurrent
@@ -175,6 +190,24 @@ class LocalSTCrossEncoder(CrossEncoderModel):
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
# create_position_ids_from_input_ids as a module-level function; the custom
# code in these models still references it. This monkey-patch restores it.
try:
import transformers.models.xlm_roberta.modeling_xlm_roberta as xlm_module
from transformers.models.xlm_roberta.modeling_xlm_roberta import XLMRobertaEmbeddings
if not hasattr(xlm_module, "create_position_ids_from_input_ids"):
setattr(
xlm_module,
"create_position_ids_from_input_ids",
XLMRobertaEmbeddings.create_position_ids_from_input_ids,
)
logger.info("Reranker: applied transformers 5.x compatibility patch for XLM-RoBERTa")
except Exception:
pass
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from CrossEncoder which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
@@ -199,6 +232,12 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Restore original logging level
transformers_logger.setLevel(original_level)
# FP16 inference: convert model weights to half precision.
# Empirically validated: 27-36% faster on MPS, quality-identical (20/20 overlap).
if self.fp16 and device != "cpu":
self._model.model.half()
logger.info("Reranker: FP16 inference enabled")
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
LocalSTCrossEncoder._executor = ThreadPoolExecutor(
@@ -210,8 +249,32 @@ class LocalSTCrossEncoder(CrossEncoderModel):
logger.info("Reranker: local provider initialized (using existing executor)")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous prediction wrapper for thread pool execution."""
scores = self._model.predict(pairs, show_progress_bar=False)
"""Synchronous prediction wrapper for thread pool execution.
Supports two optimizations (controlled via .env):
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
"""
import numpy as np
if self.bucket_batching and len(pairs) > 1:
# Sort pairs by approximate token length to create homogeneous batches.
# This eliminates padding waste — short pairs aren't padded to the length
# of the longest pair in the batch. Quality-identical by construction.
lengths = [len(pairs[i][0]) + len(pairs[i][1]) for i in range(len(pairs))]
sorted_indices = sorted(range(len(pairs)), key=lambda i: lengths[i])
sorted_pairs = [pairs[i] for i in sorted_indices]
sorted_scores = self._model.predict(sorted_pairs, batch_size=self.batch_size, show_progress_bar=False)
sorted_scores = sorted_scores.tolist() if hasattr(sorted_scores, "tolist") else list(sorted_scores)
# Restore original order
scores = [0.0] * len(pairs)
for new_pos, orig_idx in enumerate(sorted_indices):
scores[orig_idx] = sorted_scores[new_pos]
return scores
scores = self._model.predict(pairs, batch_size=self.batch_size, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
@@ -820,6 +883,17 @@ class FlashRankCrossEncoder(CrossEncoderModel):
return await loop.run_in_executor(FlashRankCrossEncoder._executor, self._predict_sync, pairs)
def _truncate_to_tokens(text: str, max_tokens: int) -> str:
"""Truncate text to at most max_tokens using the shared tiktoken encoder."""
from .memory_engine import _get_tiktoken_encoding
enc = _get_tiktoken_encoding()
tokens = enc.encode(text)
if len(tokens) <= max_tokens:
return text
return enc.decode(tokens[:max_tokens])
class LiteLLMCrossEncoder(CrossEncoderModel):
"""
LiteLLM cross-encoder implementation using LiteLLM proxy's /rerank endpoint.
@@ -843,6 +917,7 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
api_key: str | None = None,
model: str = DEFAULT_RERANKER_LITELLM_MODEL,
timeout: float = 60.0,
max_tokens_per_doc: int | None = DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
):
"""
Initialize LiteLLM cross-encoder client.
@@ -853,11 +928,15 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
model: Reranking model name (default: cohere/rerank-english-v3.0)
Use provider prefix (e.g., cohere/, together_ai/, voyage/)
timeout: Request timeout in seconds (default: 60.0)
max_tokens_per_doc: If set, truncate each document to this many tokens before
sending to the reranker (uses tiktoken cl100k_base encoding).
Useful for models with small context windows (e.g. 1024 tokens).
"""
self.api_base = api_base.rstrip("/")
self.api_key = api_key
self.model = model
self.timeout = timeout
self.max_tokens_per_doc = max_tokens_per_doc
self._async_client: httpx.AsyncClient | None = None
@property
@@ -905,6 +984,8 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
if self.max_tokens_per_doc is not None:
texts = [_truncate_to_tokens(t, self.max_tokens_per_doc) for t in texts]
indices = [idx for idx, _ in indexed_texts]
# LiteLLM /rerank follows Cohere API format
@@ -950,6 +1031,7 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
model: str = DEFAULT_RERANKER_LITELLM_SDK_MODEL,
api_base: str | None = None,
timeout: float = 60.0,
max_tokens_per_doc: int | None = DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
):
"""
Initialize LiteLLM SDK cross-encoder client.
@@ -959,11 +1041,15 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
model: Model name with provider prefix (e.g., "deepinfra/Qwen3-reranker-8B")
api_base: Custom base URL for API (optional)
timeout: Request timeout in seconds (default: 60.0)
max_tokens_per_doc: If set, truncate each document to this many tokens before
sending to the reranker (uses tiktoken cl100k_base encoding).
Useful for models with small context windows (e.g. 1024 tokens).
"""
self.api_key = api_key
self.model = model
self.api_base = api_base
self.timeout = timeout
self.max_tokens_per_doc = max_tokens_per_doc
self._initialized = False
self._litellm = None # Will be set during initialization
@@ -1017,6 +1103,8 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
if self.max_tokens_per_doc is not None:
texts = [_truncate_to_tokens(t, self.max_tokens_per_doc) for t in texts]
indices = [idx for idx, _ in indexed_texts]
# Build kwargs for rerank call
@@ -1050,6 +1138,97 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
return all_scores
class JinaMLXCrossEncoder(CrossEncoderModel):
"""
Jina Reranker v3 MLX implementation for Apple Silicon.
Uses jinaai/jina-reranker-v3-mlx a 0.6B parameter multilingual listwise reranker
optimized for Apple Silicon via the MLX framework. No transformers/PyTorch dependency.
The model is downloaded automatically from HuggingFace Hub on first use.
Requires: mlx>=0.31.0, mlx-lm>=0.31.1, safetensors>=0.6.2
"""
HF_REPO_ID = "jinaai/jina-reranker-v3-mlx"
def __init__(self, model_path: str | None = None):
"""
Args:
model_path: Local path to the downloaded model directory.
If None, the model is downloaded from HuggingFace Hub.
"""
self.model_path = model_path
self._reranker = None
@property
def provider_name(self) -> str:
return "jina-mlx"
async def initialize(self) -> None:
if self._reranker is not None:
return
try:
import mlx.core # noqa: F401
import mlx_lm # noqa: F401
except ImportError:
raise ImportError(
"mlx and mlx-lm are required for JinaMLXCrossEncoder. "
"Install with: pip install mlx>=0.31.0 mlx-lm>=0.31.1 safetensors>=0.6.2"
)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_model)
def _load_model(self) -> None:
"""Download (if needed) and load the MLX reranker. Runs in a thread."""
import os
from huggingface_hub import snapshot_download
from .jina_mlx_reranker import MLXReranker
model_path = self.model_path
if model_path is None:
logger.info(f"Reranker: downloading {self.HF_REPO_ID} from HuggingFace Hub...")
model_path = snapshot_download(repo_id=self.HF_REPO_ID)
logger.info(f"Reranker: loading jina-reranker-v3-mlx from {model_path}")
self._reranker = MLXReranker(
model_path=model_path,
projector_path=os.path.join(model_path, "projector.safetensors"),
)
logger.info("Reranker: jina-mlx provider initialized")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Score pairs grouped by query. Runs in a thread."""
if not pairs:
return []
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, doc) in enumerate(pairs):
query_groups.setdefault(query, []).append((idx, doc))
all_scores = [0.0] * len(pairs)
for query, indexed_docs in query_groups.items():
docs = [doc for _, doc in indexed_docs]
indices = [idx for idx, _ in indexed_docs]
results = self._reranker.rerank(query, docs)
for result in results:
original_idx = result["index"]
all_scores[indices[original_idx]] = result["relevance_score"]
return all_scores
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
if self._reranker is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._predict_sync, pairs)
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
@@ -1079,6 +1258,9 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
fp16=config.reranker_local_fp16,
bucket_batching=config.reranker_local_bucket_batching,
batch_size=config.reranker_local_batch_size,
)
elif provider == "cohere":
api_key = config.reranker_cohere_api_key
@@ -1098,6 +1280,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
api_base=config.reranker_litellm_api_base,
api_key=config.reranker_litellm_api_key,
model=config.reranker_litellm_model,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
)
elif provider == "litellm-sdk":
api_key = config.reranker_litellm_sdk_api_key
@@ -1109,6 +1292,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
api_key=api_key,
model=config.reranker_litellm_sdk_model,
api_base=config.reranker_litellm_sdk_api_base,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
)
elif provider == "zeroentropy":
api_key = config.reranker_zeroentropy_api_key
@@ -1122,7 +1306,9 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
elif provider == "jina-mlx":
return JinaMLXCrossEncoder()
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'flashrank', 'litellm', 'litellm-sdk', 'rrf'"
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
)
@@ -58,10 +58,16 @@ async def retry_with_backoff(
last_exception = e
if attempt < max_retries:
delay = min(base_delay * (2**attempt), max_delay)
logger.warning(
f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
)
if isinstance(e, asyncpg.exceptions.DeadlockDetectedError):
logger.warning(
f"Deadlock detected during parallel document processing — this is expected and will resolve automatically "
f"(attempt {attempt + 1}/{max_retries + 1}, retrying in {delay:.1f}s)"
)
else:
logger.warning(
f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(f"Database operation failed after {max_retries + 1} attempts: {e}")
@@ -459,10 +459,12 @@ class EntityResolver:
entity_dates = [g.event_date for _, g in sorted_groups]
# INSERT ... ON CONFLICT DO NOTHING — no row lock on already-existing entities.
# mention_count starts at 0 here; flush_pending_stats() is the sole source of
# truth for mention counting (one stat per original mention in the batch).
inserted_rows = await conn.fetch(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 1
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 0
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO NOTHING
@@ -489,13 +491,15 @@ class EntityResolver:
for row in existing_rows:
id_by_name[row["name_lower"]] = row["id"]
# Assign entity IDs back and queue for post-txn stats flush.
# Assign entity IDs back and queue one stat per original mention so that
# flush_pending_stats() increments mention_count by the true mention count,
# not just 1 per unique name.
for name_lower, g in sorted_groups:
entity_id = id_by_name.get(name_lower)
if entity_id:
for original_idx in g.indices:
entity_ids[original_idx] = entity_id
pending.append(_EntityStat(entity_id=entity_id, event_date=g.event_date))
pending.append(_EntityStat(entity_id=entity_id, event_date=g.event_date))
# Accumulate into the resolver's pending list; the orchestrator flushes
# these with await entity_resolver.flush_pending_stats() after the txn.
@@ -0,0 +1,144 @@
"""
MLX implementation of jina-reranker-v3 for Apple Silicon.
This file is adapted from the official model repository:
https://huggingface.co/jinaai/jina-reranker-v3-mlx/blob/main/rerank.py
License: CC BY-NC 4.0 (contact Jina AI for commercial usage)
Changes from upstream:
- Removed the __main__ example block
- Type annotations added to public methods
- top_n parameter added to rerank() (upstream only exposed it implicitly)
"""
import numpy as np
class _MLPProjector:
def __init__(self):
import mlx.nn as nn
self.linear1 = nn.Linear(1024, 512, bias=False)
self.linear2 = nn.Linear(512, 512, bias=False)
def __call__(self, x):
import mlx.nn as nn
x = self.linear1(x)
x = nn.relu(x)
x = self.linear2(x)
return x
def _load_projector(projector_path: str) -> _MLPProjector:
import mlx.core as mx
from safetensors import safe_open
projector = _MLPProjector()
with safe_open(projector_path, framework="numpy") as f:
projector.linear1.weight = mx.array(f.get_tensor("linear1.weight"))
projector.linear2.weight = mx.array(f.get_tensor("linear2.weight"))
return projector
def _sanitize(text: str, special_tokens: dict[str, str]) -> str:
for token in special_tokens.values():
text = text.replace(token, "")
return text
def _format_prompt(query: str, docs: list[str], special_tokens: dict[str, str]) -> str:
query = _sanitize(query, special_tokens)
docs = [_sanitize(d, special_tokens) for d in docs]
doc_token = special_tokens["doc_embed_token"]
query_token = special_tokens["query_embed_token"]
prefix = (
"<|im_start|>system\n"
"You are a search relevance expert who can determine a ranking of the passages based on how relevant they are to the query. "
"If the query is a question, how relevant a passage is depends on how well it answers the question. "
"If not, try to analyze the intent of the query and assess how well each passage satisfies the intent. "
"If an instruction is provided, you should follow the instruction when determining the ranking."
"<|im_end|>\n<|im_start|>user\n"
)
suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
body = (
f"I will provide you with {len(docs)} passages, each indicated by a numerical identifier. "
f"Rank the passages based on their relevance to query: {query}\n"
)
body += "\n".join(f'<passage id="{i}">\n{doc}{doc_token}\n</passage>' for i, doc in enumerate(docs))
body += f"\n<query>\n{query}{query_token}\n</query>"
return prefix + body + suffix
class MLXReranker:
"""
MLX-accelerated jina-reranker-v3 for Apple Silicon.
Loads the model from a local directory (use huggingface_hub.snapshot_download
to fetch jinaai/jina-reranker-v3-mlx if you don't have it already).
"""
_SPECIAL_TOKENS = {
"query_embed_token": "<|rerank_token|>",
"doc_embed_token": "<|embed_token|>",
}
_DOC_TOKEN_ID = 151670
_QUERY_TOKEN_ID = 151671
def __init__(self, model_path: str, projector_path: str):
from mlx_lm import load
self.model, self.tokenizer = load(model_path)
self.model.eval()
self.projector = _load_projector(projector_path)
def rerank(self, query: str, documents: list[str], top_n: int | None = None) -> list[dict]:
"""
Rank documents by relevance to a query.
Returns a list of dicts with keys: document, relevance_score, index.
Sorted by descending relevance_score.
"""
import mlx.core as mx
prompt = _format_prompt(query, documents, self._SPECIAL_TOKENS)
input_ids = self.tokenizer.encode(prompt)
hidden_states = self.model.model([input_ids])[0] # [seq_len, hidden_size]
input_ids_np = np.array(input_ids)
query_positions = np.where(input_ids_np == self._QUERY_TOKEN_ID)[0]
doc_positions = np.where(input_ids_np == self._DOC_TOKEN_ID)[0]
if len(query_positions) == 0:
raise ValueError("Query embed token not found in prompt")
if len(doc_positions) == 0:
raise ValueError("Document embed tokens not found in prompt")
query_hidden = mx.expand_dims(hidden_states[int(query_positions[0])], axis=0)
doc_hidden = mx.stack([hidden_states[int(p)] for p in doc_positions])
query_emb = self.projector(query_hidden) # [1, 512]
doc_emb = self.projector(doc_hidden) # [num_docs, 512]
query_exp = mx.broadcast_to(mx.expand_dims(query_emb, 0), (1, len(documents), 512))
doc_exp = mx.expand_dims(doc_emb, 0)
scores = mx.sum(doc_exp * query_exp, axis=-1) / (
mx.sqrt(mx.sum(doc_exp * doc_exp, axis=-1)) * mx.sqrt(mx.sum(query_exp * query_exp, axis=-1))
) # [1, num_docs]
scores_np = np.array(scores[0])
order = np.argsort(scores_np)[::-1]
n = min(top_n, len(documents)) if top_n is not None else len(documents)
return [
{
"document": documents[order[i]],
"relevance_score": float(scores_np[order[i]]),
"index": int(order[i]),
}
for i in range(n)
]
@@ -48,6 +48,28 @@ _llm_max_concurrent = int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_
_global_llm_semaphore = asyncio.Semaphore(_llm_max_concurrent)
def sanitize_llm_output(text: str | None) -> str | None:
"""
Sanitize text by removing characters that break downstream systems.
Removes:
- ASCII control characters (0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F): break
json.loads and PostgreSQL UTF-8 encoding; tab (0x09), newline (0x0A), and
carriage return (0x0D) are preserved as they are valid in text and JSON.
- Unicode surrogates (U+D800-U+DFFF): Invalid in UTF-8, break LLM APIs
Surrogate characters are used in UTF-16 encoding but cannot be encoded
in UTF-8. They can appear in Python strings from improperly decoded data
(e.g., from JavaScript or broken files). Control characters commonly appear
in LLM output embedded inside JSON string values.
"""
if text is None:
return None
if not text:
return text
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\ud800-\udfff]", "", text)
class OutputTooLongError(Exception):
"""
Bridge exception raised when LLM output exceeds token limits.
@@ -205,7 +227,7 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio"):
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax"):
return OpenAICompatibleLLM(
provider=provider,
api_key=api_key,
@@ -274,6 +296,7 @@ class LLMProvider:
"openai-codex",
"claude-code",
"mock",
"minimax",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -286,6 +309,8 @@ class LLMProvider:
self.base_url = "http://localhost:11434/v1"
elif self.provider == "lmstudio":
self.base_url = "http://localhost:1234/v1"
elif self.provider == "minimax":
self.base_url = "https://api.minimax.io/v1"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
@@ -608,7 +633,7 @@ class LLMProvider:
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)

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