Compare commits

...
96 Commits
Author SHA1 Message Date
Nicolò Boschi 4ae3cbf308 fix: docker startup fails with named docker volumes 2026-02-19 13:43:04 +01:00
Nicolò Boschi e785b05831 fix(mcp): stateless param not supported anymore (#406) 2026-02-19 13:42:54 +01:00
Nicolò Boschi 58c4d65778 fix: reranker crashes on provider error (#403)
* fix: reranker crashes on provider error

* fix: reranker crashes on provider error
2026-02-19 11:38:37 +01:00
Derek Bouius c3ef1555bf fix: reduce temporal ordering offset from 10s to 10ms per fact (#402)
The 10-second offset per fact caused significant timestamp drift when
ingesting many items — e.g. 600 facts would shift the last fact by
~100 minutes from its actual event time. This broke timeline views
and made occurred_start/mentioned_at unreliable for temporal queries.

Reducing to 10ms preserves fact ordering while keeping timestamps
within ~8 seconds of the original values even for large batches.
2026-02-19 10:48:18 +01:00
Nicolò Boschi dcaa9f14ab fix: clients don't respect timeout setting (#400) 2026-02-19 10:47:47 +01:00
BenandClaude Opus 4.6 41db2960c5 feat: add CrewAI integration for persistent crew memory (#319)
* feat: add CrewAI integration for persistent crew memory

Implements a CrewAI ExternalMemory storage backend that maps CrewAI's
Storage interface (save/search/reset) to Hindsight's retain/recall/delete
APIs, giving crews long-term memory with fact extraction, entity tracking,
and temporal awareness across runs.

Key features:
- HindsightStorage: drop-in Storage backend for CrewAI ExternalMemory
- HindsightReflectTool: BaseTool exposing Hindsight's reflect API
- Per-agent memory banks with customizable bank resolver
- Async compatibility layer for CrewAI's threading model
- 35 unit tests, docs site page, example script

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

* refactor: move CrewAI example to hindsight-cookbook

Move research_crew.py example from hindsight-integrations/crewai/examples/
to the cookbook repo and update the integration README to link there instead.

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

* ci: add GitHub Actions test job for CrewAI integration

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

* ci: add uv.lock for frozen installs in CI

The test-crewai-integration CI job uses `uv sync --frozen` which
requires a committed lock file.

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-18 17:05:52 +01:00
Nicolò Boschi f78278ea89 fix: document not tracked if has 0 extracted facts (#399)
* fix: document not tracked if has 0 extracted facts

* fix: document not tracked if has 0 extracted facts
2026-02-18 17:01:57 +01:00
Nicolò Boschi 117dd6988d doc: changelog for 0.4.12 (#397)
* changelog for 0.4.12

* changelog for 0.4.12
2026-02-18 14:55:19 +01:00
Nicolò Boschi 7c78ae2371 Release v0.4.12
- Update version to 0.4.12 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, 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
- Helm chart
- Sync documentation to version-0.4
2026-02-18 14:13:33 +01:00
Nicolò Boschi 6c695eb9f8 fix: improve openclaw test coverage (#396)
* fix: improve openclaw test coverage

* test(openclaw): export stripMemoryTags/extractRecallQuery and add hook integration tests

- Extract stripMemoryTags and extractRecallQuery as exported pure functions
  from index.ts so hooks share one implementation and tests cover the real code
- Update before_agent_start to call extractRecallQuery; update agent_end to
  call stripMemoryTags instead of duplicating the regex inline
- Rewrite index.test.ts to import the real functions (no more local duplicate)
  and add 11 tests for extractRecallQuery covering all envelope-stripping cases
- Add tests/hooks.integration.test.ts: loads the plugin via mock MoltbotPluginAPI
  in HTTP mode, spies on client.recall/retain, and exercises all hook behaviours:
  excluded providers, short messages, memory injection format, tag stripping,
  transcript formatting, array content blocks, metadata, document_id derivation
2026-02-18 14:10:33 +01:00
Nicolò Boschi 7eafba661e feat: add iris as file parser (#395)
* feat: add iris as file parser

* fix
2026-02-18 14:09:56 +01:00
Anton EvseevandClaude Opus 4.6 c461013047 fix(openclaw): shell safety, HTTP dual-mode, lazy reinit, per-user banks (#388)
- exec→execFile: bypass shell entirely, preventing injection via
  special characters in chat history
- HTTP dual-mode: client can now talk directly to the Hindsight API
  via HTTP (setBankMission, retain, recall) when apiUrl is configured,
  bypassing the subprocess/CLI entirely for production deployments
- HindsightClientOptions: replace 5 positional constructor args with
  a typed options object for clarity and extensibility
- sanitize(): strip null bytes from strings — Node 22 rejects them
  in execFile() args
- recall timeout: accept optional timeoutMs parameter for both HTTP
  and subprocess modes; subprocess gets a longer 30s default
- In-flight recall dedup: concurrent recalls for the same bank reuse
  one promise instead of firing duplicate requests
- Timeout/abort handling: graceful warn-level logging instead of
  error spam when recall times out
- Error cause chaining: wrap errors with { cause } for better
  debugging stack traces
- lazyReinit: recover from startup health check failure with 30s
  cooldown and concurrency guard
- Per-user banks: derive bank ID from senderId (not channelId) for
  proper memory isolation per user across channels
- buildClientOptions(): centralized helper replaces 7 duplicated
  constructor call sites

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-18 13:09:02 +01:00
Nicolò Boschi 7c99feb018 fix(go-client): add go build to CI (#393)
* feat(go-client): add NewAPIClientWithToken helper and expand recall vs reflect FAQ

- Add NewAPIClientWithToken convenience function to Go client for easy authenticated client creation
- Expand FAQ with detailed "When should I use recall vs reflect?" guidance including practical examples

* fix(go-client): add go build to CI and preserve hindsight_client.go in generator

- Add explicit 'go build ./...' step before integration tests for faster compile feedback
- Preserve hindsight_client.go as a maintained file in generate-clients.sh
2026-02-18 13:06:17 +01:00
Nicolò Boschi d06a0259cc feat: improve ai sdk tools (#394) 2026-02-18 13:06:03 +01:00
Eliah RusinandClaude Opus 4.6 be8728b313 fix(go-client): use monorepo-compatible module path (#392)
The Go SDK declared its module as github.com/vectorize-io/hindsight-client-go,
but that repository doesn't exist. Update to
github.com/vectorize-io/hindsight/hindsight-clients/go to match the actual
monorepo path, enabling standard `go get` imports with directory-prefixed tags.

Also enables isGoSubmodule in the OpenAPI generator config and updates all
import references across tests, docs, and the client generation script.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-18 10:35:13 +01:00
Derek Bouius 917893aac7 fix: restore entity retrieval in recall (#391)
Entity retrieval was removed in ab5e31f2 ("chore: remove dead code")
but the code was not dead — it populated the entities dict and
per-fact entity names returned by the recall endpoint.

This restores:
- fact_entity_map query joining unit_entities and entities tables
- entity_names on each MemoryFact result
- entities_dict with EntityState objects ordered by fact relevance
- entity count in recall log line
2026-02-18 10:33:58 +01:00
Nicolò Boschi 224b7b74c1 feat: accept pdf, images and office files (#390)
* feat: accept pdf, images and office files

* refactor: rename FileConverter to FileParser, simplify file retain API

- Rename engine/converters/ → engine/parsers/, FileConverter → FileParser,
  ConverterRegistry → FileParserRegistry, MarkitdownConverter → MarkitdownParser
- Rename env var HINDSIGHT_API_FILE_CONVERTER → HINDSIGHT_API_FILE_PARSER
- Remove async/document_tags params from FileRetainRequest (always async now)
- Add retain_files() to Python Hindsight client and retainFiles() to TypeScript client
- Add sample.pdf to doc examples for working file upload demonstrations
- Update test_file_retain.py to use new parser names and always-async behavior
- Fix Go client missing os import in api_files.go
- Simplify postgresql.py storage to minimal schema

* fix: update rust CLI tests to use is_supported_file instead of is_text_file

* fix: patch Go api_files.go to add missing 'os' import after generation

* fix: insert 'os' import after 'net/url' in api_files.go patch for correct position

* chore: regenerate OpenAPI spec and clients (converter→parser description update)
2026-02-17 18:15:03 +01:00
Chris Bartholomew 8114ef440e fix(python-client): async method parity and server keepalive timeout (#387)
* Fix async method parity and server keepalive timeout

The Python client's async methods were missing parameters available in
their sync counterparts, and the server's default keepalive timeout was
shorter than the client's, causing ServerDisconnectedError on reused
connections.

Server:
- Set uvicorn timeout_keep_alive to 30s (default was 5s). The Python
  client (aiohttp) has a 15s client-side keepalive, so the server must
  hold connections longer to prevent the client from writing to a
  closed socket.

Python client - async method parity:
- arecall(): add trace, query_timestamp, include_entities,
  include_chunks, max_entity_tokens, max_chunk_tokens. Return
  RecallResponse instead of list[RecallResult].
- areflect(): add max_tokens and response_schema.
- acreate_bank(): new async method.
- aset_mission(): new async method.
- adelete_bank(): new async method.

Tests:
- Add test verifying uvicorn keepalive timeout exceeds client default.
- Add async tests for arecall (include_chunks, include_entities, trace,
  full params), areflect (max_tokens, structured output), and
  adelete_bank.

* Fix flaky tag tests by using entity-rich content and asserting on tags

The tag tests were unreliable because:
- Generic content ("Project X meeting notes") was frequently collapsed
  during fact extraction, leaving no memories to recall
- Assertions checked LLM-rewritten text for literal substrings instead
  of checking tags, which is what the tests are actually verifying

Fix: use distinctive, entity-rich content (named people with specific
actions) that reliably survives fact extraction, and assert on tag
membership rather than text content.
2026-02-17 16:00:14 +01:00
Nicolò Boschi 6bad667344 fix(openclaw): error E2BIG on large content ingested (#389) 2026-02-17 14:11:23 +01:00
Nicolò Boschi b3f0205ead doc: add faq page (#383) 2026-02-17 11:26:27 +01:00
Nicolò Boschi 476726c2a2 feat: support azure pg_diskann (#381) 2026-02-16 16:08:54 +01:00
Nicolò Boschi 970f1b3534 ci: ensure docs get created with no extracted facts (#379) 2026-02-16 14:43:32 +01:00
Nicolò Boschi 5883e5af2d doc: add go client examples (#380)
* doc: add go client examples

* doc: add go client examples
2026-02-16 14:43:03 +01:00
Nicolò Boschi 95c4220477 feat: support for pgvectorscale (DiskANN) (#378)
* feat: support for pgvectorscale (DiskANN)

* feat: support for pgvectorscale (DiskANN)
2026-02-16 14:19:56 +01:00
Nicolò Boschi 6e30980add feat: use official go generator for Go client (#377)
* ci: add Go client integration tests

Add test-go-client job to CI workflow following the same pattern as
Python, TypeScript, and Rust client tests. The job:
- Sets up Go 1.23 with dependency caching
- Starts the Hindsight API server
- Runs integration tests using the 'integration' build tag
- Displays server logs on failure

The integration tests (hindsight-clients/go/integration_test.go) cover
all core operations: retain, recall, reflect, bank management, and
end-to-end workflows.

* Move Go cookbook content to hindsight-cookbook repo

Removes Go-specific cookbook content that was added in PR #375:
- applications/go-memory-service.md
- recipes/go-quickstart.md
- recipes/go-concurrent-pipeline.md

These have been moved to the hindsight-cookbook repository where
cookbook content should live per project conventions.

* feat(go): add CI test for Go client and patch for ogen null handling

- Add test-go-client job to GitHub Actions CI workflow
- Create post-generation patch script (patch-ogen.sh) to fix ogen's
  handling of null values in optional string fields
- Patch OptString.Decode() to check jx.Next() type before decoding,
  properly handling explicit null in JSON responses

The patch ensures generated code persists across regenerations and
handles the Hindsight API's nullable optional fields correctly.

Fixes: Go client integration tests for retain and bank operations
Note: Some tests still fail for nullable arrays/objects - those
require additional patches for other Opt* types.

* feat: use official go generator for Go client

* feat: use official go generator for Go client

* ci fixes

* chore: sync Go client with latest OpenAPI spec

- Add model_child_operation_status.go (new model)
- Update model_operation_status_response.go with child operations
- Update go.mod/go.sum dependencies
- Update api/openapi.yaml
2026-02-16 14:04:12 +01:00
Nicolò Boschi 40d42c58aa feat: support Batch API for retain (openai/groq) (#365)
* feat: support Batch API for retain (openai/groq)

* api

* stop batch api if sync

* fix(ui): improve toast notifications with brand colors and proper styling

- Replace all window.alert() calls with toast notifications
- Add interceptor-based error handling in API client
- Use different toast styles based on HTTP status codes (4xx = warning, 5xx = error)
- Apply Hindsight brand colors to toasts (primary blue for info, destructive red for errors, etc.)
- Remove obsolete error handling files (hindsight-client-with-toast.ts, api-error-handler.ts)
- Fix toast background conflicts by removing base bg-background class

* fix: restore retain_batch_tokens config that was accidentally removed during rebase
2026-02-16 13:31:50 +01:00
Nicolò Boschi aefb3fcf4d fix: improve async batch retain with large payloads (#366)
* fix: improve async batch retain with large payloads

* fix: improve async batch retain with large payloads

* api

* api

* api

* api

* api

* Clean up perf benchmark: keep only Python files

- Remove README.md and PERFORMANCE_FINDINGS.md
- Remove results/ JSON files (gitignored)
- Remove test_data/ directory
- Keep only __init__.py and retain_perf.py

* docs: explain automatic batch optimization for async retain

- Add section explaining Hindsight automatically handles batch sizing
- Users don't need to manually tune batch sizes with async mode
- Hindsight splits large batches (>10k tokens) into optimized sub-batches
- Include example showing best practices

* docs: remove emojis and code example from performance page

* fix: correct OperationDetails type to match API response

- Change optional fields to use | null instead of ?
- Fixes TypeScript compilation error in control plane build

* fix: use discriminated union for OperationDetails type

- Support both success and error states properly
- Fixes TypeScript error when setting error state

* fix: use unique document_ids in batch retain examples

- Each item in a batch must have unique document_id
- Update both Python and JavaScript examples
- Fixes test-doc-examples CI failure

* chore: trigger CI

* fix: test mocking and duplicate document_ids in examples

- Mock _get_pool() in test_async_retain_tags.py to avoid _initialized error
- Set _initialized = True on mocked MemoryEngine instances
- Fix duplicate document_ids in retain.py and retain.mjs examples

* fix: properly mock async pool/connection and fix more duplicate document_ids

- Use AsyncMock for pool.acquire() to fix 'can't be used in await' error
- Fix duplicate document_ids in retain-async examples (retain.py and retain.mjs)
- Remove batch-level document_id parameter that caused duplicates

* ci: collect all doc example failures and show summary

- Run all Python/Node.js/CLI examples regardless of individual failures
- Collect failure list and display summary at the end
- Show pass/fail count and list of failed files
- Exit with failure only after running all examples

* refactor: extract doc example testing to standalone script

- Create scripts/test-doc-examples.sh to run all examples
- Collects logs of failed examples separately
- Shows full error logs only for failures at the end
- Clean summary with pass/fail counts
- Proper exit codes
- Replaces inline bash in CI workflow

* fix: doc examples - duplicate document_ids and error handling

- retain.py: move document_id to item level to avoid duplicates
- documents.mjs: add error handling for getDocument to show clear error message

* fix: update tests for duplicate document_id validation

- test_async_retain_tags: verify operation structure instead of exact UUID
- test_delete_bank: use unique document_ids (team-doc-1, team-doc-2)
2026-02-16 12:51:42 +01:00
Eliah RusinandClaude Opus 4.6 2a47389f2c feat: add Go client SDK with ogen code generation (#375)
Add a Go client for the Hindsight API using ogen for strongly-typed code
generation from the OpenAPI 3.1 spec. The client provides a high-level
wrapper with functional options around the generated code, covering all
core operations (retain, recall, reflect, bank management).

Includes:
- ogen-based code generation with OpenAPI 3.1 spec preprocessing
- High-level Client wrapper with idiomatic Go API
- Functional options for all operations (WithBudget, WithTags, etc.)
- OgenClient() escape hatch for advanced operations
- Integration tests and godoc examples
- Go SDK reference docs and cookbook entries (quickstart, concurrent
  pipeline, memory-augmented API service)
- Updated generate-clients.sh with Go generation step

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-16 11:35:40 +01:00
abix5 b4b5c44a87 fix: propagate document tags in async retain path (#374) 2026-02-16 10:04:48 +01:00
Anton EvseevandClaude Opus 4.6 d5e62162e8 fix(openclaw): remove unused imports, retry health check, suppress unhandled rejection (#373)
- Remove unused `fs` and `execSync` imports from `embed-manager.ts`
- Remove unused `join` import from `index.ts`
- Add retry logic to external API health check (3 attempts, 2s delay) —
  container DNS may not be ready on first boot
- Use ES2022 `{ cause: error }` for better error chain preservation
- Add `.catch(() => {})` to `initPromise` to suppress Node.js unhandled
  rejection warnings (error is properly handled later in `service.start()`)

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-16 10:02:19 +01:00
Nicolò Boschi 7dad9da02d feat: allow chunks-only in recall (max_tokens=0) (#364)
* feat: allow chunks only in recall

* feat: fetch chunks independently of max_tokens filtering

Changes:
- Chunks now fetched BEFORE max_tokens filtering (Step 5.5)
- Implements batching: (max_chunk_tokens / retain_chunk_size) * 2
- Loop-based fetching until budget exhausted or no more chunks
- Handles varying chunk sizes across documents
- When max_tokens=0: returns 0 facts but still returns chunks
- When max_tokens>0: backward compatible (chunks match filtered facts)

Tests:
- Added test_recall_chunks_independence.py with 5 comprehensive tests
- Tests chunk independence, batching, ordering, and backward compat

Docs:
- Updated recall.mdx to explain new chunk behavior
- Updated memory_engine.py docstrings

Fixes chunk-related test failures by reordering chunks to match
filtered facts when max_tokens > 0 (backward compatibility).

* fix: fetch chunks after token filtering when max_tokens>0

Changes:
- When max_tokens=0: fetch chunks BEFORE token filtering (new behavior)
- When max_tokens>0: fetch chunks AFTER token filtering (backward compat)
- This ensures chunk ordering matches filtered facts for max_tokens>0
- Fixes test failures in test_chunks_and_entities_follow_fact_order,
  test_chunk_fact_mapping, test_chunk_ordering_preservation, etc.

The previous approach tried to reorder prefetched chunks, but that
caused issues when the chunk budget was exhausted before all facts
were processed. The new approach fetches chunks based on the correct
fact set for each scenario.

* fix: use ConfigResolver for bank-specific retain_chunk_size

Fixes error: Field 'retain_chunk_size' is bank-configurable and cannot
be accessed from global config.

Changed from:
- config.retain_chunk_size (global config, not allowed)

To:
- bank_config.retain_chunk_size (resolved from ConfigResolver)

This ensures the correct chunk size is used for each bank, respecting
any bank-specific overrides.

* fix: correct Budget import in test_recall_chunks_independence

Changed from:
- from hindsight_api.engine.interface import Budget (incorrect)

To:
- from hindsight_api.engine.memory_engine import Budget (correct)

This fixes the ImportError that was preventing the tests from running.

* fix: prevent infinite loop in chunk fetching and improve test content

- Add max(1, ...) to estimated_batch_size to prevent division resulting in 0
- Update test content to use more substantial examples that generate facts
- Add request_context parameter to all retain_async and recall_async test calls

* refactor: simplify chunk fetching to always use pre-filtering approach

Remove backward compatibility code that fetched chunks after token
filtering. Now chunks are always fetched from top-scored results
before max_tokens filtering, regardless of max_tokens value.

This simplifies the code by:
- Removing duplicate chunk fetching logic
- Eliminating conditional behavior based on max_tokens
- Making chunk fetching behavior consistent and predictable

Chunks are still fetched in batches and respect max_chunk_tokens limit.
2026-02-13 16:56:35 +01:00
Nicolò Boschi ff55283018 doc: changelog and blog post for 0.4.11 (#363)
* doc: changelog and blog post for 0.4.11

* doc: changelog and blog post for 0.4.11
2026-02-13 11:45:41 +01:00
Nicolò Boschi b3b541fc53 Release v0.4.11
- Update version to 0.4.11 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, 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
- Helm chart
- Sync documentation to version-0.4
2026-02-13 10:52:14 +01:00
Nicolò Boschi 4f112101ac fix(openclaw): avoid memory retain recursion (#362) 2026-02-13 10:47:39 +01:00
Nicolò Boschi e408b7e072 feat: support litellm-sdk as reranker and embeddings (#357)
* feat: support litellm-sdk for reranker endpoint

* feat: support litellm-sdk for reranker endpoint

* fix: make litellm SDK cohere test fixture async function-scoped

* fix: store litellm module reference during initialization to avoid import issues

* feat: add LiteLLM SDK embeddings support

- Add LiteLLMSDKEmbeddings class for direct API access without proxy
- Support multiple providers: Cohere, OpenAI, Together AI, HuggingFace, Voyage AI
- Automatic dimension detection via test embedding
- Provider-specific API key mapping
- Batch processing support (configurable batch size)
- Comprehensive test coverage (17 unit tests)
- Update documentation with configuration examples

Implements embeddings in same PR as reranker per user request

* fix: correct config mocking in embeddings factory tests

- Mock get_config() from its source module (hindsight_api.config)
- Fixes factory tests that were returning LocalSTEmbeddings instead of LiteLLMSDKEmbeddings
- All 17 unit tests now passing

* fix: skip Cohere integration tests when API key is invalid

- Catch initialization errors and skip tests instead of failing
- Prevents CI failures when COHERE_API_KEY is set but invalid
- Integration tests now properly skip when authentication fails

* fix: skip Cohere reranker integration tests when API key is invalid

- Add same error handling as embeddings tests
- Prevents CI failures when COHERE_API_KEY is set but invalid
- Tests now properly skip when authentication fails

* Revert "fix: skip Cohere reranker integration tests when API key is invalid"

This reverts commit 655dacaffb.

* Revert "fix: skip Cohere integration tests when API key is invalid"

This reverts commit 5d00548e39.

* fix: pass API key directly to litellm SDK functions

- Add api_key parameter to arerank(), rerank(), aembedding(), and embedding() calls
- Prevents authentication issues in multi-process environments (pytest-xdist)
- More reliable than relying solely on environment variables
- Update test assertions to expect api_key parameter

* feat: pass api_base parameter to litellm SDK calls and remove hasattr check

* fix: raise errors instead of silently returning 0.0 scores

* refactor: pass API keys directly in kwargs instead of setting env vars
2026-02-12 23:53:12 +01:00
Nicolò Boschi d871c3009d feat: support timescale pg_textsearch as text search extension (#359)
* feat: support timescale pg_textsearch as text search extension

* refactor: deduplicate text search query in retrieve_semantic_bm25_combined

Instead of maintaining 3 complete query copies (native, vchord, pg_textsearch),
now we:
- Build backend-specific parts (score_expr, order_by, where_filter)
- Use a single query template with injected backend-specific parts

This makes maintenance easier - changes to the semantic CTE or overall structure
only need to be made once.
2026-02-12 17:38:13 +01:00
DK09876andClaude Opus 4.6 d8376ecf6b Fix incorrect MCP tool parameters in docs (#358)
- Remove phantom `max_results` param from recall (only has query + max_tokens)
- Remove `budget` param from local-mcp recall (only reflect has budget)
- Add missing `name` and `mission` optional params to create_bank
- Add missing `mental_model_id` optional param to create_mental_model

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-12 08:52:05 -07:00
Nicolò Boschi 71e408c27b chore: remove dead code (#356)
* chore: remove dead code

* chore: remove dead code

* feat: support litellm-sdk for reranker endpoint
2026-02-12 14:44:00 +01:00
Nicolò Boschi c029807add feat: support for other text and vector search pg extensions (#355)
* feat: support for other text and vector search pg extensions

* test: increase timeout for test_batch_chunking_behavior to account for VectorChord BM25 tokenization overhead

* feat: support for other text and vector search pg extensions
2026-02-12 14:13:04 +01:00
Nicolò Boschi 8d731f2e5f feat: implement hierarchical configuration (system, tenant, bank) (#329)
* feat: implement hierarchical configuration (system, tenant, bank)

* feat: implement hierarchical configuration (system, tenant, bank)

* docs: add instructions for hierarchical config in CLAUDE.md

* feat: add ENABLE_BANK_CONFIG_API flag (disabled by default)

- Add HINDSIGHT_API_ENABLE_BANK_CONFIG_API env var (default: false)
- Return 403 Forbidden from bank config endpoints when disabled
- Update tests to enable the flag
- Update CLAUDE.md documentation

This provides security control over the bank configuration API,
ensuring it's only accessible when explicitly enabled.

* docs: add hierarchical configuration section

* feat(cli): add bank config commands (config, set-config, reset-config)

- Add 'hindsight bank config' to view bank configuration
- Add 'hindsight bank set-config' to update LLM settings per bank
- Add 'hindsight bank reset-config' to reset to defaults
- Implements client API calls to new bank config endpoints

* fix(cli): fix compilation errors in bank config commands

- Fix type signature: use ApiClient instead of api::Client
- Fix confirmation: use ui::prompt_confirmation instead of ui::confirm
- Fix error handling: use anyhow! macro instead of errors::Error
- Fix type conversion: convert HashMap to serde_json::Map for API call

* feat: implement type-safe hierarchical config with bank overrides

Implements a production-ready hierarchical configuration system that prevents
accidentally using global defaults when bank-specific overrides exist.

- Created StaticConfigProxy that wraps HindsightConfig
- get_config() now returns proxy that blocks access to bank-configurable fields
- Raises ConfigFieldAccessError with clear message when accessing configurable fields
- Added _get_raw_config() for internal use only
- Forces developers to use resolve_full_config(bank_id, context) for bank settings

- Added resolve_full_config() method that returns complete HindsightConfig
- Resolves hierarchy: Global (env) → Tenant → Bank
- No caching to support multi-server deployments (always fresh from DB)
- LLM provider pooling handles expensive operations separately

- Updated entire retain pipeline to pass resolved config through call chain
- memory_engine.py: Resolves config at top level where bank_id/context available
- orchestrator.py: Accepts and passes config to fact_extraction
- fact_extraction.py: Uses passed config instead of get_config()
- utils.py: Added optional config param for backward compatibility

- consolidator.py: Uses resolve_full_config() for enable_observations check
- memory_engine.py: Resolves config before triggering consolidation

- Renamed "Memory Bank" to "Bank Configuration" with tabs
- Combined Stats and Operations into "General" tab
- Consolidated Profile and Configuration into "Configuration" tab
- Moved Actions dropdown to page level (outside tabs)

- Created new component for managing bank-specific config
- Displays configurable fields: retain_chunk_size, retain_extraction_mode, etc.
- Edit via dialog with form validation
- Reset to defaults via AlertDialog confirmation
- Shows field IDs in monospace for clarity
- Visual separation with borders and hover effects

- Removed inline edit mode, switched to dialog-based editing
- Separate dialogs for Disposition and Mission editing
- Read-only display with clear edit buttons
- Removed duplicate stats cards and operations

- bank-stats-view.tsx: Overview statistics (memories, links, documents, pending ops)
- bank-operations-view.tsx: Background operations table with filtering

**Problem**: Consolidation always used global enable_observations, ignoring bank overrides
**Root Cause**: consolidator.py called get_config() instead of resolving bank-specific config
**Solution**: Pass resolved config through the entire pipeline

**Problem**: asyncpg returning JSONB as JSON string instead of parsed dict
**Solution**: Explicit JSON parsing in config_resolver.py with type checking

- All 19 API integration tests pass
- All 10 hierarchical config tests pass
- Retain operations work correctly with bank-specific config
- Consolidation respects bank-specific enable_observations setting

- Updated developer/configuration.md with type-safe config access pattern
- Added examples showing correct usage patterns
- Documented ConfigFieldAccessError and resolution methods

- get_config() now returns StaticConfigProxy (blocks configurable field access)
- Code accessing bank-configurable fields must use resolve_full_config()
- Clear migration path with helpful error messages

Fixes hierarchical configuration to be production-ready with proper type safety.

* refactor: remove LLM client pool and simplify config resolver

Since LLM config (provider, model, api_key) is now static and not
bank-configurable, the LLMClientPool is no longer needed.

Changes:
- Remove hindsight_api/llm_client_pool.py (no longer needed)
- Remove memory_engine._get_bank_llm_config() (dead code, never called)
- Simplify config_resolver.py by eliminating duplication between
  resolve_full_config() and get_bank_config()
- get_bank_config() now calls resolve_full_config() and filters results
- Remove outdated "LLM provider pooling" comments from docstrings

All tests pass (10 hierarchical config tests, 19 API integration tests)

* fix: update tests to use _get_raw_config() for configurable fields

Fixed test fixtures that were accessing configurable fields (like
enable_observations) from get_config(), which now raises
ConfigFieldAccessError due to type-safe config access.

Changes:
- test_consolidation.py: Changed enable_observations fixture to use
  _get_raw_config() instead of get_config()
- test_consolidation.py: Updated test_consolidation_returns_disabled_status
  to set bank config instead of mocking get_config()
- test_link_expansion_retrieval.py: Changed fixture to use _get_raw_config()
- test_observations.py: Changed disable_observations fixture to use
  _get_raw_config()
- Regenerated OpenAPI spec and clients

All 39 previously failing tests now pass.

* fix: add missing config parameter to test calls of extract_facts_from_text()

Fixed 45 test failures where tests were calling extract_facts_from_text()
without the new required config parameter.

Changes:
- Added config=_get_raw_config() to all extract_facts_from_text() calls
- Fixed test_main_module.py to patch _get_raw_config instead of get_config
- Updated 6 test files with 37 function call sites

All tests should now pass.

* fix: add missing config parameter to test_skip_podcast_meta_commentary

One more test was missing the config parameter for extract_facts_from_text().
2026-02-12 13:14:57 +01:00
Nicolò Boschi f9a8a8e01e fix: resolve based_on schema/serialization issues in reflect API (#348)
* fix: add default values to OpenAPI schema for default_factory fields

This commit fixes the OpenAPI schema to include default values for fields
using default_factory, which improves schema accuracy and client generation.

Changes:
1. Added FieldWithDefault() helper to inject default values into OpenAPI schema
2. Updated 14 fields using default_factory to include defaults in schema:
   - ReflectBasedOn.{memories, mental_models, directives}
   - ReflectTrace.{tool_calls, llm_calls}
   - All tags fields
   - All trigger fields
   - All include fields

3. Regenerated OpenAPI spec with proper defaults

4. Added tests to verify API returns correct format with empty banks

Note: This fixes the schema but doesn't change the v0.3.0 -> v0.4.0 breaking
change where based_on went from list to object. Clients should handle both
formats for backward compatibility.

* fix: remove client imports from API test

The test was failing in CI because it imported the client library
which isn't installed in the API test environment.

Changed to test only API JSON response format, not client parsing.
This is more appropriate for an API test anyway.

* test: add client tests for ReflectResponse parsing

Added comprehensive tests in hindsight-clients/python/tests to verify:
- v0.4.0+ format with empty based_on object
- v0.4.0+ format with null based_on
- v0.4.0+ format with populated facts
- v0.3.0 format (list) correctly fails validation
- Missing based_on field handling

These tests document the v0.3.0 -> v0.4.0 breaking change where
based_on changed from list to object.
2026-02-12 11:26:12 +01:00
Damien a713b68b1f Implement the vchord / pgvector support (#350)
* Implement the vchord / pgvector support

* feat(alembic): detect vector extension and create appropriate index
2026-02-12 10:47:07 +01:00
Nicolò Boschi 93ddd41621 feat: add reverse proxy support (#346)
* feat: add reverse proxy support

* improve

* improve

* improve

* improve

* improve

* fix: update integration test to use modern 'docker compose' command

- Replace 'docker-compose' with 'docker compose' (Docker Compose v2+)
- Add fallback to legacy docker-compose command for compatibility
- Fixes test failures on systems using Docker Compose plugin

* ci: trigger test rerun

* fix: make docker-compose detection more robust for CI

- Add get_docker_compose_command() to detect available command
- Use shutil.which() to check command availability
- Dynamically use correct command (docker compose vs docker-compose)
- Should work in both modern and legacy Docker environments

* fix: docker-compose networking in base path integration test

Fix connection refused error in test_reverse_proxy_simple_config by
handling host vs bridge networking modes correctly:

- Linux (host mode): nginx listens on 18080 directly, no port mapping
- Mac/Windows (bridge mode): nginx listens on 80, mapped to 18080

With host networking, port mappings in docker-compose don't work since
the container binds directly to the host's network namespace.
2026-02-12 10:09:53 +01:00
DK09876andClaude Opus 4.6 7ee229ba23 Fix MCP extra args rejection and bank ID resolution priority (#351)
* Fix MCP extra args rejection and bank ID resolution priority

Two fixes to the MCP middleware:

1. Strip unknown tool arguments: LLMs frequently add extra fields
   like "explanation" to tool calls. FastMCP's Pydantic TypeAdapter
   rejects these with "Unexpected keyword argument". The middleware
   now intercepts tools/call requests and removes unknown fields
   before they reach validation.

2. Bank ID resolution priority: Path now takes priority over header.
   Previously X-Bank-Id header was checked first, meaning /mcp/my-bank/
   with X-Bank-Id: other-bank would silently use other-bank in multi-bank
   mode. Now the URL path is authoritative — single-bank mode connections
   cannot be overridden by headers.

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

* docs: update MCP server docs with mental model tools and fixes

- Add all mental model tools (create, list, get, update, delete, refresh)
- Add list_banks and create_bank tool docs
- Document single-bank vs multi-bank modes
- Fix bank selection priority: path > header > default
- Add Accept header to curl example
- Add timestamp param to retain, max_tokens to recall

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-12 10:09:28 +01:00
Chris Latimer 29c0890f22 Copy page button on docs 2026-02-11 19:21:24 -07:00
Chris Bartholomew a1f22dabd2 Replace waitlist links with direct Hindsight Cloud signup URL (#349)
The waitlist is no longer needed. Update all references from
vectorize.io/hindsight/cloud to ui.hindsight.vectorize.io/signup
and change "request early access" language to "sign up".
2026-02-11 20:51:32 +01:00
Nicolò Boschi 60574ee08f fix: add trust_code env config (#347)
* fix: add trust_code env config

* doc
2026-02-11 17:06:59 +01:00
Nicolò Boschi 7d95a002c7 fix: improve model configuration for litellm gateway (#345)
* fix: improve model configuration for litellm gateway

* fix: add missing config imports for Cohere and LiteLLM providers

Add missing DEFAULT_* and ENV_* constants to cross_encoder.py and embeddings.py imports:
- DEFAULT_RERANKER_COHERE_MODEL
- DEFAULT_LITELLM_API_BASE
- DEFAULT_RERANKER_LITELLM_MODEL
- DEFAULT_EMBEDDINGS_COHERE_MODEL
- DEFAULT_EMBEDDINGS_LITELLM_MODEL
- ENV_RERANKER_COHERE_MODEL

This fixes NameError failures in test-api, test-hindsight-all, and test-upgrade CI jobs.
2026-02-11 11:24:26 +01:00
Chris Bartholomew 83ca669011 Add actual LLM token usage fields to RetainResult (#342)
* Add actual LLM token usage fields to RetainResult

RetainResult now carries llm_input_tokens, llm_output_tokens, and
llm_total_tokens populated from the engine's TokenUsage, so downstream
operation validator extensions can access actual LLM token counts.

* Test that RetainResult includes actual LLM token usage
2026-02-11 10:41:41 +01:00
DK09876andClaude Opus 4.6 e798979733 Harden MCP server: fix routing, validation, and usage metering (#341)
* fix: move mental model usage metering into engine for MCP support

Mental model validation hooks (validate_mental_model_get, validate_mental_model_refresh)
were only called in REST HTTP handlers, not in the engine. MCP tools call engine methods
directly, so usage metering was skipped entirely for MCP mental model operations.

Moved pre-validation and post-completion hooks into memory_engine.py (matching the
retain/recall/reflect pattern) and removed the duplicate code from http.py.

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

* fix: remove double validation from create_mental_model and add internal checks

- Remove pre-validation from create_mental_model since callers always call
  submit_async_refresh_mental_model next (which validates), preventing
  double credit checks
- Add is_internal checks to mental model metering validators (matching
  the existing pattern for recall/reflect) so background worker tasks
  skip billing

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

* fix: prevent 307 redirect on /mcp that breaks MCP tool discovery

Starlette's Mount class redirects /mcp to /mcp/ with a 307 Temporary
Redirect. Many MCP clients don't follow POST redirects, which causes
tool discovery to fail (0 tools discovered despite successful auth).

Add _MCPPathRewriteMiddleware that rewrites /mcp to /mcp/ at the ASGI
level before routing, preventing the redirect entirely. Both /mcp and
/mcp/ now work identically.

Add regression test test_mcp_no_trailing_slash_works to verify URLs
with and without trailing slashes discover tools correctly.

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

* harden MCP server for real-world usage

- Remove MCP_ENDPOINTS blocklist so banks named "sse"/"messages" route correctly
- Scope SSE body rewriting to text/event-stream responses only to prevent data corruption
- Add _validate_mental_model_inputs for name, source_query, max_tokens validation in MCP tools
- Improve "not found" error messages to include bank_id context
- Fix fragile tool count assertions (exact → minimum bounds)
- Add integration tests: tool execution, input validation, edge-case bank names
- Add unit tests for validation helper and tool-level validation

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

* refactor: replace Mount + rewrite middleware with wrapping middleware

Starlette's Mount class redirects /mcp -> /mcp/ with 307, which MCP clients
don't follow. Previously we patched this with _MCPPathRewriteMiddleware.

Now MCPMiddleware wraps the FastAPI app directly via add_middleware, intercepting
/mcp* requests before they reach Starlette's router. No Mount means no redirect.

- Remove _MCPPathRewriteMiddleware (no longer needed)
- Remove app.mount() call
- Add prefix parameter to MCPMiddleware
- Use app.add_middleware() for proper Starlette integration
- Simplify path stripping (just remove prefix, no mount/root_path handling)
- Update routing test to match current behavior (no MCP_ENDPOINTS blocklist)

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

* fix: update stale docstring referencing removed _MCPPathRewriteMiddleware

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-11 10:41:20 +01:00
Anton EvseevandClaude Opus 4.6 43f9a8bec2 feat(helm): TEI reranker and embedding as separate Deployments (#333)
Refactor TEI from sidecar (PR #333) to standalone Deployment+Service
pairs for independent scaling. Adds embedding support alongside reranker.

- New tei-reranker-deployment.yaml and tei-reranker-service.yaml
- New tei-embedding-deployment.yaml and tei-embedding-service.yaml
- Auto-inject RERANKER/EMBEDDINGS provider and URL env vars on API pod
- Config restructured under tei.reranker.* and tei.embedding.* in values
- Both disabled by default, opt-in via tei.reranker.enabled / tei.embedding.enabled

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-11 10:39:41 +01:00
DK09876andClaude Opus 4.6 f641b30d83 feat: add mental model CRUD tools to MCP server (#337)
* Add mental model CRUD tools to MCP server

Expose mental models (pinned reflections) as 6 new MCP tools:
- list_mental_models: List with optional tag filtering
- get_mental_model: Get by ID
- create_mental_model: Create with async content generation
- update_mental_model: Update name/source_query/tags
- delete_mental_model: Delete by ID
- refresh_mental_model: Re-run source query to update content

Both multi-bank (bank_id param) and single-bank modes supported,
following the same patterns as existing retain/recall/reflect tools.

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

* fix: include mental model tools in single-bank MCP mode and update tests

The single-bank mode tool set was hardcoded to only retain/recall/reflect,
excluding the new mental model tools. Updated all 3 test layers (unit,
routing, HTTP integration) to assert mental model tool exposure.

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

* fix: update extension test tool count for mental model tools

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

* fix: move mental model usage metering into engine for MCP support

Mental model validation hooks (validate_mental_model_get, validate_mental_model_refresh)
were only called in REST HTTP handlers, not in the engine. MCP tools call engine methods
directly, so usage metering was skipped entirely for MCP mental model operations.

Moved pre-validation and post-completion hooks into memory_engine.py (matching the
retain/recall/reflect pattern) and removed the duplicate code from http.py.

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

* fix: remove double validation from create_mental_model and add internal checks

- Remove pre-validation from create_mental_model since callers always call
  submit_async_refresh_mental_model next (which validates), preventing
  double credit checks
- Add is_internal checks to mental model metering validators (matching
  the existing pattern for recall/reflect) so background worker tasks
  skip billing

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-10 22:40:43 +01:00
Chris Bartholomew 90be7c6829 Add user_initiated flag to RequestContext for async task attribution (#338)
Async batch retain tasks need internal=True to bypass extension auth
(worker has no API key), but extensions also need to know the operation
originated from a user request. The new user_initiated flag on
RequestContext allows extensions to distinguish user-initiated async
operations from truly internal system operations like consolidation.
2026-02-10 22:37:42 +01:00
Nicolò Boschi 6eec83b20d fix: include tiktoken in slim image (#336) 2026-02-10 17:26:38 +01:00
Nicolò Boschi dd1e0986a1 feat: add docs skill (#335)
* feat: add docs skill

* feat: add docs skill
2026-02-10 14:41:51 +01:00
Nicolò Boschi 69dec8ec34 feat: add otel traceability (#330)
* feat: add comprehensive OpenTelemetry tracing

- Add tool execution spans for reflect operations
- Add tool call information (names, params) to spans
- Change verification scope from 'test' to 'verification'
- Add hindsight.reflect_generation span for done() processing
- Implement no-op tracer for improved code readability
- Update documentation for OTEL configuration
- Resolve merge conflicts from rebase

* fix: properly serialize Pydantic models in span recording

- Add _serialize_for_span() helper to handle Pydantic models
- Update all providers to use the helper function
- Fixes test failures with 'Object of type X is not JSON serializable'

* feat: add Grafana LGTM stack for unified local observability

Add Grafana LGTM (Loki, Grafana, Tempo, Mimir) as the recommended
local development observability stack. This provides traces, metrics,
and logs in a single Docker container instead of separate tools.

Changes:
- Add scripts/dev/grafana/ with docker-compose and README
- Add scripts/dev/start-grafana.sh startup script
- Update .env.example to reference Grafana LGTM
- Update configuration docs to emphasize Grafana LGTM as primary option
- Reorder OTLP backend list to show Grafana LGTM first

Benefits:
- Single container vs multiple separate tools (Jaeger, SigNoz, etc.)
- ~515MB image with full observability stack
- Compatible with existing OTLP configuration
- Simpler local development setup

* chore: remove SigNoz scripts and references

Remove SigNoz observability stack in favor of Grafana LGTM as the
sole recommended local development tracing solution.

Changes:
- Delete scripts/dev/signoz/ directory and all SigNoz configurations
- Delete scripts/dev/start-signoz.sh startup script
- Remove SigNoz references from .env.example
- Remove SigNoz from OTLP backends list in configuration docs

Grafana LGTM provides the same capabilities (traces, metrics, logs)
in a simpler single-container setup.

* feat: add consolidation span hierarchy for tracing

Add parent-child span structure for consolidation operations:
- hindsight.consolidation: Parent span for each memory being processed
- hindsight.consolidation_recall: Child span for finding related observations
- LLM call span: Automatically created by LLM provider (scope="consolidation")

This enables detailed timing breakdown in Grafana Tempo:
- Total consolidation time per memory
- Time spent in recall
- Time spent in LLM call
- Time spent executing actions (create/update)

All consolidation tests pass (31/31).

* feat: add Prometheus metrics and GenAI dashboard to Grafana stack

Add comprehensive metrics and dashboarding to the Grafana LGTM stack:

Metrics Collection:
- Configure Prometheus to scrape Hindsight API /metrics endpoint
- Scrape interval: 10 seconds
- Targets hindsight-api on host.docker.internal:8888

GenAI Dashboard:
- Pre-configured dashboard with 6 panels:
  - LLM call rate (by provider/model)
  - LLM call duration (p50/p95 by scope)
  - Token usage - input tokens/sec by scope
  - Token usage - output tokens/sec by scope
  - Operations rate (retain/recall/reflect/consolidation)
  - Operation duration p95 by operation type

Configuration:
- Mount prometheus.yml for metrics scraping
- Mount dashboards directory for auto-provisioning
- Add host.docker.internal mapping for container->host access
- Dashboard provisioning with auto-reload every 10s

Documentation:
- Updated README with metrics viewing instructions
- Added PromQL query examples
- Documented dashboard access and navigation

This provides full observability: traces (Tempo) + metrics (Prometheus/Mimir) + dashboards (Grafana)

* refactor: merge Grafana setup into existing monitoring stack

Consolidate the separate scripts/dev/grafana/ setup into the existing
scripts/dev/monitoring/ stack, using Grafana LGTM (Loki, Grafana, Tempo, Mimir).

Changes:
- Remove separate scripts/dev/grafana/ directory and start-grafana.sh
- Rewrite scripts/dev/monitoring/start.sh to use Docker + Grafana LGTM
  (was: download native Prometheus/Grafana binaries)
- Add docker-compose.yaml for Grafana LGTM container
- Add prometheus.yml for scraping Hindsight API metrics
- Mount existing dashboards from monitoring/grafana/dashboards/
- Add comprehensive README.md

Benefits:
- Single unified monitoring command: ./scripts/dev/start-monitoring.sh
- Uses existing dashboard files (hindsight-operations, hindsight-llm, hindsight-api-service)
- Simpler setup: Docker-based vs downloading/running native binaries
- Full observability: traces + metrics + logs + dashboards in one container
- Standard ports: Grafana on 3000, OTLP on 4317/4318

Architecture:
- Grafana LGTM container (~515MB) provides all components
- Dashboards auto-provisioned from monitoring/grafana/dashboards/
- Prometheus scrapes host.docker.internal:8888/metrics
- Shared hindsight-network for future service-to-service tracing

* fix: run monitoring stack in foreground for easy Ctrl+C stop

Change docker-compose from detached (-d) to foreground mode.
Users can now stop the stack with Ctrl+C instead of needing
to run docker-compose down separately.

* fix: remove invalid home dashboard path and obsolete version field

- Remove GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH environment variable
  (was pointing to wrong path causing 'Failed to load home dashboard' error)
- Remove obsolete 'version' field from docker-compose.yaml
  (docker-compose v2+ doesn't require version field)

* fix: load Hindsight dashboards in Grafana LGTM

Mount Hindsight dashboard JSON files and custom provisioning config
to make dashboards visible in Grafana.

Changes:
- Mount hindsight-operations.json, hindsight-llm.json, hindsight-api-service.json to /otel-lgtm/
- Create grafana-dashboards.yaml with all dashboard providers (default + Hindsight)
- Mount custom provisioning config to override LGTM default

All 3 Hindsight dashboards now appear in Grafana UI with metrics
from Prometheus scraping the Hindsight API /metrics endpoint.

* fix: configure Prometheus to scrape Hindsight API metrics

Update prometheus.yml to include both OTLP receiver config (from LGTM)
and scrape_configs for pulling metrics from Hindsight API.

Changes:
- Mount prometheus.yml to /otel-lgtm/prometheus.yaml (where LGTM reads it)
- Add scrape_configs section to pull from host.docker.internal:8888/metrics
- Keep OTLP receiver configuration for trace metrics
- Set scrape_interval to 5s

Verified: Prometheus now successfully scrapes hindsight_llm_calls_total
and other Hindsight metrics. Dashboards now show live data!

* feat: add comprehensive tracing for recall and improve reflect/mental_model_refresh spans

- Add recall operation tracing with parent-child span hierarchy
  - Parent: hindsight.recall with attributes (bank_id, query, fact_types, etc.)
  - Children: recall_embedding, recall_retrieval, recall_fusion, recall_rerank
  - Fixed context propagation using start_as_current_span()

- Improve reflect tracing spans
  - Remove reflect_generation spans, use reflect instead
  - Change done() tool processing to hindsight.reflect_tool_call

- Fix mental_model_refresh span nesting
  - Add _skip_span parameter to reflect_async to avoid duplicate hindsight.reflect spans
  - Mental model refresh now has clean span hierarchy without nested reflect parent

- Add comprehensive tracing verification tests
  - Test span hierarchy and attributes for all operations
  - Verify parent-child relationships
  - 5 passing tests covering recall, reflect, consolidation, and mental_model_refresh

* refactor: remove redundant is_tracing_enabled() checks

- Remove all is_tracing_enabled() conditional checks before tracing calls
- NoOpTracer/NoOpSpan handle disabled tracing automatically
- Simplify code by always calling tracer methods directly
- Fix NoOpTracer.start_as_current_span() to yield NoOpSpan instead of None

Changes:
- memory_engine.py: Remove 5 is_tracing_enabled checks in recall spans
- agent.py: Remove 2 is_tracing_enabled checks in reflect tool spans
- tracing.py: Fix NoOpTracer context manager to yield proper NoOpSpan

This eliminates ~50 lines of redundant conditional code while maintaining
identical behavior.

* docs: simplify distributed tracing section in monitoring.md

- Make tracing documentation more concise
- Focus on span hierarchy and attributes
- Remove verbose troubleshooting and performance sections
- Keep configuration.md for env vars only
2026-02-10 12:20:48 +01:00
DK09876andClaude Opus 4.6 888b50de12 Fix MCP operations not tracked for usage metering (#334)
MCP middleware was discarding tenant_id and api_key_id after authentication.
The authenticate_mcp() call mutated a RequestContext with these fields, but
tools later created a fresh RequestContext without them. This caused
UsageMeteringValidator to see tenant_id="unknown" and skip billing entirely.

Propagate tenant_id and api_key_id via ContextVars (same pattern as bank_id
and api_key) so the RequestContext passed to the memory engine has the full
auth context needed for usage tracking.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-10 09:38:15 +01:00
Dewaldt HuysamenandClaude Opus 4.6 fb7be3eced feat(openclaw): add excludeProviders config to skip recall/retain for specific providers (#332)
Adds an `excludeProviders` option to the OpenClaw plugin config that allows
users to specify message providers (e.g. 'telegram', 'discord') to exclude
from Hindsight memory recall and retention.

Closes #331

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-09 21:28:29 +01:00
Chris Latimer 4499254f6d Memory conflict blog post 2026-02-09 11:18:30 -07:00
Anatolii Lapytskyi 9943957fb7 feat(helm): add PDB and per-component affinity support (#327)
Add PodDisruptionBudget templates for api, control plane, and worker
(disabled by default). Support per-component affinity overrides with
backward-compatible global affinity fallback.
2026-02-09 18:03:37 +01:00
Nicolò Boschi 03f47e29c8 fix(helm): gke overriding HINDSIGHT_API_PORT (#328) 2026-02-09 17:59:14 +01:00
Nicolò Boschi 1240b82629 0.4.10 changelog 2026-02-09 12:08:47 +01:00
Nicolò Boschi 08f1cda3bf Release v0.4.10
- Update version to 0.4.10 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, 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
- Helm chart
- Sync documentation to version-0.4
2026-02-09 11:44:20 +01:00
Nicolò Boschi a3a9d7b37d doc: prepare doc for 0.4.10 (#325)
* doc: prepare doc for 0.4.10

* fixe

* ci
2026-02-09 11:42:37 +01:00
Nicolò Boschi c2607d7699 fix(helm): improve appVersion usage (#326) 2026-02-09 11:35:08 +01:00
Jerry HenleyandClaude Opus 4.5 e99ee0f243 Add Supabase tenant extension as built-in (#267)
Move the Supabase tenant extension into the hindsight-api package so users
can enable it with just an environment variable — no file copying or Docker
image modifications needed.

Key improvements over the original submission:
- JWKS-based local JWT verification (no network call per request) with
  automatic fallback to /auth/v1/user for legacy HS256 projects
- Service key is now optional (only needed for HS256 or health checks)
- UUID validation on user IDs before schema name construction
- Schema prefix validation against Postgres identifier rules
- Key rotation handling with automatic JWKS cache refresh
- Proper logging via Python logging module
- Tenant extension lifecycle hooks (on_startup/on_shutdown) wired into
  the server lifespan
- Public tenant_extension property on MemoryEngine
- 54 unit tests covering both verification modes, cache behavior, error
  paths, and the extension loader
- README updated to reflect JWKS-first architecture

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-09 10:16:47 +01:00
Van Vuong Ngo c568094b8c fix: do not log db user/password (#312)
* fix: security vulnerability - exposed sensitve database credentials in logs

* add comment

* fix: mask credentials of the postgeSQL connection string
2026-02-09 10:15:03 +01:00
Van Vuong Ngo 5179d5f77d feat: add docker-compose example (#313)
* feat: add docker-compose example

* fix T&V

* doc: add how to quick start hindsight with docker-compose

* chore: fix typo
2026-02-09 10:14:21 +01:00
Anton EvseevandClaude Opus 4.6 981cf6057f fix(openclaw): prevent memory wipe on every session (#323)
Use unique document_id per conversation (sessionKey + timestamp) instead
of static sessionKey. The backend CASCADE-deletes old memories when the
same document_id is reused, causing all prior facts to be lost.

Also:
- Universal envelope stripping for all channels (was Telegram-only)
- Prefer rawMessage over prompt for cleaner recall queries
- Increase recall max_tokens from 512 to 2048

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-09 10:13:19 +01:00
Nicolò Boschi d90588b3e1 feat: improve mcp tools based on endpoint (#318)
* feat: improve mcp tools based on endpoint

* feat: improve mcp tools based on endpoint

* test: add integration test for MCP endpoint routing

- Add test_mcp_endpoint_routing.py to verify single-bank vs multi-bank tool exposure
- Verifies /mcp/ exposes all tools with bank_id parameters
- Verifies /mcp/{bank_id}/ only exposes scoped tools without bank_id parameters
- Regression test for issue #317

Related: #317, #318

* test: use StreamableHTTP client for MCP endpoint routing test

Replace httpx AsyncClient SSE parsing with proper MCP StreamableHTTP
client. This correctly tests the MCP server using the actual protocol
that clients will use.

Fixes #317
2026-02-08 09:28:59 +01:00
Van Vuong Ngo d0f67c9f8b doc: improve Node.js client example (#320)
Fix doc to increase the developer experience...

- if the code is intended to be a CommonJS by using `require` then you have to wrap `await` calls in an async function
- calling `client.recall` with using the results
2026-02-07 10:02:41 +01:00
DK09876andClaude Opus 4.5 fedfb494ee feat: add TenantExtension auth to MCP endpoint (#286)
* feat: add TenantExtension auth to MCP endpoint

Replace static MCP_AUTH_TOKEN check with TenantExtension authentication,
making MCP use the same auth path as REST API.

- MCPMiddleware now calls tenant_extension.authenticate()
- Sets _current_schema from TenantContext for multi-tenant isolation
- Returns 401 on AuthenticationError (same as REST API)
- DefaultTenantExtension: no auth (local dev)
- ApiKeyTenantExtension: validates against env var
- CloudTenantExtension: HMAC + DB lookup (production)

Adds tests for middleware auth rejection, acceptance, and schema routing.

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

* Address PR review: backwards compatibility for MCP auth

- Keep MCP_AUTH_TOKEN env var for legacy MCP servers
- Add authenticate_mcp() method to TenantExtension base class
  - Default implementation calls authenticate()
  - Extensions can override to opt-out of MCP auth
- Add mcp_auth_disabled config option to ApiKeyTenantExtension
  - Set HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true to skip MCP auth
- Remove CloudTenantExtension from public docstring
- Add tests for legacy auth token and mcp_auth_disabled flag
- Update MCP docs with new auth configuration

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

* Add search_docs MCP tool for documentation search

Implements a new MCP tool that searches Hindsight documentation using
Vectorize RAG pipelines. The tool supports:
- Searching core (OSS) docs, cloud docs, or both
- Configurable number of results (1-10)
- Returns ranked results with URLs, similarity scores, and text snippets

New environment variables:
- HINDSIGHT_API_VECTORIZE_ORG_ID
- HINDSIGHT_API_VECTORIZE_API_TOKEN
- HINDSIGHT_API_VECTORIZE_CORE_PIPELINE_ID
- HINDSIGHT_API_VECTORIZE_CLOUD_PIPELINE_ID
- HINDSIGHT_API_VECTORIZE_API_BASE_URL

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

* Add documentation for search_docs MCP tool

- Add Vectorize environment variables to configuration.md
- Add search_docs tool to MCP server available tools
- Add reflect tool documentation (was missing)

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

* Add tests for search_docs MCP tool

Tests cover:
- DocsSource enum values and parsing
- _clean_text HTML stripping helper
- _search_vectorize_pipeline with mocked httpx
- Tool registration and function execution
- Source filtering (core/cloud/all)
- Result sorting by similarity
- Error handling for pipeline failures
- HTML cleaning in results
- Invalid source defaulting to 'all'

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

* Move search_docs to hindsight-cloud, add MCPExtension pattern

- Add MCPExtension base class for registering additional MCP tools
- Load MCPExtension in create_mcp_server when configured
- Remove search_docs tool (moved to hindsight-cloud CloudMCPExtension)
- Remove Vectorize config from hindsight-core
- Add tests for MCPExtension pattern
- Update docs to remove search_docs references

The MCPExtension pattern allows cloud (or any extension package) to
register additional MCP tools via:
  HINDSIGHT_API_MCP_EXTENSION=package.module:ExtensionClass

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

* Address PR review feedback

- Remove CloudTenantExtension mention from MCPMiddleware docstring
- Fix docs: clarify that ApiKeyTenantExtension must be explicitly enabled
- Revert changes to versioned docs (0.3 and 0.4) - synced automatically on release

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

* Format mcp.py line length

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

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-06 12:28:05 -07:00
Nicolò Boschi 0430588e32 fix: hindsight-embed profiles are not loaded correctly (#316)
* fix: hindsight-embed profiles are not loaded correctly

* fix: hindsight-embed profiles are not loaded correctly
2026-02-06 17:13:54 +01:00
Nicolò Boschi 2af0e08dba doc: update claude-code usage terms (#315)
* doc: update claude-code usage terms

* doc: update claude-code usage terms

* doc: update claude-code usage terms
2026-02-06 16:53:12 +01:00
Nicolò Boschi f64817814a feat: slim docker distro (#314)
* feat: slim docker distro

* feat: slim docker distro

* push
2026-02-06 15:00:24 +01:00
Nicolò Boschi fa4cbf7ef2 fix(ci): resolve flaky test failures in api tests (#311)
* fix: resolve flaky test failures in api tests

Fixed 4 critical test failures that revealed real production issues:

1. test_sensory_dimension_preservation: Updated fact extraction prompt to
   clarify that sensory/emotional details ARE important to remember even if
   they seem small. The "6 months" filter was too aggressive and causing LLM
   to skip valid observations.

2. test_llm_provider_api_methods[openai-gpt-5]: Increased max_completion_tokens
   from 200 to 500 for tool calling tests. Non-nano models like gpt-5 were
   hitting token limits before completing tool calls.

3. test_reflect_chinese_content: Added prominent anti-hallucination warnings
   to reflect agent prompts. LLM was making up names (张飞, 张三, 赵信) instead
   of using the actual names from retrieved facts (张伟, 李明). Added explicit
   instructions at the very top of system prompts to NEVER fabricate names and
   to use EXACT names from retrieved data.

4. test_llm_provider_api_methods[groq-openai/gpt-oss-120b]: Skipped this model
   in tests as it consistently times out (>120s) due to slow Groq API responses.

All changes address real production code issues, not test flakiness.

* refactor: simplify anti-hallucination prompts and document groq issue

- Removed verbose anti-hallucination section with emojis/borders
- Moved core anti-hallucination rules to top of system prompts in clean format
- Kept essential rules: NEVER make up names/entities, ONLY use tool results
- Removed language override rule (directives can control language)
- Removed specific example (too prescriptive)

Groq gpt-oss-120b:
- Documented that API hangs on receive_response_body (Groq API bug)
- Skip is justified: headers received successfully but body never arrives
- This is gpt-oss-120b specific, not a general Groq provider issue

* fix: remove groq skip as requested

- Groq gpt-oss-120b may be slow but should not be skipped
- test_extensions.py::test_reflect_pre_hook_receives_all_parameters passes locally (50s)
- CI timeout appears to be from LLM producing malformed tool names (done<|channel|>commentary)
  which triggers retries and slows down the test

* fix: ensure unique timestamps for facts across different documents

The time offset logic was resetting to 0 for each new content_index, causing
all facts from different documents/conversations to have the same base timestamp
even when they should be distinguishable.

Changed to use absolute position (i) instead of relative position (i - content_fact_start)
so that:
- Content 0, Fact 0: offset = 0s
- Content 0, Fact 1: offset = 10s
- Content 1, Fact 0: offset = 20s (now unique!)
- Content 1, Fact 1: offset = 30s

This ensures facts from different batch-retained documents have unique timestamps
for proper temporal ordering in retrieval.

Fixes test_fact_ordering.py::test_multiple_documents_ordering

* fix: increase timeout for test_llm_provider_api_methods to 300s

The groq gpt-oss-120b model can be very slow (API hangs on response body),
taking >120s to complete. Increased timeout to 300s to prevent CI flakiness
while still catching real hangs.

This affects all provider/model combinations in the test, not just Groq,
but most complete in <30s so the increased timeout won't affect them.

* fix: skip structured output for groq gpt-oss-120b, reinforce date extraction

1. Groq gpt-oss-120b doesn't support response_format (structured output)
   - Returns 400 'json_validate_failed' error
   - Retries with exponential backoff caused 300s timeout
   - Skip test #3 (structured output) for this model

2. Reinforce date extraction prompt
   - Add CRITICAL instruction to extract absolute dates like 'March 15, 2024'
   - Helps prevent flaky test_extract_facts_with_absolute_dates failures
2026-02-06 13:56:59 +01:00
Nicolò Boschi 2109397028 ci: ensure python 3.14 compatibility (#310) 2026-02-06 10:50:45 +01:00
Nicolò Boschi c4ef090a20 feat: support markdown in reflect and mental models (#307)
* feat: support markdown in reflect and mental models

* chore: regenerate clients and OpenAPI spec with markdown field descriptions
2026-02-06 10:49:13 +01:00
Dewaldt Huysamen 96f487213c fix(openclaw): remove format:uri to fix ajv warning (#309)
Remove `format: "uri"` from hindsightApiUrl schema property.

OpenClaw's schema validator uses Ajv without ajv-formats loaded, causing:
  unknown format "uri" ignored in schema at path "#/properties/hindsightApiUrl"

The URI validation isn't critical since invalid URLs will fail at connection time.
This removes the warning without affecting functionality.
2026-02-06 10:45:43 +01:00
Nicolò Boschi 0d8d805832 ci: ensure backwards/forward compatibility of the API (#306) 2026-02-05 18:43:05 +01:00
Nicolò Boschi 1cd836229b 0.4.9 changelog 2026-02-05 17:11:52 +01:00
Nicolò Boschi 90ad003c46 docs: add AI SDK integration documentation (#304)
* docs: add AI SDK integration documentation

- Add comprehensive AI SDK documentation in docs/sdks/integrations/ai-sdk.md
  - Detailed description of all three memory tools (retain, recall, reflect)
  - Complete parameter documentation and return types
  - Advanced usage patterns (streaming, multi-user, ToolLoopAgent)
  - HTTP client example for zero-dependency usage
  - TypeScript types and API reference
  - Best practices and system prompt examples

- Update AI SDK README to brief quickstart with link to docs
  - Single source of truth: comprehensive docs in documentation site
  - README now focuses on quick setup and points to full docs
  - Maintains features list and basic example for npm page

* fix
2026-02-05 17:05:18 +01:00
Nicolò Boschi 278718dd84 fix: tagged directives should be applied to tagged mental models (#303)
* fix: tagged directives should be applied to tagged mental models

* test: add unit test for based_on structure

Verify that reflect returns the correct based_on structure with:
- directives as dicts (id, name, content) in based_on.directives
- mental models as MemoryFact objects in based_on.mental-models
- memories separated properly

This ensures directives and mental models are not mixed together
in the API response.
2026-02-05 13:22:56 +01:00
Hayden Rear 093ecff48d fixed cast error (#300)
Signed-off-by: hayden.rear <[email protected]>
2026-02-05 09:05:26 +01:00
Nicolò Boschi 85b9074f43 Release v0.4.9
- Update version to 0.4.9 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, 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
- Helm chart
- Sync documentation to version-0.4
2026-02-04 20:27:05 +01:00
Nicolò Boschi 7e339e1677 feat: ai sdk integration (#299)
* feat: ai sdk integration

* more fixes

* fix(security): mental model refresh tag-based security

- Mental model refresh now passes tags with all_strict matching
- Consolidation only triggers refresh for mental models with matching tags
- Consolidation filters related observations by tags (all_strict)
- Added tests to verify tag-based security boundaries
- Updated OpenAPI spec to include tags and text_preview in list_documents
- Added tags column to documents UI table

* chore: regenerate OpenAPI spec after rebase

* fix: improve consolidation prompt for contradiction handling and mental model refresh security

- Enhanced consolidation prompt to be more explicit about capturing temporal changes in contradictions
- Fixed mental model refresh security: tagged memories now only trigger refresh of mental models with matching tags
- Added stricter tag filtering to prevent cross-scope mental model refreshes

Fixes test_consolidation_merges_contradictions by improving LLM instructions to use temporal markers like "used to X, now Y" when merging contradictory facts.

Note: test_refresh_with_tags_only_accesses_same_tagged_models still needs investigation - REFLECT operation may need additional tag filtering.

* fix: mental model refresh security - proper tag filtering in search

Fixed tool_search_mental_models to properly handle all_strict tag matching mode by using the centralized build_tags_where_clause function. Previously, the function only handled "all" vs "any" modes and always included untagged mental models when using non-"all" modes.

This ensures that when a tagged mental model is refreshed with all_strict matching, it cannot access untagged mental models, preventing cross-scope information leakage.

Fixes test_refresh_with_tags_only_accesses_same_tagged_models.

Note: test_sensory_dimension_preservation is failing but this is a pre-existing issue on main branch - the LLM model (gpt-oss-20b) is not extracting facts from sensory text. Not related to security changes.

* chore: apply formatting from pre-commit hook

* fix: allow untagged mental models to be refreshed by any consolidation

Untagged mental models are considered "global" and should be refreshed
by any consolidation, regardless of whether tagged or untagged memories
were consolidated. This maintains security boundaries while allowing
global mental models to stay fresh.

When tagged memories are consolidated:
- Refresh mental models with matching tags (security boundary)
- Also refresh untagged mental models (they're global)
- DO NOT refresh mental models with different tags

When untagged memories are consolidated:
- Only refresh untagged mental models
- DO NOT refresh tagged mental models (security boundary)

Fixes test_consolidation_only_refreshes_matching_tagged_models.
2026-02-04 20:25:59 +01:00
Chris Bartholomew dd621a69d0 Fix recall endpoint timeout handling and add query length validation (#298)
- Add MAX_QUERY_TOKENS (500) limit to prevent expensive operations on oversized queries
- Return 400 error with clear message when query exceeds token limit
- Add specific handling for TimeoutError to return 504 Gateway Timeout instead of 500
- Improves error messages for timeout scenarios
2026-02-04 17:26:25 +01:00
Nicolò Boschi 7097716204 feat: improve mental models ux on control plane (#297)
* feat: improve mental models ux on control plane

* feat: improve mental models ux on control plane

* gen

* feat(cli): add --id flag to mental model create command

* fix(cli): revert unused variable underscore prefix that breaks compilation

The underscore prefix on stdout/stderr variables was added to suppress
warnings, but these variables are actually used in assert messages,
causing compilation errors. Reverting to original names.
2026-02-04 15:49:03 +01:00
Nicolò Boschi d3302c95b9 feat: HindsightEmbedded python SDK (#293)
* feat: HindsightEmbedded python SDK

* feat: HindsightEmbedded python SDK

* fixes

* improve

* ci

* improvemnts

* fix test

* fix test

* fix: update tests to use Pydantic model attributes instead of dict access

- Fixed test_server_integration.py to access Pydantic model attributes directly
- Changed dict-style access (response["field"]) to attribute access (response.field)
- Fixed .get() calls on Pydantic models
- Updated recall() calls to access .results attribute
- Updated reflect() calls to access .text attribute
- Fixed test_list_banks to use namespace API instead of deleted default_api
- Fixed attribute shadowing in HindsightClient wrapper (renamed _*_api to _*_namespace)

* fix: add list() method to BanksAPI namespace

* fix: remove leftover async cleanup code from test_list_banks

* docs: remove Advanced Configuration section from embed.md
2026-02-04 14:41:19 +01:00
Nicolò Boschi 665877bb01 feat(hindsight-litellm): support streaming on wrappers (#296) 2026-02-04 13:59:29 +01:00
Nicolò Boschi a43d208e93 fix: improve claude code and codex for /reflect (#285)
* fix: improve mental models response

* fix: improve mental models response

* fix

* improvemnts

* fix test
2026-02-04 13:34:45 +01:00
Nicolò Boschi 34d9188e13 fix: hide hf logging (#295) 2026-02-04 13:12:30 +01:00
Anton EvseevandClaude Opus 4.5 9a776e9f58 feat(openclaw): add dynamic per-channel memory banks (#290)
Add support for per-channel memory isolation in OpenClaw plugin.
Each channel (Slack, Telegram, Discord, etc.) gets its own memory bank,
preventing memory leakage between channels.

Changes:
- Add deriveBankId() to create channel-specific bank IDs
- Bank ID format: {messageProvider}-{channelId} (e.g., slack-C123)
- Add getClientForContext() for context-aware client access
- Update hook handlers to (event, ctx) signature
- Set bank mission on first use per dynamic bank
- Add dynamicBankId and bankIdPrefix config options

Configuration:
- dynamicBankId: true (default) enables per-channel isolation
- bankIdPrefix: optional prefix for namespacing (e.g., "prod")

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-02-04 11:38:14 +01:00
Anton Evseev d02affd8f2 docs: expand external API configuration section for OpenClaw (#294)
- Add plugin configuration example with hindsightApiUrl and hindsightApiToken
- Document behavior differences when using external API mode
- Add verification steps and log messages to expect
- Explain use cases (shared memory, production, team environments)
2026-02-04 11:23:30 +01:00
Anton Evseev 6b346925e2 feat(openclaw): add external Hindsight API support (#289)
Add support for connecting to an external Hindsight API instead of
starting a local daemon. This enables:
- Shared memory across multiple OpenClaw instances
- Centralized Hindsight deployment (e.g., on GKE)
- Reduced resource usage (no local daemon per instance)

Configuration:
- HINDSIGHT_EMBED_API_URL env var or hindsightApiUrl in plugin config
- HINDSIGHT_EMBED_API_TOKEN env var or hindsightApiToken for auth

When external API is configured:
- Skip local daemon startup
- Health check external API on startup
- Pass API URL/token to CLI commands via env vars

Falls back to local daemon mode when not configured.
2026-02-04 10:24:33 +01:00
Anton Evseev 63e2964a4c fix(openclaw): improve shell argument escaping (#288)
Add comprehensive shell argument escaping using POSIX single-quote method.

Problem:
- Current code only escapes single quotes inline
- Other shell metacharacters ($, `, !, etc.) not explicitly handled
- Document ID in retain() was not escaped

Solution:
- Add exported escapeShellArg() function using POSIX single-quote escaping
- Replace inline escaping with shared function
- Escape document ID in retain()
- Add comprehensive tests (17 test cases) covering all shell-special chars

The POSIX single-quote method handles ALL shell metacharacters by wrapping
in single quotes (which protect everything except single quotes themselves)
and escaping any embedded single quotes with '\'' sequence.
2026-02-04 10:22:52 +01:00
619 changed files with 97023 additions and 7095 deletions
+27
View File
@@ -31,10 +31,22 @@ HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_LOG_LEVEL=info
# Base Path / Reverse Proxy Support (Optional)
# Set these when deploying behind a reverse proxy with path-based routing
# Example: To deploy at example.com/hindsight/, set both to "/hindsight"
# HINDSIGHT_API_BASE_PATH=/hindsight
# NEXT_PUBLIC_BASE_PATH=/hindsight
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
# HINDSIGHT_API_VECTOR_EXTENSION=pgvector
# For Azure PostgreSQL with DiskANN:
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
@@ -50,3 +62,18 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# Observability & Tracing (Optional - disabled by default)
# Enable OpenTelemetry tracing for LLM calls (GenAI semantic conventions)
# HINDSIGHT_API_OTEL_TRACES_ENABLED=true
#
# Local development with Grafana LGTM stack (recommended - see scripts/dev/grafana/README.md)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
#
# Cloud backends (Grafana Cloud, Langfuse, DataDog, etc.)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=https://your-backend-url
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-token"
#
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
+82 -2
View File
@@ -188,6 +188,55 @@ jobs:
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-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -291,6 +340,7 @@ jobs:
retention-days: 1
release-docker-images:
name: Release Docker (${{ matrix.image_name }}${{ matrix.tag_suffix }})
runs-on: ubuntu-latest
permissions:
contents: read
@@ -300,10 +350,28 @@ jobs:
include:
- target: api-only
image_name: hindsight-api
tag_suffix: ""
build_args: ""
- target: api-only
image_name: hindsight-api
tag_suffix: "-slim"
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
- target: cp-only
image_name: hindsight-control-plane
tag_suffix: ""
build_args: ""
- target: standalone
image_name: hindsight
tag_suffix: ""
build_args: ""
- target: standalone
image_name: hindsight
tag_suffix: "-slim"
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
steps:
- uses: actions/checkout@v4
@@ -341,6 +409,9 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
flavor: |
latest=auto
suffix=${{ matrix.tag_suffix }}
tags: |
type=semver,pattern={{version}},value=${{ steps.get_version.outputs.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.get_version.outputs.VERSION }}
@@ -366,7 +437,7 @@ jobs:
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
# run: ./docker/test-image.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
# Build multi-platform and push to release tags
- name: Build and push release images
@@ -375,6 +446,7 @@ jobs:
context: .
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
build-args: ${{ matrix.build_args }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
@@ -415,7 +487,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -444,6 +516,12 @@ jobs:
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 Control Plane
uses: actions/download-artifact@v4
with:
@@ -487,6 +565,8 @@ jobs:
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
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+368 -61
View File
@@ -9,42 +9,11 @@ concurrency:
cancel-in-progress: true
jobs:
build-python-packages:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: hindsight-all
path: hindsight
- name: hindsight-api
path: hindsight-api
- name: hindsight-client
path: hindsight-clients/python
- name: hindsight-embed
path: hindsight-embed
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build ${{ matrix.name }}
working-directory: ./${{ matrix.path }}
run: uv build
build-api-python-versions:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v4
@@ -105,6 +74,29 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: npm run build
build-ai-sdk-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/ai-sdk
run: npm test
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
build-control-plane:
runs-on: ubuntu-latest
@@ -285,16 +277,35 @@ jobs:
run: helm lint helm/hindsight
build-docker-images:
name: Build Docker (${{ matrix.name }})
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: api-only
name: api
variant: full
build_args: ""
- target: api-only
name: api-slim
variant: slim
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
- target: cp-only
name: control-plane
variant: full
build_args: ""
- target: standalone
name: standalone
variant: full
build_args: ""
- target: standalone
name: standalone-slim
variant: slim
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
steps:
- uses: actions/checkout@v4
@@ -313,20 +324,31 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ${{ matrix.name }} image
- name: Build ${{ matrix.name }} image (${{ matrix.variant }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
build-args: ${{ matrix.build_args }}
push: false
load: false
load: ${{ matrix.variant == 'slim' }}
tags: hindsight-${{ matrix.name }}:test
# Removed GitHub Actions cache (type=gha) - it frequently returns 502 errors
# causing buildx to fail with "failed to parse error response 502"
# Build will be slower but more reliable
# TODO: Re-enable smoke test when disk space issue is resolved
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
# Only test slim variants to save disk space (they're much smaller)
# Slim variants require external embedding providers
- name: Smoke test - verify container starts
if: matrix.variant == 'slim'
env:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_EMBEDDINGS_PROVIDER: openai
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
HINDSIGHT_API_RERANKER_PROVIDER: cohere
HINDSIGHT_API_COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
run: ./docker/test-image.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
test-api:
runs-on: ubuntu-latest
@@ -626,6 +648,185 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-go-client:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache-dependency-path: hindsight-clients/go/go.sum
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Build Go client
working-directory: ./hindsight-clients/go
run: go build ./...
- name: Run Go client tests
working-directory: ./hindsight-clients/go
run: go test -v -tags=integration
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-openclaw-integration:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Install embed dependencies
working-directory: ./hindsight-embed
run: uv sync --frozen --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Install openclaw integration dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run openclaw integration tests
working-directory: ./hindsight-integrations/openclaw
run: npm run test:integration
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-integration:
runs-on: ubuntu-latest
env:
@@ -717,6 +918,35 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-crewai-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build crewai integration
working-directory: ./hindsight-integrations/crewai
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/crewai
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/crewai
run: uv run pytest tests -v
test-litellm-integration:
runs-on: ubuntu-latest
@@ -790,6 +1020,54 @@ jobs:
working-directory: ./hindsight-embed
run: ./test.sh
test-hindsight-all:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
# For test_server_integration.py compatibility
HINDSIGHT_LLM_PROVIDER: groq
HINDSIGHT_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_LLM_MODEL: openai/gpt-oss-20b
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build hindsight-all
working-directory: ./hindsight
run: uv build
- name: Install dependencies
working-directory: ./hindsight
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-all-${{ hashFiles('hindsight/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-all-
${{ runner.os }}-huggingface-
- name: Run unit tests
working-directory: ./hindsight
run: uv run pytest tests/ -v
test-doc-examples:
runs-on: ubuntu-latest
needs: test-rust-cli
@@ -871,30 +1149,11 @@ jobs:
sleep 1
done
- name: Run Python doc examples
working-directory: ./hindsight-clients/python
run: |
for f in ../../hindsight-docs/examples/api/*.py; do
echo "Running $f..."
uv run python "$f"
done
- name: Run Node.js doc examples
run: |
for f in hindsight-docs/examples/api/*.mjs; do
echo "Running $f..."
node "$f"
done
- name: Configure CLI
run: hindsight configure --api-url http://localhost:8888
- name: Run CLI doc examples
run: |
for f in hindsight-docs/examples/api/*.sh; do
echo "Running $f..."
bash "$f"
done
- name: Run all doc examples
run: ./scripts/test-doc-examples.sh
- name: Show API server logs
if: always()
@@ -1044,4 +1303,52 @@ jobs:
git diff --stat
exit 1
fi
echo "✓ All generated files are up to date"
echo "✓ All generated files are up to date"
check-openapi-compatibility:
runs-on: ubuntu-latest
env:
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full git history to access base branch
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --index-strategy unsafe-best-match
- name: Check OpenAPI compatibility with base branch
run: |
# Get the base branch (usually main)
BASE_BRANCH="${{ github.base_ref }}"
if [ -z "$BASE_BRANCH" ]; then
echo "⚠️ Warning: No base branch found (not a PR?). Skipping compatibility check."
exit 0
fi
echo "Checking OpenAPI compatibility against base branch: $BASE_BRANCH"
# Extract the old OpenAPI spec from base branch
git show "origin/$BASE_BRANCH:hindsight-docs/static/openapi.json" > /tmp/old-openapi.json
if [ ! -s /tmp/old-openapi.json ]; then
echo "⚠️ Warning: Could not find OpenAPI spec in base branch. Skipping compatibility check."
exit 0
fi
# Check compatibility using our tool
cd hindsight-dev
uv run check-openapi-compatibility /tmp/old-openapi.json ../hindsight-docs/static/openapi.json
+4 -1
View File
@@ -46,6 +46,7 @@ hindsight-docs/static/llms-full.txt
hindsight-dev/benchmarks/locomo/results/
hindsight-dev/benchmarks/longmemeval/results/
hindsight-dev/benchmarks/consolidation/results/
hindsight-dev/benchmarks/perf/results/
benchmarks/results/
hindsight-cli/target
hindsight-clients/rust/target
@@ -53,4 +54,6 @@ hindsight-clients/rust/target
whats-next.md
TASK.md
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
# CHANGELOG.md
blog-post*
+50 -6
View File
@@ -45,6 +45,7 @@ cd hindsight-control-plane && npm run dev
./scripts/dev/start-docs.sh
```
### Generating Clients/OpenAPI
```bash
# Regenerate OpenAPI spec after API changes (REQUIRED after changing endpoints)
@@ -56,8 +57,15 @@ cd hindsight-control-plane && npm run dev
### Benchmarks
```bash
# Accuracy benchmarks
./scripts/benchmarks/run-longmemeval.sh
./scripts/benchmarks/run-locomo.sh
# Performance benchmarks
./scripts/benchmarks/run-consolidation.sh
./scripts/benchmarks/run-retain-perf.sh --document <path> # Requires API server running
# Results viewer
./scripts/benchmarks/start-visualizer.sh # View results at localhost:8001
```
@@ -237,26 +245,61 @@ def process(data: UserData) -> str:
### Adding New API Configuration Flags
When adding a new environment variable configuration:
Configuration follows a hierarchical system: **Global (env vars) → Tenant (via extension) → Bank (database)**.
Fields must be categorized as either **hierarchical** (can be overridden per-tenant/bank) or **static** (server-level only).
#### Adding a New Configuration Field
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
- Add `ENV_*` constant for the environment variable name
- 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
- Add field to `HindsightConfig` dataclass with type annotation
- **Mark as hierarchical or static** by adding to `_HIERARCHICAL_FIELDS` set (hierarchical) or leaving it out (static)
- Add initialization in `from_env()` method
```python
# Hierarchical field (can be overridden per-bank)
_HIERARCHICAL_FIELDS = {
...,
"my_setting", # Add here for hierarchical
}
# Static field - just don't add to _HIERARCHICAL_FIELDS
```
2. **main.py** (`hindsight-api/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
3. **Use the config** in code:
3. **Use hierarchical config in MemoryEngine**:
```python
# Config is resolved automatically per bank via ConfigResolver
config_dict = await self._config_resolver.get_bank_config(bank_id, context)
value = config_dict["my_setting"]
```
4. **Use static config** (non-hierarchical):
```python
from ...config import get_config
config = get_config()
value = config.your_new_field
value = config.my_static_field
```
4. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
5. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
- LLM settings (provider, model, API key, base URL)
- Operation-specific settings (retain mode, chunk size, etc.)
- Feature flags that vary by customer/bank
**Static** (server-level only):
- Infrastructure settings (database URL, port, host)
- Global limits (max concurrent operations)
- System-wide feature flags
## Environment Setup
@@ -280,3 +323,4 @@ Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
- `HINDSIGHT_API_RERANKER_PROVIDER`: local (default) or tei
- `HINDSIGHT_API_DATABASE_URL`: External PostgreSQL (uses embedded pg0 by default)
- `HINDSIGHT_API_ENABLE_BANK_CONFIG_API`: Enable per-bank config API (default: false, disabled for security)
+54 -22
View File
@@ -2,7 +2,7 @@
![Hindsight Banner](./hindsight-docs/static/img/hindsight-github-banner.png)
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://vectorize.io/hindsight/cloud)
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
@@ -42,27 +42,51 @@ If you need more control over how and when your agent stores and recalls memorie
![Hindsight Banner](./hindsight-docs/static/img/migration-code.png)
---
> 🤖 **Using a coding agent?** Install the Hindsight documentation skill for instant access to docs while you code:
> ```bash
> npx skills add https://github.com/vectorize-io/hindsight --skill hindsight-docs
> ```
> Works with Claude Code, Cursor, and other AI coding assistants.
---
## Quick Start
### Docker (recommended)
```bash
export OPENAI_API_KEY=your-key
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
>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).
API: http://localhost:8888
UI: http://localhost:9999
Install client:
### Docker (external PostgreSQL)
```bash
export OPENAI_API_KEY=sk-xxx
export HINDSIGHT_DB_PASSWORD=choose-a-password
cd docker/docker-compose
docker compose up
```
>API: http://localhost:8888
>UI: http://localhost:9999
### Client
```bash
pip install hindsight-client -U
@@ -70,7 +94,7 @@ pip install hindsight-client -U
npm install @vectorize-io/hindsight-client
```
Python example:
#### Python
```python
from hindsight_client import Hindsight
@@ -87,7 +111,29 @@ client.recall(bank_id="my-bank", query="What does Alice do?")
client.reflect(bank_id="my-bank", query="Tell me about Alice")
```
### Python (embedded, no Docker)
#### Node.js / TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const main = async () => {
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
const results = await client.recall('my-bank', 'What does Alice like?');
console.log(results);
}
main();
```
### Python Embedded (no server required)
```bash
pip install hindsight-all -U
@@ -107,20 +153,6 @@ with HindsightServer(
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
```
### Node.js / TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
await client.recall('my-bank', 'What does Alice like?');
```
---
@@ -0,0 +1,54 @@
# Docker Compose file for Hindsight with PostgreSQL and pgvector
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18)
services:
db:
# Use a PostgreSQL-Image with pgvector extension pre-installed
# see https://hub.docker.com/r/pgvector/pgvector
image: pgvector/pgvector:pg${HINDSIGHT_DB_VERSION:-18}
container_name: hindsight-db
restart: always
# Expose PostgreSQL port
# ports:
# - "5432:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
+96
View File
@@ -0,0 +1,96 @@
# Nginx Reverse Proxy with Custom Base Path
Deploy Hindsight API under `/hindsight` (or any custom path) using Nginx reverse proxy.
## Quick Start (Published Image - API Only)
```bash
docker-compose up
```
- **API:** http://localhost:8080/hindsight/docs
- **Control Plane:** http://localhost:9999 (direct access, not proxied)
## Full Stack with Custom Base Path (Requires Build)
**Important:** You cannot rebuild from the published image with build args. You must build from source.
### Build from Source with Custom Base Path
1. **Clone the repository** (if you haven't):
```bash
git clone https://github.com/vectorize-io/hindsight.git
cd hindsight
```
2. **Build with base path**:
```bash
docker build \
--build-arg NEXT_PUBLIC_BASE_PATH=/hindsight \
-f docker/standalone/Dockerfile \
-t hindsight:custom \
.
```
3. **Update docker-compose.yml** to use your built image:
```yaml
services:
hindsight:
image: hindsight:custom # ← Change this
environment:
HINDSIGHT_API_BASE_PATH: /hindsight
NEXT_PUBLIC_BASE_PATH: /hindsight
```
4. **Update nginx.conf** to handle Control Plane routes (see below)
5. **Run**:
```bash
docker-compose up
```
### Required nginx.conf for Full Stack
Replace the current `nginx.conf` with this to proxy both API and Control Plane:
```nginx
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream hindsight_api { server hindsight:8888; }
upstream hindsight_cp { server hindsight:9999; }
server {
listen 80;
# API
location ~ ^/hindsight/(docs|openapi\.json|health|metrics|v1|mcp) {
proxy_pass http://hindsight_api;
proxy_set_header Host $http_host;
}
# Control Plane static files
location ~ ^/hindsight/_next/ {
proxy_pass http://hindsight_cp;
proxy_set_header Host $http_host;
}
# Control Plane UI
location /hindsight {
proxy_pass http://hindsight_cp;
proxy_set_header Host $http_host;
}
location = / { return 301 /hindsight; }
}
}
```
### Why Build is Required
Next.js requires `basePath` at **build time**. The published image was built without a custom base path, so you must rebuild from source with the `NEXT_PUBLIC_BASE_PATH` build arg to deploy the Control Plane under a subpath.
The API works without rebuild because `HINDSIGHT_API_BASE_PATH` is a runtime environment variable.
@@ -0,0 +1,88 @@
# Hindsight API deployment with Nginx reverse proxy (API-only)
#
# This example deploys Hindsight API under the path /hindsight with:
# - Hindsight standalone image (API + Control Plane + embedded pg0)
# - Nginx reverse proxy (API only)
#
# Quick Start:
# docker-compose -f docker/docker-compose/nginx/docker-compose.yml up
#
# Access:
# API (via nginx): http://localhost:8080/hindsight/docs
# Control Plane (direct): http://localhost:9999
#
# For full stack deployment (API + Control Plane both under /hindsight):
# See README.md in this directory for instructions on building with basePath.
#
# Note: This configuration uses the published image (no build required).
# Control Plane is served directly because Next.js basePath requires
# build-time configuration. See README.md for the full stack option.
services:
# Hindsight (API + Control Plane + embedded pg0)
hindsight:
image: ghcr.io/vectorize-io/hindsight:latest
ports:
- "9999:9999" # Control Plane (direct access, not proxied)
environment:
# API base path for reverse proxy
HINDSIGHT_API_BASE_PATH: /hindsight
# LLM configuration
# Using mock provider for testing (no API key needed)
# For production, set OPENAI_API_KEY or ANTHROPIC_API_KEY and use a real provider
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-mock}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-not-needed-for-mock}
HINDSIGHT_API_LLM_MODEL: ${HINDSIGHT_API_LLM_MODEL:-mock-model}
# Production examples (uncomment and set appropriate API key):
# HINDSIGHT_API_LLM_PROVIDER: openai
# HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY}
# HINDSIGHT_API_LLM_MODEL: gpt-4o-mini
# HINDSIGHT_API_LLM_PROVIDER: anthropic
# HINDSIGHT_API_LLM_API_KEY: ${ANTHROPIC_API_KEY}
# HINDSIGHT_API_LLM_MODEL: claude-sonnet-4-20250514
# Server config
HINDSIGHT_API_HOST: 0.0.0.0
HINDSIGHT_API_PORT: 8888
HINDSIGHT_API_LOG_LEVEL: info
# Control Plane config
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
volumes:
# Persist embedded pg0 database
- hindsight_data:/app/data
# Note: Ports not exposed - access via Nginx at localhost:8080/hindsight/
# To debug directly, uncomment these ports:
# ports:
# - "8888:8888" # API
# - "9999:9999" # Control Plane
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/hindsight/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
networks:
- hindsight
# Nginx reverse proxy
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
hindsight:
condition: service_healthy
networks:
- hindsight
volumes:
hindsight_data:
networks:
hindsight:
+40
View File
@@ -0,0 +1,40 @@
# Nginx configuration for API-only reverse proxy
# Control Plane accessed directly (not through nginx)
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Upstream - Hindsight API
upstream hindsight_api {
server hindsight:8888;
}
server {
listen 80;
server_name _;
# API endpoints - forward with /hindsight prefix
location /hindsight/ {
proxy_pass http://hindsight_api;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Redirect root to API docs
location = / {
return 301 /hindsight/docs;
}
}
}
@@ -0,0 +1,32 @@
# PostgreSQL with pgvector and pg_textsearch extensions
# Note: pg_textsearch requires PostgreSQL 17+
FROM postgres:17
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
git \
postgresql-server-dev-17 \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install pgvector
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install
# Install pg_textsearch
RUN cd /tmp && \
git clone https://github.com/timescale/pg_textsearch.git && \
cd pg_textsearch && \
make && \
make install
# Clean up source files and build dependencies
RUN rm -rf /tmp/pgvector /tmp/pg_textsearch && \
apt-get purge -y --auto-remove build-essential git postgresql-server-dev-17
# Ensure extensions are preloaded
RUN echo "shared_preload_libraries = 'pg_textsearch'" >> /usr/share/postgresql/postgresql.conf.sample
@@ -0,0 +1,91 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and Timescale pg_textsearch
# docker compose -f docker/docker-compose/pg_textsearch/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/pg_textsearch/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
services:
db:
# Use custom PostgreSQL image with pgvector and pg_textsearch extensions
build:
context: .
dockerfile: Dockerfile
container_name: hindsight-db
restart: always
# Expose PostgreSQL port
ports:
- "5437:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- hindsight-net
pg-textsearch-init:
build:
context: .
dockerfile: Dockerfile
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'Waiting for PostgreSQL to be ready...';
until pg_isready -h hindsight-db -p 5432 -U hindsight_user; do
echo 'PostgreSQL is unavailable - sleeping';
sleep 2;
done;
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Creating extensions in hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE;';
echo 'Database and extensions created successfully';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: pgvector
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pg_textsearch
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
@@ -0,0 +1,83 @@
# Docker Compose file for Hindsight with S3 file storage (SeaweedFS)
#
# SeaweedFS (Apache 2.0) provides an S3-compatible object storage backend
# for storing uploaded files instead of PostgreSQL BYTEA storage.
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18)
# - SEAWEEDFS_S3_ACCESS_KEY: S3 access key (default: hindsight_s3_key)
# - SEAWEEDFS_S3_SECRET_KEY: S3 secret key (default: hindsight_s3_secret)
services:
db:
image: pgvector/pgvector:pg${HINDSIGHT_DB_VERSION:-18}
container_name: hindsight-db
restart: always
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker
networks:
- hindsight-net
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: hindsight-seaweedfs
restart: always
# Single-node mode: master + volume + filer + S3 gateway all in one process
command: >
server
-s3
-s3.port=8333
-s3.config=/etc/seaweedfs/s3.json
-ip.bind=0.0.0.0
volumes:
- seaweedfs_data:/data
- ./s3.json:/etc/seaweedfs/s3.json:ro
# Expose S3 API port (uncomment to access from host)
# ports:
# - "8333:8333"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# S3 file storage configuration (SeaweedFS)
- HINDSIGHT_API_FILE_STORAGE_TYPE=s3
- HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=hindsight
- HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT=http://seaweedfs:8333
- HINDSIGHT_API_FILE_STORAGE_S3_REGION=us-east-1
- HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=${SEAWEEDFS_S3_ACCESS_KEY:-hindsight_s3_key}
- HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=${SEAWEEDFS_S3_SECRET_KEY:-hindsight_s3_secret}
depends_on:
- db
- seaweedfs
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
seaweedfs_data:
@@ -0,0 +1,19 @@
{
"identities": [
{
"name": "hindsight",
"credentials": [
{
"accessKey": "hindsight_s3_key",
"secretKey": "hindsight_s3_secret"
}
],
"actions": [
"Admin",
"Read",
"Write",
"List"
]
}
]
}
@@ -0,0 +1,16 @@
# Git
.git
.gitignore
.gitattributes
# Docker
docker-compose.yaml
.dockerignore
# Documentation
README.md
*.md
# Environment
.env
.env.example
@@ -0,0 +1,25 @@
# PostgreSQL Configuration
HINDSIGHT_DB_USER=hindsight_user
HINDSIGHT_DB_PASSWORD=change-me-to-secure-password
HINDSIGHT_DB_NAME=hindsight_db
# Hindsight Version
HINDSIGHT_VERSION=latest
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER=openai
OPENAI_API_KEY=your-openai-api-key-here
# Alternative LLM providers (uncomment and configure as needed):
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# ANTHROPIC_API_KEY=your-anthropic-api-key
# HINDSIGHT_API_LLM_PROVIDER=gemini
# GEMINI_API_KEY=your-gemini-api-key
# HINDSIGHT_API_LLM_PROVIDER=groq
# GROQ_API_KEY=your-groq-api-key
# Vector and Text Search (already configured in docker-compose.yaml)
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION=pg_textsearch
@@ -0,0 +1,55 @@
# PostgreSQL with pgvector, pgvectorscale, and pg_textsearch extensions
# All three extensions from Timescale/pgvector for high-performance vector and text search
# Note: Requires PostgreSQL 16+
FROM postgres:17
# Install build dependencies and Rust toolchain
RUN apt-get update && apt-get install -y \
build-essential \
git \
postgresql-server-dev-17 \
libpq-dev \
cmake \
curl \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Rust toolchain (required for pgvectorscale)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
# Install pgvector (required by pgvectorscale)
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install && \
rm -rf /tmp/pgvector
# Install cargo-pgrx (PostgreSQL extension framework for Rust)
RUN cargo install cargo-pgrx --version 0.12.5 --locked && \
cargo pgrx init --pg17 /usr/bin/pg_config
# Install pgvectorscale (DiskANN index support)
RUN cd /tmp && \
git clone --branch 0.5.1 https://github.com/timescale/pgvectorscale.git && \
cd pgvectorscale/pgvectorscale && \
cargo pgrx install --release && \
rm -rf /tmp/pgvectorscale
# Install pg_textsearch (BM25 text search)
RUN cd /tmp && \
git clone https://github.com/timescale/pg_textsearch.git && \
cd pg_textsearch && \
make && \
make install && \
rm -rf /tmp/pg_textsearch
# Clean up build dependencies (keep runtime dependencies)
RUN apt-get purge -y --auto-remove git cmake curl && \
rm -rf /root/.cargo/registry /root/.cargo/git
# Ensure extensions are preloaded (pg_textsearch requires preloading)
RUN echo "shared_preload_libraries = 'pg_textsearch'" >> /usr/share/postgresql/postgresql.conf.sample
+101
View File
@@ -0,0 +1,101 @@
# Hindsight with Timescale Extensions
This Docker Compose setup provides a complete Hindsight deployment with **Timescale extensions**:
- **pgvectorscale** - DiskANN algorithm for disk-based scalable vector search
- **pg_textsearch** - High-performance BM25 text search
Both extensions are from [Timescale](https://github.com/timescale) and provide production-grade performance.
## Prerequisites
- Docker and Docker Compose installed
- OpenAI API key (or another LLM provider)
## Quick Start
```bash
# Set environment variables
export HINDSIGHT_DB_PASSWORD="your-secure-password"
export OPENAI_API_KEY="your-openai-api-key"
# Build and start
docker compose -f docker/docker-compose/timescale/docker-compose.yaml up -d --build
# Check logs
docker compose -f docker/docker-compose/timescale/docker-compose.yaml logs -f
```
**Access:**
- API: http://localhost:8888
- Control Plane: http://localhost:9999
## Stop and Clean Up
```bash
# Stop services
docker compose -f docker/docker-compose/timescale/docker-compose.yaml down
# Remove volumes (deletes all data)
docker compose -f docker/docker-compose/timescale/docker-compose.yaml down -v
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_DB_PASSWORD` | PostgreSQL password | `hindsight_password` |
| `HINDSIGHT_DB_USER` | PostgreSQL username | `hindsight_user` |
| `HINDSIGHT_DB_NAME` | Database name | `hindsight_db` |
| `HINDSIGHT_VERSION` | Hindsight Docker image version | `latest` |
| `OPENAI_API_KEY` | OpenAI API key | (required) |
| `HINDSIGHT_API_LLM_PROVIDER` | LLM provider | `openai` |
### Why Timescale Extensions?
**pgvectorscale (DiskANN):**
- 28x lower p95 latency vs dedicated vector databases
- 16x higher query throughput at 99% recall
- 60-75% cost reduction (disk is cheaper than RAM)
- Best for large datasets (10M+ vectors)
**pg_textsearch (BM25):**
- High-performance keyword retrieval
- Native BM25 ranking algorithm
- Optimized for full-text search
## Troubleshooting
### Extensions not installed
Check if extensions are available:
```bash
docker exec -it hindsight-db-timescale psql -U hindsight_user -d hindsight_db -c "\dx"
```
You should see:
- `vector` (pgvector)
- `vectorscale` (pgvectorscale/DiskANN)
- `pg_textsearch` (BM25 search)
### Build fails
If the Docker build fails during pgvectorscale compilation:
1. Ensure you have sufficient memory (recommended: 4GB+)
2. Check Docker build logs for Rust compilation errors
3. Try building with more resources: `docker compose build --no-cache --memory 4g`
### Port conflicts
If port 5438 is already in use, modify the `ports` section in docker-compose.yaml.
## Learn More
- [pgvectorscale GitHub](https://github.com/timescale/pgvectorscale)
- [pg_textsearch GitHub](https://github.com/timescale/pg_textsearch)
- [HNSW vs DiskANN](https://www.tigerdata.com/learn/hnsw-vs-diskann)
- [Hindsight Documentation](https://hindsight.dev)
@@ -0,0 +1,108 @@
name: hindsight
# Docker Compose file for Hindsight with Timescale extensions
# - pgvectorscale: DiskANN vector search (disk-based, scalable)
# - pg_textsearch: BM25 text search (high-performance keyword retrieval)
#
# Quick start:
# docker compose -f docker/docker-compose/timescale/docker-compose.yaml up -d --build
#
# Required environment variables:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - OPENAI_API_KEY (or configure another LLM provider)
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
services:
db:
# Custom PostgreSQL image with Timescale extensions (pgvectorscale + pg_textsearch)
build:
context: .
dockerfile: Dockerfile
container_name: hindsight-db-timescale
restart: always
# Expose PostgreSQL port (using 5438 to avoid conflicts with other setups)
ports:
- "5438:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- hindsight-net
# Health check to ensure database is ready
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hindsight_user"]
interval: 5s
timeout: 5s
retries: 5
timescale-init:
build:
context: .
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Installing Timescale extensions...';
echo '1/3: Installing pgvector (required by pgvectorscale)...';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
echo '2/3: Installing pgvectorscale (DiskANN vector search)...';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;';
echo '3/3: Installing pg_textsearch (BM25 text search)...';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE;';
echo '';
echo '✅ Timescale extensions installed successfully';
echo '';
echo 'Installed extensions:';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c \"\\dx\" | grep -E '(vector|vectorscale|pg_textsearch)';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app-timescale
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Timescale Extensions
# pgvectorscale: DiskANN algorithm for disk-based scalable vector search
HINDSIGHT_API_VECTOR_EXTENSION: pgvectorscale
# pg_textsearch: High-performance BM25 text search
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pg_textsearch
depends_on:
db:
condition: service_healthy
timescale-init:
condition: service_completed_successfully
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
@@ -0,0 +1,93 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18)
services:
db:
# Use a PostgreSQL-Image with vectorchord extension pre-installed
image: tensorchord/vchord-suite:pg${HINDSIGHT_DB_VERSION:-18-latest}
container_name: hindsight-db
restart: always
# Expose PostgreSQL port
ports:
- "5436:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker
networks:
- hindsight-net
vectorchord-init:
image: tensorchord/vchord-suite:pg18-latest
#container_name: vectorchord-init
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'Waiting for PostgreSQL to be ready...';
until pg_isready -h hindsight-db -p 5432 -U hindsight_user; do
echo 'PostgreSQL is unavailable - sleeping';
sleep 2;
done;
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Creating extensions in hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vchord CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;';
echo 'Creating llmlingua2 tokenizer';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c \"SELECT create_tokenizer('llmlingua2', \\$\\$ model = \\\"llmlingua2\\\" \\$\\$);\" 2>/dev/null || echo 'Tokenizer already exists or creation skipped';
echo 'Database and extensions created successfully';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration (uses OpenAI for testing vchord)
# LLM configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: vchord
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: vchord
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
+67 -2
View File
@@ -8,6 +8,7 @@
# Set to false when using external providers (TEI, OpenAI, Cohere)
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
# Only effective when INCLUDE_LOCAL_MODELS=true
# NOTE: tiktoken encodings are ALWAYS preloaded (required for air-gapped deployments)
#
# Examples:
# docker build -t hindsight . # Both (standalone)
@@ -111,6 +112,10 @@ RUN rm -f package-lock.json && sed -i '/"@vectorize-io\/hindsight-client":/d' pa
# Copy built SDK directly into node_modules (more reliable than npm link in Docker)
COPY --from=sdk-builder /app/hindsight-clients/typescript ./node_modules/@vectorize-io/hindsight-client
# Accept base path as build argument for reverse proxy deployments
# Usage: docker build --build-arg NEXT_PUBLIC_BASE_PATH=/hindsight ...
ARG NEXT_PUBLIC_BASE_PATH=""
# Build Control Plane - run next build first, then custom standalone copy
# (The build:standalone script expects a specific path structure that differs in Docker)
RUN npm exec -- next build
@@ -165,8 +170,35 @@ RUN chown -R hindsight:hindsight /app
USER hindsight
# Create pg0 data directory as hindsight user so that Docker seeds new named
# volumes with correct ownership (UID 1000) on first use, avoiding the
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
@@ -185,7 +217,6 @@ print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
@@ -208,6 +239,10 @@ ENV HINDSIGHT_API_LOG_LEVEL=info
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=false
ENV PYTHONUNBUFFERED=1
# Suppress verbose transformers/HuggingFace model loading warnings
ENV TRANSFORMERS_VERBOSITY=error
ENV HF_HUB_VERBOSITY=error
ENV TOKENIZERS_PARALLELISM=false
CMD ["/app/start-all.sh"]
@@ -291,8 +326,35 @@ RUN chown -R hindsight:hindsight /app
USER hindsight
# Create pg0 data directory as hindsight user so that Docker seeds new named
# volumes with correct ownership (UID 1000) on first use, avoiding the
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
@@ -311,7 +373,6 @@ print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
print('Models cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
@@ -336,6 +397,10 @@ ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=true
ENV PYTHONUNBUFFERED=1
# Suppress verbose transformers/HuggingFace model loading warnings
ENV TRANSFORMERS_VERBOSITY=error
ENV HF_HUB_VERBOSITY=error
ENV TOKENIZERS_PARALLELISM=false
CMD ["/app/start-all.sh"]
@@ -6,28 +6,40 @@
# Can be run locally or in CI pipelines.
#
# Usage:
# ./scripts/docker-smoke-test.sh <image> [target]
# ./docker/test-image.sh <image> [target]
#
# Arguments:
# image - Docker image to test (e.g., hindsight-api:test, ghcr.io/vectorize-io/hindsight:latest)
# target - Optional: 'cp-only' for control plane, otherwise assumes API image (default: api)
#
# Environment variables:
# GROQ_API_KEY - Required for API/standalone images (LLM verification)
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: groq)
# HINDSIGHT_API_LLM_MODEL - LLM model (default: llama-3.3-70b-versatile)
# SMOKE_TEST_TIMEOUT - Timeout in seconds (default: 120)
# SMOKE_TEST_CONTAINER_NAME - Container name (default: hindsight-smoke-test)
# GROQ_API_KEY - Required for API/standalone images (LLM verification)
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: groq)
# HINDSIGHT_API_LLM_MODEL - LLM model (default: llama-3.3-70b-versatile)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER - Embeddings provider (optional, for slim images: openai, cohere, tei)
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY - OpenAI API key for embeddings (optional)
# HINDSIGHT_API_RERANKER_PROVIDER - Reranker provider (optional, for slim images: cohere, tei)
# HINDSIGHT_API_COHERE_API_KEY - Cohere API key for reranking (optional)
# SMOKE_TEST_TIMEOUT - Timeout in seconds (default: 120)
# SMOKE_TEST_CONTAINER_NAME - Container name (default: hindsight-smoke-test)
#
# Examples:
# # Test a locally built image
# ./scripts/docker-smoke-test.sh hindsight-api:test
# # Test a locally built full image
# ./docker/test-image.sh hindsight-api:test
#
# # Test a released image
# ./scripts/docker-smoke-test.sh ghcr.io/vectorize-io/hindsight:latest
# ./docker/test-image.sh ghcr.io/vectorize-io/hindsight:latest
#
# # Test control plane image
# ./scripts/docker-smoke-test.sh hindsight-control-plane:test cp-only
# ./docker/test-image.sh hindsight-control-plane:test cp-only
#
# # Test slim image with external providers
# export GROQ_API_KEY=gsk_xxx
# export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
# export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxx
# export HINDSIGHT_API_RERANKER_PROVIDER=cohere
# export HINDSIGHT_API_COHERE_API_KEY=xxx
# ./docker/test-image.sh hindsight-slim:test
#
# Exit codes:
# 0 - Success (container healthy)
@@ -108,12 +120,32 @@ if [ "$TARGET" = "cp-only" ]; then
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
"$IMAGE"
else
docker run -d --name "$CONTAINER_NAME" \
-e HINDSIGHT_API_LLM_PROVIDER="$LLM_PROVIDER" \
-e HINDSIGHT_API_LLM_API_KEY="${GROQ_API_KEY}" \
-e HINDSIGHT_API_LLM_MODEL="$LLM_MODEL" \
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
"$IMAGE"
# Build docker run command with required and optional env vars
DOCKER_CMD="docker run -d --name $CONTAINER_NAME"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_PROVIDER=$LLM_PROVIDER"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_API_KEY=${GROQ_API_KEY}"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_MODEL=$LLM_MODEL"
# Add optional embeddings provider config
if [ -n "${HINDSIGHT_API_EMBEDDINGS_PROVIDER:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_PROVIDER=${HINDSIGHT_API_EMBEDDINGS_PROVIDER}"
fi
if [ -n "${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY}"
fi
# Add optional reranker provider config
if [ -n "${HINDSIGHT_API_RERANKER_PROVIDER:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_RERANKER_PROVIDER=${HINDSIGHT_API_RERANKER_PROVIDER}"
fi
if [ -n "${HINDSIGHT_API_COHERE_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_COHERE_API_KEY=${HINDSIGHT_API_COHERE_API_KEY}"
fi
DOCKER_CMD="$DOCKER_CMD -p ${HEALTH_PORT}:${HEALTH_PORT}"
DOCKER_CMD="$DOCKER_CMD $IMAGE"
eval $DOCKER_CMD
fi
# Wait for health endpoint
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
#
# Local Test Script for Slim Docker Images
#
# This script makes it easy to test slim images locally with external providers.
# It expects API keys to be set in environment variables.
#
# Usage:
# export GROQ_API_KEY=gsk_xxx
# export OPENAI_API_KEY=sk-xxx
# export COHERE_API_KEY=xxx
# ./docker/test-slim-local.sh
#
# Or inline:
# GROQ_API_KEY=gsk_xxx OPENAI_API_KEY=sk_xxx COHERE_API_KEY=xxx ./docker/test-slim-local.sh
#
set -euo pipefail
# Check for required API keys
if [ -z "${GROQ_API_KEY:-}" ]; then
echo "❌ Error: GROQ_API_KEY environment variable is required"
echo "Set it with: export GROQ_API_KEY=gsk_xxx"
exit 1
fi
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "❌ Error: OPENAI_API_KEY environment variable is required"
echo "Set it with: export OPENAI_API_KEY=sk-xxx"
exit 1
fi
if [ -z "${COHERE_API_KEY:-}" ]; then
echo "❌ Error: COHERE_API_KEY environment variable is required"
echo "Set it with: export COHERE_API_KEY=xxx"
exit 1
fi
# Configuration
IMAGE="${1:-hindsight-slim:test}"
echo "Testing image: $IMAGE"
echo ""
# Set up external providers
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=$OPENAI_API_KEY
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=$COHERE_API_KEY
# Run the test
exec "$(dirname "$0")/test-image.sh" "$IMAGE" standalone
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.8
appVersion: "0.4.8"
version: 0.4.12
appVersion: "0.4.12"
keywords:
- ai
- memory
+32
View File
@@ -127,6 +127,38 @@ API URL for control plane
{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }}
{{- end }}
{{/*
TEI reranker labels
*/}}
{{- define "hindsight.tei.reranker.labels" -}}
{{ include "hindsight.labels" . }}
app.kubernetes.io/component: tei-reranker
{{- end }}
{{/*
TEI reranker selector labels
*/}}
{{- define "hindsight.tei.reranker.selectorLabels" -}}
{{ include "hindsight.selectorLabels" . }}
app.kubernetes.io/component: tei-reranker
{{- end }}
{{/*
TEI embedding labels
*/}}
{{- define "hindsight.tei.embedding.labels" -}}
{{ include "hindsight.labels" . }}
app.kubernetes.io/component: tei-embedding
{{- end }}
{{/*
TEI embedding selector labels
*/}}
{{- define "hindsight.tei.embedding.selectorLabels" -}}
{{ include "hindsight.selectorLabels" . }}
app.kubernetes.io/component: tei-embedding
{{- end }}
{{/*
Get the name of the secret to use
*/}}
+17 -2
View File
@@ -33,7 +33,7 @@ spec:
- name: api
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version }}"
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
ports:
- name: http
@@ -60,10 +60,25 @@ spec:
- name: HINDSIGHT_API_WORKER_ENABLED
value: "false"
{{- end }}
{{- /* Explicitly set port to override K8s service discovery env var (HINDSIGHT_API_PORT) */}}
- name: HINDSIGHT_API_PORT
value: {{ .Values.api.service.targetPort | quote }}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- if .Values.tei.reranker.enabled }}
- name: HINDSIGHT_API_RERANKER_PROVIDER
value: "tei"
- name: HINDSIGHT_API_RERANKER_TEI_URL
value: "http://{{ include "hindsight.fullname" . }}-tei-reranker:{{ .Values.tei.reranker.port }}"
{{- end }}
{{- if .Values.tei.embedding.enabled }}
- name: HINDSIGHT_API_EMBEDDINGS_PROVIDER
value: "tei"
- name: HINDSIGHT_API_EMBEDDINGS_TEI_URL
value: "http://{{ include "hindsight.fullname" . }}-tei-embedding:{{ .Values.tei.embedding.port }}"
{{- end }}
{{- /* Only use api.secrets when not using existingSecret (for chart-managed secrets) */}}
{{- if not .Values.existingSecret }}
{{- range $key, $value := .Values.api.secrets }}
@@ -84,7 +99,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
{{- with (.Values.api.affinity | default .Values.affinity) }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -33,7 +33,7 @@ spec:
- name: control-plane
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version }}"
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
ports:
- name: http
@@ -71,7 +71,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
{{- with (.Values.controlPlane.affinity | default .Values.affinity) }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
+56
View File
@@ -0,0 +1,56 @@
{{- if and .Values.api.enabled .Values.api.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-api
labels:
{{- include "hindsight.api.labels" . | nindent 4 }}
spec:
{{- if .Values.api.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.api.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.api.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.api.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.api.selectorLabels" . | nindent 6 }}
{{- end }}
---
{{- if and .Values.controlPlane.enabled .Values.controlPlane.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-control-plane
labels:
{{- include "hindsight.controlPlane.labels" . | nindent 4 }}
spec:
{{- if .Values.controlPlane.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.controlPlane.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.controlPlane.selectorLabels" . | nindent 6 }}
{{- end }}
---
{{- if and .Values.worker.enabled .Values.worker.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-worker
labels:
{{- include "hindsight.worker.labels" . | nindent 4 }}
spec:
{{- if .Values.worker.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.worker.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.worker.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.worker.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.worker.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -0,0 +1,76 @@
{{- if .Values.tei.embedding.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hindsight.fullname" . }}-tei-embedding
labels:
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.tei.embedding.replicaCount }}
selector:
matchLabels:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: tei-embedding
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.tei.embedding.image.repository }}:{{ .Values.tei.embedding.image.tag }}"
imagePullPolicy: {{ .Values.tei.embedding.image.pullPolicy }}
args:
- "--model-id"
- {{ .Values.tei.embedding.model | quote }}
- "--hostname"
- "0.0.0.0"
{{- range .Values.tei.embedding.args }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.tei.embedding.port }}
protocol: TCP
env:
- name: PORT
value: {{ .Values.tei.embedding.port | quote }}
{{- range $key, $value := .Values.tei.embedding.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.tei.embedding.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.tei.embedding.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.tei.embedding.resources | nindent 10 }}
volumeMounts:
- name: model-cache
mountPath: /data
volumes:
- name: model-cache
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.tei.embedding.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-tei-embedding
labels:
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.tei.embedding.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 4 }}
{{- end }}
@@ -0,0 +1,76 @@
{{- if .Values.tei.reranker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hindsight.fullname" . }}-tei-reranker
labels:
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.tei.reranker.replicaCount }}
selector:
matchLabels:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: tei-reranker
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.tei.reranker.image.repository }}:{{ .Values.tei.reranker.image.tag }}"
imagePullPolicy: {{ .Values.tei.reranker.image.pullPolicy }}
args:
- "--model-id"
- {{ .Values.tei.reranker.model | quote }}
- "--hostname"
- "0.0.0.0"
{{- range .Values.tei.reranker.args }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.tei.reranker.port }}
protocol: TCP
env:
- name: PORT
value: {{ .Values.tei.reranker.port | quote }}
{{- range $key, $value := .Values.tei.reranker.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.tei.reranker.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.tei.reranker.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.tei.reranker.resources | nindent 10 }}
volumeMounts:
- name: model-cache
mountPath: /data
volumes:
- name: model-cache
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,17 @@
{{- if .Values.tei.reranker.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-tei-reranker
labels:
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.tei.reranker.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 4 }}
{{- end }}
@@ -32,7 +32,7 @@ spec:
- name: worker
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version }}"
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
command: ["hindsight-worker"]
ports:
@@ -99,7 +99,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
{{- with (.Values.worker.affinity | default .Values.affinity) }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
+110 -4
View File
@@ -1,7 +1,8 @@
# Default values for hindsight
# Chart version - use this to set a consistent image tag across all components
version: "0.1.1"
# Global version override - use this to set a consistent image tag across all components
# If not set, defaults to Chart.appVersion from Chart.yaml
# version: ""
# Use an existing secret instead of creating one from values
# When set, all keys from this secret are injected as environment variables via envFrom
@@ -57,6 +58,15 @@ api:
timeoutSeconds: 3
failureThreshold: 3
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Environment variables
env:
#HINDSIGHT_API_LLM_PROVIDER: "groq"
@@ -75,7 +85,7 @@ worker:
image:
repository: ghcr.io/vectorize-io/hindsight-api
pullPolicy: IfNotPresent
# tag defaults to .Values.version if not specified
# tag: "" # defaults to .Values.version, then Chart.appVersion if not specified
service:
# Service for metrics scraping (headless for StatefulSet)
@@ -121,6 +131,15 @@ worker:
# HTTP port for metrics/health (matches service.targetPort)
HINDSIGHT_API_WORKER_HTTP_PORT: "8889"
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Secret environment variables (inherited from api.secrets if not specified)
secrets: {}
@@ -164,6 +183,15 @@ controlPlane:
timeoutSeconds: 3
failureThreshold: 3
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Environment variables
env:
NODE_ENV: "production"
@@ -262,9 +290,87 @@ nodeSelector: {}
# Tolerations
tolerations: []
# Affinity
# Affinity (applied to all components unless overridden per-component)
affinity: {}
# TEI (Text Embeddings Inference) - optional standalone deployments
# for reranking and/or embedding models
tei:
reranker:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/huggingface/text-embeddings-inference
tag: cpu-1.8.3
pullPolicy: IfNotPresent
model: "cross-encoder/ms-marco-MiniLM-L-6-v2"
port: 8090
args:
- "--auto-truncate"
env:
PAYLOAD_LIMIT: "10000000"
MAX_CLIENT_BATCH_SIZE: "256"
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 15
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
embedding:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/huggingface/text-embeddings-inference
tag: cpu-1.8.3
pullPolicy: IfNotPresent
model: "sentence-transformers/all-MiniLM-L6-v2"
port: 8091
args: []
env:
PAYLOAD_LIMIT: "10000000"
MAX_CLIENT_BATCH_SIZE: "256"
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
livenessProbe:
httpGet:
path: /health
port: 8091
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /health
port: 8091
initialDelaySeconds: 15
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Autoscaling
autoscaling:
enabled: false
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.8"
__version__ = "0.4.12"
@@ -6,6 +6,7 @@ Create Date: 2025-11-27 11:54:19.228030
"""
import os
from collections.abc import Sequence
import sqlalchemy as sa
@@ -21,6 +22,96 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
conn = op.get_bind()
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
# Validate configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN requires pgvector. Install with: CREATE EXTENSION vector; then vectorscale or pg_diskann CASCADE;"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
return "pgvectorscale"
elif pg_diskann_check:
return "pg_diskann"
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
return "vchord"
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
return "pgvector"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _detect_text_search_extension() -> str:
"""
Detect or validate text search extension: 'native', 'vchord', or 'pg_textsearch'.
Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
Creates the extension if needed.
"""
text_search_extension = os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
if text_search_extension == "vchord":
# Create vchord_bm25 extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord_bm25'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "vchord"
elif text_search_extension == "pg_textsearch":
# Create pg_textsearch extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "pg_textsearch"
elif text_search_extension == "native":
return "native"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native', 'vchord', or 'pg_textsearch'"
)
def upgrade() -> None:
"""Upgrade schema - create all tables from scratch."""
@@ -166,11 +257,29 @@ def upgrade() -> None:
)
# Add search_vector column for full-text search
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
""")
# Type depends on configured text search backend
text_search_ext = _detect_text_search_extension()
if text_search_ext == "vchord":
# VectorChord BM25: bm25vector type (no GENERATED - tokenization happens on INSERT)
# Note: vchord_bm25 extension creates types in bm25_catalog schema
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector bm25_catalog.bm25vector
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector TEXT
""")
else: # native
# Native PostgreSQL: tsvector with automatic generation
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
""")
op.create_index("idx_memory_units_bank_id", "memory_units", ["bank_id"])
op.create_index("idx_memory_units_document_id", "memory_units", ["document_id"])
@@ -200,19 +309,61 @@ def upgrade() -> None:
["bank_id", sa.text("event_date DESC")],
postgresql_where=sa.text("fact_type = 'observation'"),
)
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
# Create vector index - conditional based on available extension
vector_ext = _detect_vector_extension()
# Create BM25 full-text search index on search_vector
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING gin(search_vector)
""")
if vector_ext == "pgvectorscale":
# Use DiskANN index for pgvectorscale (disk-based, scalable)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
# Use DiskANN index for pg_diskann (Azure)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
# Use vchordrq index for vchord (supports high-dimensional embeddings)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
# Use HNSW index for pgvector
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
# Create full-text search index on search_vector
# Index type depends on text search backend
if text_search_ext == "vchord":
# VectorChord BM25 index
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch BM25 index on text column
# Note: pg_textsearch doesn't support expressions, so we index the main text column
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING bm25(text)
WITH (text_config='english')
""")
else: # native
# Native PostgreSQL GIN index
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING gin(search_vector)
""")
op.execute("""
CREATE MATERIALIZED VIEW memory_units_bm25 AS
@@ -0,0 +1,70 @@
"""Add file_storage table for BYTEA-based file storage
Revision ID: a1b2c3d4e5f6
Revises: y0t1u2v3w4x5
Create Date: 2026-02-16
Creates a dedicated table for storing uploaded files using BYTEA.
This provides zero-config file storage that "just works" for development
and small deployments. For production/scale, use S3-compatible storage.
Files are stored in a separate table to avoid bloating the documents table.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "a1b2c3d4e5f6"
down_revision: str | Sequence[str] | None = "y0t1u2v3w4x5"
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:
"""Create file_storage table for BYTEA storage."""
schema = _get_schema_prefix()
# Create file_storage table (minimal: just key + data)
op.execute(
f"""
CREATE TABLE {schema}file_storage (
storage_key TEXT PRIMARY KEY,
data BYTEA NOT NULL
)
"""
)
# Add file tracking columns to documents table
op.execute(
f"""
ALTER TABLE {schema}documents
ADD COLUMN IF NOT EXISTS file_storage_key TEXT,
ADD COLUMN IF NOT EXISTS file_original_name TEXT,
ADD COLUMN IF NOT EXISTS file_content_type TEXT
"""
)
def downgrade() -> None:
"""Remove file_storage table and related columns."""
schema = _get_schema_prefix()
# Drop columns from documents table
op.execute(
f"""
ALTER TABLE {schema}documents
DROP COLUMN IF EXISTS file_storage_key,
DROP COLUMN IF EXISTS file_original_name,
DROP COLUMN IF EXISTS file_content_type
"""
)
# Drop file_storage table
op.execute(f"DROP TABLE IF EXISTS {schema}file_storage")
@@ -10,9 +10,11 @@ This migration:
3. Adds consolidation tracking columns to the 'banks' table
"""
import os
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = "n9i0j1k2l3m4"
@@ -27,10 +29,106 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
conn = op.get_bind()
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
# Validate configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN requires pgvector. Install with: CREATE EXTENSION vector; then vectorscale or pg_diskann CASCADE;"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
return "pgvectorscale"
elif pg_diskann_check:
return "pg_diskann"
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
return "vchord"
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
return "pgvector"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _detect_text_search_extension() -> str:
"""
Detect or validate text search extension: 'native', 'vchord', or 'pg_textsearch'.
Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
Creates the extension if needed.
"""
text_search_extension = os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
if text_search_extension == "vchord":
# Create vchord_bm25 extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord_bm25'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "vchord"
elif text_search_extension == "pg_textsearch":
# Create pg_textsearch extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "pg_textsearch"
elif text_search_extension == "native":
return "native"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native', 'vchord', or 'pg_textsearch'"
)
def upgrade() -> None:
"""Create learnings and pinned_reflections tables."""
schema = _get_schema_prefix()
# Detect which vector extension is available
vector_ext = _detect_vector_extension()
# Detect which text search extension to use
text_search_ext = _detect_text_search_extension()
# 1. Create learnings table
op.execute(f"""
CREATE TABLE {schema}learnings (
@@ -57,18 +155,60 @@ def upgrade() -> None:
# Indexes for learnings
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING hnsw (embedding vector_cosine_ops)
""")
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
# Full-text search for learnings
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', text)) STORED
""")
op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)")
if text_search_ext == "vchord":
# VectorChord BM25: bm25vector type (no GENERATED - tokenization happens on INSERT)
# Note: vchord_bm25 extension creates types in bm25_catalog schema
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector bm25_catalog.bm25vector
""")
op.execute(f"""
CREATE INDEX idx_learnings_text_search ON {schema}learnings
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector TEXT
""")
op.execute(f"""
CREATE INDEX idx_learnings_text_search ON {schema}learnings
USING bm25(text) WITH (text_config='english')
""")
else: # native
# Native PostgreSQL: tsvector with automatic generation
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', text)) STORED
""")
op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)")
# 2. Create pinned_reflections table
op.execute(f"""
@@ -94,21 +234,64 @@ def upgrade() -> None:
# Indexes for pinned_reflections
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING hnsw (embedding vector_cosine_ops)
""")
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
# Full-text search for pinned_reflections
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING gin(search_vector)
""")
if text_search_ext == "vchord":
# VectorChord BM25: bm25vector type (no GENERATED - tokenization happens on INSERT/UPDATE)
# Note: vchord_bm25 extension creates types in bm25_catalog schema
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector bm25_catalog.bm25vector
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector TEXT
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING bm25(content)
WITH (text_config='english')
""")
else: # native
# Native PostgreSQL: tsvector with automatic generation
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING gin(search_vector)
""")
# 3. Add consolidation tracking columns to banks table
op.execute(f"""
@@ -0,0 +1,60 @@
"""Fix mental_models primary key to be scoped per bank
Revision ID: w8r9s0t1u2v3
Revises: v7q8r9s0t1u2
Create Date: 2026-02-05
This migration fixes a critical bank isolation bug where mental_models.id was
globally unique across all banks instead of being scoped per bank. This caused
conflicts when different banks tried to use the same custom ID.
CRITICAL FIX: Changes primary key from (id) to (bank_id, id) to ensure proper isolation.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "w8r9s0t1u2v3"
down_revision: str | Sequence[str] | None = "v7q8r9s0t1u2"
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:
"""Change mental_models primary key from (id) to (bank_id, id) for proper bank isolation."""
schema = _get_schema_prefix()
# Drop the old primary key constraint (just id)
# Note: The constraint might be named differently on different DBs
# Try both old names (pinned_reflections_pkey from original, mental_models_pkey from rename)
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS pinned_reflections_pkey")
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS mental_models_pkey")
# Create the new composite primary key (bank_id, id)
# This ensures IDs are scoped per bank, not globally
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT mental_models_pkey PRIMARY KEY (bank_id, id)
""")
def downgrade() -> None:
"""Revert mental_models primary key from (bank_id, id) to (id)."""
schema = _get_schema_prefix()
# Drop the composite primary key
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS mental_models_pkey")
# Restore the old primary key (just id)
# WARNING: This downgrade will fail if there are duplicate IDs across banks
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT mental_models_pkey PRIMARY KEY (id)
""")
@@ -0,0 +1,64 @@
"""Add config JSONB column to banks table for hierarchical configuration
Revision ID: x9s0t1u2v3w4
Revises: w8r9s0t1u2v3
Create Date: 2026-02-09
This migration adds a `config` JSONB column to the banks table to support
per-bank configuration overrides. This enables hierarchical configuration where:
- Global config is loaded from environment variables
- Tenant config is provided via TenantExtension
- Bank config overrides are stored in banks.config JSONB column
The config column stores overrides for hierarchical fields (LLM settings,
retention parameters, retrieval settings, etc.) in Python field name format.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "x9s0t1u2v3w4"
down_revision: str | Sequence[str] | None = "w8r9s0t1u2v3"
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:
"""Add config JSONB column to banks table with GIN index."""
schema = _get_schema_prefix()
# Add config column to banks table
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN config JSONB NOT NULL DEFAULT '{{}}'::jsonb
""")
# Add GIN index for efficient JSONB queries
op.execute(f"""
CREATE INDEX idx_banks_config
ON {schema}banks
USING gin(config)
""")
def downgrade() -> None:
"""Remove config column and index from banks table."""
schema = _get_schema_prefix()
# Drop index first
op.execute(f"DROP INDEX IF EXISTS {schema}idx_banks_config")
# Drop column
op.execute(f"""
ALTER TABLE {schema}banks
DROP COLUMN IF EXISTS config
""")
@@ -0,0 +1,49 @@
"""Add GIN index on async_operations.result_metadata for parent_operation_id queries
Revision ID: y0t1u2v3w4x5
Revises: x9s0t1u2v3w4
Create Date: 2026-02-13
This migration adds a GIN index on the result_metadata JSONB column in the
async_operations table to support efficient queries for child operations by
parent_operation_id.
The index enables fast lookups when querying for child operations:
SELECT * FROM async_operations
WHERE result_metadata::jsonb @> '{"parent_operation_id": "uuid"}'::jsonb
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "y0t1u2v3w4x5"
down_revision: str | Sequence[str] | None = "x9s0t1u2v3w4"
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:
"""Add GIN index on result_metadata for efficient parent_operation_id queries."""
schema = _get_schema_prefix()
# Add GIN index for JSONB containment queries (@> operator)
op.execute(f"""
CREATE INDEX idx_async_operations_result_metadata
ON {schema}async_operations
USING gin(result_metadata)
""")
def downgrade() -> None:
"""Remove GIN index on result_metadata."""
schema = _get_schema_prefix()
# Drop index
op.execute(f"DROP INDEX IF EXISTS {schema}idx_async_operations_result_metadata")
+29 -19
View File
@@ -6,7 +6,6 @@ Provides both HTTP REST API and MCP (Model Context Protocol) server.
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI
@@ -46,14 +45,14 @@ def create_app(
# Both HTTP and MCP
app = create_app(memory, mcp_api_enabled=True)
"""
mcp_app = None
mcp_servers = None
# Create MCP app first if enabled (we need its lifespan for chaining)
# Create MCP servers first if enabled (we need their lifespans for chaining)
if mcp_api_enabled:
try:
from .mcp import create_mcp_app
from .mcp import MCPMiddleware, create_mcp_servers
mcp_app = create_mcp_app(memory=memory)
mcp_servers = create_mcp_servers(memory=memory)
except ImportError as e:
logger.error(f"MCP server requested but dependencies not available: {e}")
logger.error("Install with: pip install hindsight-api[mcp]")
@@ -70,30 +69,41 @@ def create_app(
app = FastAPI(title="Hindsight API", version="0.0.7")
logger.info("HTTP REST API disabled")
# Mount MCP server and chain its lifespan if enabled
if mcp_app is not None:
# Get the MCP app's underlying Starlette app for lifespan access
mcp_starlette_app = mcp_app.mcp_app
# Add MCP middleware and chain its lifespan if enabled
if mcp_servers is not None:
multi_bank_server, single_bank_server, multi_bank_starlette_app, single_bank_starlette_app = mcp_servers
# Store the original lifespan
original_lifespan = app.router.lifespan_context
@asynccontextmanager
async def chained_lifespan(app_instance: FastAPI):
"""Chain the MCP lifespan with the main app lifespan."""
# Start MCP lifespan first
async with mcp_starlette_app.router.lifespan_context(mcp_starlette_app):
logger.info("MCP lifespan started")
# Then start the original app lifespan
async with original_lifespan(app_instance):
yield
logger.info("MCP lifespan stopped")
"""Chain both MCP lifespans with the main app lifespan."""
# Start both MCP lifespans (multi-bank and single-bank)
async with multi_bank_starlette_app.router.lifespan_context(multi_bank_starlette_app):
async with single_bank_starlette_app.router.lifespan_context(single_bank_starlette_app):
logger.info("MCP lifespans started (multi-bank and single-bank)")
# Then start the original app lifespan
async with original_lifespan(app_instance):
yield
logger.info("MCP lifespans stopped")
# Replace the app's lifespan with the chained version
app.router.lifespan_context = chained_lifespan
# Mount the MCP middleware
app.mount(mcp_mount_path, mcp_app)
# Add MCP as a wrapping middleware — intercepts /mcp* requests directly,
# passes everything else through to the FastAPI app. No Starlette Mount
# means no 307 redirect for /mcp (no trailing slash).
app.add_middleware(
MCPMiddleware,
memory=memory,
prefix=mcp_mount_path,
multi_bank_app=multi_bank_starlette_app,
single_bank_app=single_bank_starlette_app,
multi_bank_server=multi_bank_server,
single_bank_server=single_bank_server,
)
logger.info(f"MCP server enabled at {mcp_mount_path}/")
return app
+523 -103
View File
@@ -5,6 +5,7 @@ This module provides the create_app function to create and configure
the FastAPI application with all API endpoints.
"""
import asyncio
import json
import logging
import uuid
@@ -12,7 +13,7 @@ from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any, Literal
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
from hindsight_api.extensions import AuthenticationError
@@ -31,11 +32,47 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]:
return {}
from typing import Callable
from pydantic import BaseModel, ConfigDict, Field, field_validator
from hindsight_api import MemoryEngine
def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
"""
Field wrapper that ensures default_factory values appear in OpenAPI schema.
Pydantic doesn't include default_factory in OpenAPI schemas, causing OpenAPI
Generator to make fields Optional with default=None instead of non-optional
with the correct default value.
This wrapper adds json_schema_extra to include the default in the schema.
"""
# Determine the default value for the schema based on the factory
if default_factory is list:
schema_default = []
elif default_factory is dict:
schema_default = {}
else:
# For custom factories (like IncludeOptions), use empty dict as placeholder
schema_default = {}
# Add or merge json_schema_extra
json_extra = kwargs.pop("json_schema_extra", {})
if isinstance(json_extra, dict):
json_extra["default"] = schema_default
else:
# If json_schema_extra was a function, we can't merge easily
# Fall back to just setting default
json_extra = {"default": schema_default}
return Field(default_factory=default_factory, json_schema_extra=json_extra, **kwargs)
from hindsight_api.config import get_config
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, fq_table
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
@@ -45,6 +82,8 @@ 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."""
@@ -100,8 +139,8 @@ class RecallRequest(BaseModel):
query_timestamp: str | None = Field(
default=None, description="ISO format date string (e.g., '2023-05-30T23:40:00')"
)
include: IncludeOptions = Field(
default_factory=IncludeOptions,
include: IncludeOptions = FieldWithDefault(
IncludeOptions,
description="Options for including additional data (entities are included by default)",
)
tags: list[str] | None = Field(
@@ -391,6 +430,36 @@ class RetainRequest(BaseModel):
)
class FileRetainMetadata(BaseModel):
"""Metadata for a single file in file retain request."""
document_id: str | None = Field(default=None, description="Document ID (auto-generated if not provided)")
context: str | None = Field(default=None, description="Context for the file")
metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata")
tags: list[str] | None = Field(default=None, description="Tags for this file")
timestamp: str | None = Field(default=None, description="ISO timestamp")
class FileRetainRequest(BaseModel):
"""Request model for file retain endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"files_metadata": [
{"document_id": "report_2024", "tags": ["quarterly"]},
{"context": "meeting notes"},
],
}
}
)
files_metadata: list[FileRetainMetadata] | None = Field(
default=None,
description="Metadata for each file (optional, must match number of files if provided)",
)
class RetainResponse(BaseModel):
"""Response model for retain endpoint."""
@@ -415,7 +484,7 @@ 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 and find this ID. 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.",
)
usage: TokenUsage | None = Field(
default=None,
@@ -423,6 +492,26 @@ class RetainResponse(BaseModel):
)
class FileRetainResponse(BaseModel):
"""Response model for file upload endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"operation_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001",
"550e8400-e29b-41d4-a716-446655440002",
],
}
},
)
operation_ids: list[str] = Field(
description="Operation IDs for tracking file conversion operations. Use GET /v1/default/banks/{bank_id}/operations to list operations."
)
class FactsIncludeOptions(BaseModel):
"""Options for including facts (based_on) in reflect results."""
@@ -520,7 +609,9 @@ class ReflectFact(BaseModel):
)
id: str | None = None
text: str
text: str = Field(
description="Fact text. When type='observation', this contains markdown-formatted consolidated knowledge"
)
type: str | None = None # fact type: world, experience, observation
context: str | None = None
occurred_start: str | None = None
@@ -565,18 +656,16 @@ class ReflectLLMCall(BaseModel):
class ReflectBasedOn(BaseModel):
"""Evidence the response is based on: memories, mental models, and directives."""
memories: list[ReflectFact] = Field(default_factory=list, description="Memory facts used to generate the response")
mental_models: list[ReflectMentalModel] = Field(
default_factory=list, description="Mental models used during reflection"
)
directives: list[ReflectDirective] = Field(default_factory=list, description="Directives applied during reflection")
memories: list[ReflectFact] = FieldWithDefault(list, description="Memory facts used to generate the response")
mental_models: list[ReflectMentalModel] = FieldWithDefault(list, description="Mental models used during reflection")
directives: list[ReflectDirective] = FieldWithDefault(list, description="Directives applied during reflection")
class ReflectTrace(BaseModel):
"""Execution trace of LLM and tool calls during reflection."""
tool_calls: list[ReflectToolCall] = Field(default_factory=list, description="Tool calls made during reflection")
llm_calls: list[ReflectLLMCall] = Field(default_factory=list, description="LLM calls made during reflection")
tool_calls: list[ReflectToolCall] = FieldWithDefault(list, description="Tool calls made during reflection")
llm_calls: list[ReflectLLMCall] = FieldWithDefault(list, description="LLM calls made during reflection")
class ReflectResponse(BaseModel):
@@ -585,7 +674,7 @@ class ReflectResponse(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"text": "Based on my understanding, AI is a transformative technology...",
"text": "## AI Overview\n\nBased on my understanding, AI is a **transformative technology**:\n\n- Used extensively in healthcare\n- Discussed in recent conversations\n- Continues to evolve rapidly",
"based_on": {
"memories": [
{"id": "123", "text": "AI is used in healthcare", "type": "world"},
@@ -613,7 +702,9 @@ class ReflectResponse(BaseModel):
}
)
text: str
text: str = Field(
description="The reflect response as well-formatted markdown (headers, lists, bold/italic, code blocks, etc.)"
)
based_on: ReflectBasedOn | None = Field(
default=None,
description="Evidence used to generate the response. Only present when include.facts is set.",
@@ -786,6 +877,55 @@ class CreateBankRequest(BaseModel):
background: str | None = Field(default=None, description="Deprecated: use mission instead")
class BankConfigUpdate(BaseModel):
"""Request model for updating bank configuration."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"updates": {
"llm_model": "claude-sonnet-4-5",
"retain_extraction_mode": "verbose",
"retain_custom_instructions": "Extract technical details carefully",
}
}
}
)
updates: dict[str, Any] = Field(
description="Configuration overrides. Keys can be in Python field format (llm_provider) "
"or environment variable format (HINDSIGHT_API_LLM_PROVIDER). "
"Only hierarchical fields can be overridden per-bank."
)
class BankConfigResponse(BaseModel):
"""Response model for bank configuration."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"bank_id": "my-bank",
"config": {
"llm_provider": "openai",
"llm_model": "gpt-4",
"retain_extraction_mode": "verbose",
},
"overrides": {
"llm_model": "gpt-4",
"retain_extraction_mode": "verbose",
},
}
}
)
bank_id: str = Field(description="Bank identifier")
config: dict[str, Any] = Field(
description="Fully resolved configuration with all hierarchical overrides applied (Python field names)"
)
overrides: dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)")
class GraphDataResponse(BaseModel):
"""Response model for graph data endpoint."""
@@ -863,6 +1003,7 @@ class ListDocumentsResponse(BaseModel):
"updated_at": "2024-01-15T10:30:00Z",
"text_length": 5420,
"memory_unit_count": 15,
"tags": ["user_a", "session_123"],
}
],
"total": 50,
@@ -934,7 +1075,7 @@ class DocumentResponse(BaseModel):
created_at: str
updated_at: str
memory_unit_count: int
tags: list[str] = Field(default_factory=list, description="Tags associated with this document")
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
class DeleteDocumentResponse(BaseModel):
@@ -1058,7 +1199,7 @@ class DirectiveResponse(BaseModel):
content: str
priority: int = 0
is_active: bool = True
tags: list[str] = Field(default_factory=list)
tags: list[str] = FieldWithDefault(list)
created_at: str | None = None
updated_at: str | None = None
@@ -1076,7 +1217,7 @@ class CreateDirectiveRequest(BaseModel):
content: str = Field(description="The directive text to inject into prompts")
priority: int = Field(default=0, description="Higher priority directives are injected first")
is_active: bool = Field(default=True, description="Whether this directive is active")
tags: list[str] = Field(default_factory=list, description="Tags for filtering")
tags: list[str] = FieldWithDefault(list, description="Tags for filtering")
class UpdateDirectiveRequest(BaseModel):
@@ -1110,10 +1251,12 @@ class MentalModelResponse(BaseModel):
bank_id: str
name: str
source_query: str
content: str
tags: list[str] = Field(default_factory=list)
content: str = Field(
description="The mental model content as well-formatted markdown (auto-generated from reflect endpoint)"
)
tags: list[str] = FieldWithDefault(list)
max_tokens: int = Field(default=2048)
trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger)
trigger: MentalModelTrigger = FieldWithDefault(MentalModelTrigger)
last_refreshed_at: str | None = None
created_at: str | None = None
reflect_response: dict | None = Field(
@@ -1134,6 +1277,7 @@ class CreateMentalModelRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "team-communication",
"name": "Team Communication Preferences",
"source_query": "How does the team prefer to communicate?",
"tags": ["team"],
@@ -1143,17 +1287,21 @@ class CreateMentalModelRequest(BaseModel):
}
)
id: str | None = Field(
None, description="Optional custom ID for the mental model (alphanumeric lowercase with hyphens)"
)
name: str = Field(description="Human-readable name for the mental model")
source_query: str = Field(description="The query to run to generate content")
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
tags: list[str] = FieldWithDefault(list, description="Tags for scoped visibility")
max_tokens: int = Field(default=2048, ge=256, le=8192, description="Maximum tokens for generated content")
trigger: MentalModelTrigger = Field(default_factory=MentalModelTrigger, description="Trigger settings")
trigger: MentalModelTrigger = FieldWithDefault(MentalModelTrigger, description="Trigger settings")
class CreateMentalModelResponse(BaseModel):
"""Response model for mental model creation."""
operation_id: str = Field(description="Operation ID to track progress")
mental_model_id: str | None = Field(None, description="ID of the created mental model")
operation_id: str = Field(description="Operation ID to track refresh progress")
class UpdateMentalModelRequest(BaseModel):
@@ -1259,6 +1407,16 @@ class CancelOperationResponse(BaseModel):
operation_id: str
class ChildOperationStatus(BaseModel):
"""Status of a child operation (for batch operations)."""
operation_id: str
status: str
sub_batch_index: int | None = None
items_count: int | None = None
error_message: str | None = None
class OperationStatusResponse(BaseModel):
"""Response model for getting a single operation status."""
@@ -1283,6 +1441,13 @@ class OperationStatusResponse(BaseModel):
updated_at: str | None = None
completed_at: str | None = None
error_message: str | None = None
result_metadata: dict[str, Any] | None = Field(
default=None,
description="Internal metadata for debugging. Structure may change without notice. Not for production use.",
)
child_operations: list[ChildOperationStatus] | None = Field(
default=None, description="Child operations for batch operations (if applicable)"
)
class AsyncOperationSubmitResponse(BaseModel):
@@ -1307,6 +1472,8 @@ class FeaturesInfo(BaseModel):
observations: bool = Field(description="Whether observations (auto-consolidation) are enabled")
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: bool = Field(description="Whether the background worker is enabled")
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled")
class VersionResponse(BaseModel):
@@ -1320,6 +1487,8 @@ class VersionResponse(BaseModel):
"observations": False,
"mcp": True,
"worker": True,
"bank_config_api": False,
"file_upload_api": True,
},
}
}
@@ -1385,6 +1554,26 @@ def create_app(
app.state.prometheus_reader = None
# Metrics collector is already initialized as no-op by default
# Initialize OpenTelemetry tracing if enabled
if config.otel_traces_enabled:
if not config.otel_exporter_otlp_endpoint:
logging.warning("OTEL tracing enabled but no endpoint configured. Tracing disabled.")
else:
from hindsight_api.tracing import create_span_recorder, initialize_tracing
try:
initialize_tracing(
service_name=config.otel_service_name,
endpoint=config.otel_exporter_otlp_endpoint,
headers=config.otel_exporter_otlp_headers,
deployment_environment=config.otel_deployment_environment,
)
create_span_recorder()
logging.info("OpenTelemetry tracing enabled and configured")
except Exception as e:
logging.error(f"Failed to initialize tracing: {e}")
logging.warning("Continuing without tracing")
# Startup: Initialize database and memory system (migrations run inside initialize if enabled)
if initialize_memory:
await memory.initialize()
@@ -1417,6 +1606,12 @@ def create_app(
poller_task = asyncio.create_task(poller.run())
logging.info(f"Worker poller started (worker_id={worker_id})")
# Call tenant extension startup hook (e.g. JWKS fetch for Supabase)
tenant_extension = memory.tenant_extension
if tenant_extension:
await tenant_extension.on_startup()
logging.info("Tenant extension started")
# Call HTTP extension startup hook
if http_extension:
await http_extension.on_startup()
@@ -1435,6 +1630,11 @@ def create_app(
pass
logging.info("Worker poller stopped")
# Call tenant extension shutdown hook
if tenant_extension:
await tenant_extension.on_shutdown()
logging.info("Tenant extension stopped")
# Call HTTP extension shutdown hook
if http_extension:
await http_extension.on_shutdown()
@@ -1445,6 +1645,9 @@ def create_app(
logging.info("Memory system closed")
from hindsight_api import __version__
from hindsight_api.config import get_config
config = get_config()
app = FastAPI(
title="Hindsight HTTP API",
@@ -1458,6 +1661,7 @@ def create_app(
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
},
lifespan=lifespan,
root_path=config.base_path,
)
# IMPORTANT: Set memory on app.state immediately, don't wait for lifespan
@@ -1564,17 +1768,22 @@ def _register_routes(app: FastAPI):
Returns version info and feature flags that can be used by clients
to determine which capabilities are available.
Note: observations flag shows the global default. Individual banks
may override this setting via bank-specific configuration.
"""
from hindsight_api import __version__
from hindsight_api.config import get_config
from hindsight_api.config import _get_raw_config
config = get_config()
config = _get_raw_config()
return VersionResponse(
api_version=__version__,
features=FeaturesInfo(
observations=config.enable_observations,
mcp=config.mcp_enabled,
worker=config.worker_enabled,
bank_config_api=config.enable_bank_config_api,
file_upload_api=config.enable_file_upload_api,
),
)
@@ -1718,6 +1927,15 @@ def _register_routes(app: FastAPI):
handler_start = time.time()
metrics = get_metrics_collector()
# Validate query length to prevent expensive operations on oversized queries
encoding = _get_tiktoken_encoding()
query_tokens = len(encoding.encode(request.query))
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.",
)
try:
# Default to world and experience if not specified (exclude observation)
fact_types = request.types if request.types else list(VALID_RECALL_FACT_TYPES)
@@ -1832,6 +2050,15 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except (asyncio.TimeoutError, TimeoutError):
handler_duration = time.time() - handler_start
logger.error(
f"[RECALL TIMEOUT] bank={bank_id} handler_duration={handler_duration:.3f}s - database query timed out"
)
raise HTTPException(
status_code=504,
detail="Request timed out while searching memories. Try a shorter or more specific query.",
)
except Exception as e:
import traceback
@@ -1889,17 +2116,17 @@ def _register_routes(app: FastAPI):
directives = []
for fact_type, facts in core_result.based_on.items():
if fact_type == "directives":
# Directives have different structure (id, name, content)
# Directives are dicts with id, name, content (not MemoryFact objects)
for directive in facts:
directives.append(
ReflectDirective(
id=directive.id,
name=directive.name,
content=directive.content,
id=directive["id"],
name=directive["name"],
content=directive["content"],
)
)
elif fact_type == "mental_models":
# Mental models are MemoryFact with type "mental_models"
elif fact_type == "mental-models":
# Mental models are MemoryFact with type "mental-models" (note: hyphen, not underscore)
for fact in facts:
mental_models.append(
ReflectMentalModel(
@@ -2290,23 +2517,6 @@ def _register_routes(app: FastAPI):
):
"""Get a mental model by ID."""
try:
# Pre-operation validation hook
validator = app.state.memory._operation_validator
if validator:
from hindsight_api.extensions.operation_validator import MentalModelGetContext
ctx = MentalModelGetContext(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
validation = await validator.validate_mental_model_get(ctx)
if not validation.allowed:
raise OperationValidationError(
validation.reason or "Operation not allowed",
status_code=validation.status_code,
)
mental_model = await app.state.memory.get_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
@@ -2315,25 +2525,6 @@ def _register_routes(app: FastAPI):
if mental_model is None:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
# Post-operation hook
if validator:
from hindsight_api.extensions.operation_validator import MentalModelGetResult
content = mental_model.get("content", "")
output_tokens = len(content) // 4 if content else 0
result_ctx = MentalModelGetResult(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
output_tokens=output_tokens,
success=True,
)
try:
await validator.on_mental_model_get_complete(result_ctx)
except Exception as hook_err:
logger.warning(f"Post-mental-model-get hook error (non-fatal): {hook_err}")
return MentalModelResponse(**mental_model)
except (AuthenticationError, HTTPException):
raise
@@ -2363,29 +2554,13 @@ def _register_routes(app: FastAPI):
):
"""Create a mental model (async - returns operation_id)."""
try:
# Pre-operation validation hook
validator = app.state.memory._operation_validator
if validator:
from hindsight_api.extensions.operation_validator import MentalModelRefreshContext
ctx = MentalModelRefreshContext(
bank_id=bank_id,
mental_model_id=None, # Not yet created
request_context=request_context,
)
validation = await validator.validate_mental_model_refresh(ctx)
if not validation.allowed:
raise OperationValidationError(
validation.reason or "Operation not allowed",
status_code=validation.status_code,
)
# 1. Create the mental model with placeholder content
mental_model = await app.state.memory.create_mental_model(
bank_id=bank_id,
name=body.name,
source_query=body.source_query,
content="Generating content...",
mental_model_id=body.id if body.id else None,
tags=body.tags if body.tags else None,
max_tokens=body.max_tokens,
trigger=body.trigger.model_dump() if body.trigger else None,
@@ -2397,7 +2572,7 @@ def _register_routes(app: FastAPI):
mental_model_id=mental_model["id"],
request_context=request_context,
)
return CreateMentalModelResponse(operation_id=result["operation_id"])
return CreateMentalModelResponse(mental_model_id=mental_model["id"], operation_id=result["operation_id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
@@ -2426,23 +2601,6 @@ def _register_routes(app: FastAPI):
):
"""Refresh a mental model by re-running its source query (async)."""
try:
# Pre-operation validation hook
validator = app.state.memory._operation_validator
if validator:
from hindsight_api.extensions.operation_validator import MentalModelRefreshContext
ctx = MentalModelRefreshContext(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
validation = await validator.validate_mental_model_refresh(ctx)
if not validation.allowed:
raise OperationValidationError(
validation.reason or "Operation not allowed",
status_code=validation.status_code,
)
result = await app.state.memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
@@ -3279,6 +3437,112 @@ 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.get(
"/v1/default/banks/{bank_id}/config",
response_model=BankConfigResponse,
summary="Get bank configuration",
description="Get fully resolved configuration for a bank including all hierarchical overrides (global → tenant → bank). "
"The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.",
operation_id="get_bank_config",
tags=["Banks"],
)
async def api_get_bank_config(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Get configuration for a bank with all hierarchical overrides applied."""
if not get_config().enable_bank_config_api:
raise HTTPException(
status_code=404,
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Get resolved config from config resolver
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
# Get bank-specific overrides only
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
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 GET /v1/default/banks/{bank_id}/config: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/config",
response_model=BankConfigResponse,
summary="Update bank configuration",
description="Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). "
"Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).",
operation_id="update_bank_config",
tags=["Banks"],
)
async def api_update_bank_config(
bank_id: str, request: BankConfigUpdate, request_context: RequestContext = Depends(get_request_context)
):
"""Update configuration overrides for a bank."""
if not get_config().enable_bank_config_api:
raise HTTPException(
status_code=404,
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Update config via config resolver (validates configurable fields and permissions)
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
# Return updated config
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
except ValueError as e:
# Validation error (e.g., trying to override static field)
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/config: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/config",
response_model=BankConfigResponse,
summary="Reset bank configuration",
description="Reset bank configuration to defaults by removing all bank-specific overrides. "
"The bank will then use global and tenant-level configuration only.",
operation_id="reset_bank_config",
tags=["Banks"],
)
async def api_reset_bank_config(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Reset bank configuration to defaults (remove all overrides)."""
if not get_config().enable_bank_config_api:
raise HTTPException(
status_code=404,
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
)
try:
# Reset config via config resolver
await app.state.memory._config_resolver.reset_bank_config(bank_id)
# Return updated config (should match defaults now)
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
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 DELETE /v1/default/banks/{bank_id}/config: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/consolidate",
response_model=ConsolidationResponse,
@@ -3369,6 +3633,21 @@ def _register_routes(app: FastAPI):
}
)
else:
# Check if batch API is enabled - if so, require async mode
from hindsight_api.config import get_config
config = get_config()
if config.retain_batch_enabled:
raise HTTPException(
status_code=400,
detail=(
"Batch API is enabled (HINDSIGHT_API_RETAIN_BATCH_ENABLED=true) but async=false. "
"Batch operations can take several minutes to hours and will timeout in synchronous mode. "
"Please set async=true in your request to use background processing, or disable batch API "
"by setting HINDSIGHT_API_RETAIN_BATCH_ENABLED=false in your environment."
),
)
# Synchronous processing: wait for completion (record metrics)
with metrics.record_operation("retain", bank_id=bank_id, source="api"):
result, usage = await app.state.memory.retain_batch_async(
@@ -3406,6 +3685,147 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/memories (retain): {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/files/retain",
response_model=FileRetainResponse,
summary="Convert files to memories",
description="Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\n"
"This endpoint handles file upload, conversion, and memory creation in a single operation.\n\n"
"**Features:**\n"
"- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n"
"- Automatic file-to-markdown conversion using pluggable parsers\n"
"- Files stored in object storage (PostgreSQL by default, S3 for production)\n"
"- Each file becomes a separate document with optional metadata/tags\n"
"- Always processes asynchronously — returns operation IDs immediately\n\n"
"**The system automatically:**\n"
"1. Stores uploaded files in object storage\n"
"2. Converts files to markdown\n"
"3. Creates document records with file metadata\n"
"4. Extracts facts and creates memory units (same as regular retain)\n\n"
"Use the operations endpoint to monitor progress.\n\n"
"**Request format:** multipart/form-data with:\n"
"- `files`: One or more files to upload\n"
"- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n"
"**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).",
operation_id="file_retain",
tags=["Files"],
)
async def api_file_retain(
bank_id: str,
files: list[UploadFile] = File(..., description="Files to upload and convert"),
request: str = Form(..., description="JSON string with FileRetainRequest model"),
request_context: RequestContext = Depends(get_request_context),
):
"""Upload and convert files to memories."""
from hindsight_api.config import get_config
config = get_config()
# Check if file upload API is enabled
if not config.enable_file_upload_api:
raise HTTPException(
status_code=404,
detail="File upload API is disabled. Set HINDSIGHT_API_ENABLE_FILE_UPLOAD_API=true to enable.",
)
try:
# Parse request JSON
try:
request_data = FileRetainRequest.model_validate_json(request)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid request JSON: {str(e)}",
)
# Validate file count
if len(files) > config.file_conversion_max_batch_size:
raise HTTPException(
status_code=400,
detail=f"Too many files. Maximum {config.file_conversion_max_batch_size} files per request.",
)
# Validate files_metadata count matches files count if provided
if request_data.files_metadata and len(request_data.files_metadata) != len(files):
raise HTTPException(
status_code=400,
detail=f"files_metadata count ({len(request_data.files_metadata)}) must match files count ({len(files)})",
)
# Prepare file items and calculate total batch size
file_items = []
total_batch_size = 0
for i, file in enumerate(files):
# Read file content to check size
file_content = await file.read()
size = len(file_content)
total_batch_size += size
# Create a temporary file-like object from the bytes
import io
file_obj = io.BytesIO(file_content)
# Create a mock UploadFile with the necessary attributes
class FileWrapper:
def __init__(self, content, filename, content_type):
self._content = content
self.filename = filename
self.content_type = content_type
self._buffer = io.BytesIO(content)
async def read(self):
return self._content
wrapped_file = FileWrapper(file_content, file.filename, file.content_type)
# Get per-file metadata
file_meta = request_data.files_metadata[i] if request_data.files_metadata else FileRetainMetadata()
doc_id = file_meta.document_id or f"file_{uuid.uuid4()}"
item = {
"file": wrapped_file,
"document_id": doc_id,
"context": file_meta.context,
"metadata": file_meta.metadata or {},
"tags": file_meta.tags or [],
"timestamp": file_meta.timestamp,
}
file_items.append(item)
# Check total batch size after processing all files
if total_batch_size > config.file_conversion_max_batch_size_bytes:
total_mb = total_batch_size / (1024 * 1024)
raise HTTPException(
status_code=400,
detail=f"Total batch size ({total_mb:.1f}MB) exceeds maximum of {config.file_conversion_max_batch_size_mb}MB",
)
result = await app.state.memory.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser=config.file_parser,
document_tags=None,
request_context=request_context,
)
return FileRetainResponse.model_validate(
{
"operation_ids": result["operation_ids"],
}
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in /v1/default/banks/{bank_id}/files/retain: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/memories",
response_model=DeleteResponse,
+221 -65
View File
@@ -8,7 +8,11 @@ from contextvars import ContextVar
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
from hindsight_api.models import RequestContext
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
@@ -29,7 +33,8 @@ logger = logging.getLogger(__name__)
# Default bank_id from environment variable
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
# MCP authentication token (optional - if set, Bearer token auth is required)
# Legacy MCP authentication token (for backwards compatibility)
# If set, this token is checked first before TenantExtension auth
MCP_AUTH_TOKEN = os.environ.get("HINDSIGHT_API_MCP_AUTH_TOKEN")
# Context variable to hold the current bank_id
@@ -38,6 +43,10 @@ _current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default
# Context variable to hold the current API key (for tenant auth propagation)
_current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None)
# Context variables for tenant_id and api_key_id (set by authenticate, used by usage metering)
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
@@ -49,57 +58,161 @@ def get_current_api_key() -> str | None:
return _current_api_key.get()
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
def get_current_tenant_id() -> str | None:
"""Get the current tenant_id from context."""
return _current_tenant_id.get()
def get_current_api_key_id() -> str | None:
"""Get the current api_key_id from context."""
return _current_api_key_id.get()
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
Args:
memory: MemoryEngine instance (required)
multi_bank: If True, expose all tools with bank_id parameters (default).
If False, only expose bank-scoped tools without bank_id parameters.
Returns:
Configured FastMCP server instance with stateless_http enabled
Configured FastMCP server instance
"""
# Use stateless_http=True for Claude Code compatibility
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
mcp = FastMCP("hindsight-mcp-server")
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
tools=None, # All tools
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
include_bank_id_param=multi_bank,
tools=None
if multi_bank
else {
"retain",
"recall",
"reflect",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
}, # Scoped tools for single-bank mode (excludes bank management: list_banks, create_bank)
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
)
register_mcp_tools(mcp, memory, config)
# Load and register additional tools from MCP extension if configured
mcp_extension = load_extension("MCP", MCPExtension)
if mcp_extension:
logger.info(f"Loading MCP extension: {mcp_extension.__class__.__name__}")
mcp_extension.register_tools(mcp, memory)
# Make all tools tolerant of extra arguments from LLMs (e.g., "explanation")
_make_tools_tolerant(mcp)
return mcp
def _make_tools_tolerant(mcp: FastMCP) -> None:
"""Wrap all tool run methods to strip unknown arguments before validation.
LLMs frequently add extra fields like "explanation" or "reasoning" to tool calls.
FastMCP's Pydantic TypeAdapter rejects these with "Unexpected keyword argument".
This wraps each tool's run() to filter arguments to only known parameters.
"""
try:
for name, tool in mcp._tool_manager._tools.items():
if hasattr(tool, "parameters") and tool.parameters:
allowed = set(tool.parameters.get("properties", {}).keys())
original_run = tool.run
async def _tolerant_run(arguments, _allowed=allowed, _orig=original_run):
extra_keys = set(arguments.keys()) - _allowed
if extra_keys:
logger.debug(f"Stripping unknown arguments from tool call: {extra_keys}")
arguments = {k: v for k, v in arguments.items() if k in _allowed}
return await _orig(arguments)
# FunctionTool is a Pydantic model with extra='forbid', so use
# object.__setattr__ to bypass Pydantic's setter validation.
object.__setattr__(tool, "run", _tolerant_run)
except (AttributeError, KeyError) as e:
logger.warning(f"Could not make tools tolerant of extra arguments: {e}")
class MCPMiddleware:
"""ASGI middleware that handles authentication and extracts bank_id from header or path.
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
This middleware wraps the main FastAPI app and intercepts requests matching the
configured prefix (default: /mcp). Non-MCP requests pass through to the inner app.
Authentication:
If HINDSIGHT_API_MCP_AUTH_TOKEN is set, all requests must include a valid
Authorization header with Bearer token or direct token matching the configured value.
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
2. Otherwise, uses TenantExtension.authenticate_mcp() from the MemoryEngine
- DefaultTenantExtension: no auth required (local dev)
- ApiKeyTenantExtension: validates against env var
Bank ID can be provided via:
1. X-Bank-Id header (recommended for Claude Code)
2. URL path: /mcp/{bank_id}/
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback default)
Two modes based on URL structure:
For Claude Code, configure with:
1. Multi-bank mode (for /mcp/ root endpoint):
- Exposes all tools: retain, recall, reflect, list_banks, create_bank
- All tools include optional bank_id parameter for cross-bank operations
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var
2. Single-bank mode (for /mcp/{bank_id}/ endpoints):
- Exposes bank-scoped tools only: retain, recall, reflect
- No bank_id parameter (comes from URL)
- No bank management tools (list_banks, create_bank)
- Recommended for agent isolation
Bank ID resolution priority:
1. URL path (e.g., /mcp/{bank_id}/) → single-bank mode
2. X-Bank-Id header → multi-bank mode
3. HINDSIGHT_MCP_BANK_ID env var → multi-bank mode (default: "default")
Examples:
# Single-bank mode (recommended for agent isolation)
claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/ \\
--header "Authorization: Bearer <token>"
# Multi-bank mode (for cross-bank operations)
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
"""
def __init__(self, app, memory: MemoryEngine):
def __init__(
self,
app,
memory: MemoryEngine,
prefix: str = "/mcp",
multi_bank_app=None,
single_bank_app=None,
multi_bank_server=None,
single_bank_server=None,
):
self.app = app
self.prefix = prefix
self.memory = memory
self.mcp_server = create_mcp_server(memory)
self.mcp_app = self.mcp_server.http_app(path="/")
# Expose the lifespan for the parent app to chain
self.lifespan = self.mcp_app.lifespan_handler if hasattr(self.mcp_app, "lifespan_handler") else None
self.tenant_extension = memory._tenant_extension
if multi_bank_app and single_bank_app:
# Pre-created servers (used when called via add_middleware from create_app)
self.multi_bank_app = multi_bank_app
self.single_bank_app = single_bank_app
self.multi_bank_server = multi_bank_server
self.single_bank_server = single_bank_server
else:
# Create servers internally (for direct construction / tests)
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/", stateless_http=True)
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
self.single_bank_app = self.single_bank_server.http_app(path="/", stateless_http=True)
def _get_header(self, scope: dict, name: str) -> str | None:
"""Extract a header value from ASGI scope."""
@@ -111,9 +224,20 @@ class MCPMiddleware:
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.mcp_app(scope, receive, send)
await self.app(scope, receive, send)
return
path = scope.get("path", "")
# Check if this is an MCP request (matches prefix)
if not (path == self.prefix or path.startswith(self.prefix + "/")):
# Not an MCP request — pass through to the inner app
await self.app(scope, receive, send)
return
# Strip prefix from path
path = path[len(self.prefix) :] or "/"
# Extract auth token from header (for tenant auth propagation)
auth_header = self._get_header(scope, "Authorization")
auth_token: str | None = None
@@ -121,62 +245,92 @@ class MCPMiddleware:
# Support both "Bearer <token>" and direct token
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
# Authenticate if MCP_AUTH_TOKEN is configured
# Authenticate: check legacy MCP_AUTH_TOKEN first, then TenantExtension
tenant_context = None
auth_tenant_id: str | None = None
auth_api_key_id: str | None = None
if MCP_AUTH_TOKEN:
# Legacy authentication mode - validate against static token
if not auth_token:
await self._send_error(send, 401, "Authorization header required")
return
if auth_token != MCP_AUTH_TOKEN:
await self._send_error(send, 401, "Invalid authentication token")
return
# Legacy mode doesn't use tenant schemas
tenant_context = None
else:
# Use TenantExtension.authenticate_mcp() for auth
try:
auth_context = RequestContext(api_key=auth_token)
tenant_context = await self.tenant_extension.authenticate_mcp(auth_context)
# Capture tenant_id and api_key_id set by authenticate() for usage metering
auth_tenant_id = auth_context.tenant_id
auth_api_key_id = auth_context.api_key_id
except AuthenticationError as e:
await self._send_error(send, 401, str(e))
return
path = scope.get("path", "")
# Set schema from tenant context so downstream DB queries use the correct schema
schema_token = (
_current_schema.set(tenant_context.schema_name) if tenant_context and tenant_context.schema_name else None
)
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
root_path = scope.get("root_path", "")
if root_path and path.startswith(root_path):
path = path[len(root_path) :] or "/"
# Also handle case where mount path wasn't stripped (e.g., /mcp/...)
if path.startswith("/mcp/"):
path = path[4:] # Remove /mcp prefix
elif path == "/mcp":
path = "/"
# Try to get bank_id from header first (for Claude Code compatibility)
bank_id = self._get_header(scope, "X-Bank-Id")
# MCP endpoint paths that should not be treated as bank_ids
MCP_ENDPOINTS = {"sse", "messages"}
# If no header, try to extract from path: /{bank_id}/...
# Resolve bank_id: path takes priority over header.
# Path = user's explicit connection endpoint (e.g., /mcp/my-bank/).
# X-Bank-Id header = per-request override for multi-bank mode only.
bank_id = None
bank_id_from_path = False
new_path = path
if not bank_id and path.startswith("/") and len(path) > 1:
# First, try to extract from path: /{bank_id}/...
if path.startswith("/") and len(path) > 1:
parts = path[1:].split("/", 1)
# Don't treat MCP endpoints as bank_ids
if parts[0] and parts[0] not in MCP_ENDPOINTS:
# First segment looks like a bank_id
if parts[0]:
bank_id = parts[0]
bank_id_from_path = True
new_path = "/" + parts[1] if len(parts) > 1 else "/"
# If no path-based bank_id, try X-Bank-Id header (multi-bank mode)
if not bank_id:
bank_id = self._get_header(scope, "X-Bank-Id")
# Fall back to default bank_id
if not bank_id:
bank_id = DEFAULT_BANK_ID
logger.debug(f"Using default bank_id: {bank_id}")
# Set bank_id and api_key context
# Select the appropriate MCP app based on how bank_id was provided:
# - Path-based bank_id → single-bank app (no bank_id param, scoped tools)
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
# Set bank_id, api_key, tenant_id, and api_key_id context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Store tenant_id and api_key_id from authentication for usage metering
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
try:
new_scope = scope.copy()
new_scope["path"] = new_path
# Clear root_path since we're passing directly to the app
new_scope["root_path"] = ""
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing
# 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".
is_sse_response = False
async def send_wrapper(message):
if message["type"] == "http.response.body":
nonlocal is_sse_response
if message["type"] == "http.response.start":
for header_name, header_value in message.get("headers", []):
if header_name == b"content-type" and b"text/event-stream" in header_value:
is_sse_response = True
break
if message["type"] == "http.response.body" and bank_id_from_path and is_sse_response:
body = message.get("body", b"")
if body and b"/messages" in body:
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
@@ -184,11 +338,17 @@ class MCPMiddleware:
message = {**message, "body": body}
await send(message)
await self.mcp_app(new_scope, receive, send_wrapper)
await target_app(new_scope, receive, send_wrapper)
finally:
_current_bank_id.reset(bank_id_token)
if api_key_token is not None:
_current_api_key.reset(api_key_token)
if tenant_id_token is not None:
_current_tenant_id.reset(tenant_id_token)
if api_key_id_token is not None:
_current_api_key_id.reset(api_key_id_token)
if schema_token is not None:
_current_schema.reset(schema_token)
async def _send_error(self, send, status: int, message: str):
"""Send an error response."""
@@ -208,23 +368,19 @@ class MCPMiddleware:
)
def create_mcp_app(memory: MemoryEngine):
"""
Create an ASGI app that handles MCP requests.
def create_mcp_servers(memory: MemoryEngine):
"""Create multi-bank and single-bank MCP servers and their Starlette apps.
Authentication:
Set HINDSIGHT_API_MCP_AUTH_TOKEN to require Bearer token authentication.
If not set, MCP endpoint is open (for local development).
Bank ID can be provided via:
1. X-Bank-Id header: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
2. URL path: /mcp/{bank_id}/
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback, default: "default")
Args:
memory: MemoryEngine instance
Returns the servers and apps separately so lifespans can be chained before
the middleware wraps the main app.
Returns:
ASGI application
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
"""
return MCPMiddleware(None, memory)
multi_bank_server = create_mcp_server(memory, multi_bank=True)
multi_bank_app = multi_bank_server.http_app(path="/", stateless_http=True)
single_bank_server = create_mcp_server(memory, multi_bank=False)
single_bank_app = single_bank_server.http_app(path="/", stateless_http=True)
return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app
+7 -1
View File
@@ -4,6 +4,8 @@ Banner display for Hindsight API startup.
Shows the logo and tagline with gradient colors.
"""
from .utils import mask_network_location
# Gradient colors: #0074d9 -> #009296
GRADIENT_START = (0, 116, 217) # #0074d9
GRADIENT_END = (0, 146, 150) # #009296
@@ -84,16 +86,20 @@ def print_startup_info(
reranker_provider: str,
mcp_enabled: bool = False,
version: str | None = None,
vector_extension: str | None = None,
text_search_extension: str | None = None,
):
"""Print styled startup information."""
print(color_start("Starting Hindsight API..."))
if version:
print(f" {dim('Version:')} {color(f'v{version}', 0.1)}")
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
print(f" {dim('Database:')} {color(database_url, 0.4)}")
print(f" {dim('Database:')} {color(mask_network_location(database_url), 0.4)}")
print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}")
print(f" {dim('Embeddings:')} {color(embeddings_provider, 0.8)}")
print(f" {dim('Reranker:')} {color(reranker_provider, 1.0)}")
extensions = f"{vector_extension or 'default'} (vector) / {text_search_extension or 'default'} (text)"
print(f" {dim('Extensions:')} {color(extensions, 0.4)}")
if mcp_enabled:
print(f" {dim('MCP:')} {color_end('enabled at /mcp')}")
print()
+491 -8
View File
@@ -8,8 +8,9 @@ import json
import logging
import os
import sys
from dataclasses import dataclass
from dataclasses import dataclass, field, fields
from datetime import datetime, timezone
from typing import Any
from dotenv import find_dotenv, load_dotenv
@@ -18,6 +19,103 @@ load_dotenv(find_dotenv(usecwd=True), override=True)
logger = logging.getLogger(__name__)
class ConfigFieldAccessError(AttributeError):
"""Raised when trying to access a bank-configurable field from global config."""
pass
class StaticConfigProxy:
"""
Proxy that wraps HindsightConfig and only allows access to static (non-configurable) fields.
Raises ConfigFieldAccessError when trying to access configurable fields that vary per-bank.
Forces developers to use get_resolved_config(bank_id, context) for bank-specific settings.
"""
def __init__(self, config: "HindsightConfig"):
object.__setattr__(self, "_config", config)
object.__setattr__(self, "_configurable_fields", HindsightConfig.get_configurable_fields())
def __getattribute__(self, name: str):
if name.startswith("_"):
return object.__getattribute__(self, name)
configurable_fields = object.__getattribute__(self, "_configurable_fields")
if name in configurable_fields:
raise ConfigFieldAccessError(
f"Field '{name}' is bank-configurable and cannot be accessed from global config. "
f"Use ConfigResolver.resolve_full_config(bank_id, context) to get bank-specific config. "
f"This prevents accidentally using global defaults when bank-specific overrides exist."
)
config = object.__getattribute__(self, "_config")
return getattr(config, name)
def __setattr__(self, name: str, value):
raise AttributeError("Config is read-only. Modifications must go through ConfigResolver.")
# Configuration field markers for hierarchical configuration
def hierarchical(default_value):
"""
Mark a config field as hierarchical (can be overridden per-tenant/bank).
Hierarchical fields can be customized at the tenant or bank level via database
configuration. Examples: LLM settings, retention parameters, retrieval settings.
"""
return field(default=default_value, metadata={"hierarchical": True})
def static(default_value):
"""
Mark a config field as static (server-level only, cannot be overridden).
Static fields are infrastructure-level settings that affect the entire server
and cannot vary per tenant or bank. Examples: database URL, API port, worker settings.
"""
return field(default=default_value, metadata={"hierarchical": False})
# Configuration key normalization utilities
def normalize_config_key(key: str) -> str:
"""
Convert environment variable format to Python field name format.
Examples:
HINDSIGHT_API_LLM_PROVIDER -> llm_provider
LLM_MODEL -> llm_model
llm_model -> llm_model (already normalized)
Args:
key: Environment variable name or Python field name
Returns:
Normalized Python field name (lowercase snake_case)
"""
if key.startswith("HINDSIGHT_API_"):
key = key[len("HINDSIGHT_API_") :]
return key.lower()
def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
"""
Normalize all keys in a config dict to Python field names.
Allows users to provide config overrides in either format:
- Python field format: {"llm_provider": "openai"}
- Env var format: {"HINDSIGHT_API_LLM_PROVIDER": "openai"}
Args:
config: Dict with env var or Python field names as keys
Returns:
Dict with all keys normalized to Python field names
"""
return {normalize_config_key(k): v for k, v in config.items()}
# Environment variable names
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
@@ -31,6 +129,11 @@ ENV_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_LLM_INITIAL_BACKOFF"
ENV_LLM_MAX_BACKOFF = "HINDSIGHT_API_LLM_MAX_BACKOFF"
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
# Defaults for service tiers
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
@@ -66,27 +169,48 @@ ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
# Cohere configuration (separate for embeddings and reranker)
ENV_EMBEDDINGS_COHERE_API_KEY = "HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY"
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
ENV_EMBEDDINGS_COHERE_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL"
ENV_RERANKER_COHERE_API_KEY = "HINDSIGHT_API_RERANKER_COHERE_API_KEY"
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
# LiteLLM gateway configuration (for embeddings and reranker via LiteLLM proxy)
# Deprecated: Legacy shared Cohere API key (for backward compatibility)
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
# LiteLLM configuration (separate for embeddings and reranker)
ENV_EMBEDDINGS_LITELLM_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE"
ENV_EMBEDDINGS_LITELLM_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY"
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"
# LiteLLM SDK configuration (direct API access, no proxy needed)
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY"
ENV_EMBEDDINGS_LITELLM_SDK_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL"
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
# Deprecated: Legacy shared LiteLLM config (for backward compatibility)
ENV_LITELLM_API_BASE = "HINDSIGHT_API_LITELLM_API_BASE"
ENV_LITELLM_API_KEY = "HINDSIGHT_API_LITELLM_API_KEY"
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
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_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"
@@ -94,12 +218,17 @@ ENV_RERANKER_MAX_CANDIDATES = "HINDSIGHT_API_RERANKER_MAX_CANDIDATES"
ENV_RERANKER_FLASHRANK_MODEL = "HINDSIGHT_API_RERANKER_FLASHRANK_MODEL"
ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
ENV_VECTOR_EXTENSION = "HINDSIGHT_API_VECTOR_EXTENSION"
ENV_TEXT_SEARCH_EXTENSION = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION"
ENV_HOST = "HINDSIGHT_API_HOST"
ENV_PORT = "HINDSIGHT_API_PORT"
ENV_BASE_PATH = "HINDSIGHT_API_BASE_PATH"
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
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"
@@ -108,6 +237,13 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
# OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
@@ -119,6 +255,29 @@ ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_RETAIN_BATCH_POLL_INTERVAL_SECONDS"
# File storage configuration
ENV_FILE_STORAGE_TYPE = "HINDSIGHT_API_FILE_STORAGE_TYPE"
ENV_FILE_STORAGE_S3_BUCKET = "HINDSIGHT_API_FILE_STORAGE_S3_BUCKET"
ENV_FILE_STORAGE_S3_REGION = "HINDSIGHT_API_FILE_STORAGE_S3_REGION"
ENV_FILE_STORAGE_S3_ENDPOINT = "HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT"
ENV_FILE_STORAGE_S3_ACCESS_KEY_ID = "HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID"
ENV_FILE_STORAGE_S3_SECRET_ACCESS_KEY = "HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY"
ENV_FILE_STORAGE_GCS_BUCKET = "HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET"
ENV_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY"
ENV_FILE_STORAGE_AZURE_CONTAINER = "HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER"
ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME"
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE"
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN"
# Observations settings (consolidated knowledge from facts)
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
@@ -183,6 +342,7 @@ DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDING_DIMENSION = 384
@@ -190,6 +350,9 @@ DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
)
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
DEFAULT_RERANKER_MAX_CANDIDATES = 300
@@ -199,17 +362,29 @@ DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
# Vector extension (pgvector, vchord, or pgvectorscale)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
# Text search extension (native PostgreSQL, vchord BM25, or Timescale pg_textsearch)
DEFAULT_TEXT_SEARCH_EXTENSION = "native" # Options: "native", "vchord", "pg_textsearch"
# LiteLLM defaults
DEFAULT_LITELLM_API_BASE = "http://localhost:4000"
DEFAULT_EMBEDDINGS_LITELLM_MODEL = "text-embedding-3-small"
DEFAULT_RERANKER_LITELLM_MODEL = "cohere/rerank-english-v3.0"
# LiteLLM SDK defaults
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8888
DEFAULT_BASE_PATH = "" # Empty string = root path
DEFAULT_LOG_LEVEL = "info"
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
DEFAULT_WORKERS = 1
DEFAULT_MCP_ENABLED = True
DEFAULT_ENABLE_BANK_CONFIG_API = False # Disabled by default for security
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
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
@@ -224,6 +399,17 @@ 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
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in seconds
# File storage defaults
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
DEFAULT_FILE_PARSER = "markitdown" # File parser to use (markitdown is the only supported parser)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage)
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
@@ -251,6 +437,11 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
# OpenTelemetry tracing configuration
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -329,6 +520,8 @@ class HindsightConfig:
# Database
database_url: str
database_schema: str
vector_extension: str # "pgvector" or "vchord"
text_search_extension: str # "native" or "vchord"
# LLM (default, used as fallback for per-operation config)
llm_provider: str
@@ -340,6 +533,8 @@ class HindsightConfig:
llm_initial_backoff: float
llm_max_backoff: float
llm_timeout: float
llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto"
llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper)
# Vertex AI configuration
llm_vertexai_project_id: str | None
@@ -381,27 +576,47 @@ class HindsightConfig:
embeddings_provider: str
embeddings_local_model: str
embeddings_local_force_cpu: bool
embeddings_local_trust_remote_code: bool
embeddings_tei_url: str | None
embeddings_openai_base_url: str | None
embeddings_cohere_api_key: str | None
embeddings_cohere_model: str
embeddings_cohere_base_url: str | None
embeddings_litellm_api_base: str
embeddings_litellm_api_key: str | None
embeddings_litellm_model: str
embeddings_litellm_sdk_api_key: str | None
embeddings_litellm_sdk_model: str
embeddings_litellm_sdk_api_base: str | None
# Reranker
reranker_provider: str
reranker_local_model: str
reranker_local_force_cpu: bool
reranker_local_max_concurrent: int
reranker_local_trust_remote_code: bool
reranker_tei_url: str | None
reranker_tei_batch_size: int
reranker_tei_max_concurrent: int
reranker_max_candidates: int
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
reranker_litellm_api_base: str
reranker_litellm_api_key: str | None
reranker_litellm_model: str
reranker_litellm_sdk_api_key: str | None
reranker_litellm_sdk_model: str
reranker_litellm_sdk_api_base: str | None
# Server
host: str
port: int
base_path: str
log_level: str
log_format: str
mcp_enabled: bool
enable_bank_config_api: bool
# Recall
graph_retriever: str
@@ -416,6 +631,29 @@ class HindsightConfig:
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_custom_instructions: str | None
retain_batch_tokens: int
retain_batch_enabled: bool
retain_batch_poll_interval_seconds: int
# File storage (static - server-level only)
file_storage_type: str # "native" (PostgreSQL) or "s3" (S3-compatible)
file_storage_s3_bucket: str | None # S3 bucket name (required for s3 storage)
file_storage_s3_region: str | None # S3 region (optional, uses SDK default)
file_storage_s3_endpoint: str | None # S3 endpoint URL (for MinIO, R2, etc.)
file_storage_s3_access_key_id: str | None # S3 access key (optional, uses env/IAM)
file_storage_s3_secret_access_key: str | None # S3 secret key (optional, uses env/IAM)
file_storage_gcs_bucket: str | None # GCS bucket name (required for gcs storage)
file_storage_gcs_service_account_key: str | None # GCS service account key JSON (optional, uses ADC)
file_storage_azure_container: str | None # Azure container name (required for azure storage)
file_storage_azure_account_name: str | None # Azure storage account name
file_storage_azure_account_key: str | None # Azure storage account key
file_parser: str # File parser to use (e.g., "markitdown", "iris")
file_parser_iris_token: str | None # Vectorize API token for iris parser (VECTORIZE_TOKEN)
file_parser_iris_org_id: str | None # Vectorize org ID for iris parser (VECTORIZE_ORG_ID)
file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined)
file_conversion_max_batch_size: int # Max files per request
enable_file_upload_api: bool
file_delete_after_retain: bool
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
@@ -447,6 +685,141 @@ class HindsightConfig:
# Reflect agent settings
reflect_max_iterations: int
# OpenTelemetry tracing configuration
otel_traces_enabled: bool
otel_exporter_otlp_endpoint: str | None
otel_exporter_otlp_headers: str | None
otel_service_name: str
otel_deployment_environment: str
# Class-level sets for configuration categorization
# CREDENTIAL_FIELDS: Never exposed via API, never configurable per-tenant/bank
_CREDENTIAL_FIELDS = {
# API Keys
"llm_api_key",
"retain_llm_api_key",
"reflect_llm_api_key",
"consolidation_llm_api_key",
# Base URLs (could expose infrastructure)
"llm_base_url",
"retain_llm_base_url",
"reflect_llm_base_url",
"consolidation_llm_base_url",
"embeddings_tei_base_url",
"reranker_tei_base_url",
"reranker_cohere_base_url",
# Service Account Keys
"llm_vertexai_service_account_key",
# File storage credentials
"file_storage_s3_access_key_id",
"file_storage_s3_secret_access_key",
"file_storage_gcs_service_account_key",
"file_storage_azure_account_key",
# File parser credentials
"file_parser_iris_token",
}
# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
# These fields are manually tagged as safe to expose and modify.
# Excludes credentials, infrastructure config, provider/model selection, and performance tuning.
_CONFIGURABLE_FIELDS = {
# Retention settings (behavioral)
"retain_chunk_size",
"retain_extraction_mode",
"retain_custom_instructions",
# Consolidation settings
"enable_observations",
}
@property
def file_conversion_max_batch_size_bytes(self) -> int:
"""Get maximum total batch size in bytes."""
return self.file_conversion_max_batch_size_mb * 1024 * 1024
@classmethod
def get_configurable_fields(cls) -> set[str]:
"""
Get set of field names that are configurable per-tenant/bank via API.
Configurable fields are manually tagged behavioral settings that are safe
to expose and modify (e.g., retain_chunk_size, custom_instructions).
Excludes credentials, infrastructure config, and provider/model selection.
Returns:
Set of configurable field names
"""
return cls._CONFIGURABLE_FIELDS.copy()
@classmethod
def get_credential_fields(cls) -> set[str]:
"""
Get set of field names that are credentials (NEVER exposed via API).
Credential fields include API keys, base URLs, and service account keys.
These must never be returned in API responses or accepted in updates.
Returns:
Set of credential field names
"""
return cls._CREDENTIAL_FIELDS.copy()
@classmethod
def get_hierarchical_fields(cls) -> set[str]:
"""
DEPRECATED: Use get_configurable_fields() instead.
Kept for backward compatibility during migration.
"""
return cls.get_configurable_fields()
@classmethod
def get_static_fields(cls) -> set[str]:
"""
Get set of field names that are static (server-level only).
Static fields are infrastructure-level settings that cannot vary
per tenant or bank. These include database config, API port, worker settings, etc.
Also includes credential fields which are never configurable.
Returns:
Set of static field names
"""
# Get all field names from dataclass
all_fields = {f.name for f in fields(cls)}
# Static fields = all fields - configurable fields
return all_fields - cls._CONFIGURABLE_FIELDS
def validate(self) -> None:
"""Validate configuration values and raise errors for invalid combinations."""
# Validate vector_extension
valid_extensions = ("pgvector", "vchord", "pgvectorscale")
if self.vector_extension not in valid_extensions:
raise ValueError(
f"Invalid vector_extension: {self.vector_extension}. Must be one of: {', '.join(valid_extensions)}"
)
# Validate text_search_extension
valid_text_search = ("native", "vchord", "pg_textsearch")
if self.text_search_extension not in valid_text_search:
raise ValueError(
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
)
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks
if self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than "
f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}"
f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens"
f"\n (current model: {self.retain_llm_model or self.llm_model}, "
f"provider: {self.retain_llm_provider or self.llm_provider})"
)
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
@@ -454,10 +827,12 @@ class HindsightConfig:
llm_provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
llm_model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(llm_provider)
return cls(
config = cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
vector_extension=os.getenv(ENV_VECTOR_EXTENSION, DEFAULT_VECTOR_EXTENSION).lower(),
text_search_extension=os.getenv(ENV_TEXT_SEARCH_EXTENSION, DEFAULT_TEXT_SEARCH_EXTENSION).lower(),
# LLM
llm_provider=llm_provider,
llm_api_key=os.getenv(ENV_LLM_API_KEY),
@@ -468,6 +843,8 @@ class HindsightConfig:
llm_initial_backoff=float(os.getenv(ENV_LLM_INITIAL_BACKOFF, str(DEFAULT_LLM_INITIAL_BACKOFF))),
llm_max_backoff=float(os.getenv(ENV_LLM_MAX_BACKOFF, str(DEFAULT_LLM_MAX_BACKOFF))),
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
@@ -551,9 +928,27 @@ class HindsightConfig:
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
).lower()
in ("true", "1"),
embeddings_local_trust_remote_code=os.getenv(
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
# Cohere embeddings (with backward-compatible fallback to shared API key)
embeddings_cohere_api_key=os.getenv(ENV_EMBEDDINGS_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
embeddings_cohere_model=os.getenv(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL),
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
# LiteLLM embeddings (with backward-compatible fallback to shared config)
embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE)
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
embeddings_litellm_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
embeddings_litellm_model=os.getenv(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL),
# LiteLLM SDK embeddings (direct API access)
embeddings_litellm_sdk_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_API_KEY),
embeddings_litellm_sdk_model=os.getenv(
ENV_EMBEDDINGS_LITELLM_SDK_MODEL, DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL
),
embeddings_litellm_sdk_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_API_BASE) or None,
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
@@ -564,19 +959,38 @@ class HindsightConfig:
reranker_local_max_concurrent=int(
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
),
reranker_local_trust_remote_code=os.getenv(
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
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(
os.getenv(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT))
),
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
# Cohere reranker (with backward-compatible fallback to shared API key)
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
# LiteLLM reranker (with backward-compatible fallback to shared config)
reranker_litellm_api_base=os.getenv(ENV_RERANKER_LITELLM_API_BASE)
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),
# 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),
reranker_litellm_sdk_api_base=os.getenv(ENV_RERANKER_LITELLM_SDK_API_BASE) or None,
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
base_path=os.getenv(ENV_BASE_PATH, DEFAULT_BASE_PATH),
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
mpfp_top_k_neighbors=int(os.getenv(ENV_MPFP_TOP_K_NEIGHBORS, str(DEFAULT_MPFP_TOP_K_NEIGHBORS))),
@@ -603,6 +1017,39 @@ class HindsightConfig:
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
),
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
== "true",
retain_batch_poll_interval_seconds=int(
os.getenv(ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS, str(DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS))
),
# File storage
file_storage_type=os.getenv(ENV_FILE_STORAGE_TYPE, DEFAULT_FILE_STORAGE_TYPE),
file_storage_s3_bucket=os.getenv(ENV_FILE_STORAGE_S3_BUCKET) or None,
file_storage_s3_region=os.getenv(ENV_FILE_STORAGE_S3_REGION) or None,
file_storage_s3_endpoint=os.getenv(ENV_FILE_STORAGE_S3_ENDPOINT) or None,
file_storage_s3_access_key_id=os.getenv(ENV_FILE_STORAGE_S3_ACCESS_KEY_ID) or None,
file_storage_s3_secret_access_key=os.getenv(ENV_FILE_STORAGE_S3_SECRET_ACCESS_KEY) or None,
file_storage_gcs_bucket=os.getenv(ENV_FILE_STORAGE_GCS_BUCKET) or None,
file_storage_gcs_service_account_key=os.getenv(ENV_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY) or None,
file_storage_azure_container=os.getenv(ENV_FILE_STORAGE_AZURE_CONTAINER) or None,
file_storage_azure_account_name=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME) or None,
file_storage_azure_account_key=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY) or None,
file_parser=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER),
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_conversion_max_batch_size_mb=int(
os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB))
),
file_conversion_max_batch_size=int(
os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE))
),
enable_file_upload_api=os.getenv(ENV_ENABLE_FILE_UPLOAD_API, str(DEFAULT_ENABLE_FILE_UPLOAD_API)).lower()
== "true",
file_delete_after_retain=os.getenv(
ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN)
).lower()
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
consolidation_batch_size=int(
@@ -630,7 +1077,16 @@ class HindsightConfig:
),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
# OpenTelemetry tracing configuration
otel_traces_enabled=os.getenv(ENV_OTEL_TRACES_ENABLED, str(DEFAULT_OTEL_TRACES_ENABLED)).lower()
in ("true", "1", "yes"),
otel_exporter_otlp_endpoint=os.getenv(ENV_OTEL_EXPORTER_OTLP_ENDPOINT) or None,
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
)
config.validate()
return config
def get_llm_base_url(self) -> str:
"""Get the LLM base URL, with provider-specific defaults."""
@@ -708,8 +1164,35 @@ class HindsightConfig:
_config_cache: HindsightConfig | None = None
def get_config() -> HindsightConfig:
"""Get the cached configuration, loading from environment on first call."""
def get_config() -> StaticConfigProxy:
"""
Get global configuration with ONLY static (non-configurable) fields accessible.
This returns a proxy that prevents access to bank-configurable fields
(like enable_observations, retain_chunk_size, etc.).
For bank-specific configuration, use:
config_resolver.resolve_full_config(bank_id, context)
This design prevents accidentally using global defaults when bank-specific
overrides exist.
Returns:
StaticConfigProxy that only exposes static infrastructure fields
Raises:
ConfigFieldAccessError: If you try to access a bank-configurable field
"""
return StaticConfigProxy(_get_raw_config())
def _get_raw_config() -> HindsightConfig:
"""
Get raw config (internal use only).
INTERNAL USE ONLY. Do not use this directly in application code.
Use get_config() for static fields or ConfigResolver.resolve_full_config() for bank-specific config.
"""
global _config_cache
if _config_cache is None:
_config_cache = HindsightConfig.from_env()
@@ -0,0 +1,274 @@
"""
Configuration resolution with hierarchical overrides.
Resolves config values through the hierarchy:
Global (env vars) → Tenant config (via extension) → Bank config (database)
Config values are resolved on every request to ensure consistency across
multiple API servers.
"""
import json
import logging
from dataclasses import asdict
from typing import Any
import asyncpg
from hindsight_api.config import HindsightConfig, _get_raw_config, normalize_config_dict
from hindsight_api.extensions.tenant import TenantExtension
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
class ConfigResolver:
"""Resolves hierarchical configuration with tenant/bank overrides."""
def __init__(self, pool: asyncpg.Pool, tenant_extension: TenantExtension | None = None):
"""
Initialize config resolver.
Args:
pool: Database connection pool
tenant_extension: Optional tenant extension for tenant-level config and permissions
"""
self.pool = pool
self.tenant_extension = tenant_extension
self._global_config = _get_raw_config()
self._configurable_fields = HindsightConfig.get_configurable_fields()
self._credential_fields = HindsightConfig.get_credential_fields()
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
"""
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
This is for INTERNAL USE ONLY. Returns the complete config object with all fields
including credentials and static fields. Use get_bank_config() for API responses.
Resolution order:
1. Global config (from environment variables)
2. Tenant config overrides (from TenantExtension.get_tenant_config())
3. Bank config overrides (from banks.config JSONB)
Args:
bank_id: Bank identifier
context: Request context for tenant config resolution
Returns:
Complete HindsightConfig with hierarchical overrides applied
"""
# Start with global config (all fields)
config_dict = asdict(self._global_config)
# Load tenant config overrides (if tenant extension available)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
# Load bank config overrides
bank_overrides = await self._load_bank_config(bank_id)
if bank_overrides:
config_dict.update(bank_overrides)
logger.debug(f"Applied bank config overrides for bank {bank_id}: {list(bank_overrides.keys())}")
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
# Create a new config instance by copying the global config and updating fields
resolved_config = HindsightConfig(**config_dict)
return resolved_config
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
"""
Get fully resolved config for a bank (filtered by permissions).
Resolution order:
1. Global config (from environment variables)
2. Tenant config overrides (from TenantExtension.get_tenant_config())
3. Bank config overrides (from banks.config JSONB)
Note: Config is resolved on every call (not cached) to ensure consistency
across multiple API servers.
SECURITY:
- Only returns configurable fields (excludes static/infrastructure fields)
- Filters out ALL credential fields (API keys, base URLs, etc.)
- Further filtered by tenant/bank permissions if extension provides them
Args:
bank_id: Bank identifier
context: Request context for tenant config resolution and permissions
Returns:
Dict of allowed configurable fields only (never includes credentials or static fields)
"""
# Resolve full config with all hierarchical overrides
resolved_config = await self.resolve_full_config(bank_id, context)
config_dict = asdict(resolved_config)
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
# PERMISSIONS: Further filter based on tenant/bank permissions
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
return filtered
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
Load bank config overrides from banks.config JSONB column.
Args:
bank_id: Bank identifier
Returns:
Dict of config overrides (only configurable fields, normalized keys)
"""
try:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT config FROM banks WHERE bank_id = $1
""",
bank_id,
)
if row and row["config"]:
config_data = row["config"]
# Handle case where JSONB is returned as JSON string
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Normalize keys (handle both env var format and Python field format)
normalized = normalize_config_dict(config_data)
# Only return overrides for configurable fields
return {k: v for k, v in normalized.items() if k in self._configurable_fields}
except Exception as e:
logger.error(f"Failed to load bank config for {bank_id}: {e}")
return {}
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
"""
Update bank configuration overrides (with permission checking).
Args:
bank_id: Bank identifier
updates: Dict of config field names to new values.
Keys can be in env var format (HINDSIGHT_API_LLM_PROVIDER)
or Python field format (llm_provider).
Only configurable fields are allowed.
context: Request context for permission checking
Raises:
ValueError: If attempting to override invalid/disallowed fields
"""
# Normalize keys
normalized_updates = normalize_config_dict(updates)
# SECURITY: Reject credential fields explicitly
credential_attempts = set(normalized_updates.keys()) & self._credential_fields
if credential_attempts:
raise ValueError(
f"Cannot set credential fields via API: {sorted(credential_attempts)}. "
f"Credentials (API keys, base URLs) must be set at server level only."
)
# Validate all fields are configurable
invalid_fields = set(normalized_updates.keys()) - self._configurable_fields
if invalid_fields:
static_fields = HindsightConfig.get_static_fields()
invalid_static = invalid_fields & static_fields
if invalid_static:
raise ValueError(
f"Cannot override static (server-level) fields: {sorted(invalid_static)}. "
f"Only configurable fields can be overridden per-bank. "
f"Configurable fields include: {sorted(list(self._configurable_fields)[:10])}... "
f"(total: {len(self._configurable_fields)} fields)"
)
else:
raise ValueError(
f"Unknown configuration fields: {sorted(invalid_fields)}. "
f"Valid configurable fields: {sorted(list(self._configurable_fields)[:10])}..."
)
# PERMISSIONS: Check tenant/bank permissions
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
disallowed = set(normalized_updates.keys()) - allowed_fields
if disallowed:
raise ValueError(
f"Not allowed to modify fields: {sorted(disallowed)}. "
f"Your permissions allow: {sorted(list(allowed_fields)[:10])}..."
if allowed_fields
else "Not allowed to modify fields: {sorted(disallowed)}. "
"Your permissions do not allow any config modifications."
)
except ValueError:
raise # Re-raise permission errors
except Exception as e:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET config = config || $1::jsonb,
updated_at = now()
WHERE bank_id = $2
""",
json.dumps(normalized_updates),
bank_id,
)
logger.info(f"Updated bank config for {bank_id}: {list(normalized_updates.keys())}")
async def reset_bank_config(self, bank_id: str) -> None:
"""
Reset bank configuration to defaults (remove all overrides).
Args:
bank_id: Bank identifier
"""
async with self.pool.acquire() as conn:
await conn.execute(
"""
UPDATE banks
SET config = '{}'::jsonb,
updated_at = now()
WHERE bank_id = $1
""",
bank_id,
)
logger.info(f"Reset bank config for {bank_id} to defaults")
@@ -18,6 +18,7 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from ...config import get_config
from ..memory_engine import fq_table
from ..retain import embedding_utils
from .prompts import (
@@ -82,9 +83,8 @@ async def run_consolidation_job(
Returns:
Dict with consolidation results
"""
from ...config import get_config
config = get_config()
# Resolve bank-specific config with hierarchical overrides
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
@@ -143,6 +143,9 @@ async def run_consolidation_job(
"skipped": 0,
}
# Track all unique tags from consolidated memories for mental model refresh filtering
consolidated_tags: set[str] = set()
batch_num = 0
last_progress_timings = {} # Track timings at last progress log
while True:
@@ -176,6 +179,11 @@ async def run_consolidation_job(
for memory in memories:
mem_start = time.time()
# Track tags from this memory for mental model refresh filtering
memory_tags = memory.get("tags") or []
if memory_tags:
consolidated_tags.update(memory_tags)
# Process the memory (uses its own connection internally)
async with pool.acquire() as conn:
result = await _process_memory(
@@ -284,10 +292,12 @@ async def run_consolidation_job(
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
# Trigger mental model refreshes for models with refresh_after_consolidation=true
# SECURITY: Only refresh mental models with matching tags (or all if no tags were consolidated)
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
bank_id=bank_id,
request_context=request_context,
consolidated_tags=list(consolidated_tags) if consolidated_tags else None,
perf=perf,
)
stats["mental_models_refreshed"] = mental_models_refreshed
@@ -301,15 +311,20 @@ async def _trigger_mental_model_refreshes(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
consolidated_tags: list[str] | None = None,
perf: ConsolidationPerfLog | None = None,
) -> int:
"""
Trigger refreshes for mental models with refresh_after_consolidation=true.
SECURITY: Only triggers refresh for mental models whose tags overlap with the
consolidated memory tags, preventing unnecessary refreshes across security boundaries.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
consolidated_tags: Tags from memories that were consolidated (None = refresh all)
perf: Performance logging
Returns:
@@ -318,22 +333,52 @@ async def _trigger_mental_model_refreshes(
pool = memory_engine._pool
# Find mental models with refresh_after_consolidation=true
# SECURITY: Control which mental models get refreshed based on tags
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, name
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
""",
bank_id,
)
if consolidated_tags:
# Tagged memories were consolidated - refresh:
# 1. Mental models with overlapping tags (security boundary)
# 2. Untagged mental models (they're "global" and available to all contexts)
# DO NOT refresh mental models with different tags
rows = await conn.fetch(
f"""
SELECT id, name, tags
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
AND (
(tags IS NOT NULL AND tags != '{{}}' AND tags && $2::varchar[])
OR (tags IS NULL OR tags = '{{}}')
)
""",
bank_id,
consolidated_tags,
)
else:
# Untagged memories were consolidated - only refresh untagged mental models
# SECURITY: Tagged mental models are NOT refreshed when untagged memories are consolidated
rows = await conn.fetch(
f"""
SELECT id, name, tags
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
AND (tags IS NULL OR tags = '{{}}')
""",
bank_id,
)
if not rows:
return 0
if perf:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
if consolidated_tags:
perf.log(
f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true "
f"(filtered by tags: {consolidated_tags})"
)
else:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
# Submit refresh tasks for each mental model
refreshed_count = 0
@@ -381,92 +426,109 @@ async def _process_memory(
Returns:
Dict with action summary: created/updated/merged counts
"""
from ...tracing import get_tracer, is_tracing_enabled
fact_text = memory["text"]
memory_id = memory["id"]
fact_tags = memory.get("tags") or []
# Find related observations using the full recall system (NO tag filtering)
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
)
if perf:
perf.record_timing("recall", time.time() - t0)
# Create parent span for this memory's consolidation
tracer = get_tracer()
if is_tracing_enabled():
consolidation_span = tracer.start_span("hindsight.consolidation")
consolidation_span.set_attribute("hindsight.memory_id", str(memory_id))
consolidation_span.set_attribute("hindsight.bank_id", bank_id)
else:
consolidation_span = None
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
try:
# Find related observations using the full recall system
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
t0 = time.time()
related_observations = await _find_related_observations(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
query=fact_text,
request_context=request_context,
tags=fact_tags, # Pass source memory's tags for security
)
if perf:
perf.record_timing("recall", time.time() - t0)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
observations=related_observations, # Can be empty list
mission=mission,
)
if perf:
perf.record_timing("llm", time.time() - t0)
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
if not actions:
# LLM returned empty array - fact is purely ephemeral, skip
return {"action": "skipped", "reason": "no_durable_knowledge"}
if not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
# Execute all actions and collect results
results = []
for action in actions:
action_type = action.get("action")
if action_type == "update":
result = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
elif action_type == "create":
result = await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
mentioned_at=memory.get("mentioned_at"),
perf=perf,
)
results.append(result)
# Summarize results
created = sum(1 for r in results if r.get("action") == "created")
updated = sum(1 for r in results if r.get("action") == "updated")
merged = sum(1 for r in results if r.get("action") == "merged")
if not results:
# No valid actions executed
return {"action": "skipped", "reason": "no_valid_actions"}
if len(results) == 1:
return results[0]
# Summarize results
created = sum(1 for r in results if r.get("action") == "created")
updated = sum(1 for r in results if r.get("action") == "updated")
merged = sum(1 for r in results if r.get("action") == "merged")
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
if len(results) == 1:
return results[0]
return {
"action": "multiple",
"created": created,
"updated": updated,
"merged": merged,
"total_actions": len(results),
}
finally:
if consolidation_span:
consolidation_span.end()
async def _execute_update_action(
@@ -666,34 +728,57 @@ async def _find_related_observations(
bank_id: str,
query: str,
request_context: "RequestContext",
tags: list[str] | None = None,
) -> list[dict[str, Any]]:
"""
Find observations related to the given query using optimized recall.
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
potentially related observations regardless of scope, so the LLM can
decide on tag routing (same scope update vs cross-scope create).
SECURITY: Filters by tags using all_strict matching to prevent cross-tenant/cross-user
information leakage. Observations are only consolidated within the same tag scope.
Uses max_tokens to naturally limit observations (no artificial count limit).
Includes source memories with dates for LLM context.
Args:
tags: Optional tags to filter observations (uses all_strict matching for security)
Returns:
List of related observations with their tags, source memories, and dates
"""
# Use recall to find related observations with token budget
# max_tokens naturally limits how many observations are returned
from ...config import get_config
from ...tracing import get_tracer, is_tracing_enabled
config = get_config()
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
_quiet=True, # Suppress logging
# NO tags parameter - intentionally get ALL observations
)
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
tags_match = "all_strict" if tags else "any"
# Create span for recall operation within consolidation
tracer = get_tracer()
if is_tracing_enabled():
recall_span = tracer.start_span("hindsight.consolidation_recall")
recall_span.set_attribute("hindsight.bank_id", bank_id)
recall_span.set_attribute("hindsight.query", query[:100]) # Truncate for brevity
recall_span.set_attribute("hindsight.fact_type", "observation")
else:
recall_span = None
try:
recall_result = await memory_engine.recall_async(
bank_id=bank_id,
query=query,
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
fact_type=["observation"], # Only retrieve observations
request_context=request_context,
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
_quiet=True, # Suppress logging
)
finally:
if recall_span:
recall_span.end()
# If no observations returned, return empty list
if not recall_result.results:
@@ -931,15 +1016,34 @@ async def _create_observation_directly(
t0 = time.time()
observation_id = uuid.uuid4()
# Query varies based on text search backend
config = get_config()
if config.text_search_extension == "vchord":
# VectorChord: manually tokenize and insert search_vector
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
tags, event_date, occurred_start, occurred_end, mentioned_at, search_vector
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10,
tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector)
RETURNING id
"""
else: # native or pg_textsearch
# Native PostgreSQL: search_vector is GENERATED ALWAYS, don't include it
# pg_textsearch: indexes operate on base columns directly, don't populate search_vector
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
tags, event_date, occurred_start, occurred_end, mentioned_at
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
RETURNING id
"""
row = await conn.fetchrow(
f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
tags, event_date, occurred_start, occurred_end, mentioned_at
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
RETURNING id
""",
query,
observation_id,
bank_id,
observation_text,
@@ -2,7 +2,7 @@
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (observations) and merge with existing knowledge when appropriate.
You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text.
You must output ONLY valid JSON with no markdown code blocks or additional text. However, the "text" field within each observation should use markdown formatting (headers, lists, bold, etc.) for clarity and readability.
## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
@@ -32,13 +32,16 @@ BAD examples:
## MERGE RULES (when comparing to existing observations):
1. REDUNDANT: Same information worded differently → update existing
2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
3. UPDATE: New state replacing old state → update with history
2. CONTRADICTION: Opposite information about same topic → update with temporal markers showing change
Example: "Alex used to love pizza but now hates it" OR "Alex's pizza preference changed from love to hate"
3. UPDATE: New state replacing old state → update showing the transition with "used to", "now", "changed from X to Y"
## CRITICAL RULES:
- NEVER merge facts about DIFFERENT people
- NEVER merge unrelated topics (food preferences vs work vs hobbies)
- When merging contradictions, capture the CHANGE (before → after)
- When merging contradictions, the "text" field MUST capture BOTH states with temporal markers:
* Use "used to X, now Y" OR "changed from X to Y" OR "X but now Y"
* DO NOT just state the new fact - you MUST show the change
- Keep observations focused on ONE specific topic per person
- The "text" field MUST contain durable knowledge, not ephemeral state
- Do NOT include "tags" in output - tags are handled automatically"""
@@ -68,10 +71,15 @@ Instructions:
- New topic → CREATE new observation
- Purely ephemeral → return []
Output JSON array of actions:
Output JSON array of actions (the "text" field should use markdown formatting for structure):
[
{{"action": "update", "learning_id": "uuid-from-observations", "text": "updated knowledge", "reason": "..."}},
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
{{"action": "update", "learning_id": "uuid-from-observations", "text": "## Updated Knowledge\n\n**Key point**: details here\n\n- Supporting detail 1\n- Supporting detail 2", "reason": "..."}},
{{"action": "create", "text": "## New Durable Knowledge\n\nDescription with **emphasis** and proper structure", "reason": "..."}}
]
Return [] if fact contains no durable knowledge."""
Return [] if fact contains no durable knowledge.
IMPORTANT: Format the "text" field with markdown for better readability:
- Use headers, lists, bold/italic, tables where appropriate
- CRITICAL: Add blank lines before and after block elements (tables, code blocks, lists)
- Ensure proper spacing for markdown to render correctly"""
@@ -9,6 +9,7 @@ Configuration via environment variables - see hindsight_api.config for all env v
import asyncio
import logging
import os
import warnings
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
@@ -20,23 +21,23 @@ from ..config import (
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
ENV_COHERE_API_KEY,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_RERANKER_COHERE_BASE_URL,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_LITELLM_MODEL,
ENV_RERANKER_LITELLM_SDK_API_KEY,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_MAX_CONCURRENT,
@@ -101,7 +102,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
_executor: ThreadPoolExecutor | None = None
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
def __init__(self, model_name: str | None = None, max_concurrent: int = 4, force_cpu: bool = False):
def __init__(
self,
model_name: str | None = None,
max_concurrent: int = 4,
force_cpu: bool = False,
trust_remote_code: bool = False,
):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -112,9 +119,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Higher values may cause CPU thrashing under load.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
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)
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self._model = None
LocalSTCrossEncoder._max_concurrent = max_concurrent
@@ -162,11 +173,29 @@ class LocalSTCrossEncoder(CrossEncoderModel):
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
# 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")
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", message=".*was not found in model state dict.*")
warnings.filterwarnings("ignore", message=".*UNEXPECTED.*")
# Also suppress transformers library logging temporarily
transformers_logger = logging.getLogger("transformers")
original_level = transformers_logger.level
transformers_logger.setLevel(logging.ERROR)
try:
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
trust_remote_code=self.trust_remote_code,
)
finally:
# Restore original logging level
transformers_logger.setLevel(original_level)
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
@@ -801,6 +830,126 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
return all_scores
class LiteLLMSDKCrossEncoder(CrossEncoderModel):
"""
LiteLLM SDK cross-encoder for direct API integration.
Supports reranking via LiteLLM SDK without requiring a proxy server.
Supported providers: Cohere, DeepInfra, Together AI, HuggingFace, Jina AI, Voyage AI, AWS Bedrock.
Example model names:
- cohere/rerank-english-v3.0
- deepinfra/Qwen3-reranker-8B
- together_ai/Salesforce/Llama-Rank-V1
- huggingface/BAAI/bge-reranker-v2-m3
"""
def __init__(
self,
api_key: str,
model: str = DEFAULT_RERANKER_LITELLM_SDK_MODEL,
api_base: str | None = None,
timeout: float = 60.0,
):
"""
Initialize LiteLLM SDK cross-encoder client.
Args:
api_key: API key for the reranking provider
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)
"""
self.api_key = api_key
self.model = model
self.api_base = api_base
self.timeout = timeout
self._initialized = False
self._litellm = None # Will be set during initialization
@property
def provider_name(self) -> str:
return "litellm-sdk"
async def initialize(self) -> None:
"""Initialize the LiteLLM SDK client."""
if self._initialized:
return
try:
import litellm
self._litellm = litellm # Store reference
except ImportError:
raise ImportError("litellm is required for LiteLLMSDKCrossEncoder. Install it with: pip install litellm")
api_base_msg = f" at {self.api_base}" if self.api_base else ""
logger.info(f"Reranker: initializing LiteLLM SDK provider with model {self.model}{api_base_msg}")
self._initialized = True
logger.info("Reranker: LiteLLM SDK provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using the LiteLLM SDK.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if not self._initialized:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
# Group pairs by query for efficient batching
# LiteLLM rerank expects one query with multiple documents
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
# Build kwargs for rerank call
rerank_kwargs = {
"model": self.model,
"query": query,
"documents": texts,
"api_key": self.api_key,
}
if self.api_base:
rerank_kwargs["api_base"] = self.api_base
response = await self._litellm.arerank(**rerank_kwargs)
# Map scores back to original positions
# Response format: RerankResponse with results list
# Each result is a TypedDict with "index" and "relevance_score"
if hasattr(response, "results") and response.results:
for result in response.results:
# Results are TypedDicts, use dict-style access
original_idx = result["index"]
score = result.get("relevance_score", result.get("score", 0.0))
all_scores[indices[original_idx]] = score
elif isinstance(response, list):
# Direct list of scores (unlikely but defensive)
for i, score in enumerate(response):
all_scores[indices[i]] = score
else:
logger.warning(f"Unexpected response format from LiteLLM rerank: {type(response)}")
return all_scores
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
@@ -829,26 +978,41 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
model_name=config.reranker_local_model,
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
)
elif provider == "cohere":
api_key = os.environ.get(ENV_COHERE_API_KEY)
api_key = config.reranker_cohere_api_key
if not api_key:
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL)
base_url = os.environ.get(ENV_RERANKER_COHERE_BASE_URL) or None
return CohereCrossEncoder(api_key=api_key, model=model, base_url=base_url)
raise ValueError(f"{ENV_RERANKER_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_cohere_model,
base_url=config.reranker_cohere_base_url,
)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir)
elif provider == "litellm":
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL)
return LiteLLMCrossEncoder(api_base=api_base, api_key=api_key, model=model)
return LiteLLMCrossEncoder(
api_base=config.reranker_litellm_api_base,
api_key=config.reranker_litellm_api_key,
model=config.reranker_litellm_model,
)
elif provider == "litellm-sdk":
api_key = config.reranker_litellm_sdk_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_LITELLM_SDK_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'litellm-sdk'"
)
return LiteLLMSDKCrossEncoder(
api_key=api_key,
model=config.reranker_litellm_sdk_model,
api_base=config.reranker_litellm_sdk_api_base,
)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'rrf'"
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'litellm-sdk', 'rrf'"
)
+202 -22
View File
@@ -11,6 +11,7 @@ Configuration via environment variables - see hindsight_api.config for all env v
import logging
import os
import warnings
from abc import ABC, abstractmethod
import httpx
@@ -18,24 +19,23 @@ import httpx
from ..config import (
DEFAULT_EMBEDDINGS_COHERE_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_LITELLM_API_BASE,
ENV_COHERE_API_KEY,
ENV_EMBEDDINGS_COHERE_BASE_URL,
ENV_EMBEDDINGS_COHERE_MODEL,
ENV_EMBEDDINGS_LITELLM_MODEL,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_TEI_URL,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_LLM_API_KEY,
)
@@ -94,7 +94,7 @@ class LocalSTEmbeddings(Embeddings):
The embedding dimension is auto-detected from the model.
"""
def __init__(self, model_name: str | None = None, force_cpu: bool = False):
def __init__(self, model_name: str | None = None, force_cpu: bool = False, trust_remote_code: bool = False):
"""
Initialize local SentenceTransformers embeddings.
@@ -103,9 +103,13 @@ class LocalSTEmbeddings(Embeddings):
Default: BAAI/bge-small-en-v1.5
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
Default: False (disabled for security)
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self._model = None
self._dimension: int | None = None
@@ -157,11 +161,29 @@ class LocalSTEmbeddings(Embeddings):
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", message=".*was not found in model state dict.*")
warnings.filterwarnings("ignore", message=".*UNEXPECTED.*")
# Also suppress transformers library logging temporarily
transformers_logger = logging.getLogger("transformers")
original_level = transformers_logger.level
transformers_logger.setLevel(logging.ERROR)
try:
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
trust_remote_code=self.trust_remote_code,
)
finally:
# Restore original logging level
transformers_logger.setLevel(original_level)
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
@@ -700,6 +722,148 @@ class LiteLLMEmbeddings(Embeddings):
return all_embeddings
class LiteLLMSDKEmbeddings(Embeddings):
"""
LiteLLM SDK embeddings for direct API integration.
Supports embeddings via LiteLLM SDK without requiring a proxy server.
Supported providers: Cohere, OpenAI, Azure OpenAI, HuggingFace, Voyage AI, Together AI, etc.
Example model names:
- cohere/embed-english-v3.0
- openai/text-embedding-3-small
- together_ai/togethercomputer/m2-bert-80M-8k-retrieval
- voyage/voyage-2
"""
def __init__(
self,
api_key: str,
model: str = DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
api_base: str | None = None,
batch_size: int = 100,
timeout: float = 60.0,
):
"""
Initialize LiteLLM SDK embeddings client.
Args:
api_key: API key for the embedding provider
model: Model name with provider prefix (e.g., "cohere/embed-english-v3.0")
api_base: Custom base URL for API (optional)
batch_size: Maximum batch size for embedding requests (default: 100)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_key = api_key
self.model = model
self.api_base = api_base
self.batch_size = batch_size
self.timeout = timeout
self._litellm = None # Will be set during initialization
self._dimension: int | None = None
@property
def provider_name(self) -> str:
return "litellm-sdk"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
async def initialize(self) -> None:
"""Initialize the LiteLLM SDK client and detect dimension."""
if self._litellm is not None:
return
try:
import litellm
self._litellm = litellm # Store reference
except ImportError:
raise ImportError("litellm is required for LiteLLMSDKEmbeddings. Install it with: pip install litellm")
api_base_msg = f" at {self.api_base}" if self.api_base else ""
logger.info(f"Embeddings: initializing LiteLLM SDK provider with model {self.model}{api_base_msg}")
# Do a test embedding to detect dimension
try:
# Build kwargs for embedding call
embed_kwargs = {
"model": self.model,
"input": ["test"],
"api_key": self.api_key,
}
if self.api_base:
embed_kwargs["api_base"] = self.api_base
# Use async embedding method (standard in litellm)
response = await self._litellm.aembedding(**embed_kwargs)
# Extract dimension from response
if response.data and len(response.data) > 0:
self._dimension = len(response.data[0]["embedding"])
else:
raise RuntimeError(f"Unable to detect embedding dimension for model {self.model}")
except Exception as e:
raise RuntimeError(f"Failed to initialize LiteLLM SDK embeddings: {e}")
logger.info(f"Embeddings: LiteLLM SDK provider initialized (model: {self.model}, dim: {self._dimension})")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings using the LiteLLM SDK.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors (one per input text)
"""
if self._litellm is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
all_embeddings = []
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
try:
# Build kwargs for embedding call
embed_kwargs = {
"model": self.model,
"input": batch,
"api_key": self.api_key,
}
if self.api_base:
embed_kwargs["api_base"] = self.api_base
# Use sync embedding (litellm doesn't have async in thread-safe way)
response = self._litellm.embedding(**embed_kwargs)
# Extract embeddings from response
# Sort by index to ensure correct order
batch_embeddings = sorted(response.data, key=lambda x: x.get("index", 0))
all_embeddings.extend([e["embedding"] for e in batch_embeddings])
except Exception as e:
import traceback
logger.error(
f"Error in LiteLLM embedding for batch starting at index {i}: {e}\n"
f"Traceback: {traceback.format_exc()}"
)
raise
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on configuration.
@@ -723,6 +887,7 @@ def create_embeddings_from_env() -> Embeddings:
return LocalSTEmbeddings(
model_name=config.embeddings_local_model,
force_cpu=config.embeddings_local_force_cpu,
trust_remote_code=config.embeddings_local_trust_remote_code,
)
elif provider == "openai":
# Use dedicated embeddings API key, or fall back to LLM API key
@@ -736,18 +901,33 @@ def create_embeddings_from_env() -> Embeddings:
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "cohere":
api_key = os.environ.get(ENV_COHERE_API_KEY)
api_key = config.embeddings_cohere_api_key
if not api_key:
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL)
base_url = os.environ.get(ENV_EMBEDDINGS_COHERE_BASE_URL) or None
return CohereEmbeddings(api_key=api_key, model=model, base_url=base_url)
raise ValueError(f"{ENV_EMBEDDINGS_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
return CohereEmbeddings(
api_key=api_key,
model=config.embeddings_cohere_model,
base_url=config.embeddings_cohere_base_url,
)
elif provider == "litellm":
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL)
return LiteLLMEmbeddings(api_base=api_base, api_key=api_key, model=model)
return LiteLLMEmbeddings(
api_base=config.embeddings_litellm_api_base,
api_key=config.embeddings_litellm_api_key,
model=config.embeddings_litellm_model,
)
elif provider == "litellm-sdk":
api_key = config.embeddings_litellm_sdk_api_key
if not api_key:
raise ValueError(
f"{ENV_EMBEDDINGS_LITELLM_SDK_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'litellm-sdk'"
)
return LiteLLMSDKEmbeddings(
api_key=api_key,
model=config.embeddings_litellm_sdk_model,
api_base=config.embeddings_litellm_sdk_api_base,
)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere', 'litellm'"
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'tei', 'openai', 'cohere', 'litellm', 'litellm-sdk'"
)
@@ -48,6 +48,7 @@ class MemoryEngineInterface(ABC):
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
document_tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Retain a batch of memory items.
@@ -55,8 +56,9 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
contents: List of content dicts with 'content', optional 'event_date',
'context', 'metadata', 'document_id'.
'context', 'metadata', 'document_id', and per-item 'tags'.
request_context: Request context for authentication.
document_tags: Optional tags applied to all items in the batch.
Returns:
Dict with processing results.
@@ -561,6 +563,7 @@ class MemoryEngineInterface(ABC):
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
document_tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Submit a batch retain operation to run asynchronously.
@@ -569,6 +572,7 @@ class MemoryEngineInterface(ABC):
bank_id: The memory bank ID.
contents: List of content dicts to retain.
request_context: Request context for authentication.
document_tags: Optional tags applied to all items in the async batch.
Returns:
Dict with operation_id and items_count.
@@ -128,6 +128,67 @@ class LLMInterface(ABC):
"""
pass
async def supports_batch_api(self) -> bool:
"""
Check if this provider supports batch API operations.
Returns:
True if provider supports submit_batch/get_batch_status/retrieve_batch_results
"""
return False
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""
Submit a batch of requests to the provider's batch API.
Args:
requests: List of request dicts in JSONL format (custom_id, method, url, body)
endpoint: API endpoint for the batch (e.g., "/v1/chat/completions")
completion_window: Completion window (e.g., "24h")
Returns:
Dict with batch metadata: {"batch_id": str, "status": str, ...}
Raises:
NotImplementedError: If provider doesn't support batch API
"""
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""
Get the status of a batch job.
Args:
batch_id: Batch identifier returned from submit_batch
Returns:
Dict with status info: {"batch_id": str, "status": str, "completed_at": str, ...}
Raises:
NotImplementedError: If provider doesn't support batch API
"""
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""
Retrieve completed batch results.
Args:
batch_id: Batch identifier returned from submit_batch
Returns:
List of result dicts (one per request, matched by custom_id)
Raises:
NotImplementedError: If provider doesn't support batch API
"""
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
@abstractmethod
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
@@ -67,6 +67,7 @@ def create_llm_provider(
model: str,
reasoning_effort: str,
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
@@ -80,7 +81,8 @@ def create_llm_provider(
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier (for Groq provider).
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
vertexai_region: Vertex AI region (for VertexAI provider).
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
@@ -156,6 +158,7 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
)
else:
@@ -177,6 +180,7 @@ class LLMProvider:
model: str,
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
):
"""
Initialize LLM provider.
@@ -187,15 +191,17 @@ class LLMProvider:
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto"). Default: None (uses Groq's default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
# Default to 'auto' for best performance, users can override to 'on_demand' for free tier
self.groq_service_tier = groq_service_tier or os.getenv(ENV_LLM_GROQ_SERVICE_TIER, "auto")
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
# Validate provider
valid_providers = [
@@ -272,6 +278,7 @@ class LLMProvider:
model=self.model,
reasoning_effort=self.reasoning_effort,
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
"""
Typed metadata models for async operations.
These dataclasses define the structure of result_metadata for different operation types.
The metadata is exposed in the API for debugging purposes and may change without notice.
"""
from dataclasses import asdict, dataclass
from typing import Any
@dataclass
class BatchRetainParentMetadata:
"""Metadata for parent batch_retain operations (when split into sub-batches)."""
items_count: int
total_tokens: int
num_sub_batches: int
is_parent: bool = True
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class BatchRetainChildMetadata:
"""Metadata for child batch_retain operations (individual sub-batches)."""
items_count: int
parent_operation_id: str
sub_batch_index: int
total_sub_batches: int
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class RetainMetadata:
"""Metadata for regular retain operations (non-batched, deprecated async path)."""
items_count: int
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class ConsolidationMetadata:
"""Metadata for consolidation operations."""
# Currently empty, but structure for future fields
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class RefreshMentalModelMetadata:
"""Metadata for mental model refresh operations."""
mental_model_id: str
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@@ -0,0 +1,62 @@
"""File parser implementations."""
from .base import FileParser, UnsupportedFileTypeError
from .iris import IrisParser
from .markitdown import MarkitdownParser
__all__ = ["FileParser", "UnsupportedFileTypeError", "IrisParser", "MarkitdownParser", "FileParserRegistry"]
class FileParserRegistry:
"""Registry for file parsers with auto-detection."""
def __init__(self):
"""Initialize empty parser registry."""
self._parsers: dict[str, FileParser] = {}
def register(self, parser: FileParser):
"""
Register a parser.
Args:
parser: FileParser instance
"""
self._parsers[parser.name()] = parser
def get_parser(
self,
name: str | None,
filename: str,
content_type: str | None = None,
) -> FileParser:
"""
Get parser by name or auto-detect.
Args:
name: Parser name (e.g., "markitdown") or None for auto-detect
filename: File name for auto-detection
content_type: MIME type (optional)
Returns:
FileParser instance
Raises:
ValueError: If no suitable parser found
"""
if name:
# Explicit parser requested — return it directly, let the parser
# raise UnsupportedFileTypeError from convert() if needed
if name not in self._parsers:
raise ValueError(f"Parser '{name}' not found. Available: {list(self._parsers.keys())}")
return self._parsers[name]
# Auto-detect parser
for parser in self._parsers.values():
if parser.supports(filename, content_type):
return parser
raise ValueError(f"No parser found for {filename}. Available parsers: {list(self._parsers.keys())}")
def list_parsers(self) -> list[str]:
"""Get list of registered parser names."""
return list(self._parsers.keys())
@@ -0,0 +1,58 @@
"""Abstract base class for file parsers."""
from abc import ABC, abstractmethod
class UnsupportedFileTypeError(Exception):
"""Raised by a parser when it does not support the given file type."""
pass
class FileParser(ABC):
"""Abstract base for file to markdown parsers."""
@abstractmethod
async def convert(self, file_data: bytes, filename: str) -> str:
"""
Parse file to markdown.
Args:
file_data: Raw file bytes
filename: Original filename (used for format detection)
Returns:
Markdown content as string
Raises:
UnsupportedFileTypeError: If the file type is not supported by this parser
RuntimeError: If parsing fails for another reason
"""
pass
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""
Check if parser supports this file type.
Override this for local/static extension-based filtering.
Parsers that delegate to a remote service should leave this as True
and raise UnsupportedFileTypeError from convert() instead.
Args:
filename: File name (used for extension check)
content_type: MIME type (optional)
Returns:
True if this parser can handle the file (default: True)
"""
return True
@abstractmethod
def name(self) -> str:
"""
Get parser name.
Returns:
Parser name (e.g., "markitdown")
"""
pass
@@ -0,0 +1,137 @@
"""Iris parser implementation using the Vectorize Iris HTTP API."""
import asyncio
import logging
import mimetypes
import time
import httpx
from .base import FileParser, UnsupportedFileTypeError
logger = logging.getLogger(__name__)
_IRIS_BASE_URL = "https://api.vectorize.io/v1"
_DEFAULT_POLL_INTERVAL = 2.0 # seconds
_DEFAULT_TIMEOUT = 300.0 # seconds
class IrisParser(FileParser):
"""
Iris file parser using the Vectorize Iris cloud extraction service.
Uploads files to the Vectorize Iris API, starts an extraction job,
and polls until the text is ready. The API determines which file types
are supported — UnsupportedFileTypeError is raised if the file is rejected.
Authentication:
Requires HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN and
HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID environment variables,
or pass them explicitly via the constructor.
"""
def __init__(
self,
token: str,
org_id: str,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
timeout: float = _DEFAULT_TIMEOUT,
):
"""
Initialize iris parser.
Args:
token: Vectorize API token
org_id: Vectorize organization ID
poll_interval: Seconds between status poll requests (default: 2)
timeout: Maximum seconds to wait for extraction (default: 300)
"""
self._token = token
self._org_id = org_id
self._poll_interval = poll_interval
self._timeout = timeout
self._auth_headers = {"Authorization": f"Bearer {token}"}
async def convert(self, file_data: bytes, filename: str) -> str:
"""
Parse file to text using the Vectorize Iris API.
Raises:
UnsupportedFileTypeError: If the Iris API rejects the file type (4xx)
RuntimeError: If extraction fails for another reason
"""
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
async with httpx.AsyncClient() as client:
# Step 1: Request a presigned upload URL
init_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/files",
headers=self._auth_headers,
json={"name": filename, "contentType": content_type},
)
_raise_for_status(init_resp, filename, "file upload init")
init_data = init_resp.json()
file_id: str = init_data["fileId"]
upload_url: str = init_data["uploadUrl"]
# Step 2: Upload the file bytes to the presigned URL (no auth header)
upload_resp = await client.put(
upload_url,
content=file_data,
headers={"Content-Type": content_type},
)
_raise_for_status(upload_resp, filename, "file upload")
# Step 3: Start extraction
extract_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/extraction",
headers=self._auth_headers,
json={"fileId": file_id},
)
_raise_for_status(extract_resp, filename, "start extraction")
extraction_id: str = extract_resp.json()["extractionId"]
# Step 4: Poll until ready or timeout
deadline = time.monotonic() + self._timeout
while True:
status_resp = await client.get(
f"{_IRIS_BASE_URL}/org/{self._org_id}/extraction/{extraction_id}",
headers=self._auth_headers,
)
_raise_for_status(status_resp, filename, "poll extraction status")
status_data = status_resp.json()
if status_data.get("ready"):
data = status_data.get("data", {})
if not data.get("success"):
error = data.get("error", "unknown error")
raise RuntimeError(f"Iris extraction failed for '{filename}': {error}")
text = data.get("text")
if not text:
raise RuntimeError(f"No content extracted from '{filename}'")
return text
if time.monotonic() >= deadline:
raise RuntimeError(f"Iris extraction timed out after {self._timeout}s for '{filename}'")
await asyncio.sleep(self._poll_interval)
def name(self) -> str:
"""Get parser name."""
return "iris"
def _raise_for_status(response: httpx.Response, filename: str, step: str) -> None:
"""
Raise an appropriate error including the response body on HTTP errors.
Raises UnsupportedFileTypeError for 4xx responses (file rejected by the API),
RuntimeError for other HTTP errors.
"""
if not response.is_error:
return
body = response.text or "<empty>"
msg = f"Iris API error during {step} for '{filename}': {response.status_code} {response.reason_phrase}{body}"
if response.is_client_error:
raise UnsupportedFileTypeError(msg)
raise RuntimeError(msg)
@@ -0,0 +1,109 @@
"""Markitdown parser implementation."""
import asyncio
import logging
import tempfile
from pathlib import Path
from .base import FileParser
logger = logging.getLogger(__name__)
class MarkitdownParser(FileParser):
"""
Markitdown file parser.
Uses Microsoft's markitdown library to convert various file formats
to markdown including PDF, Office docs, images (via OCR), audio, HTML.
Supported formats:
- PDF (.pdf)
- Word (.docx, .doc)
- PowerPoint (.pptx, .ppt)
- Excel (.xlsx, .xls)
- Images (.jpg, .jpeg, .png) - with OCR
- HTML (.html, .htm)
- Text (.txt, .md)
- Audio (.mp3, .wav) - with transcription
"""
def __init__(self):
"""Initialize markitdown parser."""
# Lazy import to avoid requiring markitdown for all users
try:
from markitdown import MarkItDown
self._markitdown = MarkItDown()
except ImportError as e:
raise ImportError(
"markitdown package is required for file parsing. Install with: pip install markitdown"
) from e
async def convert(self, file_data: bytes, filename: str) -> str:
"""Parse file to markdown using markitdown."""
# markitdown is synchronous, so we run it in executor to avoid blocking
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._convert_sync, file_data, filename)
def _convert_sync(self, file_data: bytes, filename: str) -> str:
"""Synchronous parsing (runs in thread pool)."""
# Write to temp file (markitdown requires file path)
with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp:
tmp.write(file_data)
tmp_path = tmp.name
try:
# Parse using markitdown
result = self._markitdown.convert(tmp_path)
if not result or not result.text_content:
raise RuntimeError(f"No content extracted from '{filename}'")
return result.text_content
except Exception as e:
logger.error(f"Markitdown parsing failed for {filename}: {e}")
raise RuntimeError(f"Failed to parse '{filename}': {e}") from e
finally:
# Clean up temp file
try:
Path(tmp_path).unlink()
except Exception:
pass
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""Check if markitdown supports this file type."""
# Supported extensions (from markitdown docs)
supported_extensions = {
# Documents
".pdf",
".docx",
".doc",
".pptx",
".ppt",
".xlsx",
".xls",
# Images (with OCR)
".jpg",
".jpeg",
".png",
# Web
".html",
".htm",
# Text
".txt",
".md",
".csv",
# Audio (with transcription)
".mp3",
".wav",
}
ext = Path(filename).suffix.lower()
return ext in supported_extensions
def name(self) -> str:
"""Get parser name."""
return "markitdown"
@@ -84,7 +84,7 @@ class AnthropicLLM(LLMInterface):
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
scope="verification",
max_retries=0,
)
logger.info("Anthropic connection verified successfully")
@@ -223,6 +223,24 @@ class AnthropicLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.stop_reason if hasattr(response, "stop_reason") else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0:
logger.info(
@@ -397,16 +415,41 @@ class AnthropicLLM(LLMInterface):
# Record metrics
metrics = get_metrics_collector()
duration = time.time() - start_time
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=time.time() - start_time,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -95,7 +95,7 @@ class ClaudeCodeLLM(LLMInterface):
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="test",
scope="verification",
max_retries=0,
)
logger.info("Claude Code connection verified successfully")
@@ -237,6 +237,23 @@ class ClaudeCodeLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
# Log slow calls
if duration > 10.0:
logger.info(
@@ -291,61 +308,202 @@ class ClaudeCodeLLM(LLMInterface):
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Make an LLM API call with tool/function calling support using Claude Agent SDK.
Note: This is a simplified implementation. Full tool support would require
integrating with Claude Agent SDK's tool system.
This implementation uses ClaudeSDKClient (not query()) because custom tools via
SDK MCP servers are only supported with the client. Tools are converted from OpenAI
format to SDK MCP tools, and tool names are formatted as mcp__hindsight_tools__{name}.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature.
max_completion_tokens: Maximum tokens in response (not used by Claude Agent SDK).
temperature: Sampling temperature (not used by Claude Agent SDK).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
tool_choice: How to choose tools (not used by Claude Agent SDK).
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
# For now, use regular call without tools
# Full implementation would require mapping OpenAI tool format to Claude Agent SDK tools
logger.warning(
"Claude Code provider does not fully support tool calling yet. Falling back to regular text completion."
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
SdkMcpTool,
TextBlock,
ToolUseBlock,
create_sdk_mcp_server,
)
result = await self.call(
messages=messages,
response_format=None,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
return_usage=True,
start_time = time.time()
# Convert OpenAI tool format to Claude Agent SDK SdkMcpTool format
sdk_tools: list[SdkMcpTool] = []
tool_names: list[str] = []
for tool in tools:
func = tool.get("function", {})
tool_name = func.get("name", "")
tool_description = func.get("description", "")
parameters = func.get("parameters", {})
# Create a handler with proper closure to avoid transport issues
def make_handler(name: str):
async def handler(args: dict[str, Any]) -> dict[str, Any]:
# Return immediately with success - tool execution happens externally
return {
"content": [
{
"type": "text",
"text": f"[Tool {name} called successfully]",
}
]
}
return handler
sdk_tools.append(
SdkMcpTool(
name=tool_name,
description=tool_description,
input_schema=parameters,
handler=make_handler(tool_name),
)
)
tool_names.append(tool_name)
# Create an MCP server with the tools
mcp_server = create_sdk_mcp_server(
name="hindsight_tools",
version="1.0.0",
tools=sdk_tools if sdk_tools else None,
)
if isinstance(result, tuple):
text, usage = result
return LLMToolCallResult(
content=text,
tool_calls=[],
finish_reason="stop",
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
else:
# Fallback if return_usage didn't work as expected
return LLMToolCallResult(
content=str(result),
tool_calls=[],
finish_reason="stop",
input_tokens=0,
output_tokens=0,
)
# Build system prompt and user content from messages
system_prompt = ""
user_content = ""
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt += ("\n\n" + content) if system_prompt else content
elif role == "user":
user_content += ("\n\n" + content) if user_content else content
elif role == "assistant":
# Include previous assistant messages as context
user_content += f"\n\n[Previous assistant response: {content}]"
elif role == "tool":
# Tool results are already in tool_results_map, append to user context
tool_call_id = msg.get("tool_call_id", "")
user_content += f"\n\n[Tool result for {tool_call_id}: {content}]"
# Format tool names for SDK MCP servers: mcp__{server_name}__{tool_name}
# This is required by the Claude Agent SDK for MCP server tools
allowed_tool_names = [f"mcp__hindsight_tools__{name}" for name in tool_names]
# Configure SDK options with MCP server
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
max_turns=1, # Single-turn for API-style interactions
mcp_servers={"hindsight_tools": mcp_server} if sdk_tools else {},
allowed_tools=allowed_tool_names if allowed_tool_names else [],
)
# Call Claude Agent SDK with retry logic
last_exception = None
for attempt in range(max_retries + 1):
try:
full_text = ""
tool_calls: list[LLMToolCall] = []
# Use ClaudeSDKClient for tool calling support
# Note: query() does NOT support custom tools, only ClaudeSDKClient does
async with ClaudeSDKClient(options=options) as client:
# Send the query
await client.query(user_content)
# Receive response
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(block, ToolUseBlock):
# SDK returns tool names with MCP prefix (mcp__hindsight_tools__{name})
# Strip the prefix to return original tool name expected by caller
tool_name = block.name
if tool_name.startswith("mcp__hindsight_tools__"):
tool_name = tool_name.replace("mcp__hindsight_tools__", "", 1)
tool_calls.append(
LLMToolCall(
id=block.id,
name=tool_name,
arguments=block.input,
)
)
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
# Estimate token usage (Claude Agent SDK doesn't report exact counts)
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(full_text) // 4
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=estimated_input,
output_tokens=estimated_output,
success=True,
)
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, time={duration:.3f}s"
)
return LLMToolCallResult(
content=full_text if full_text else None,
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
input_tokens=estimated_input,
output_tokens=estimated_output,
)
except Exception as e:
last_exception = e
# Check for authentication errors
error_str = str(e).lower()
if "auth" in error_str or "login" in error_str or "credential" in error_str:
logger.error(f"Claude Code authentication error: {e}")
raise RuntimeError(
f"Claude Code authentication failed: {e}\n\n"
"Run 'claude auth login' to authenticate with Claude Pro/Max."
) from e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Claude Code tool call error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Claude Code tool call error after {max_retries + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Claude Code tool call failed after all retries")
async def cleanup(self) -> None:
"""Clean up resources (no HTTP client to close for Claude Agent SDK)."""
@@ -136,6 +136,7 @@ class CodexLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Codex LLM verified: {self.model}")
except Exception as e:
@@ -177,6 +178,9 @@ class CodexLLM(LLMInterface):
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
system_instruction += schema_msg
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
# Build Codex request payload
payload = {
"model": self.model,
@@ -192,7 +196,7 @@ class CodexLLM(LLMInterface):
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": True,
"reasoning": {"summary": self.reasoning_summary},
"reasoning": {"summary": reasoning_summary},
"store": False, # Codex uses stateless mode
"stream": True, # SSE streaming
"include": ["reasoning.encrypted_content"],
@@ -258,6 +262,26 @@ class CodexLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
# Estimate tokens for tracing
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
if return_usage:
# Codex doesn't provide token counts, estimate based on content
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
@@ -283,13 +307,20 @@ class CodexLLM(LLMInterface):
"Run 'codex auth login' to re-authenticate."
) from e
# Log the actual error message from the API
error_detail = e.response.text[:500] if hasattr(e.response, "text") else str(e)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1})")
logger.warning(
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Codex HTTP error after {max_retries + 1} attempts: {e}")
logger.error(
f"Codex HTTP error after {max_retries + 1} attempts: Status {status_code}, Detail: {error_detail}"
)
raise
except httpx.RequestError as e:
@@ -379,8 +410,22 @@ class CodexLLM(LLMInterface):
"""
Make API call with tool calling support.
Note: This is a basic implementation. Full tool calling support for Codex
may require additional SSE event parsing.
Parses Codex SSE stream to extract tool calls from response.output_item.done events.
Tools are converted from OpenAI format to Codex format (flat structure at top level).
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature.
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
@@ -413,20 +458,22 @@ class CodexLLM(LLMInterface):
)
# Convert tools to Codex format
# Codex expects tools with type and name/description/parameters at top level
codex_tools = []
for tool in tools:
func = tool.get("function", {})
codex_tools.append(
{
"type": "function",
"function": {
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
},
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
}
)
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
payload = {
"model": self.model,
"instructions": system_instruction,
@@ -434,7 +481,7 @@ class CodexLLM(LLMInterface):
"tools": codex_tools,
"tool_choice": tool_choice,
"parallel_tool_calls": True,
"reasoning": {"summary": self.reasoning_summary},
"reasoning": {"summary": reasoning_summary},
"store": False,
"stream": True,
"include": ["reasoning.encrypted_content"],
@@ -451,8 +498,16 @@ class CodexLLM(LLMInterface):
url = f"{self.base_url}/codex/responses"
# Debug logging for troubleshooting
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
# Log response details on error
if response.status_code != 200:
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
response.raise_for_status()
# Parse SSE for tool calls and content
@@ -470,6 +525,28 @@ class CodexLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls] if tool_calls else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=0, # Codex doesn't provide token counts
output_tokens=0,
duration=duration,
finish_reason="tool_calls" if tool_calls else "stop",
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -512,13 +589,30 @@ class CodexLLM(LLMInterface):
if event_type == "response.text.delta" and "delta" in data:
content += data["delta"]
# Extract tool calls
elif event_type == "response.function_call_arguments.delta":
# Handle tool call events (implementation depends on actual Codex SSE format)
pass
# Extract completed tool calls from response.output_item.done
elif event_type == "response.output_item.done":
item = data.get("item", {})
if item.get("type") == "function_call" and item.get("status") == "completed":
tool_name = item.get("name", "")
arguments_str = item.get("arguments", "{}")
call_id = item.get("call_id", "")
except json.JSONDecodeError:
pass
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
tool_calls.append(
LLMToolCall(
id=call_id,
name=tool_name,
arguments=arguments,
)
)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {e}, data_str: {data_str[:200]}")
return content if content else None, tool_calls
@@ -136,6 +136,7 @@ class GeminiLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"{self.provider.upper()} connection verified successfully")
except Exception as e:
@@ -275,6 +276,29 @@ class GeminiLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
finish_reason = None
if hasattr(response, "candidates") and response.candidates:
if hasattr(response.candidates[0], "finish_reason"):
finish_reason = str(response.candidates[0].finish_reason)
span_recorder = get_span_recorder()
from hindsight_api.tracing import _serialize_for_span
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0 and input_tokens > 0:
logger.info(
@@ -466,6 +490,30 @@ class GeminiLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -65,6 +65,7 @@ class MockLLM(LLMInterface):
# Storage for test verification
self._mock_calls: list[dict] = []
self._mock_response: Any = None
self._mock_exception: Exception | None = None
async def verify_connection(self) -> None:
"""
@@ -124,6 +125,27 @@ class MockLLM(LLMInterface):
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
# Raise mock exception if configured
if self._mock_exception is not None:
raise self._mock_exception
# Record trace span (minimal for mock provider)
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content="mock response",
input_tokens=10,
output_tokens=5,
duration=0.001, # Mock calls are instant
finish_reason="stop",
error=None,
)
# Return mock response
if self._mock_response is not None:
result = self._mock_response
@@ -183,20 +205,54 @@ class MockLLM(LLMInterface):
}
self._mock_calls.append(call_record)
# Raise mock exception if configured
if self._mock_exception is not None:
raise self._mock_exception
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
if self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
return self._mock_response
# Allow setting just tool calls as a list
if isinstance(self._mock_response, list):
return LLMToolCallResult(
result = self._mock_response
elif isinstance(self._mock_response, list):
# Allow setting just tool calls as a list
result = LLMToolCallResult(
tool_calls=[
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
for i, tc in enumerate(self._mock_response)
],
finish_reason="tool_calls",
)
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
return LLMToolCallResult(content="mock response", finish_reason="stop")
# Record span with mock values
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in result.tool_calls]
if result.tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result.content,
input_tokens=10, # Mock value
output_tokens=5, # Mock value
duration=0.1, # Mock value
finish_reason=result.finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return result
async def cleanup(self) -> None:
"""Clean up resources (no-op for mock provider)."""
@@ -215,6 +271,16 @@ class MockLLM(LLMInterface):
"""
self._mock_response = response
def set_mock_exception(self, exception: Exception) -> None:
"""
Set an exception to raise from mock calls.
Args:
exception: The exception to raise on the next call.
After raising, the exception is cleared.
"""
self._mock_exception = exception
def get_mock_calls(self) -> list[dict]:
"""
Get the list of recorded mock calls.
@@ -230,5 +296,6 @@ class MockLLM(LLMInterface):
return self._mock_calls
def clear_mock_calls(self) -> None:
"""Clear the recorded mock calls."""
"""Clear the recorded mock calls and any set exception."""
self._mock_calls = []
self._mock_exception = None
@@ -16,6 +16,7 @@ Features:
"""
import asyncio
import io
import json
import logging
import os
@@ -96,8 +97,9 @@ class OpenAICompatibleLLM(LLMInterface):
if self.provider in ("openai", "groq") and not self.api_key:
raise ValueError(f"API key is required for {self.provider}")
# Groq service tier configuration
self.groq_service_tier = groq_service_tier or os.getenv("HINDSIGHT_API_LLM_GROQ_SERVICE_TIER", "auto")
# Service tier configuration (from config, not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = kwargs.get("openai_service_tier")
# Get timeout config
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
@@ -130,6 +132,7 @@ class OpenAICompatibleLLM(LLMInterface):
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Connection verified: {self.provider}/{self.model}")
except Exception as e:
@@ -368,6 +371,24 @@ class OpenAICompatibleLLM(LLMInterface):
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.choices[0].finish_reason if response.choices else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0 and usage:
ratio = max(1, output_tokens) / max(1, input_tokens)
@@ -556,6 +577,30 @@ class OpenAICompatibleLLM(LLMInterface):
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
@@ -739,6 +784,140 @@ class OpenAICompatibleLLM(LLMInterface):
raise last_exception
raise RuntimeError("Ollama call failed after all retries")
async def supports_batch_api(self) -> bool:
"""Check if this provider supports batch API operations."""
# Only OpenAI and Groq support batch API
return self.provider in ("openai", "groq")
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""
Submit a batch of requests to OpenAI/Groq Batch API.
Args:
requests: List of request dicts with custom_id, method, url, body
endpoint: API endpoint (e.g., "/v1/chat/completions")
completion_window: Completion window (e.g., "24h")
Returns:
Dict with batch metadata including batch_id
Raises:
NotImplementedError: If provider doesn't support batch API
"""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
logger.info(f"Submitting batch with {len(requests)} requests to {self.provider}")
# Format requests as JSONL
jsonl_content = "\n".join(json.dumps(req) for req in requests)
# Upload file to provider (wrap in BytesIO with filename)
file_bytes = io.BytesIO(jsonl_content.encode("utf-8"))
file_bytes.name = "batch_input.jsonl" # OpenAI SDK needs a filename
file_response = await self._client.files.create(
file=file_bytes,
purpose="batch",
)
logger.debug(f"Uploaded batch file: {file_response.id}")
# Create batch
batch_response = await self._client.batches.create(
input_file_id=file_response.id,
endpoint=endpoint,
completion_window=completion_window,
)
logger.info(f"Batch submitted: {batch_response.id}, status={batch_response.status}")
return {
"batch_id": batch_response.id,
"status": batch_response.status,
"input_file_id": file_response.id,
"created_at": batch_response.created_at,
"request_count": len(requests),
}
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""
Get the status of a batch job.
Args:
batch_id: Batch identifier
Returns:
Dict with status info (batch_id, status, completed_at, etc.)
"""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.batches.retrieve(batch_id)
result = {
"batch_id": batch.id,
"status": batch.status,
"created_at": batch.created_at,
"request_counts": {
"total": batch.request_counts.total if batch.request_counts else 0,
"completed": batch.request_counts.completed if batch.request_counts else 0,
"failed": batch.request_counts.failed if batch.request_counts else 0,
},
}
if batch.completed_at:
result["completed_at"] = batch.completed_at
if batch.output_file_id:
result["output_file_id"] = batch.output_file_id
if batch.error_file_id:
result["error_file_id"] = batch.error_file_id
if batch.errors:
result["errors"] = batch.errors
return result
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""
Retrieve completed batch results.
Args:
batch_id: Batch identifier
Returns:
List of result dicts (one per request, matched by custom_id)
"""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
# Get batch status
batch = await self._client.batches.retrieve(batch_id)
if batch.status != "completed":
raise ValueError(f"Batch {batch_id} is not completed yet (status: {batch.status})")
if not batch.output_file_id:
raise ValueError(f"Batch {batch_id} has no output file")
# Download results file
logger.debug(f"Downloading results for batch {batch_id} from file {batch.output_file_id}")
file_content = await self._client.files.content(batch.output_file_id)
# Parse JSONL results
results = []
for line in file_content.text.strip().split("\n"):
if line:
results.append(json.loads(line))
logger.info(f"Retrieved {len(results)} results for batch {batch_id}")
return results
async def cleanup(self) -> None:
"""Clean up resources (close OpenAI client connections)."""
if hasattr(self, "_client") and self._client:
@@ -402,7 +402,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -447,7 +447,7 @@ async def run_reflect_agent(
result = await llm_config.call_with_tools(
messages=messages,
tools=tools,
scope="reflect_agent",
scope="reflect_tool_call",
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
)
llm_duration = int((time.time() - llm_start) * 1000)
@@ -479,7 +479,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -550,7 +550,7 @@ async def run_reflect_agent(
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
scope="reflect_agent_final",
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
@@ -617,23 +617,30 @@ async def run_reflect_agent(
)
continue
# Process done tool
return await _process_done_tool(
done_call,
available_memory_ids,
available_mental_model_ids,
available_observation_ids,
iteration + 1,
total_tools_called,
tool_trace,
_get_llm_trace(),
_get_usage(),
_log_completion,
reflect_id,
directives_applied=directives_applied,
llm_config=llm_config,
response_schema=response_schema,
)
# Process done tool - wrap with tool call span
from hindsight_api.tracing import get_tracer
tracer = get_tracer()
span_name = "hindsight.reflect_tool_call"
with tracer.start_as_current_span(span_name) as span:
span.set_attribute("hindsight.scope", "reflect_tool_call")
span.set_attribute("hindsight.operation", "reflect_tool_call")
return await _process_done_tool(
done_call,
available_memory_ids,
available_mental_model_ids,
available_observation_ids,
iteration + 1,
total_tools_called,
tool_trace,
_get_llm_trace(),
_get_usage(),
_log_completion,
reflect_id,
directives_applied=directives_applied,
llm_config=llm_config,
response_schema=response_schema,
)
# Execute other tools in parallel (exclude done tool in all its format variants)
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
@@ -842,17 +849,67 @@ async def _execute_tool_with_timing(
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
) -> tuple[dict[str, Any], int]:
"""Execute a tool call and return result with timing."""
start = time.time()
result = await _execute_tool(
tc.name,
tc.arguments,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
duration_ms = int((time.time() - start) * 1000)
return result, duration_ms
from hindsight_api.tracing import get_tracer
start_time = time.time()
# Create span for tool execution
tracer = get_tracer()
# Normalize tool name for span
normalized_name = _normalize_tool_name(tc.name)
span_name = f"hindsight.reflect_tool_exec.{normalized_name}"
# Calculate timestamps
start_time_ns = time.time_ns()
with tracer.start_as_current_span(
span_name,
start_time=start_time_ns,
end_on_exit=False,
) as span:
# Set attributes
span.set_attribute("hindsight.tool.name", normalized_name)
span.set_attribute("hindsight.tool.id", tc.id)
span.set_attribute("hindsight.tool.arguments", json.dumps(tc.arguments))
try:
result = await _execute_tool(
tc.name,
tc.arguments,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
)
# Set success attributes
if isinstance(result, dict) and "error" in result:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.ERROR, result["error"]))
else:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.OK))
duration_ms = int((time.time() - start_time) * 1000)
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
# End span with correct timestamp
end_time_ns = time.time_ns()
span.end(end_time=end_time_ns)
return result, duration_ms
except Exception as e:
from opentelemetry.trace import Status, StatusCode
span.set_status(Status(StatusCode.ERROR, str(e)))
span.record_exception(e)
duration_ms = int((time.time() - start_time) * 1000)
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
end_time_ns = time.time_ns()
span.end(end_time=end_time_ns)
raise
async def _execute_tool(
@@ -871,21 +928,21 @@ async def _execute_tool(
query = args.get("query")
if not query:
return {"error": "search_mental_models requires a query parameter"}
max_results = args.get("max_results") or 5
max_results = int(args.get("max_results") or 5)
return await search_mental_models_fn(query, max_results)
elif tool_name == "search_observations":
query = args.get("query")
if not query:
return {"error": "search_observations requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
max_tokens = max(int(args.get("max_tokens") or 5000), 1000) # Default 5000, min 1000
return await search_observations_fn(query, max_tokens)
elif tool_name == "recall":
query = args.get("query")
if not query:
return {"error": "recall requires a query parameter"}
max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000
max_tokens = max(int(args.get("max_tokens") or 2048), 1000) # Default 2048, min 1000
return await recall_fn(query, max_tokens)
elif tool_name == "expand":
@@ -904,18 +961,18 @@ def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
if tool_name == "search_mental_models":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_results = args.get("max_results") or 5
max_results = int(args.get("max_results") or 5)
return f"(query={query_preview}, max_results={max_results})"
elif tool_name == "search_observations":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_tokens = max(args.get("max_tokens") or 5000, 1000)
max_tokens = max(int(args.get("max_tokens") or 5000), 1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "recall":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
# Show actual value used (default 2048, min 1000)
max_tokens = max(args.get("max_tokens") or 2048, 1000)
max_tokens = max(int(args.get("max_tokens") or 2048), 1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
@@ -31,7 +31,7 @@ class ReflectAction(BaseModel):
default=None, description="Observation sections for done action (when output_mode=observations)"
)
# Plain text answer fields (for output_mode=answer)
answer: str | None = Field(default=None, description="Plain text answer for done action (no markdown)")
answer: str | None = Field(default=None, description="Well-formatted markdown answer for done action")
answer_memory_ids: list[str] | None = Field(
default=None, description="Memory IDs supporting the answer", alias="memory_ids"
)
@@ -148,7 +148,15 @@ def build_system_prompt_for_tools(
parts = []
# Inject directives at the VERY START for maximum prominence
# Anti-hallucination rule at the very top
parts.extend(
[
"CRITICAL: You MUST ONLY use information from retrieved tool results. NEVER make up names, people, events, or entities.",
"",
]
)
# Inject directives after anti-hallucination rule
if directives:
parts.append(build_directives_section(directives))
@@ -162,7 +170,7 @@ def build_system_prompt_for_tools(
parts.extend(
[
"## CRITICAL RULES",
"- You must NEVER fabricate information that has no basis in retrieved data",
"- ONLY use information from tool results - no external knowledge or guessing",
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
"- You MUST search before saying you don't have information",
"",
@@ -300,9 +308,11 @@ def build_system_prompt_for_tools(
parts.extend(
[
"",
"## Output Format: Plain Text Answer",
"Call done() with a plain text 'answer' field.",
"- Do NOT use markdown formatting",
"## Output Format: Well-Formatted Markdown Answer",
"Call done() with a well-formatted markdown 'answer' field.",
"- USE markdown formatting for structure (headers, lists, bold, italic, code blocks, tables, etc.)",
"- CRITICAL: Add blank lines before and after block elements (tables, code blocks, lists)",
"- Format for clarity and readability with proper spacing and hierarchy",
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
"- Put IDs ONLY in the memory_ids/mental_model_ids/observation_ids arrays, not in the answer",
]
@@ -463,21 +473,41 @@ def build_final_prompt(
parts.append(
"\n## Instructions\n"
"Provide a thoughtful answer by synthesizing and reasoning from the retrieved data above. "
"You can make reasonable inferences from the memories, but don't completely fabricate information."
"You can make reasonable inferences from the memories, but don't completely fabricate information. "
"If the exact answer isn't stated, use what IS stated to give the best possible answer. "
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question."
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question.\n\n"
"IMPORTANT: Output ONLY the final answer. Do NOT include meta-commentary like "
'"I\'ll search..." or "Let me analyze...". Do NOT explain your reasoning process. '
"Just provide the direct synthesized answer."
)
return "\n".join(parts)
FINAL_SYSTEM_PROMPT = """You are a thoughtful assistant that synthesizes answers from retrieved memories.
FINAL_SYSTEM_PROMPT = """CRITICAL: You MUST ONLY use information from retrieved tool results. NEVER make up names, people, events, or entities.
You are a thoughtful assistant that synthesizes answers from retrieved memories.
Your approach:
- Reason over the retrieved memories to answer the question
- Make reasonable inferences when the exact answer isn't explicitly stated
- Connect related memories to form a complete picture
- Be helpful - if you have related information, use it to give the best possible answer
- ONLY use information from tool results - no external knowledge or guessing
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
Do NOT fabricate information that has no basis in the retrieved data."""
FORMATTING: Use proper markdown formatting in your answer:
- Headers (##, ###) for sections
- Lists (bullet or numbered) for enumerations
- Bold/italic for emphasis
- Tables with proper syntax (ensure blank line before and after)
- Code blocks where appropriate
- CRITICAL: Always add blank lines before and after block elements (tables, code blocks, lists)
- Proper spacing between sections
CRITICAL: Output ONLY the final synthesized answer. Do NOT include:
- Meta-commentary about what you're doing ("I'll search...", "Let me analyze...")
- Explanations of your reasoning process
- Descriptions of your approach
Just provide the direct answer with proper markdown formatting."""
@@ -54,19 +54,18 @@ async def tool_search_mental_models(
Dict with matching mental models including content and freshness info
"""
from ..memory_engine import fq_table
from ..search.tags import build_tags_where_clause
# Build filters dynamically
filters = ""
params: list[Any] = [bank_id, str(query_embedding), max_results]
next_param = 4
# Use the centralized tag filtering logic
if tags:
if tags_match == "all":
filters += f" AND tags @> ${next_param}::varchar[]"
else:
filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
params.append(tags)
next_param += 1
tag_clause, tag_params, next_param = build_tags_where_clause(tags, param_offset=next_param, match=tags_match)
filters += f" {tag_clause}"
params.extend(tag_params)
if exclude_ids:
filters += f" AND id != ALL(${next_param}::text[])"
@@ -139,7 +139,7 @@ TOOL_DONE_ANSWER = {
"properties": {
"answer": {
"type": "string",
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
@@ -190,7 +190,7 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
"properties": {
"answer": {
"type": "string",
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
},
"memory_ids": {
"type": "array",
@@ -542,7 +542,12 @@ Output: ONLY 2 facts (skip coffee preference - too trivial):
QUALITY OVER QUANTITY
══════════════════════════════════════════════════════════════════════════
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
Ask: "Would this be useful to recall in 6 months?" If no, skip it.
IMPORTANT: Sensory/emotional details and observations that provide meaningful context
about experiences ARE important to remember, even if they seem small (e.g., how food
tasted, how someone looked, how loud music was). Extract these if they characterize
an experience or person."""
# Assembled concise prompt (backward compatible - exact same output as before)
CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
@@ -641,6 +646,7 @@ For EVENTS (fact_kind="event") - MUST SET BOTH occurred_start AND occurred_end:
- Convert relative dates → absolute using Event Date as reference
- If Event Date is "Saturday, March 15, 2020", then "yesterday" = Friday, March 14, 2020
- Dates mentioned in text (e.g., "in March 2020") should use THAT year, not current year
- CRITICAL: If the content mentions an absolute date (e.g., "March 15, 2024", "2024-03-15"), you MUST extract it and set occurred_start in ISO format
- Always include the day name (Monday, Tuesday, etc.) in the 'when' field
- Set occurred_start AND occurred_end to WHEN IT HAPPENED (not when mentioned)
- For single-day/point events: set occurred_end = occurred_start (same timestamp)
@@ -689,44 +695,20 @@ Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 2: Moved apartment, causal_relations: [{target_index: 1, relation_type: "caused_by"}]"""
async def _extract_facts_from_chunk(
chunk: str,
chunk_index: int,
total_chunks: int,
event_date: datetime,
context: str,
llm_config: "LLMConfig",
agent_name: str = None,
) -> tuple[list[dict[str, str]], TokenUsage]:
def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
"""
Extract facts from a single chunk (internal helper for parallel processing).
Build extraction prompt and response schema based on config.
Note: event_date parameter is kept for backward compatibility but not used in prompt.
The LLM extracts temporal information from the context string instead.
Returns:
Tuple of (prompt, response_schema)
"""
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
# Determine which fact types to extract
# Note: We use "assistant" in the prompt but convert to "bank" for storage
fact_types_instruction = "Extract ONLY 'world' and 'assistant' type facts."
# Check config for extraction mode and causal link extraction
config = get_config()
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
# Select base prompt based on extraction mode
if extraction_mode == "custom":
# Custom mode: inject user-provided guidelines
if not config.retain_custom_instructions:
logger.warning(
"extraction_mode='custom' but HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS not set. "
"Falling back to 'concise' mode."
)
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
else:
@@ -742,33 +724,26 @@ async def _extract_facts_from_chunk(
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
# Build the full prompt with or without causal relationships section
# Select appropriate response schema based on extraction mode and causal links
# Add causal relationships section if enabled
if extract_causal_links:
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
if extraction_mode == "verbose":
response_schema = FactExtractionResponseVerbose
else:
response_schema = FactExtractionResponse
response_schema = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
else:
response_schema = FactExtractionResponseNoCausal
# Retry logic for JSON validation errors
max_retries = 2
last_error = None
return prompt, response_schema
# Sanitize input text to prevent Unicode encoding errors (e.g., unpaired surrogates)
sanitized_chunk = _sanitize_text(chunk)
sanitized_context = _sanitize_text(context) if context else "none"
# Build user message with metadata and chunk content in a clear format
# Format event_date with day of week for better temporal reasoning
# Handle both datetime objects and ISO string formats (from deserialized async tasks)
def _build_user_message(chunk: str, chunk_index: int, total_chunks: int, event_date: datetime, context: str) -> str:
"""Build user message for fact extraction."""
from .orchestrator import parse_datetime_flexible
sanitized_chunk = _sanitize_text(chunk)
sanitized_context = _sanitize_text(context) if context else "none"
event_date = parse_datetime_flexible(event_date)
event_date_formatted = event_date.strftime("%A, %B %d, %Y") # e.g., "Monday, June 10, 2024"
user_message = f"""Extract facts from the following text chunk.
event_date_formatted = event_date.strftime("%A, %B %d, %Y")
return f"""Extract facts from the following text chunk.
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_formatted} ({event_date.isoformat()})
@@ -777,6 +752,70 @@ Context: {sanitized_context}
Text:
{sanitized_chunk}"""
def _build_request_body(llm_config, config, prompt: str, user_message: str, response_schema: type) -> dict:
"""Build request body for LLM API call."""
request_body = {
"model": llm_config.model,
"messages": [{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
"temperature": 0.1,
}
# Add max_completion_tokens if configured
if config.retain_max_completion_tokens:
request_body["max_completion_tokens"] = config.retain_max_completion_tokens
# Add service_tier for OpenAI Flex Processing
if llm_config.provider == "openai" and llm_config._provider_impl.openai_service_tier:
request_body["service_tier"] = llm_config._provider_impl.openai_service_tier
# Add response_format (JSON schema)
if hasattr(response_schema, "model_json_schema"):
schema = response_schema.model_json_schema()
request_body["response_format"] = {
"type": "json_schema",
"json_schema": {"name": "facts", "schema": schema},
}
return request_body
async def _extract_facts_from_chunk(
chunk: str,
chunk_index: int,
total_chunks: int,
event_date: datetime,
context: str,
llm_config: "LLMConfig",
config,
agent_name: str = None,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
Extract facts from a single chunk (internal helper for parallel processing).
Note: event_date parameter is kept for backward compatibility but not used in prompt.
The LLM extracts temporal information from the context string instead.
"""
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
# Build prompt and schema using helper function
prompt, response_schema = _build_extraction_prompt_and_schema(config)
# Check config for extraction mode and causal link extraction
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
# Build user message using helper function
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context)
# Retry logic for JSON validation errors
max_retries = 2
last_error = None
usage = TokenUsage() # Track cumulative usage across retries
for attempt in range(max_retries):
try:
@@ -796,7 +835,7 @@ Text:
extraction_response_json, call_usage = await llm_config.call(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="memory_extract_facts",
scope="retain_extract_facts",
temperature=0.1,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=max_retries,
@@ -1005,6 +1044,29 @@ Text:
except BadRequestError as e:
last_error = e
error_str = str(e).lower()
# Check if error is related to max_tokens/completion_tokens not being supported
if any(
keyword in error_str
for keyword in [
"max_tokens",
"max_completion_tokens",
"maximum context",
"token limit",
"context length",
]
):
# Provide helpful error message with configuration suggestions
raise ValueError(
f"Model does not support the required output token limit.\n\n"
f"The model '{llm_config.model}' (provider: {llm_config.provider}) failed with: {e}\n\n"
f"You have two options to fix this:\n"
f" 1. Use a different model that supports at least {config.retain_max_completion_tokens} output tokens\n"
f" 2. Decrease HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value your model supports\n"
f" (current value: {config.retain_max_completion_tokens}, must be > RETAIN_CHUNK_SIZE={config.retain_chunk_size})"
) from e
if "json_validate_failed" in str(e):
logger.warning(
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}"
@@ -1026,6 +1088,7 @@ async def _extract_facts_with_auto_split(
event_date: datetime,
context: str,
llm_config: LLMConfig,
config,
agent_name: str = None,
) -> tuple[list[dict[str, str]], TokenUsage]:
"""
@@ -1041,6 +1104,7 @@ async def _extract_facts_with_auto_split(
event_date: Reference date for temporal information
context: Context about the conversation/document
llm_config: LLM configuration to use
config: Resolved HindsightConfig for this bank
agent_name: Optional agent name (memory owner)
Returns:
@@ -1059,6 +1123,7 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
)
except OutputTooLongError:
@@ -1103,6 +1168,7 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
),
_extract_facts_with_auto_split(
@@ -1112,6 +1178,7 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
),
]
@@ -1135,6 +1202,7 @@ async def extract_facts_from_text(
event_date: datetime,
llm_config: LLMConfig,
agent_name: str,
config,
context: str = "",
) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
"""
@@ -1149,9 +1217,10 @@ async def extract_facts_from_text(
Args:
text: Input text (conversation, article, etc.)
event_date: Reference date for resolving relative times
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Agent name (memory owner)
config: Resolved HindsightConfig for this bank
context: Context about the conversation/document
Returns:
Tuple of (facts, chunks, usage) where:
@@ -1159,7 +1228,6 @@ async def extract_facts_from_text(
- chunks: List of tuples (chunk_text, fact_count) for each chunk
- usage: Aggregated token usage across all LLM calls
"""
config = get_config()
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
# Log chunk count before starting LLM requests
@@ -1178,6 +1246,7 @@ async def extract_facts_from_text(
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
)
for i, chunk in enumerate(chunks)
@@ -1205,12 +1274,424 @@ from .types import ExtractedFact as ExtractedFactType
logger = logging.getLogger(__name__)
# Each fact gets 10 seconds offset to preserve ordering within a document
SECONDS_PER_FACT = 10
# Each fact gets 10ms offset to preserve ordering within a document
SECONDS_PER_FACT = 0.01
async def extract_facts_from_contents_batch_api(
contents: list[RetainContent],
llm_config,
agent_name: str,
config,
pool=None,
operation_id: str | None = None,
schema: str | None = None,
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
Extract facts using LLM Batch API (OpenAI/Groq).
Submits all chunks as a single batch, polls until complete, then processes results.
Only called when config.retain_batch_enabled=True.
Args:
contents: List of RetainContent objects to process
llm_config: LLM configuration with batch API support
agent_name: Name of the agent
config: Resolved HindsightConfig for this bank
pool: Database connection pool (for storing batch state)
operation_id: Async operation ID (for crash recovery)
schema: Database schema (for multi-tenant support)
Returns:
Tuple of (extracted_facts, chunks_metadata, usage)
"""
if not contents:
return [], [], TokenUsage()
logger.info(f"Using Batch API for fact extraction ({len(contents)} contents)")
# Check config for extraction mode and causal link extraction (used throughout)
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
# Check if provider supports batch API
if not await llm_config._provider_impl.supports_batch_api():
logger.warning(f"Batch API not supported for provider {llm_config.provider}, falling back to sync mode")
return await extract_facts_from_contents(contents, llm_config, agent_name, config, pool, operation_id, schema)
# Check if we're resuming an existing batch (crash recovery)
batch_id = None
if operation_id and pool:
from ..task_backend import fq_table
table = fq_table("async_operations", schema)
row = await pool.fetchrow(
f"SELECT result_metadata FROM {table} WHERE operation_id = $1",
operation_id,
)
if row and row["result_metadata"]:
metadata = row["result_metadata"]
if isinstance(metadata, str):
metadata = json.loads(metadata)
batch_id = metadata.get("batch_id")
if batch_id:
logger.info(f"Resuming existing batch: batch_id={batch_id} (crash recovery)")
# Step 1: Chunk all contents and build batch requests (skip if resuming)
all_chunks_info = [] # List of (chunk_text, content_index, chunk_index_in_content, event_date, context)
batch_requests = []
# Build prompt and schema once (same for all chunks)
prompt, response_schema = _build_extraction_prompt_and_schema(config)
for content_index, item in enumerate(contents):
chunks = chunk_text(item.content, max_chars=config.retain_chunk_size)
for chunk_index_in_content, chunk in enumerate(chunks):
all_chunks_info.append((chunk, content_index, chunk_index_in_content, item.event_date, item.context))
# Build batch request for this chunk
custom_id = f"chunk_{len(all_chunks_info) - 1}" # Global chunk index
# Build user message using helper function
user_message = _build_user_message(
chunk, chunk_index_in_content, len(chunks), item.event_date, item.context
)
# Build request body using helper function
request_body = _build_request_body(llm_config, config, prompt, user_message, response_schema)
batch_requests.append(
{"custom_id": custom_id, "method": "POST", "url": "/v1/chat/completions", "body": request_body}
)
if not batch_requests and not batch_id: # No requests and not resuming
return [], [], TokenUsage()
# Step 2: Submit batch (skip if resuming)
if not batch_id:
logger.info(f"Submitting batch with {len(batch_requests)} chunk requests")
batch_metadata = await llm_config._provider_impl.submit_batch(batch_requests)
batch_id = batch_metadata["batch_id"]
logger.info(f"Batch submitted: {batch_id}, polling every {config.retain_batch_poll_interval_seconds}s")
# CRITICAL: Store minimal batch state in operation metadata for crash recovery
# This allows resuming polling if worker restarts
if operation_id and pool:
batch_state = {
"batch_id": batch_id,
"batch_provider": llm_config.provider,
"chunk_count": len(batch_requests),
}
# Update operation result_metadata
from ..task_backend import fq_table
table = fq_table("async_operations", schema)
await pool.execute(
f"""
UPDATE {table}
SET result_metadata = result_metadata || $1::jsonb, updated_at = now()
WHERE operation_id = $2
""",
json.dumps(batch_state),
operation_id,
)
logger.info(f"Stored batch state for operation {operation_id} (crash recovery enabled)")
else:
logger.info(f"Resuming polling for existing batch: {batch_id}")
# Step 3: Poll until complete
import time
start_time = time.time()
while True:
status_info = await llm_config._provider_impl.get_batch_status(batch_id)
status = status_info["status"]
elapsed = time.time() - start_time
logger.info(
f"Batch {batch_id}: status={status}, "
f"completed={status_info['request_counts']['completed']}/{status_info['request_counts']['total']}, "
f"elapsed={elapsed:.0f}s"
)
if status == "completed":
break
elif status in ("failed", "expired", "cancelled"):
error_msg = status_info.get("errors", "Unknown error")
raise RuntimeError(f"Batch {batch_id} failed with status {status}: {error_msg}")
# Wait before polling again
await asyncio.sleep(config.retain_batch_poll_interval_seconds)
logger.info(f"Batch {batch_id} completed in {elapsed:.0f}s, retrieving results")
# Step 4: Retrieve results
batch_results = await llm_config._provider_impl.retrieve_batch_results(batch_id)
# Map results by custom_id
results_by_id = {result["custom_id"]: result for result in batch_results}
# Step 5: Parse results into facts (same as sync mode)
all_facts_from_llm = []
chunks_metadata = []
total_usage = TokenUsage()
for chunk_idx, (chunk_content, content_index, chunk_index_in_content, event_date, context) in enumerate(
all_chunks_info
):
custom_id = f"chunk_{chunk_idx}"
result = results_by_id.get(custom_id)
if not result:
logger.warning(f"Missing result for {custom_id}, skipping")
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
)
)
continue
# Check for errors
if result.get("error"):
logger.error(f"Error in {custom_id}: {result['error']}")
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
)
)
continue
# Extract response
response_body = result.get("response", {}).get("body", {})
choices = response_body.get("choices", [])
if not choices:
logger.warning(f"No choices in response for {custom_id}")
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
)
)
continue
# Parse JSON content
message = choices[0].get("message", {})
content_str = message.get("content", "{}")
try:
extraction_response_json = json.loads(content_str)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON for {custom_id}: {e}")
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
)
)
continue
# Parse facts (reuse existing logic from _extract_facts_from_chunk)
raw_facts = extraction_response_json.get("facts", [])
chunk_facts = []
for i, llm_fact in enumerate(raw_facts):
if not isinstance(llm_fact, dict):
continue
def get_value(field_name):
value = llm_fact.get(field_name)
if value and value != "" and value != [] and value != {} and str(value).upper() != "N/A":
return value
return None
what = get_value("what")
if not what:
what = get_value("factual_core")
if not what:
continue
when = get_value("when")
who = get_value("who")
why = get_value("why")
# Critical field: fact_type
original_fact_type = llm_fact.get("fact_type")
fact_type = original_fact_type
# Convert "assistant" → "experience"
if fact_type == "assistant":
fact_type = "experience"
# Validate fact_type
if fact_type not in ["world", "experience", "opinion"]:
fact_kind = llm_fact.get("fact_kind")
if fact_kind == "assistant":
fact_type = "experience"
elif fact_kind in ["world", "experience", "opinion"]:
fact_type = fact_kind
else:
fact_type = "world"
# Build combined fact text
combined_parts = [what]
if when:
combined_parts.append(f"When: {when}")
if who:
combined_parts.append(f"Involving: {who}")
if why:
combined_parts.append(why)
combined_text = " | ".join(combined_parts)
# Temporal fields
fact_data = {}
fact_kind = llm_fact.get("fact_kind", "conversation")
if fact_kind not in ["conversation", "event", "other"]:
fact_kind = "conversation"
if fact_kind == "event":
occurred_start = get_value("occurred_start")
occurred_end = get_value("occurred_end")
if not occurred_start:
fact_data["occurred_start"] = _infer_temporal_date(combined_text, event_date)
else:
fact_data["occurred_start"] = occurred_start
if occurred_end:
fact_data["occurred_end"] = occurred_end
elif fact_data.get("occurred_start"):
fact_data["occurred_end"] = fact_data["occurred_start"]
# Entities
entities = get_value("entities")
if entities:
validated_entities = []
for ent in entities:
if isinstance(ent, str):
validated_entities.append(Entity(text=ent))
elif isinstance(ent, dict) and "text" in ent:
try:
validated_entities.append(Entity.model_validate(ent))
except Exception:
pass
if validated_entities:
fact_data["entities"] = validated_entities
# Causal relations
if extract_causal_links:
validated_relations = []
causal_relations_raw = get_value("causal_relations")
if causal_relations_raw:
for rel in causal_relations_raw:
if not isinstance(rel, dict):
continue
target_idx = rel.get("target_index")
relation_type = rel.get("relation_type")
strength = rel.get("strength", 1.0)
if target_idx is None or relation_type is None:
continue
if target_idx < 0 or target_idx >= i:
continue
try:
validated_relations.append(
CausalRelation(
target_fact_index=target_idx, relation_type=relation_type, strength=strength
)
)
except Exception:
pass
if validated_relations:
fact_data["causal_relations"] = validated_relations
# Always set mentioned_at
fact_data["mentioned_at"] = event_date.isoformat()
try:
fact = Fact(fact=combined_text, fact_type=fact_type, **fact_data)
chunk_facts.append(fact)
except Exception as e:
logger.error(f"Failed to create Fact model for fact {i}: {e}")
continue
all_facts_from_llm.extend(chunk_facts)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content,
fact_count=len(chunk_facts),
content_index=content_index,
chunk_index=chunk_idx,
)
)
# Track token usage
usage_data = response_body.get("usage", {})
if usage_data:
total_usage = total_usage + TokenUsage(
input_tokens=usage_data.get("prompt_tokens", 0),
output_tokens=usage_data.get("completion_tokens", 0),
total_tokens=usage_data.get("total_tokens", 0),
)
# Step 6: Convert to ExtractedFact objects with proper chunk mapping
# Group facts by chunk
facts_by_chunk = [] # List of (chunk_metadata, [facts])
fact_start_idx = 0
for chunk_meta in chunks_metadata:
chunk_facts = all_facts_from_llm[fact_start_idx : fact_start_idx + chunk_meta.fact_count]
facts_by_chunk.append((chunk_meta, chunk_facts))
fact_start_idx += chunk_meta.fact_count
# Now convert to ExtractedFactType
extracted_facts = []
global_fact_idx = 0
for chunk_meta, chunk_facts in facts_by_chunk:
content = contents[chunk_meta.content_index]
for fact_from_llm in chunk_facts:
extracted_fact = ExtractedFactType(
fact_text=fact_from_llm.fact,
fact_type=fact_from_llm.fact_type,
entities=[e.text for e in (fact_from_llm.entities or [])],
occurred_start=_parse_datetime(fact_from_llm.occurred_start) if fact_from_llm.occurred_start else None,
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
causal_relations=_convert_causal_relations(fact_from_llm.causal_relations or [], global_fact_idx),
content_index=chunk_meta.content_index,
chunk_index=chunk_meta.chunk_index,
context=content.context,
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
)
extracted_facts.append(extracted_fact)
global_fact_idx += 1
# Step 7: Add temporal offsets
_add_temporal_offsets(extracted_facts, contents)
logger.info(f"Batch API extracted {len(extracted_facts)} facts from {len(all_chunks_info)} chunks")
return extracted_facts, chunks_metadata, total_usage
async def extract_facts_from_contents(
contents: list[RetainContent], llm_config, agent_name: str
contents: list[RetainContent],
llm_config,
agent_name: str,
config,
pool=None,
operation_id: str | None = None,
schema: str | None = None,
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
"""
Extract facts from multiple content items in parallel.
@@ -1221,10 +1702,16 @@ async def extract_facts_from_contents(
3. Adds time offsets to preserve fact ordering within each content
4. Returns typed ExtractedFact and ChunkMetadata objects
Routes to batch API mode if config.retain_batch_enabled=True.
Args:
contents: List of RetainContent objects to process
llm_config: LLM configuration for fact extraction
agent_name: Name of the agent (for agent-related fact detection)
config: Resolved HindsightConfig for this bank
pool: Database connection pool (passed to batch API for state storage)
operation_id: Async operation ID (passed to batch API for crash recovery)
schema: Database schema (passed to batch API for multi-tenant support)
Returns:
Tuple of (extracted_facts, chunks_metadata, usage)
@@ -1232,6 +1719,12 @@ async def extract_facts_from_contents(
if not contents:
return [], [], TokenUsage()
# Route to batch API if enabled
if config.retain_batch_enabled:
return await extract_facts_from_contents_batch_api(
contents, llm_config, agent_name, config, pool, operation_id, schema
)
# Step 1: Create parallel fact extraction tasks
fact_extraction_tasks = []
for item in contents:
@@ -1243,6 +1736,7 @@ async def extract_facts_from_contents(
context=item.context,
llm_config=llm_config,
agent_name=agent_name,
config=config,
)
fact_extraction_tasks.append(task)
@@ -1347,28 +1841,21 @@ def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[C
def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainContent]) -> None:
"""
Add time offsets to preserve fact ordering within each content.
Add time offsets to preserve fact ordering across all contents.
This allows retrieval to distinguish between facts that happened earlier vs later
in the same conversation, even when the base event_date is the same.
This allows retrieval to distinguish between facts from different documents/conversations
even when they have the same base event_date, and also between facts within the same
conversation.
Uses absolute position across all facts to ensure unique timestamps.
Modifies facts in place.
"""
from .orchestrator import parse_datetime_flexible
# Group facts by content_index
current_content_idx = 0
content_fact_start = 0
for i, fact in enumerate(facts):
if fact.content_index != current_content_idx:
# Moved to next content
current_content_idx = fact.content_index
content_fact_start = i
# Calculate position within this content
fact_position = i - content_fact_start
offset = timedelta(seconds=fact_position * SECONDS_PER_FACT)
# Use absolute position across all facts to ensure uniqueness across different contents
offset = timedelta(seconds=i * SECONDS_PER_FACT)
# Apply offset to all temporal fields (handle both datetime objects and ISO strings)
if fact.occurred_start:
@@ -7,6 +7,7 @@ Handles insertion of facts into the database.
import json
import logging
from ...config import get_config
from ..memory_engine import fq_table
from .fact_extraction import _sanitize_text
from .types import ProcessedFact
@@ -70,28 +71,59 @@ async def insert_facts_batch(
# Batch insert all facts
# Note: tags are passed as JSON strings and converted back to varchar[] via jsonb_array_elements_text + array_agg
results = await conn.fetch(
f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
)
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
# Query varies based on text search backend
config = get_config()
if config.text_search_extension == "vchord":
# VectorChord: manually tokenize and insert search_vector
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
)
FROM input_data
RETURNING id
""",
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
tokenize(COALESCE(text, '') || ' ' || COALESCE(context, ''), 'llmlingua2')::bm25_catalog.bm25vector
FROM input_data
RETURNING id
"""
else: # native or pg_textsearch
# Native PostgreSQL: search_vector is GENERATED ALWAYS, don't include it
# pg_textsearch: indexes operate on base columns directly, don't populate search_vector
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
)
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
)
FROM input_data
RETURNING id
"""
results = await conn.fetch(
query,
bank_id,
fact_texts,
embeddings,
@@ -76,11 +76,14 @@ async def retain_batch(
duplicate_checker_fn,
bank_id: str,
contents_dicts: list[RetainContentDict],
config,
document_id: str | None = None,
is_first_batch: bool = True,
fact_type_override: str | None = None,
confidence_score: float | None = None,
document_tags: list[str] | None = None,
operation_id: str | None = None,
schema: str | None = None,
) -> tuple[list[list[str]], TokenUsage]:
"""
Process a batch of content through the retain pipeline.
@@ -94,6 +97,7 @@ async def retain_batch(
duplicate_checker_fn: Function to check for duplicate facts
bank_id: Bank identifier
contents_dicts: List of content dictionaries
config: Resolved HindsightConfig for this bank
document_id: Optional document ID
is_first_batch: Whether this is the first batch
fact_type_override: Override fact type for all facts
@@ -144,20 +148,38 @@ async def retain_batch(
# Step 1: Extract facts from all contents
step_start = time.time()
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(contents, llm_config, agent_name)
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
contents, llm_config, agent_name, config, pool, operation_id, schema
)
log_buffer.append(
f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s"
)
if not extracted_facts:
# Still need to create document if document_id was provided
# Still need to create document if document_id was provided or chunks exist
from collections import defaultdict
docs_tracked = 0
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
await fact_storage.ensure_bank_exists(conn, bank_id)
# Handle document tracking even with no facts
# Group contents by document_id (consistent with normal path)
contents_by_doc_early = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
contents_by_doc_early[doc_id].append((idx, content_dict))
if document_id:
# Legacy: single document_id parameter
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if contents_dicts:
first_item = contents_dicts[0]
@@ -172,40 +194,59 @@ async def retain_batch(
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
docs_tracked += 1
else:
# Check for per-item document_ids
from collections import defaultdict
# Handle per-item document_ids and/or chunks (mirrors normal path logic)
has_any_doc_ids = any(item.get("document_id") for item in contents_dicts)
contents_by_doc = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
if doc_id:
contents_by_doc[doc_id].append((idx, content_dict))
if has_any_doc_ids or chunks:
for original_doc_id, doc_contents in contents_by_doc_early.items():
should_create_doc = (original_doc_id is not None) or chunks
if not should_create_doc:
continue
for doc_id, doc_contents in contents_by_doc.items():
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params, document_tags
)
actual_doc_id = original_doc_id
if actual_doc_id is None:
# No document_id but have chunks - generate one
actual_doc_id = str(uuid.uuid4())
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn,
bank_id,
actual_doc_id,
combined_content,
is_first_batch,
retain_params,
merged_tags,
)
docs_tracked += 1
total_time = time.time() - start_time
doc_status = f"{docs_tracked} document(s) tracked" if docs_tracked > 0 else "no document tracked"
logger.info(
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s (document tracked, no facts)"
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s ({doc_status}, no facts)"
)
return [[] for _ in contents], usage
@@ -252,6 +293,13 @@ async def retain_batch(
# Legacy: single document_id parameter
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params = {}
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
if contents_dicts:
first_item = contents_dicts[0]
if first_item.get("context"):
@@ -266,7 +314,7 @@ async def retain_batch(
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
document_ids_added.append(document_id)
doc_id_mapping[None] = document_id # For backwards compatibility
@@ -294,6 +342,13 @@ async def retain_batch(
# Combine content for this document
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
# Extract retain params from first content item
retain_params = {}
if doc_contents:
@@ -316,7 +371,7 @@ async def retain_batch(
combined_content,
is_first_batch,
retain_params,
document_tags,
merged_tags,
)
document_ids_added.append(actual_doc_id)
@@ -13,12 +13,10 @@ from .reranking import CrossEncoderReranker
from .retrieval import (
ParallelRetrievalResult,
get_default_graph_retriever,
retrieve_parallel,
set_default_graph_retriever,
)
__all__ = [
"retrieve_parallel",
"get_default_graph_retriever",
"set_default_graph_retriever",
"ParallelRetrievalResult",
@@ -85,116 +85,6 @@ def set_default_graph_retriever(retriever: GraphRetriever) -> None:
_default_graph_retriever = retriever
async def retrieve_semantic(
conn,
query_emb_str: str,
bank_id: str,
fact_type: str,
limit: int,
tags: list[str] | None = None,
) -> list[RetrievalResult]:
"""
Semantic retrieval via vector similarity.
Args:
conn: Database connection
query_emb_str: Query embedding as string
agent_id: bank ID
fact_type: Fact type to filter
limit: Maximum results to return
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
List of RetrievalResult objects
"""
from .tags import TagsMatch, build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 5)
params = [query_emb_str, bank_id, fact_type, limit]
if tags:
params.append(tags)
results = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= 0.3
{tags_clause}
ORDER BY embedding <=> $1::vector
LIMIT $4
""",
*params,
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
async def retrieve_bm25(
conn,
query_text: str,
bank_id: str,
fact_type: str,
limit: int,
tags: list[str] | None = None,
) -> list[RetrievalResult]:
"""
BM25 keyword retrieval via full-text search.
Args:
conn: Database connection
query_text: Query text
agent_id: bank ID
fact_type: Fact type to filter
limit: Maximum results to return
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
List of RetrievalResult objects
"""
import re
from .tags import TagsMatch, build_tags_where_clause_simple
# Sanitize query text: remove special characters that have meaning in tsquery
# Keep only alphanumeric characters and spaces
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
# Split and filter empty strings
tokens = [token for token in sanitized_text.split() if token]
if not tokens:
# If no valid tokens, return empty results
return []
# Convert query to tsquery using OR for more flexible matching
# This prevents empty results when some terms are missing
query_tsquery = " | ".join(tokens)
tags_clause = build_tags_where_clause_simple(tags, 5)
params = [query_tsquery, bank_id, fact_type, limit]
if tags:
params.append(tags)
results = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = $3
AND search_vector @@ to_tsquery('english', $1)
{tags_clause}
ORDER BY bm25_score DESC
LIMIT $4
""",
*params,
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
async def retrieve_semantic_bm25_combined(
conn,
query_emb_str: str,
@@ -268,18 +158,41 @@ async def retrieve_semantic_bm25_combined(
result_dict[ft][0].append(RetrievalResult.from_db_row(row))
return result_dict
query_tsquery = " | ".join(tokens)
# Build BM25 query based on text search backend
config = get_config()
# Build tags clause - param 6 if tags provided
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
params = [query_emb_str, bank_id, fact_types, limit, query_tsquery]
# Build backend-specific BM25 parts
if config.text_search_extension == "vchord":
# VectorChord BM25: use <&> operator with to_bm25query and tokenize
# Note: VectorChord scores are negative (higher = better, so -1 > -10)
bm25_score_expr = "search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($5, 'llmlingua2'))"
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = "" # No additional WHERE filter for vchord
params = [query_emb_str, bank_id, fact_types, limit, query_text] # Pass raw query_text for tokenization
elif config.text_search_extension == "pg_textsearch":
# Timescale pg_textsearch: use <@> operator with to_bm25query
# Note: pg_textsearch scores are negative (lower/more negative = better, so -10 > -1)
# We negate the score to maintain API consistency (higher = better)
bm25_score_expr = "-(text <@> to_bm25query($5, 'idx_memory_units_text_search'))"
bm25_order_by = "text <@> to_bm25query($5, 'idx_memory_units_text_search') ASC"
bm25_where_filter = "" # No additional WHERE filter for pg_textsearch
params = [query_emb_str, bank_id, fact_types, limit, query_text]
else: # native
# Native PostgreSQL: use ts_rank_cd with to_tsquery
query_tsquery = " | ".join(tokens)
bm25_score_expr = "ts_rank_cd(search_vector, to_tsquery('english', $5))"
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = "AND search_vector @@ to_tsquery('english', $5)"
params = [query_emb_str, bank_id, fact_types, limit, query_tsquery]
if tags:
params.append(tags)
# Combined CTE query for both semantic and BM25 across all fact types
# Uses window functions to limit per fact_type per method
results = await conn.fetch(
f"""
# Single query template with backend-specific parts injected
query = f"""
WITH semantic_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
@@ -296,13 +209,13 @@ async def retrieve_semantic_bm25_combined(
bm25_ranked AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
NULL::float AS similarity,
ts_rank_cd(search_vector, to_tsquery('english', $5)) AS bm25_score,
{bm25_score_expr} AS bm25_score,
'bm25' AS source,
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY ts_rank_cd(search_vector, to_tsquery('english', $5)) DESC) AS rn
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY {bm25_order_by}) AS rn
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = ANY($3)
AND search_vector @@ to_tsquery('english', $5)
{bm25_where_filter}
{tags_clause}
),
semantic AS (
@@ -318,9 +231,11 @@ async def retrieve_semantic_bm25_combined(
SELECT * FROM semantic
UNION ALL
SELECT * FROM bm25
""",
*params,
)
"""
# Combined CTE query for both semantic and BM25 across all fact types
# Uses window functions to limit per fact_type per method
results = await conn.fetch(query, *params)
# Group results by fact_type and source
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
@@ -561,623 +476,6 @@ async def retrieve_temporal_combined(
return results_by_ft
async def retrieve_temporal(
conn,
query_emb_str: str,
bank_id: str,
fact_type: str,
start_date: datetime,
end_date: datetime,
budget: int,
semantic_threshold: float = 0.1,
tags: list[str] | None = None,
) -> list[RetrievalResult]:
"""
Temporal retrieval with spreading activation.
Strategy:
1. Find entry points (facts in date range with semantic relevance)
2. Spread through temporal links to related facts
3. Score by temporal proximity + semantic similarity + link weight
Args:
conn: Database connection
query_emb_str: Query embedding as string
agent_id: bank ID
fact_type: Fact type to filter
start_date: Start of time range
end_date: End of time range
budget: Node budget for spreading
semantic_threshold: Minimum semantic similarity to include
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
List of RetrievalResult objects with temporal scores
"""
# Ensure start_date and end_date are timezone-aware (UTC) to match database datetimes
if start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=UTC)
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
from .tags import TagsMatch, build_tags_where_clause_simple
tags_clause = build_tags_where_clause_simple(tags, 7)
params = [query_emb_str, bank_id, fact_type, start_date, end_date, semantic_threshold]
if tags:
params.append(tags)
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = $3
AND embedding IS NOT NULL
AND (
-- Match if occurred range overlaps with query range
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
AND occurred_start <= $5 AND occurred_end >= $4)
OR
-- Match if mentioned_at falls within query range
(mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
OR
-- Match if any occurred date is set and overlaps (even if only start or end is set)
(occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
OR
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
AND (1 - (embedding <=> $1::vector)) >= $6
{tags_clause}
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, (embedding <=> $1::vector) ASC
LIMIT 10
""",
*params,
)
if not entry_points:
return []
# Calculate temporal scores for entry points
total_days = (end_date - start_date).total_seconds() / 86400
mid_date = start_date + (end_date - start_date) / 2 # Calculate once for all comparisons
results = []
visited = set()
for ep in entry_points:
unit_id = str(ep["id"])
visited.add(unit_id)
# Calculate temporal proximity using the most relevant date
# Priority: occurred_start/end (event time) > mentioned_at (mention time)
best_date = None
if ep["occurred_start"] is not None and ep["occurred_end"] is not None:
# Use midpoint of occurred range
best_date = ep["occurred_start"] + (ep["occurred_end"] - ep["occurred_start"]) / 2
elif ep["occurred_start"] is not None:
best_date = ep["occurred_start"]
elif ep["occurred_end"] is not None:
best_date = ep["occurred_end"]
elif ep["mentioned_at"] is not None:
best_date = ep["mentioned_at"]
# Temporal proximity score (closer to range center = higher score)
if best_date:
days_from_mid = abs((best_date - mid_date).total_seconds() / 86400)
temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
else:
temporal_proximity = 0.5 # Fallback if no dates (shouldn't happen due to WHERE clause)
# Create RetrievalResult with temporal scores
ep_result = RetrievalResult.from_db_row(dict(ep))
ep_result.temporal_score = temporal_proximity
ep_result.temporal_proximity = temporal_proximity
results.append(ep_result)
# Spread through temporal links using BATCHED neighbor fetching
# Map node_id -> (semantic_sim, temporal_score) for propagation
node_scores = {str(ep["id"]): (ep["similarity"], 1.0) for ep in entry_points}
frontier = list(node_scores.keys()) # Current batch of nodes to expand
budget_remaining = budget - len(entry_points)
batch_size = 20 # Process this many nodes per DB query
while frontier and budget_remaining > 0:
# Take a batch from frontier
batch_ids = frontier[:batch_size]
frontier = frontier[batch_size:]
# Batch fetch all neighbors for this batch of nodes
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
ml.weight, ml.link_type, ml.from_unit_id,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($2::uuid[])
AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= 0.1
AND mu.fact_type = $3
AND mu.embedding IS NOT NULL
AND (1 - (mu.embedding <=> $1::vector)) >= $4
ORDER BY ml.weight DESC
LIMIT $5
""",
query_emb_str,
batch_ids,
fact_type,
semantic_threshold,
batch_size * 10, # Allow up to 10 neighbors per node in batch
)
for n in neighbors:
neighbor_id = str(n["id"])
if neighbor_id in visited:
continue
visited.add(neighbor_id)
budget_remaining -= 1
# Get parent's scores for propagation
parent_id = str(n["from_unit_id"])
_, parent_temporal_score = node_scores.get(parent_id, (0.5, 0.5))
# Calculate temporal score for neighbor using best available date
neighbor_best_date = None
if n["occurred_start"] is not None and n["occurred_end"] is not None:
neighbor_best_date = n["occurred_start"] + (n["occurred_end"] - n["occurred_start"]) / 2
elif n["occurred_start"] is not None:
neighbor_best_date = n["occurred_start"]
elif n["occurred_end"] is not None:
neighbor_best_date = n["occurred_end"]
elif n["mentioned_at"] is not None:
neighbor_best_date = n["mentioned_at"]
if neighbor_best_date:
days_from_mid = abs((neighbor_best_date - mid_date).total_seconds() / 86400)
neighbor_temporal_proximity = (
1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
)
else:
neighbor_temporal_proximity = 0.3 # Lower score if no temporal data
# Boost causal links (same as graph retrieval)
link_type = n["link_type"]
if link_type in ("causes", "caused_by"):
causal_boost = 2.0
elif link_type in ("enables", "prevents"):
causal_boost = 1.5
else:
causal_boost = 1.0
# Propagate temporal score through links (decay, with causal boost)
propagated_temporal = parent_temporal_score * n["weight"] * causal_boost * 0.7
# Combined temporal score
combined_temporal = max(neighbor_temporal_proximity, propagated_temporal)
# Create RetrievalResult with temporal scores
neighbor_result = RetrievalResult.from_db_row(dict(n))
neighbor_result.temporal_score = combined_temporal
neighbor_result.temporal_proximity = neighbor_temporal_proximity
results.append(neighbor_result)
# Track scores for propagation and add to frontier
if budget_remaining > 0 and combined_temporal > 0.2:
node_scores[neighbor_id] = (n["similarity"], combined_temporal)
frontier.append(neighbor_id)
if budget_remaining <= 0:
break
return results
async def retrieve_parallel(
pool,
query_text: str,
query_embedding_str: str,
bank_id: str,
fact_type: str,
thinking_budget: int,
question_date: datetime | None = None,
query_analyzer: Optional["QueryAnalyzer"] = None,
graph_retriever: GraphRetriever | None = None,
temporal_constraint: tuple | None = None, # Pre-extracted temporal constraint
tags: list[str] | None = None, # Visibility scope tags for filtering
) -> ParallelRetrievalResult:
"""
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
Args:
pool: Database connection pool
query_text: Query text
query_embedding_str: Query embedding as string
bank_id: Bank ID
fact_type: Fact type to filter
thinking_budget: Budget for graph traversal and retrieval limits
question_date: Optional date when question was asked (for temporal filtering)
query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer)
graph_retriever: Graph retrieval strategy (defaults to configured retriever)
temporal_constraint: Pre-extracted temporal constraint (optional)
tags: Optional list of tags for visibility filtering (OR matching)
Returns:
ParallelRetrievalResult with semantic, bm25, graph, temporal results and timings
"""
retriever = graph_retriever or get_default_graph_retriever()
# Use optimized parallel path for MPFP and LinkExpansion (runs all methods truly in parallel)
# BFS uses legacy path that extracts temporal constraint upfront
if retriever.name in ("mpfp", "link_expansion"):
return await _retrieve_parallel_mpfp(
pool,
query_text,
query_embedding_str,
bank_id,
fact_type,
thinking_budget,
temporal_constraint,
retriever,
question_date,
query_analyzer,
tags=tags,
)
else:
# For BFS, extract temporal constraint upfront (legacy path)
if temporal_constraint is None:
from .temporal_extraction import extract_temporal_constraint
temporal_constraint = extract_temporal_constraint(
query_text, reference_date=question_date, analyzer=query_analyzer
)
return await _retrieve_parallel_bfs(
pool,
query_text,
query_embedding_str,
bank_id,
fact_type,
thinking_budget,
temporal_constraint,
retriever,
tags=tags,
)
@dataclass
class _TimedResult:
"""Internal result with timing."""
results: list[RetrievalResult]
time: float
conn_wait: float = 0.0 # Connection acquisition wait time
async def _retrieve_parallel_mpfp(
pool,
query_text: str,
query_embedding_str: str,
bank_id: str,
fact_type: str,
thinking_budget: int,
temporal_constraint: tuple | None,
retriever: GraphRetriever,
question_date: datetime | None = None,
query_analyzer=None,
tags: list[str] | None = None,
) -> ParallelRetrievalResult:
"""
MPFP retrieval with true parallelization.
All methods run independently in parallel:
- Semantic: vector similarity search
- BM25: keyword search
- Graph: MPFP traversal (does its own semantic seeds internally)
- Temporal: date extraction (if needed) + date-range search
Temporal extraction runs IN PARALLEL with other retrievals, so even if
dateparser is slow, it doesn't block semantic/BM25/graph.
"""
import time
async def run_semantic() -> _TimedResult:
"""Independent semantic retrieval."""
start = time.time()
acquire_start = time.time()
async with acquire_with_retry(pool) as conn:
conn_wait = time.time() - acquire_start
results = await retrieve_semantic(
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget, tags=tags
)
return _TimedResult(results, time.time() - start, conn_wait)
async def run_bm25() -> _TimedResult:
"""Independent BM25 retrieval."""
start = time.time()
acquire_start = time.time()
async with acquire_with_retry(pool) as conn:
conn_wait = time.time() - acquire_start
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget, tags=tags)
return _TimedResult(results, time.time() - start, conn_wait)
async def run_graph() -> tuple[list[RetrievalResult], float, MPFPTimings | None]:
"""Independent graph retrieval - does its own semantic seeds."""
start = time.time()
# MPFP does its own semantic seeds via _find_semantic_seeds
# Note: temporal_seeds not used here to avoid dependency on temporal extraction
results, mpfp_timing = await retriever.retrieve(
pool=pool,
query_embedding_str=query_embedding_str,
bank_id=bank_id,
fact_type=fact_type,
budget=thinking_budget,
query_text=query_text,
semantic_seeds=None, # Let MPFP find its own seeds
temporal_seeds=None, # Don't wait for temporal extraction
tags=tags,
)
return results, time.time() - start, mpfp_timing
@dataclass
class _TemporalWithConstraint:
"""Temporal results with the extracted constraint."""
results: list[RetrievalResult]
time: float
constraint: tuple | None
extraction_time: float # Time spent in query analyzer (dateparser)
conn_wait: float = 0.0 # Connection acquisition wait time
async def run_temporal_with_extraction() -> _TemporalWithConstraint:
"""
Extract temporal constraint AND run temporal retrieval.
This runs in parallel with semantic/BM25/graph, so dateparser
latency doesn't block other retrievals.
"""
start = time.time()
# Use pre-provided constraint if available
tc = temporal_constraint
extraction_time = 0.0
# Otherwise extract from query (this is the potentially slow dateparser call)
if tc is None:
from .temporal_extraction import extract_temporal_constraint
extraction_start = time.time()
tc = extract_temporal_constraint(query_text, reference_date=question_date, analyzer=query_analyzer)
extraction_time = time.time() - extraction_start
# If no temporal constraint found, return empty (but still report extraction time)
if tc is None:
return _TemporalWithConstraint([], time.time() - start, None, extraction_time, 0.0)
# Run temporal retrieval with the extracted constraint
tc_start, tc_end = tc
acquire_start = time.time()
async with acquire_with_retry(pool) as conn:
conn_wait = time.time() - acquire_start
results = await retrieve_temporal(
conn,
query_embedding_str,
bank_id,
fact_type,
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=0.1,
)
return _TemporalWithConstraint(results, time.time() - start, tc, extraction_time, conn_wait)
# Run ALL methods in parallel (including temporal extraction!)
semantic_result, bm25_result, graph_result, temporal_result = await asyncio.gather(
run_semantic(),
run_bm25(),
run_graph(),
run_temporal_with_extraction(),
)
graph_results, graph_time, mpfp_timing = graph_result
# Compute max connection wait across all methods (graph handles its own connections)
max_conn_wait = max(semantic_result.conn_wait, bm25_result.conn_wait, temporal_result.conn_wait)
return ParallelRetrievalResult(
semantic=semantic_result.results,
bm25=bm25_result.results,
graph=graph_results,
temporal=temporal_result.results if temporal_result.results else None,
timings={
"semantic": semantic_result.time,
"bm25": bm25_result.time,
"graph": graph_time,
"temporal": temporal_result.time,
"temporal_extraction": temporal_result.extraction_time,
},
temporal_constraint=temporal_result.constraint,
mpfp_timings=[mpfp_timing] if mpfp_timing else [],
max_conn_wait=max_conn_wait,
)
async def _get_temporal_entry_points(
conn,
query_embedding_str: str,
bank_id: str,
fact_type: str,
start_date: datetime,
end_date: datetime,
limit: int = 20,
semantic_threshold: float = 0.1,
) -> list[RetrievalResult]:
"""Get temporal entry points (facts in date range with semantic relevance)."""
if start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=UTC)
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = $3
AND embedding IS NOT NULL
AND (
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
AND occurred_start <= $5 AND occurred_end >= $4)
OR (mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
OR (occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
OR (occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
AND (1 - (embedding <=> $1::vector)) >= $6
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC,
(embedding <=> $1::vector) ASC
LIMIT $7
""",
query_embedding_str,
bank_id,
fact_type,
start_date,
end_date,
semantic_threshold,
limit,
)
results = []
total_days = max((end_date - start_date).total_seconds() / 86400, 1)
mid_date = start_date + (end_date - start_date) / 2
for row in rows:
result = RetrievalResult.from_db_row(dict(row))
# Calculate temporal proximity score
best_date = None
if row["occurred_start"] and row["occurred_end"]:
best_date = row["occurred_start"] + (row["occurred_end"] - row["occurred_start"]) / 2
elif row["occurred_start"]:
best_date = row["occurred_start"]
elif row["occurred_end"]:
best_date = row["occurred_end"]
elif row["mentioned_at"]:
best_date = row["mentioned_at"]
if best_date:
days_from_mid = abs((best_date - mid_date).total_seconds() / 86400)
result.temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0)
else:
result.temporal_proximity = 0.5
result.temporal_score = result.temporal_proximity
results.append(result)
return results
async def _retrieve_parallel_bfs(
pool,
query_text: str,
query_embedding_str: str,
bank_id: str,
fact_type: str,
thinking_budget: int,
temporal_constraint: tuple | None,
retriever: GraphRetriever,
tags: list[str] | None = None,
) -> ParallelRetrievalResult:
"""BFS retrieval: all methods run in parallel (original behavior)."""
import time
async def run_semantic() -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_semantic(
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget, tags=tags
)
return _TimedResult(results, time.time() - start)
async def run_bm25() -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget, tags=tags)
return _TimedResult(results, time.time() - start)
async def run_graph() -> _TimedResult:
start = time.time()
results, _ = await retriever.retrieve(
pool=pool,
query_embedding_str=query_embedding_str,
bank_id=bank_id,
fact_type=fact_type,
budget=thinking_budget,
query_text=query_text,
tags=tags,
)
return _TimedResult(results, time.time() - start)
async def run_temporal(tc_start, tc_end) -> _TimedResult:
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_temporal(
conn,
query_embedding_str,
bank_id,
fact_type,
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=0.1,
tags=tags,
)
return _TimedResult(results, time.time() - start)
if temporal_constraint:
tc_start, tc_end = temporal_constraint
semantic_r, bm25_r, graph_r, temporal_r = await asyncio.gather(
run_semantic(),
run_bm25(),
run_graph(),
run_temporal(tc_start, tc_end),
)
return ParallelRetrievalResult(
semantic=semantic_r.results,
bm25=bm25_r.results,
graph=graph_r.results,
temporal=temporal_r.results,
timings={
"semantic": semantic_r.time,
"bm25": bm25_r.time,
"graph": graph_r.time,
"temporal": temporal_r.time,
},
temporal_constraint=temporal_constraint,
)
else:
semantic_r, bm25_r, graph_r = await asyncio.gather(
run_semantic(),
run_bm25(),
run_graph(),
)
return ParallelRetrievalResult(
semantic=semantic_r.results,
bm25=bm25_r.results,
graph=graph_r.results,
temporal=None,
timings={
"semantic": semantic_r.time,
"bm25": bm25_r.time,
"graph": graph_r.time,
},
temporal_constraint=None,
)
async def retrieve_all_fact_types_parallel(
pool,
query_text: str,
@@ -188,7 +188,7 @@ def get_system_message(disposition: DispositionTraits) -> str:
" ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
)
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting. IMPORTANT: Detect the language of the question and respond in the SAME language. Do not translate to English if the question is in another language."
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting. CRITICAL: ONLY use the facts and information provided in the prompt - do not make up names, events, or information that weren't mentioned. If you don't have enough information to answer, say so. IMPORTANT: Detect the language of the question and respond in the SAME language. Do not translate to English if the question is in another language."
async def reflect(
@@ -0,0 +1,77 @@
"""File storage backends for uploaded files."""
from collections.abc import Callable
from .base import FileStorage
from .postgresql import PostgreSQLFileStorage
__all__ = ["FileStorage", "PostgreSQLFileStorage", "create_file_storage"]
def create_file_storage(
storage_type: str,
pool_getter: Callable | None = None,
schema: str | None = None,
**kwargs,
) -> FileStorage:
"""
Create file storage backend based on configuration.
Args:
storage_type: "native" (PostgreSQL BYTEA) or "s3" (S3-compatible object storage)
pool_getter: Database pool getter (required for native)
schema: Database schema (for native multi-tenant)
**kwargs: Additional args passed to storage backend
Returns:
FileStorage instance
Raises:
ValueError: If storage_type is unknown or required args are missing
"""
if storage_type == "native":
if not pool_getter:
raise ValueError("pool_getter required for native (PostgreSQL) storage")
return PostgreSQLFileStorage(pool_getter=pool_getter, schema=schema)
elif storage_type == "s3":
from ...config import get_config
from .s3 import S3FileStorage
config = get_config()
bucket = config.file_storage_s3_bucket
if not bucket:
raise ValueError("HINDSIGHT_API_FILE_STORAGE_S3_BUCKET is required for S3 storage")
return S3FileStorage(
bucket=bucket,
region=config.file_storage_s3_region,
endpoint=config.file_storage_s3_endpoint,
access_key_id=config.file_storage_s3_access_key_id,
secret_access_key=config.file_storage_s3_secret_access_key,
)
elif storage_type == "gcs":
from ...config import get_config
from .gcs import GCSFileStorage
config = get_config()
bucket = config.file_storage_gcs_bucket
if not bucket:
raise ValueError("HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET is required for GCS storage")
return GCSFileStorage(
bucket=bucket,
service_account_key=config.file_storage_gcs_service_account_key,
)
elif storage_type == "azure":
from ...config import get_config
from .azure import AzureFileStorage
config = get_config()
container = config.file_storage_azure_container
if not container:
raise ValueError("HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER is required for Azure storage")
return AzureFileStorage(
container_name=container,
account_name=config.file_storage_azure_account_name,
account_key=config.file_storage_azure_account_key,
)
else:
raise ValueError(f"Unknown storage type: {storage_type}. Supported: 'native', 's3', 'gcs', 'azure'.")
@@ -0,0 +1,62 @@
"""Azure Blob Storage backend using obstore."""
import logging
from datetime import timedelta
import obstore as obs
from obstore.store import AzureStore
from .base import FileStorage
logger = logging.getLogger(__name__)
class AzureFileStorage(FileStorage):
"""
Azure Blob Storage backend.
Uses obstore (Rust-backed) for high-throughput async access to Azure Blob Storage.
Supports account key, SAS token, and default Azure credentials.
"""
def __init__(
self,
container_name: str,
account_name: str | None = None,
account_key: str | None = None,
):
kwargs: dict = {}
if account_name:
kwargs["account_name"] = account_name
if account_key:
kwargs["account_key"] = account_key
self._store = AzureStore(container_name, **kwargs)
logger.info(f"Initialized Azure file storage: container={container_name}, account={account_name}")
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
await obs.put_async(self._store, key, file_data)
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in Azure")
return key
async def retrieve(self, key: str) -> bytes:
try:
response = await obs.get_async(self._store, key)
return await response.bytes_async()
except Exception as e:
if "not found" in str(e).lower() or "BlobNotFound" in str(e):
raise FileNotFoundError(f"File not found: {key}") from e
raise
async def delete(self, key: str) -> None:
await obs.delete_async(self._store, key)
async def exists(self, key: str) -> bool:
try:
await obs.head_async(self._store, key)
return True
except Exception:
return False
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in))
@@ -0,0 +1,83 @@
"""Abstract base class for file storage backends."""
from abc import ABC, abstractmethod
class FileStorage(ABC):
"""Abstract base for file storage backends."""
@abstractmethod
async def store(
self,
file_data: bytes,
key: str,
metadata: dict[str, str] | None = None,
) -> str:
"""
Store file and return storage key.
Args:
file_data: Raw file bytes
key: Storage key (e.g., "banks/{bank_id}/files/{file_id}.pdf")
metadata: Optional metadata to store with file
Returns:
Storage key that can be used to retrieve the file
"""
pass
@abstractmethod
async def retrieve(self, key: str) -> bytes:
"""
Retrieve file by storage key.
Args:
key: Storage key
Returns:
File data as bytes
Raises:
FileNotFoundError: If file does not exist
"""
pass
@abstractmethod
async def delete(self, key: str) -> None:
"""
Delete file by storage key.
Args:
key: Storage key
"""
pass
@abstractmethod
async def exists(self, key: str) -> bool:
"""
Check if file exists.
Args:
key: Storage key
Returns:
True if file exists, False otherwise
"""
pass
@abstractmethod
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
"""
Get a URL for downloading the file.
For PostgreSQL storage, this might be a relative API path.
For S3, this would be a pre-signed URL.
Args:
key: Storage key
expires_in: Expiration time in seconds (may be ignored for some backends)
Returns:
Download URL or path
"""
pass
@@ -0,0 +1,59 @@
"""Google Cloud Storage backend using obstore."""
import logging
from datetime import timedelta
import obstore as obs
from obstore.store import GCSStore
from .base import FileStorage
logger = logging.getLogger(__name__)
class GCSFileStorage(FileStorage):
"""
Google Cloud Storage backend.
Uses obstore (Rust-backed) for high-throughput async access to GCS.
Supports Application Default Credentials, service account keys, and explicit credentials.
"""
def __init__(
self,
bucket: str,
service_account_key: str | None = None,
):
kwargs: dict = {}
if service_account_key:
kwargs["service_account_key"] = service_account_key
self._store = GCSStore(bucket, **kwargs)
logger.info(f"Initialized GCS file storage: bucket={bucket}")
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
await obs.put_async(self._store, key, file_data)
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in GCS")
return key
async def retrieve(self, key: str) -> bytes:
try:
response = await obs.get_async(self._store, key)
return await response.bytes_async()
except Exception as e:
if "not found" in str(e).lower():
raise FileNotFoundError(f"File not found: {key}") from e
raise
async def delete(self, key: str) -> None:
await obs.delete_async(self._store, key)
async def exists(self, key: str) -> bool:
try:
await obs.head_async(self._store, key)
return True
except Exception:
return False
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in))
@@ -0,0 +1,139 @@
"""PostgreSQL BYTEA-based file storage (default, zero-config)."""
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import asyncpg
from .base import FileStorage
logger = logging.getLogger(__name__)
def fq_table(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with optional schema prefix."""
if schema:
return f'"{schema}".{table}'
return table
class PostgreSQLFileStorage(FileStorage):
"""
PostgreSQL BYTEA-based file storage.
Stores files directly in PostgreSQL using BYTEA columns.
This is the default storage backend - zero configuration required!
Pros:
- Works out of the box (no external dependencies)
- Transactional consistency with database
- Simple backups (included in pg_dump)
- Good performance for <10MB files
Cons:
- Database bloat for large/many files
- Not ideal for distributed deployments
- Higher cost than object storage at scale
For production/scale, consider S3FileStorage instead.
"""
def __init__(self, pool_getter: Callable[[], "asyncpg.Pool"], schema: str | None = None):
"""
Initialize PostgreSQL file storage.
Args:
pool_getter: Function that returns asyncpg connection pool
schema: Database schema (for multi-tenant support)
"""
self._pool_getter = pool_getter
self._schema = schema
async def store(
self,
file_data: bytes,
key: str,
metadata: dict[str, str] | None = None,
) -> str:
"""Store file in PostgreSQL."""
pool = self._pool_getter()
async with pool.acquire() as conn:
await conn.execute(
f"""
INSERT INTO {fq_table("file_storage", self._schema)}
(storage_key, data)
VALUES ($1, $2)
ON CONFLICT (storage_key) DO UPDATE SET
data = EXCLUDED.data
""",
key,
file_data,
)
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in PostgreSQL")
return key
async def retrieve(self, key: str) -> bytes:
"""Retrieve file from PostgreSQL."""
pool = self._pool_getter()
async with pool.acquire() as conn:
row = await conn.fetchrow(
f"""
SELECT data FROM {fq_table("file_storage", self._schema)}
WHERE storage_key = $1
""",
key,
)
if not row:
raise FileNotFoundError(f"File not found: {key}")
return bytes(row["data"])
async def delete(self, key: str) -> None:
"""Delete file from PostgreSQL."""
pool = self._pool_getter()
async with pool.acquire() as conn:
result = await conn.execute(
f"""
DELETE FROM {fq_table("file_storage", self._schema)}
WHERE storage_key = $1
""",
key,
)
# Check if anything was deleted
if result == "DELETE 0":
logger.warning(f"Attempted to delete non-existent file: {key}")
async def exists(self, key: str) -> bool:
"""Check if file exists in PostgreSQL."""
pool = self._pool_getter()
async with pool.acquire() as conn:
row = await conn.fetchrow(
f"""
SELECT 1 FROM {fq_table("file_storage", self._schema)}
WHERE storage_key = $1
""",
key,
)
return row is not None
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
"""
Get download URL for PostgreSQL-stored file.
Returns an API endpoint path (not a pre-signed URL since the file
is stored in the database). The expires_in parameter is ignored
for PostgreSQL storage.
"""
# Return API path for download endpoint
# (expires_in ignored for database storage - auth handled at API level)
return f"/v1/default/files/download/{key}"
@@ -0,0 +1,71 @@
"""S3 object storage backend using obstore."""
import logging
from datetime import timedelta
import obstore as obs
from obstore.store import S3Store
from .base import FileStorage
logger = logging.getLogger(__name__)
class S3FileStorage(FileStorage):
"""
S3-compatible object storage backend.
Uses obstore (Rust-backed) for high-throughput async access to
Amazon S3, MinIO, Cloudflare R2, and other S3-compliant APIs.
"""
def __init__(
self,
bucket: str,
region: str | None = None,
endpoint: str | None = None,
access_key_id: str | None = None,
secret_access_key: str | None = None,
):
kwargs: dict = {}
if region:
kwargs["region"] = region
if endpoint:
kwargs["endpoint"] = endpoint
# Allow plain HTTP for local S3-compatible services (MinIO, LocalStack, etc.)
if endpoint.startswith("http://"):
kwargs["allow_http"] = True
if access_key_id:
kwargs["access_key_id"] = access_key_id
if secret_access_key:
kwargs["secret_access_key"] = secret_access_key
self._store = S3Store(bucket, **kwargs)
logger.info(f"Initialized S3 file storage: bucket={bucket}, region={region}, endpoint={endpoint}")
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
await obs.put_async(self._store, key, file_data)
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in S3")
return key
async def retrieve(self, key: str) -> bytes:
try:
response = await obs.get_async(self._store, key)
return await response.bytes_async()
except Exception as e:
if "not found" in str(e).lower() or "NoSuchKey" in str(e):
raise FileNotFoundError(f"File not found: {key}") from e
raise
async def delete(self, key: str) -> None:
await obs.delete_async(self._store, key)
async def exists(self, key: str) -> bool:
try:
await obs.head_async(self._store, key)
return True
except Exception:
return False
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in))
+10 -1
View File
@@ -19,6 +19,7 @@ async def extract_facts(
context: str = "",
llm_config: "LLMConfig" = None,
agent_name: str = None,
config=None,
) -> tuple[list["Fact"], list[tuple[str, int]]]:
"""
Extract semantic facts from text using LLM.
@@ -35,6 +36,7 @@ async def extract_facts(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Optional agent name to help identify agent-related facts
config: HindsightConfig to use (defaults to global config if not provided)
Returns:
Tuple of (facts, chunks) where:
@@ -47,12 +49,19 @@ async def extract_facts(
if not text or not text.strip():
return [], []
# Use provided config or fall back to global config
if config is None:
from ..config import _get_raw_config
config = _get_raw_config()
facts, chunks, _ = await extract_facts_from_text(
text,
event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name,
config=config,
context=context,
)
if not facts:
@@ -16,10 +16,11 @@ with the system (e.g., running migrations for tenant schemas).
"""
from hindsight_api.extensions.base import Extension
from hindsight_api.extensions.builtin import ApiKeyTenantExtension
from hindsight_api.extensions.builtin import ApiKeyTenantExtension, SupabaseTenantExtension
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
from hindsight_api.extensions.http import HttpExtension
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.mcp import MCPExtension
from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
@@ -57,6 +58,8 @@ __all__ = [
"DefaultExtensionContext",
# HTTP Extension
"HttpExtension",
# MCP Extension
"MCPExtension",
# Operation Validator - Core
"OperationValidationError",
"OperationValidatorExtension",
@@ -77,6 +80,7 @@ __all__ = [
"MentalModelRefreshResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"SupabaseTenantExtension",
"AuthenticationError",
"RequestContext",
"Tenant",
@@ -6,13 +6,17 @@ They can be used directly or serve as examples for custom implementations.
Available built-in extensions:
- ApiKeyTenantExtension: Simple API key validation with public schema
- SupabaseTenantExtension: Supabase JWT validation with per-user schema isolation
Example usage:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
"""
from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
__all__ = [
"ApiKeyTenantExtension",
"SupabaseTenantExtension",
]
@@ -0,0 +1,433 @@
"""
Supabase Tenant Extension for Hindsight
Validates Supabase JWTs and maps authenticated users to isolated memory banks.
Each user gets their own PostgreSQL schema based on their Supabase user ID.
This extension enables multi-tenant memory isolation for applications using
Supabase Auth - each authenticated user's memories are stored in a separate
schema, ensuring complete data isolation.
Features:
- Local JWT Verification: Validates tokens locally using JWKS public keys
(no network call per request)
- Automatic Schema Isolation: Each user gets {prefix}_{user_id} schema
- Zero User Management: Leverages your existing Supabase Auth setup
- Production Ready: Includes health checks, timeouts, key rotation handling,
and error handling
- Built-in: Ships with Hindsight, no extra installation needed
- Legacy Support: Falls back to /auth/v1/user endpoint for HS256 projects
JWT Verification Strategy:
By default, JWTs are verified locally using public keys from the Supabase
JWKS endpoint (/auth/v1/.well-known/jwks.json). This is the Supabase-recommended
approach: no network call per request, fast, and secure.
If JWKS keys are unavailable (e.g., legacy HS256 projects), the extension
falls back to calling /auth/v1/user per request for validation. This requires
the service_role key to be configured.
Configuration via environment variables:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
HINDSIGHT_API_TENANT_SUPABASE_URL=https://your-project.supabase.co
# Optional - only required for legacy HS256 projects or health checks
HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY=your-service-role-key
# Optional
HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user # Default: "user" (creates user_<uuid> schemas)
Usage:
Clients pass their Supabase JWT in the Authorization header:
curl -H "Authorization: Bearer <supabase_jwt>" \\
https://your-hindsight-server/v1/default/banks/my-bank/memories/recall
Author: BrighterBalance (https://brighterbalance.app)
License: MIT
"""
from __future__ import annotations
import logging
import re
import time
import httpx
import jwt as pyjwt
from jwt import PyJWK
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
__all__ = ["SupabaseTenantExtension"]
# Minimum expected JWT length (JWTs are typically 100+ characters)
MIN_TOKEN_LENGTH = 20
# Timeout for Supabase API calls
REQUEST_TIMEOUT_SECONDS = 10.0
# JWKS cache TTL — Supabase Edge caches JWKS for 10 minutes, so we match that
JWKS_CACHE_TTL_SECONDS = 600
# Minimum interval between JWKS refreshes to avoid hammering the endpoint
JWKS_MIN_REFRESH_INTERVAL_SECONDS = 30
# Algorithms supported by Supabase Auth for asymmetric JWT signing
SUPPORTED_ALGORITHMS = ["RS256", "ES256"]
# Supabase user IDs are UUIDs — validate before using in schema names
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
# Schema prefix must be a valid Postgres identifier component (letters, digits, underscores)
_SCHEMA_PREFIX_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
class SupabaseTenantExtension(TenantExtension):
"""
TenantExtension that validates Supabase JWTs for multi-tenant isolation.
Each authenticated user gets their own PostgreSQL schema, ensuring complete
memory isolation between users. The schema name is derived from the user's
Supabase user ID (the ``sub`` claim in the JWT).
JWT verification uses JWKS (local, no network call per request) when
asymmetric keys are configured in Supabase, and falls back to the
``/auth/v1/user`` endpoint for legacy HS256 projects.
Example:
User with ID "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
gets schema "user_a1b2c3d4_e5f6_7890_abcd_ef1234567890"
"""
def __init__(self, config: dict[str, str]) -> None:
"""
Initialize with configuration from environment variables.
Config keys are derived from HINDSIGHT_API_TENANT_* env vars:
- HINDSIGHT_API_TENANT_SUPABASE_URL -> config["supabase_url"] (required)
- HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY -> config["supabase_service_key"] (optional)
- HINDSIGHT_API_TENANT_SCHEMA_PREFIX -> config["schema_prefix"] (optional)
Args:
config: Dictionary of configuration values from environment
Raises:
ValueError: If required configuration is missing
"""
super().__init__(config)
self.supabase_url = (config.get("supabase_url") or "").rstrip("/")
self.supabase_service_key = config.get("supabase_service_key")
self.schema_prefix = config.get("schema_prefix", "user")
# Track initialized schemas to avoid redundant migrations
self._initialized_schemas: set[str] = set()
# Reusable HTTP client (created on startup)
self._http_client: httpx.AsyncClient | None = None
# JWKS state
self._jwks_keys: dict[str, PyJWK] = {}
self._jwks_last_fetched: float = 0
self._use_jwks: bool = False
if not self.supabase_url:
raise ValueError(
"HINDSIGHT_API_TENANT_SUPABASE_URL is required. "
"Set it to your Supabase project URL (e.g., https://xxx.supabase.co)"
)
if not _SCHEMA_PREFIX_RE.match(self.schema_prefix):
raise ValueError(
f"Invalid schema_prefix '{self.schema_prefix}'. "
"Must be a valid Postgres identifier (letters, digits, underscores, starting with a letter or underscore)."
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def on_startup(self) -> None:
"""
Called when Hindsight starts.
Creates a reusable HTTP client, fetches JWKS for local JWT verification,
and optionally verifies connectivity to Supabase.
"""
logger.info("Initializing Supabase tenant extension")
logger.info("Supabase URL: %s", self.supabase_url)
logger.info("Schema prefix: %s_", self.schema_prefix)
self._http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
# Attempt to fetch JWKS for fast local JWT verification
await self._try_init_jwks()
# Optional health check using service key
if self.supabase_service_key:
await self._health_check()
async def on_shutdown(self) -> None:
"""Called when Hindsight shuts down. Closes the HTTP client."""
logger.info("Shutting down Supabase tenant extension")
if self._http_client:
await self._http_client.aclose()
self._http_client = None
# ------------------------------------------------------------------
# JWKS management
# ------------------------------------------------------------------
async def _try_init_jwks(self) -> None:
"""Fetch JWKS and decide verification mode (local JWKS vs legacy endpoint)."""
try:
await self._fetch_jwks()
if self._jwks_keys:
self._use_jwks = True
logger.info(
"JWKS loaded — using local JWT verification with %d key(s)",
len(self._jwks_keys),
)
return
# JWKS endpoint returned no keys — project likely uses legacy HS256
logger.warning(
"JWKS endpoint returned no signing keys. "
"Falling back to /auth/v1/user endpoint for JWT verification. "
"For better performance, enable asymmetric JWT signing in your "
"Supabase dashboard (Project Settings → Auth → JWT Algorithm)."
)
except Exception as e:
logger.warning(
"Could not fetch JWKS (%s). Falling back to /auth/v1/user endpoint for JWT verification.",
e,
)
# Legacy mode requires service key
if not self.supabase_service_key:
raise ValueError(
"HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY is required when JWKS "
"is not available. Either enable asymmetric JWT signing in your "
"Supabase project or provide the service_role key."
)
self._use_jwks = False
async def _fetch_jwks(self) -> None:
"""Fetch public signing keys from the Supabase JWKS endpoint."""
if self._http_client is None:
raise RuntimeError("HTTP client not initialized")
url = f"{self.supabase_url}/auth/v1/.well-known/jwks.json"
response = await self._http_client.get(url)
response.raise_for_status()
jwks_data = response.json()
keys: dict[str, PyJWK] = {}
for key_data in jwks_data.get("keys", []):
kid = key_data.get("kid")
if kid:
keys[kid] = PyJWK(key_data)
self._jwks_keys = keys
self._jwks_last_fetched = time.monotonic()
async def _get_signing_key(self, token: str) -> PyJWK:
"""
Resolve the signing key for a token from the JWKS cache.
If the key ID (``kid``) is not in the cache, triggers one JWKS refresh
to handle key rotation before raising an error.
"""
header = pyjwt.get_unverified_header(token)
kid = header.get("kid")
if not kid:
raise AuthenticationError("Token missing key ID (kid) header")
# Refresh cache if stale
now = time.monotonic()
if now - self._jwks_last_fetched > JWKS_CACHE_TTL_SECONDS:
logger.debug("JWKS cache expired, refreshing")
await self._fetch_jwks()
if kid in self._jwks_keys:
return self._jwks_keys[kid]
# Key not found — try one forced refresh to handle key rotation,
# but only if we haven't just refreshed
if now - self._jwks_last_fetched > JWKS_MIN_REFRESH_INTERVAL_SECONDS:
logger.info("Signing key %s not in cache, refreshing JWKS for possible key rotation", kid)
await self._fetch_jwks()
if kid in self._jwks_keys:
return self._jwks_keys[kid]
raise AuthenticationError("Unable to find signing key for token")
# ------------------------------------------------------------------
# Authentication
# ------------------------------------------------------------------
async def authenticate(self, context: RequestContext) -> TenantContext:
"""
Validate a Supabase JWT and return tenant context.
Uses local JWKS verification when available (no network call per
request), falling back to the ``/auth/v1/user`` endpoint for legacy
HS256 projects.
Args:
context: Request context containing the API key (JWT)
Returns:
TenantContext with schema_name set to ``{prefix}_{user_uuid}``
Raises:
AuthenticationError: If token is missing, invalid, or expired
"""
token = context.api_key
if not token:
raise AuthenticationError("Missing Authorization header. Expected: Bearer <supabase_jwt>")
if len(token) < MIN_TOKEN_LENGTH:
raise AuthenticationError("Invalid token format")
if self._http_client is None:
raise AuthenticationError("Extension not initialized")
# Verify the JWT and extract user ID
if self._use_jwks:
user_id = await self._verify_token_jwks(token)
else:
user_id = await self._verify_token_legacy(token)
# Validate user ID format before using in schema name
if not _UUID_RE.match(user_id):
raise AuthenticationError("Invalid user ID format in token")
# Build isolated schema name — hyphens to underscores for Postgres compatibility
safe_user_id = user_id.replace("-", "_")
schema_name = f"{self.schema_prefix}_{safe_user_id}"
# Initialize schema on first access
if schema_name not in self._initialized_schemas:
await self._initialize_schema(schema_name)
return TenantContext(schema_name=schema_name)
async def _verify_token_jwks(self, token: str) -> str:
"""
Verify a JWT locally using cached JWKS public keys.
Validates signature, expiration, issuer, and audience. Returns the
user ID from the ``sub`` claim.
Raises:
AuthenticationError: If the token is invalid or expired.
"""
try:
signing_key = await self._get_signing_key(token)
payload = pyjwt.decode(
token,
signing_key.key,
algorithms=SUPPORTED_ALGORITHMS,
audience="authenticated",
issuer=f"{self.supabase_url}/auth/v1",
)
except pyjwt.ExpiredSignatureError:
raise AuthenticationError("Token has expired")
except pyjwt.InvalidAudienceError:
raise AuthenticationError("Invalid token audience")
except pyjwt.InvalidIssuerError:
raise AuthenticationError("Invalid token issuer")
except pyjwt.DecodeError:
raise AuthenticationError("Invalid token")
except AuthenticationError:
raise
except Exception as e:
raise AuthenticationError(f"Token verification failed: {e!s}")
user_id = payload.get("sub")
if not user_id:
raise AuthenticationError("Token valid but missing subject (sub) claim")
return user_id
async def _verify_token_legacy(self, token: str) -> str:
"""
Verify a JWT by calling the Supabase ``/auth/v1/user`` endpoint.
This is the fallback for projects using legacy HS256 JWT signing.
Adds a network round-trip per request.
Raises:
AuthenticationError: If the token is invalid or the request fails.
"""
try:
response = await self._http_client.get(
f"{self.supabase_url}/auth/v1/user",
headers={
"Authorization": f"Bearer {token}",
"apikey": self.supabase_service_key,
},
)
if response.status_code == 401:
raise AuthenticationError("Invalid or expired token")
if response.status_code != 200:
raise AuthenticationError(f"Authentication failed: {response.status_code}")
user_data = response.json()
user_id = user_data.get("id")
if not user_id:
raise AuthenticationError("Token valid but no user ID found")
return user_id
except AuthenticationError:
raise
except httpx.TimeoutException:
raise AuthenticationError("Authentication timeout - please retry")
except httpx.RequestError as e:
raise AuthenticationError(f"Connection error: {e!s}")
# ------------------------------------------------------------------
# Schema management
# ------------------------------------------------------------------
async def _initialize_schema(self, schema_name: str) -> None:
"""Run migrations for a new tenant schema and cache the result."""
logger.info("Initializing schema: %s", schema_name)
try:
await self.context.run_migration(schema_name)
self._initialized_schemas.add(schema_name)
logger.info("Schema ready: %s", schema_name)
except Exception as e:
logger.error("Schema initialization failed for %s: %s", schema_name, e)
raise AuthenticationError(f"Failed to initialize tenant: {e!s}")
async def list_tenants(self) -> list[Tenant]:
"""Return all tenant schemas that have been initialized."""
return [Tenant(schema=schema) for schema in self._initialized_schemas]
# ------------------------------------------------------------------
# Health check
# ------------------------------------------------------------------
async def _health_check(self) -> None:
"""Verify connectivity to Supabase using the auth health endpoint."""
try:
response = await self._http_client.get(
f"{self.supabase_url}/auth/v1/health",
headers={"apikey": self.supabase_service_key},
)
if response.status_code == 200:
logger.info("Supabase connection verified")
else:
logger.warning("Supabase health check returned %d", response.status_code)
except Exception as e:
logger.warning("Could not verify Supabase connection: %s", e)
@@ -54,6 +54,7 @@ class ApiKeyTenantExtension(TenantExtension):
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true (optional, disable auth for MCP endpoints)
For multi-tenant setups with separate schemas per tenant, implement a custom
TenantExtension that looks up the schema based on the API key or token claims.
@@ -64,6 +65,8 @@ class ApiKeyTenantExtension(TenantExtension):
self.expected_api_key = config.get("api_key")
if not self.expected_api_key:
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
# Allow disabling MCP auth for backwards compatibility
self.mcp_auth_disabled = config.get("mcp_auth_disabled", "").lower() in ("true", "1", "yes")
async def authenticate(self, context: RequestContext) -> TenantContext:
"""Validate API key and return configured schema context."""
@@ -74,3 +77,14 @@ class ApiKeyTenantExtension(TenantExtension):
async def list_tenants(self) -> list[Tenant]:
"""Return configured schema for single-tenant setup."""
return [Tenant(schema=get_config().database_schema)]
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
"""
Authenticate MCP requests.
If mcp_auth_disabled is set, skip authentication for backwards compatibility.
Otherwise, delegate to authenticate().
"""
if self.mcp_auth_disabled:
return TenantContext(schema_name=get_config().database_schema)
return await self.authenticate(context)
@@ -96,7 +96,13 @@ class DefaultExtensionContext(ExtensionContext):
async def run_migration(self, schema: str) -> None:
"""Run migrations for a specific schema."""
from hindsight_api.migrations import ensure_embedding_dimension, run_migrations
from hindsight_api.config import get_config
from hindsight_api.migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
# Prefer getting URL from memory engine (handles pg0 case where URL is set after init)
db_url = self._database_url
@@ -107,6 +113,9 @@ class DefaultExtensionContext(ExtensionContext):
run_migrations(db_url, schema=schema)
# Get config for vector extension setting
config = get_config()
# Ensure embedding column dimension matches the model's dimension
# This is needed because migrations create columns with default dimension
if self._memory_engine is not None:
@@ -114,7 +123,15 @@ class DefaultExtensionContext(ExtensionContext):
if embeddings is not None:
dimension = getattr(embeddings, "dimension", None)
if dimension is not None:
ensure_embedding_dimension(db_url, dimension, schema=schema)
ensure_embedding_dimension(
db_url, dimension, schema=schema, vector_extension=config.vector_extension
)
# Ensure vector indexes match the configured extension
ensure_vector_extension(db_url, vector_extension=config.vector_extension, schema=schema)
# Ensure text search columns/indexes match the configured extension
ensure_text_search_extension(db_url, text_search_extension=config.text_search_extension, schema=schema)
def get_memory_engine(self) -> "MemoryEngineInterface":
"""Get the memory engine interface."""
@@ -0,0 +1,42 @@
"""MCP Extension for registering additional MCP tools.
This extension allows external packages (like hindsight-cloud) to register
additional MCP tools on the Hindsight MCP server.
Example:
HINDSIGHT_API_MCP_EXTENSION=hindsight_cloud.extensions:CloudMCPExtension
"""
import logging
from abc import abstractmethod
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.extensions.base import Extension
logger = logging.getLogger(__name__)
class MCPExtension(Extension):
"""Base class for MCP extensions that register additional tools.
Subclass this to add MCP tools in extension packages.
Example:
class CloudMCPExtension(MCPExtension):
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
@mcp.tool()
async def my_custom_tool(query: str) -> str:
return "result"
"""
@abstractmethod
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
"""Register additional MCP tools.
Args:
mcp: FastMCP server instance to register tools on
memory: MemoryEngine instance for accessing memory operations
"""
pass
@@ -132,6 +132,10 @@ class RetainResult:
unit_ids: list[list[str]] # List of unit IDs per content item
success: bool = True
error: str | None = None
# Actual LLM token usage (populated by engine when available)
llm_input_tokens: int | None = None
llm_output_tokens: int | None = None
llm_total_tokens: int | None = None
@dataclass
@@ -2,6 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from hindsight_api.extensions.base import Extension
from hindsight_api.models import RequestContext
@@ -87,3 +88,70 @@ class TenantExtension(Extension, ABC):
For single-tenant setups, return [Tenant(schema="public")].
"""
...
async def get_tenant_config(self, context: RequestContext) -> dict[str, Any]:
"""
Get tenant-specific configuration overrides.
This method is called during hierarchical configuration resolution to get
tenant-level config overrides. The returned dict should contain Python field
names (lowercase snake_case) as keys, not environment variable names.
Example:
{"llm_model": "gpt-4", "retain_extraction_mode": "verbose"}
The default implementation returns an empty dict (no tenant-specific config).
Override this method in custom extensions to provide tenant-specific configuration.
Args:
context: The request context containing tenant information.
Returns:
Dict of config field names to values (only configurable fields).
Empty dict if no tenant-specific config.
"""
return {}
async def get_allowed_config_fields(self, context: RequestContext, bank_id: str) -> set[str] | None:
"""
Get set of config fields that this tenant/bank is allowed to modify.
This method controls which configurable fields can be modified via the bank config API.
It enables fine-grained permission control per tenant or per bank.
Examples:
- Return None: Allow all configurable fields (default)
- Return {"retain_chunk_size", "retain_custom_instructions"}: Allow only these fields
- Return set(): Allow no modifications (read-only)
The default implementation returns None (all configurable fields allowed).
Override this method in custom extensions to implement custom permission logic.
Args:
context: The request context containing tenant information.
bank_id: The bank identifier for per-bank permissions.
Returns:
Set of allowed field names, or None to allow all configurable fields.
Returned fields must be a subset of HindsightConfig.get_configurable_fields().
"""
return None
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
"""
Authenticate MCP requests.
By default, this calls authenticate(). Override this method to provide
different authentication behavior for MCP endpoints (e.g., to disable
auth for backwards compatibility with existing MCP servers).
Args:
context: The action context containing API key and other auth data.
Returns:
TenantContext with the schema_name for database operations.
Raises:
AuthenticationError: If authentication fails.
"""
return await self.authenticate(context)
+55 -2
View File
@@ -23,7 +23,7 @@ import uvicorn
from . import MemoryEngine, __version__
from .api import create_app
from .banner import print_banner
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, _get_raw_config
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
@@ -68,7 +68,7 @@ def main():
global _memory
# Load configuration from environment (for CLI args defaults)
config = get_config()
config = _get_raw_config()
parser = argparse.ArgumentParser(
prog="hindsight-api",
@@ -155,6 +155,8 @@ def main():
config = HindsightConfig(
database_url=config.database_url,
database_schema=config.database_schema,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
llm_provider=config.llm_provider,
llm_api_key=config.llm_api_key,
llm_model=config.llm_model,
@@ -164,6 +166,8 @@ def main():
llm_initial_backoff=config.llm_initial_backoff,
llm_max_backoff=config.llm_max_backoff,
llm_timeout=config.llm_timeout,
llm_groq_service_tier=config.llm_groq_service_tier,
llm_openai_service_tier=config.llm_openai_service_tier,
llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
@@ -197,23 +201,43 @@ def main():
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
embeddings_tei_url=config.embeddings_tei_url,
embeddings_openai_base_url=config.embeddings_openai_base_url,
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
embeddings_cohere_model=config.embeddings_cohere_model,
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
embeddings_litellm_model=config.embeddings_litellm_model,
embeddings_litellm_sdk_api_key=config.embeddings_litellm_sdk_api_key,
embeddings_litellm_sdk_model=config.embeddings_litellm_sdk_model,
embeddings_litellm_sdk_api_base=config.embeddings_litellm_sdk_api_base,
reranker_provider=config.reranker_provider,
reranker_local_model=config.reranker_local_model,
reranker_local_force_cpu=config.reranker_local_force_cpu,
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
reranker_tei_url=config.reranker_tei_url,
reranker_tei_batch_size=config.reranker_tei_batch_size,
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
reranker_max_candidates=config.reranker_max_candidates,
reranker_cohere_api_key=config.reranker_cohere_api_key,
reranker_cohere_model=config.reranker_cohere_model,
reranker_cohere_base_url=config.reranker_cohere_base_url,
reranker_litellm_api_base=config.reranker_litellm_api_base,
reranker_litellm_api_key=config.reranker_litellm_api_key,
reranker_litellm_model=config.reranker_litellm_model,
reranker_litellm_sdk_api_key=config.reranker_litellm_sdk_api_key,
reranker_litellm_sdk_model=config.reranker_litellm_sdk_model,
reranker_litellm_sdk_api_base=config.reranker_litellm_sdk_api_base,
host=args.host,
port=args.port,
base_path=config.base_path,
log_level=args.log_level,
log_format=config.log_format,
mcp_enabled=config.mcp_enabled,
enable_bank_config_api=config.enable_bank_config_api,
graph_retriever=config.graph_retriever,
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
recall_max_concurrent=config.recall_max_concurrent,
@@ -223,6 +247,27 @@ def main():
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_custom_instructions=config.retain_custom_instructions,
retain_batch_tokens=config.retain_batch_tokens,
retain_batch_enabled=config.retain_batch_enabled,
retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds,
file_storage_type=config.file_storage_type,
file_storage_s3_bucket=config.file_storage_s3_bucket,
file_storage_s3_region=config.file_storage_s3_region,
file_storage_s3_endpoint=config.file_storage_s3_endpoint,
file_storage_s3_access_key_id=config.file_storage_s3_access_key_id,
file_storage_s3_secret_access_key=config.file_storage_s3_secret_access_key,
file_storage_gcs_bucket=config.file_storage_gcs_bucket,
file_storage_gcs_service_account_key=config.file_storage_gcs_service_account_key,
file_storage_azure_container=config.file_storage_azure_container,
file_storage_azure_account_name=config.file_storage_azure_account_name,
file_storage_azure_account_key=config.file_storage_azure_account_key,
file_parser=config.file_parser,
file_parser_iris_token=config.file_parser_iris_token,
file_parser_iris_org_id=config.file_parser_iris_org_id,
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
file_conversion_max_batch_size=config.file_conversion_max_batch_size,
enable_file_upload_api=config.enable_file_upload_api,
file_delete_after_retain=config.file_delete_after_retain,
enable_observations=config.enable_observations,
consolidation_batch_size=config.consolidation_batch_size,
consolidation_max_tokens=config.consolidation_max_tokens,
@@ -242,6 +287,11 @@ def main():
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
reflect_max_iterations=config.reflect_max_iterations,
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
otel_traces_enabled=config.otel_traces_enabled,
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
otel_service_name=config.otel_service_name,
otel_deployment_environment=config.otel_deployment_environment,
)
config.configure_logging()
if not args.daemon:
@@ -319,6 +369,7 @@ def main():
"proxy_headers": args.proxy_headers,
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
"loop": loop_impl, # Explicitly set event loop implementation
"timeout_keep_alive": 30, # Exceed aiohttp's 15s client timeout so the client always closes first
}
# Add optional parameters if provided
@@ -347,6 +398,8 @@ def main():
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
version=__version__,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
)
# Start idle checker in daemon mode
+608 -5
View File
@@ -35,6 +35,12 @@ class MCPToolsConfig:
# How to resolve API key for tenant auth (optional)
api_key_resolver: Callable[[], str | None] | None = None
# How to resolve tenant_id for usage metering (set by MCP middleware after auth)
tenant_id_resolver: Callable[[], str | None] | None = None
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
api_key_id_resolver: Callable[[], str | None] | None = None
# Whether to include bank_id as a parameter on tools (for multi-bank support)
include_bank_id_param: bool = False
@@ -50,13 +56,15 @@ class MCPToolsConfig:
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
"""Create RequestContext with API key from resolver if available.
"""Create RequestContext with auth details from resolvers.
This enables tenant auth to work with MCP tools by propagating
the Bearer token from the MCP middleware to the memory engine.
This enables tenant auth and usage metering to work with MCP tools by propagating
the authentication results from the MCP middleware to the memory engine.
"""
api_key = config.api_key_resolver() if config.api_key_resolver else None
return RequestContext(api_key=api_key)
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
def parse_timestamp(timestamp: str) -> datetime | None:
@@ -119,7 +127,19 @@ def register_mcp_tools(
memory: MemoryEngine instance
config: Tool configuration
"""
tools_to_register = config.tools or {"retain", "recall", "reflect", "list_banks", "create_bank"}
tools_to_register = config.tools or {
"retain",
"recall",
"reflect",
"list_banks",
"create_bank",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
}
if "retain" in tools_to_register:
_register_retain(mcp, memory, config)
@@ -136,6 +156,25 @@ def register_mcp_tools(
if "create_bank" in tools_to_register:
_register_create_bank(mcp, memory, config)
# Mental model tools
if "list_mental_models" in tools_to_register:
_register_list_mental_models(mcp, memory, config)
if "get_mental_model" in tools_to_register:
_register_get_mental_model(mcp, memory, config)
if "create_mental_model" in tools_to_register:
_register_create_mental_model(mcp, memory, config)
if "update_mental_model" in tools_to_register:
_register_update_mental_model(mcp, memory, config)
if "delete_mental_model" in tools_to_register:
_register_delete_mental_model(mcp, memory, config)
if "refresh_mental_model" in tools_to_register:
_register_refresh_mental_model(mcp, memory, config)
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the retain tool."""
@@ -511,3 +550,567 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
def _validate_mental_model_inputs(
name: str | None = None, source_query: str | None = None, max_tokens: int | None = None
) -> str | None:
"""Validate mental model inputs, returning an error message or None if valid."""
if name is not None and not name.strip():
return "name cannot be empty"
if source_query is not None and not source_query.strip():
return "source_query cannot be empty"
if max_tokens is not None and (max_tokens < 256 or max_tokens > 8192):
return f"max_tokens must be between 256 and 8192, got {max_tokens}"
return None
# =========================================================================
# MENTAL MODEL TOOLS
# =========================================================================
def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the list_mental_models tool."""
if config.include_bank_id_param:
@mcp.tool()
async def list_mental_models(
tags: list[str] | None = None,
bank_id: str | None = None,
) -> str:
"""
List mental models (pinned reflections) for a memory bank.
Mental models are living documents that stay current by periodically re-running
a source query through reflect. Use them to maintain up-to-date summaries,
preferences, or synthesized knowledge.
Args:
tags: Optional tags to filter by (returns models matching any tag)
bank_id: Optional bank to list from (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured", "items": []}'
models = await memory.list_mental_models(
bank_id=target_bank,
tags=tags,
request_context=_get_request_context(config),
)
return json.dumps({"items": models}, indent=2, default=str)
except Exception as e:
logger.error(f"Error listing mental models: {e}", exc_info=True)
return f'{{"error": "{e}", "items": []}}'
else:
@mcp.tool()
async def list_mental_models(
tags: list[str] | None = None,
) -> dict:
"""
List mental models (pinned reflections) for this memory bank.
Mental models are living documents that stay current by periodically re-running
a source query through reflect. Use them to maintain up-to-date summaries,
preferences, or synthesized knowledge.
Args:
tags: Optional tags to filter by (returns models matching any tag)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured", "items": []}
models = await memory.list_mental_models(
bank_id=target_bank,
tags=tags,
request_context=_get_request_context(config),
)
return {"items": models}
except Exception as e:
logger.error(f"Error listing mental models: {e}", exc_info=True)
return {"error": str(e), "items": []}
def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the get_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def get_mental_model(
mental_model_id: str,
bank_id: str | None = None,
) -> str:
"""
Get a specific mental model by ID.
Returns the full mental model including its generated content, source query,
and metadata. Use list_mental_models first to discover available model IDs.
Args:
mental_model_id: The ID of the mental model to retrieve
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
model = await memory.get_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if model is None:
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
return json.dumps(model, indent=2, default=str)
except Exception as e:
logger.error(f"Error getting mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def get_mental_model(
mental_model_id: str,
) -> dict:
"""
Get a specific mental model by ID.
Returns the full mental model including its generated content, source query,
and metadata. Use list_mental_models first to discover available model IDs.
Args:
mental_model_id: The ID of the mental model to retrieve
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
model = await memory.get_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if model is None:
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
return model
except Exception as e:
logger.error(f"Error getting mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def create_mental_model(
name: str,
source_query: str,
mental_model_id: str | None = None,
tags: list[str] | None = None,
max_tokens: int = 2048,
bank_id: str | None = None,
) -> str:
"""
Create a new mental model (pinned reflection).
A mental model is a living document generated by running the source_query through
reflect. The content is auto-generated asynchronously - use the returned operation_id
to track progress.
EXAMPLES:
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
- name="Communication Style", source_query="How does the user prefer to communicate?"
Args:
name: Human-readable name for the mental model
source_query: The query to run through reflect to generate content
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
tags: Optional tags for scoped visibility filtering
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return json.dumps({"error": validation_error})
request_context = _get_request_context(config)
# Create with placeholder content
model = await memory.create_mental_model(
bank_id=target_bank,
name=name,
source_query=source_query,
content="Generating content...",
mental_model_id=mental_model_id,
tags=tags,
max_tokens=max_tokens,
request_context=request_context,
)
# Schedule async refresh to generate actual content
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=model["id"],
request_context=request_context,
)
return json.dumps(
{
"mental_model_id": model["id"],
"operation_id": result["operation_id"],
"status": "created",
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
}
)
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error creating mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def create_mental_model(
name: str,
source_query: str,
mental_model_id: str | None = None,
tags: list[str] | None = None,
max_tokens: int = 2048,
) -> dict:
"""
Create a new mental model (pinned reflection).
A mental model is a living document generated by running the source_query through
reflect. The content is auto-generated asynchronously - use the returned operation_id
to track progress.
EXAMPLES:
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
- name="Communication Style", source_query="How does the user prefer to communicate?"
Args:
name: Human-readable name for the mental model
source_query: The query to run through reflect to generate content
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
tags: Optional tags for scoped visibility filtering
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return {"error": validation_error}
request_context = _get_request_context(config)
model = await memory.create_mental_model(
bank_id=target_bank,
name=name,
source_query=source_query,
content="Generating content...",
mental_model_id=mental_model_id,
tags=tags,
max_tokens=max_tokens,
request_context=request_context,
)
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=model["id"],
request_context=request_context,
)
return {
"mental_model_id": model["id"],
"operation_id": result["operation_id"],
"status": "created",
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error creating mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the update_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
source_query: str | None = None,
max_tokens: int | None = None,
tags: list[str] | None = None,
bank_id: str | None = None,
) -> str:
"""
Update a mental model's metadata.
Changes the name, source query, or tags of an existing mental model.
To regenerate the content, use refresh_mental_model after updating the source query.
Args:
mental_model_id: The ID of the mental model to update
name: New name (leave None to keep current)
source_query: New source query (leave None to keep current)
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
tags: New tags (leave None to keep current)
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return json.dumps({"error": validation_error})
model = await memory.update_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
name=name,
source_query=source_query,
max_tokens=max_tokens,
tags=tags,
request_context=_get_request_context(config),
)
if model is None:
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
return json.dumps(model, indent=2, default=str)
except Exception as e:
logger.error(f"Error updating mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def update_mental_model(
mental_model_id: str,
name: str | None = None,
source_query: str | None = None,
max_tokens: int | None = None,
tags: list[str] | None = None,
) -> dict:
"""
Update a mental model's metadata.
Changes the name, source query, or tags of an existing mental model.
To regenerate the content, use refresh_mental_model after updating the source query.
Args:
mental_model_id: The ID of the mental model to update
name: New name (leave None to keep current)
source_query: New source query (leave None to keep current)
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
tags: New tags (leave None to keep current)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
)
if validation_error:
return {"error": validation_error}
model = await memory.update_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
name=name,
source_query=source_query,
max_tokens=max_tokens,
tags=tags,
request_context=_get_request_context(config),
)
if model is None:
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
return model
except Exception as e:
logger.error(f"Error updating mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the delete_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def delete_mental_model(
mental_model_id: str,
bank_id: str | None = None,
) -> str:
"""
Delete a mental model.
Permanently removes a mental model and its generated content.
Args:
mental_model_id: The ID of the mental model to delete
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
deleted = await memory.delete_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if not deleted:
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
return json.dumps({"status": "deleted", "mental_model_id": mental_model_id})
except Exception as e:
logger.error(f"Error deleting mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def delete_mental_model(
mental_model_id: str,
) -> dict:
"""
Delete a mental model.
Permanently removes a mental model and its generated content.
Args:
mental_model_id: The ID of the mental model to delete
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
deleted = await memory.delete_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
if not deleted:
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
return {"status": "deleted", "mental_model_id": mental_model_id}
except Exception as e:
logger.error(f"Error deleting mental model: {e}", exc_info=True)
return {"error": str(e)}
def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the refresh_mental_model tool."""
if config.include_bank_id_param:
@mcp.tool()
async def refresh_mental_model(
mental_model_id: str,
bank_id: str | None = None,
) -> str:
"""
Refresh a mental model by re-running its source query.
Schedules an async task to re-run the source query through reflect and update the
mental model's content with fresh results. Use this after adding new memories or
when the mental model's content may be stale.
Args:
mental_model_id: The ID of the mental model to refresh
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
return json.dumps(
{
"operation_id": result["operation_id"],
"status": "queued",
"message": f"Refresh queued for mental model '{mental_model_id}'.",
}
)
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool()
async def refresh_mental_model(
mental_model_id: str,
) -> dict:
"""
Refresh a mental model by re-running its source query.
Schedules an async task to re-run the source query through reflect and update the
mental model's content with fresh results. Use this after adding new memories or
when the mental model's content may be stale.
Args:
mental_model_id: The ID of the mental model to refresh
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank,
mental_model_id=mental_model_id,
request_context=_get_request_context(config),
)
return {
"operation_id": result["operation_id"],
"status": "queued",
"message": f"Refresh queued for mental model '{mental_model_id}'.",
}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
return {"error": str(e)}
+562 -13
View File
@@ -25,12 +25,77 @@ from alembic.config import Config
from alembic.script.revision import ResolutionError
from sqlalchemy import create_engine, text
from .utils import mask_network_location
logger = logging.getLogger(__name__)
# Advisory lock ID for migrations (arbitrary unique number)
MIGRATION_LOCK_ID = 123456789
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
"""
Validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Args:
conn: SQLAlchemy connection object
vector_extension: Configured extension ("pgvector", "vchord", or "pgvectorscale")
Returns:
"pgvector", "vchord", "pgvectorscale", or "pg_diskann"
Raises:
RuntimeError: If configured extension is not installed
"""
# Verify the configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector to be installed first
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN (pgvectorscale/pg_diskann) requires pgvector to be installed. "
"Install it with: CREATE EXTENSION vector; then CREATE EXTENSION vectorscale CASCADE; (or pg_diskann on Azure)"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
logger.debug("Using vector extension: pgvectorscale (DiskANN)")
return "pgvectorscale"
elif pg_diskann_check:
logger.debug("Using vector extension: pg_diskann (Azure DiskANN)")
return "pg_diskann" # Return distinct name for parameter handling
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. "
"Install either:\n"
" - pgvectorscale (open source): CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
logger.debug("Using configured vector extension: vchord")
return "vchord"
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
logger.debug("Using configured vector extension: pgvector")
return "pgvector"
else:
raise ValueError(
f"Invalid vector_extension: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _get_schema_lock_id(schema: str) -> int:
"""
Generate a unique advisory lock ID for a schema.
@@ -54,7 +119,7 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
"""
schema_name = schema or "public"
logger.info(f"Running database migrations to head for schema '{schema_name}'...")
logger.info(f"Database URL: {database_url}")
logger.info(f"Database URL: {mask_network_location(database_url)}")
logger.info(f"Script location: {script_location}")
# Create Alembic configuration programmatically (no alembic.ini needed)
@@ -240,6 +305,48 @@ def run_migrations(
"Please install it with: CREATE EXTENSION vector;"
) from e
# If using pgvectorscale, ensure vectorscale extension is also installed
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if vector_extension == "pgvectorscale":
logger.debug("Checking pgvectorscale (vectorscale) extension availability...")
vectorscale_check = conn.execute(
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
).scalar()
if vectorscale_check:
logger.info("pgvectorscale extension already installed")
else:
# Extension doesn't exist - try to install
logger.info("pgvectorscale extension not found, attempting to install...")
try:
conn.execute(text("CREATE EXTENSION vectorscale CASCADE"))
conn.commit()
logger.info("pgvectorscale extension installed successfully")
except Exception as e:
# Installation failed - check one more time in case another process installed it
conn.rollback()
vectorscale_recheck = conn.execute(
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
).fetchone()
if vectorscale_recheck:
logger.warning(
"Could not install pgvectorscale extension (permission denied?), "
"but extension exists. Continuing..."
)
else:
# Extension truly doesn't exist and we can't install it
logger.error(
f"pgvectorscale extension is not installed and cannot be installed: {e}. "
f"Please ensure pgvectorscale is installed by a database administrator. "
f"See: https://github.com/timescale/pgvectorscale#installation"
)
raise RuntimeError(
"pgvectorscale extension is required but not installed. "
"Please install it with: CREATE EXTENSION vectorscale CASCADE;"
) from e
# Run migrations while holding the lock
_run_migrations_internal(database_url, script_location, schema=schema)
finally:
@@ -322,6 +429,7 @@ def ensure_embedding_dimension(
database_url: str,
required_dimension: int,
schema: str | None = None,
vector_extension: str = "pgvector",
) -> None:
"""
Ensure the embedding column dimension matches the model's dimension.
@@ -336,6 +444,7 @@ def ensure_embedding_dimension(
database_url: SQLAlchemy database URL
required_dimension: The embedding dimension required by the model
schema: Target PostgreSQL schema name (None for public)
vector_extension: Configured vector extension ("pgvector" or "vchord")
Raises:
RuntimeError: If dimension mismatch with existing data
@@ -359,6 +468,10 @@ def ensure_embedding_dimension(
logger.debug(f"memory_units table does not exist in schema '{schema_name}', skipping dimension check")
return
# Detect which vector extension is available
vector_ext = _detect_vector_extension(conn, vector_extension)
logger.info(f"Using vector extension: {vector_ext}")
# Get current column dimension from pg_attribute
# pgvector stores dimension in atttypmod
current_dim = conn.execute(
@@ -406,8 +519,7 @@ def ensure_embedding_dimension(
# Table is empty, safe to alter column
logger.info(f"Altering embedding column dimension from {current_dimension} to {required_dimension}")
# Drop the HNSW index on embedding column if it exists
# Only drop indexes that use 'hnsw' and reference the 'embedding' column
# Drop existing vector index (works for both HNSW and vchordrq)
conn.execute(
text(f"""
DO $$
@@ -417,7 +529,7 @@ def ensure_embedding_dimension(
SELECT indexname FROM pg_indexes
WHERE schemaname = '{schema_name}'
AND tablename = 'memory_units'
AND indexdef LIKE '%hnsw%'
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%')
AND indexdef LIKE '%embedding%'
LOOP
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
@@ -432,15 +544,452 @@ def ensure_embedding_dimension(
)
conn.commit()
# Recreate the HNSW index
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
ON {schema_name}.memory_units
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
# Recreate index with appropriate type based on detected extension
if vector_ext == "pgvectorscale":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_diskann
ON {schema_name}.memory_units
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
)
logger.info(f"Created DiskANN index for {required_dimension}-dimensional embeddings")
elif vector_ext == "vchord":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_vchordrq
ON {schema_name}.memory_units
USING vchordrq (embedding vector_l2_ops)
""")
)
logger.info(f"Created vchordrq index for {required_dimension}-dimensional embeddings")
else: # pgvector
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
ON {schema_name}.memory_units
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
logger.info(f"Created HNSW index for {required_dimension}-dimensional embeddings")
conn.commit()
logger.info(f"Successfully changed embedding dimension to {required_dimension}")
def ensure_vector_extension(
database_url: str,
vector_extension: str = "pgvector",
schema: str | None = None,
) -> None:
"""
Ensure the vector indexes match the configured vector extension.
This function checks the current vector index type in the database
and adjusts it if necessary:
- If index type matches configured extension: no action needed
- If they differ and tables are empty: drop old indexes, recreate with new type
- If they differ and tables have data: raise error with migration guidance
Args:
database_url: SQLAlchemy database URL
vector_extension: Configured vector extension ("pgvector" or "vchord")
schema: Target PostgreSQL schema name (None for public)
Raises:
RuntimeError: If extension mismatch with existing data
"""
schema_name = schema or "public"
engine = create_engine(database_url)
with engine.connect() as conn:
# Detect which vector extension should be used
target_ext = _detect_vector_extension(conn, vector_extension)
logger.info(f"Target vector extension: {target_ext}")
# Tables with vector indexes to check
tables_to_check = [
("memory_units", "idx_memory_units_embedding"),
("learnings", "idx_learnings_embedding"),
("pinned_reflections", "idx_pinned_reflections_embedding"),
]
# Determine target index type
if target_ext in ("pgvectorscale", "pg_diskann"):
target_index_type = "diskann"
elif target_ext == "vchord":
target_index_type = "vchordrq"
else:
target_index_type = "hnsw"
mismatched_tables = []
tables_with_data = []
for table_name, index_name in tables_to_check:
# Check if table exists
table_exists = conn.execute(
text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = :schema AND table_name = :table_name
)
"""),
{"schema": schema_name, "table_name": table_name},
).scalar()
if not table_exists:
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
continue
# Check current index type by querying pg_indexes
current_index_info = conn.execute(
text("""
SELECT indexdef
FROM pg_indexes
WHERE schemaname = :schema
AND tablename = :table_name
AND indexname LIKE :index_pattern
"""),
{"schema": schema_name, "table_name": table_name, "index_pattern": "%embedding%"},
).fetchone()
if not current_index_info:
logger.warning(f"No embedding index found for {table_name}, will create it")
mismatched_tables.append((table_name, index_name, None))
continue
indexdef = current_index_info[0].lower()
if "diskann" in indexdef:
current_index_type = "diskann"
elif "vchordrq" in indexdef:
current_index_type = "vchordrq"
elif "hnsw" in indexdef:
current_index_type = "hnsw"
else:
logger.warning(f"Unknown index type for {table_name}: {indexdef}")
continue
# Check if index type matches target
if current_index_type != target_index_type:
logger.info(
f"Index type mismatch on {table_name}: current={current_index_type}, target={target_index_type}"
)
mismatched_tables.append((table_name, index_name, current_index_type))
# Check if table has data
row_count = conn.execute(
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
).scalar()
if row_count > 0:
tables_with_data.append((table_name, row_count))
else:
logger.debug(f"Index type OK for {table_name}: {current_index_type}")
# If no mismatches, we're done
if not mismatched_tables:
logger.debug(f"All vector indexes match configured extension: {target_ext}")
return
# If there's data in any mismatched table, raise error
if tables_with_data:
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
# Map index type back to extension name for error message
current_ext_name = {"diskann": "pgvectorscale", "vchordrq": "vchord", "hnsw": "pgvector"}.get(
current_index_type, current_index_type
)
raise RuntimeError(
f"Cannot change vector extension from {current_index_type} to {target_index_type}: "
f"the following tables contain data: {table_list}. "
f"To change vector extension, you must either:\n"
f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; "
f"DELETE FROM {schema_name}.learnings; DELETE FROM {schema_name}.pinned_reflections; then restart\n"
f" 2. Use the current vector extension (set HINDSIGHT_API_VECTOR_EXTENSION='{current_ext_name}')"
)
# Tables are empty, safe to recreate indexes
logger.info(f"Recreating vector indexes for {target_ext}")
for table_name, index_name, current_type in mismatched_tables:
# Drop existing index if it exists
if current_type:
logger.info(f"Dropping {current_type} index on {table_name}")
conn.execute(text(f"DROP INDEX IF EXISTS {schema_name}.{index_name}"))
# Create new index with appropriate type
if target_ext == "pgvectorscale":
logger.info(f"Creating DiskANN index on {table_name} (pgvectorscale)")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
)
elif target_ext == "pg_diskann":
logger.info(f"Creating DiskANN index on {table_name} (pg_diskann/Azure)")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
)
elif target_ext == "vchord":
logger.info(f"Creating vchordrq index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING vchordrq (embedding vector_l2_ops)
""")
)
else: # pgvector
logger.info(f"Creating HNSW index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
conn.commit()
logger.info(f"Successfully migrated vector indexes to {target_ext}")
def ensure_text_search_extension(
database_url: str,
text_search_extension: str = "native",
schema: str | None = None,
) -> None:
"""
Ensure the text search columns and indexes match the configured extension.
This function checks the current search_vector column type and index type
in the database and adjusts them if necessary:
- If they match configured extension: no action needed
- If they differ and tables are empty: drop old column/index, recreate with new type
- If they differ and tables have data: raise error with migration guidance
Args:
database_url: SQLAlchemy database URL
text_search_extension: Configured text search extension ("native" or "vchord")
schema: Target PostgreSQL schema name (None for public)
Raises:
RuntimeError: If extension mismatch with existing data
"""
schema_name = schema or "public"
engine = create_engine(database_url)
with engine.connect() as conn:
# Tables with search_vector columns to check
tables_to_check = [
"memory_units",
"reflections", # Renamed from pinned_reflections in p1k2l3m4n5o6 migration
]
# Determine target column type and index type
if text_search_extension == "vchord":
target_column_type = "bm25vector"
target_index_type = "bm25"
elif text_search_extension == "pg_textsearch":
target_column_type = "text"
target_index_type = "bm25"
else: # native
target_column_type = "tsvector"
target_index_type = "gin"
mismatched_tables = []
tables_with_data = []
for table_name in tables_to_check:
# Check if table exists
table_exists = conn.execute(
text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = :schema AND table_name = :table_name
)
"""),
{"schema": schema_name, "table_name": table_name},
).scalar()
if not table_exists:
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
continue
# Get current column type from information_schema
current_column_info = conn.execute(
text("""
SELECT data_type, udt_name
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table_name
AND column_name = 'search_vector'
"""),
{"schema": schema_name, "table_name": table_name},
).fetchone()
if not current_column_info:
logger.warning(f"No search_vector column found for {table_name}, will create it")
mismatched_tables.append((table_name, None, None))
continue
# Check column type (udt_name contains the actual type: tsvector, bm25vector, etc.)
current_column_type = current_column_info[1] # udt_name
# Get current index type
current_index_info = conn.execute(
text("""
SELECT am.amname
FROM pg_indexes pi
JOIN pg_class c ON c.relname = pi.indexname
JOIN pg_am am ON am.oid = c.relam
WHERE pi.schemaname = :schema
AND pi.tablename = :table_name
AND pi.indexname LIKE '%text_search%'
"""),
{"schema": schema_name, "table_name": table_name},
).fetchone()
current_index_type = current_index_info[0] if current_index_info else None
# Check if column and index types match target
column_matches = current_column_type == target_column_type
index_matches = current_index_type == target_index_type if current_index_type else False
if not (column_matches and index_matches):
logger.info(
f"Text search mismatch on {table_name}: "
f"column={current_column_type} (want {target_column_type}), "
f"index={current_index_type} (want {target_index_type})"
)
mismatched_tables.append((table_name, current_column_type, current_index_type))
# Check if table has data
row_count = conn.execute(text(f"SELECT COUNT(*) FROM {schema_name}.{table_name}")).scalar()
if row_count > 0:
tables_with_data.append((table_name, row_count))
else:
logger.debug(f"Text search OK for {table_name}: {current_column_type}/{current_index_type}")
# If no mismatches, we're done
if not mismatched_tables:
logger.debug(f"All text search columns/indexes match configured extension: {text_search_extension}")
return
# If there's data in any mismatched table, raise error
if tables_with_data:
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
# Detect current extension from column type
current_col_type = mismatched_tables[0][1]
if current_col_type == "tsvector":
current_ext = "native"
elif current_col_type == "bm25vector":
current_ext = "vchord"
elif current_col_type == "text":
current_ext = "pg_textsearch"
else:
current_ext = "unknown"
raise RuntimeError(
f"Cannot change text search extension from {current_ext} to {text_search_extension}: "
f"the following tables contain data: {table_list}. "
f"To change text search extension, you must either:\n"
f" 1. Clear all data: DELETE FROM {schema_name}.memory_units; "
f"DELETE FROM {schema_name}.reflections; then restart\n"
f" 2. Use the current text search extension (set HINDSIGHT_API_TEXT_SEARCH_EXTENSION='{current_ext}')"
)
# Tables are empty, safe to recreate columns/indexes
logger.info(f"Recreating text search columns/indexes for {text_search_extension}")
for table_name, current_col_type, current_idx_type in mismatched_tables:
# Drop existing index if it exists
if current_idx_type:
logger.info(f"Dropping {current_idx_type} index on {table_name}")
conn.execute(
text(f"""
DROP INDEX IF EXISTS {schema_name}.idx_{table_name.replace(".", "_")}_text_search
""")
)
# Drop existing column if it exists
if current_col_type:
logger.info(f"Dropping {current_col_type} column on {table_name}")
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} DROP COLUMN IF EXISTS search_vector"))
# Create new column with appropriate type
if text_search_extension == "vchord":
logger.info(f"Creating bm25vector column on {table_name}")
# Note: vchord_bm25 extension creates types in bm25_catalog schema
conn.execute(
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector bm25_catalog.bm25vector")
)
# Create BM25 index
logger.info(f"Creating BM25 index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
)
elif text_search_extension == "pg_textsearch":
logger.info(f"Creating TEXT column on {table_name}")
# Dummy TEXT column for consistency (indexes operate on base columns)
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector TEXT"))
# Create BM25 index on expression
logger.info(f"Creating BM25 index on {table_name}")
# Different expression for each table
if table_name == "memory_units":
index_expr = "(COALESCE(text, '') || ' ' || COALESCE(context, ''))"
else: # reflections
index_expr = "(COALESCE(name, '') || ' ' || content)"
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING bm25({index_expr})
WITH (text_config='english')
""")
)
else: # native
logger.info(f"Creating tsvector column on {table_name}")
# Different GENERATED expression for each table
if table_name == "memory_units":
generated_expr = "to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))"
else: # reflections
generated_expr = "to_tsvector('english', COALESCE(name, '') || ' ' || content)"
conn.execute(
text(f"""
ALTER TABLE {schema_name}.{table_name}
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS ({generated_expr}) STORED
""")
)
# Create GIN index
logger.info(f"Creating GIN index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING gin(search_vector)
""")
)
conn.commit()
logger.info(f"Successfully migrated text search to {text_search_extension}")
+2 -1
View File
@@ -20,7 +20,8 @@ class RequestContext:
api_key: str | None = None
api_key_id: str | None = None # UUID of the API key used for authentication
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
internal: bool = False # True for background/internal operations (not user-visible)
internal: bool = False # True for background/internal operations (skips extension auth)
user_initiated: bool = False # True for async operations that originated from a user request
from pgvector.sqlalchemy import Vector
+480
View File
@@ -0,0 +1,480 @@
"""
OpenTelemetry distributed tracing instrumentation for Hindsight API.
This module provides tracing for:
- LLM API calls with full prompts/completions following GenAI semantic conventions
- Token usage and model information
- Error tracking and finish reasons
Tracing is conditional and disabled by default. When enabled, traces are exported
to Langfuse (or any OTLP-compatible backend) via OTLP HTTP protocol.
"""
import json
import logging
import time
from typing import Any, Optional
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import Status, StatusCode
logger = logging.getLogger(__name__)
def _serialize_for_span(obj: Any) -> str:
"""Serialize an object for span recording, handling Pydantic models."""
if isinstance(obj, str):
return obj
if hasattr(obj, "model_dump_json"):
# Pydantic v2 model
return obj.model_dump_json()
if hasattr(obj, "json"):
# Pydantic v1 model
return obj.json()
if hasattr(obj, "model_dump"):
# Pydantic v2 model - convert to dict then json
return json.dumps(obj.model_dump())
if hasattr(obj, "dict"):
# Pydantic v1 model - convert to dict then json
return json.dumps(obj.dict())
# Fallback to json.dumps for dicts and other types
return json.dumps(obj)
# No-op tracer for when tracing is disabled
class NoOpTracer:
"""No-op tracer that provides the same interface as OpenTelemetry Tracer but does nothing."""
def start_as_current_span(self, name: str, **kwargs):
"""Return a no-op context manager that yields a NoOpSpan."""
from contextlib import contextmanager
@contextmanager
def noop_span_context():
yield NoOpSpan()
return noop_span_context()
def start_span(self, name: str, **kwargs):
"""Return a no-op span."""
return NoOpSpan()
class NoOpSpan:
"""No-op span that provides the same interface as OpenTelemetry Span but does nothing."""
def set_attribute(self, key: str, value: Any) -> None:
"""No-op."""
pass
def set_status(self, status: Any) -> None:
"""No-op."""
pass
def record_exception(self, exception: Exception) -> None:
"""No-op."""
pass
def add_event(self, name: str, attributes: dict | None = None) -> None:
"""No-op."""
pass
def end(self, end_time: int | None = None) -> None:
"""No-op."""
pass
# Global tracer instance
_tracer: trace.Tracer | NoOpTracer = NoOpTracer()
_tracing_enabled: bool = False
# GenAI semantic convention attribute names (based on v1.37 spec)
class GenAIAttributes:
"""GenAI semantic convention attribute names."""
# Operation and provider
OPERATION_NAME = "gen_ai.operation.name"
PROVIDER_NAME = "gen_ai.provider.name"
# Model information
REQUEST_MODEL = "gen_ai.request.model"
RESPONSE_MODEL = "gen_ai.response.model"
# Token usage
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
# Messages and prompts
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
INPUT_MESSAGES = "gen_ai.input.messages"
OUTPUT_MESSAGES = "gen_ai.output.messages"
# Response metadata
FINISH_REASONS = "gen_ai.response.finish_reasons"
# Error tracking
ERROR_TYPE = "error.type"
# Provider name mapping (Hindsight internal -> GenAI semantic convention)
PROVIDER_NAME_MAPPING = {
"openai": "openai",
"anthropic": "anthropic",
"gemini": "google",
"vertexai": "google",
"groq": "groq",
"ollama": "ollama",
"lmstudio": "lmstudio",
"openai-codex": "openai",
"claude-code": "anthropic",
"mock": "mock",
}
def initialize_tracing(
service_name: str,
endpoint: str,
headers: Optional[str] = None,
deployment_environment: str = "development",
) -> None:
"""
Initialize OpenTelemetry tracing with OTLP exporter.
Args:
service_name: Name of the service for resource attributes
endpoint: OTLP endpoint URL (e.g., https://cloud.langfuse.com/api/public/otel)
headers: Optional headers in format "key1=value1,key2=value2"
deployment_environment: Deployment environment (e.g., development, staging, production)
"""
global _tracer, _tracing_enabled
# Create resource with service information
resource = Resource.create(
{
"service.name": service_name,
"service.version": "0.4.8", # Could import from __version__
"deployment.environment.name": deployment_environment,
}
)
# Parse headers
headers_dict = {}
if headers:
for pair in headers.split(","):
if "=" in pair:
key, value = pair.split("=", 1)
headers_dict[key.strip()] = value.strip()
# Create OTLP HTTP exporter
# Note: Langfuse expects /v1/traces path appended to base endpoint
otlp_endpoint = endpoint if endpoint.endswith("/v1/traces") else f"{endpoint}/v1/traces"
otlp_exporter = OTLPSpanExporter(
endpoint=otlp_endpoint,
headers=headers_dict,
)
# Create tracer provider with batch processor
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
# Set global tracer provider
trace.set_tracer_provider(provider)
# Get tracer for this application
_tracer = trace.get_tracer(__name__)
_tracing_enabled = True
logger.info(f"Tracing initialized: endpoint={otlp_endpoint}, service={service_name}")
def get_tracer() -> trace.Tracer | NoOpTracer:
"""
Get the global tracer instance.
Returns a no-op tracer if tracing is disabled, so callers don't need to check for None.
This improves code readability by allowing direct use without null checks.
"""
return _tracer
def create_operation_span(operation: str, bank_id: str | None = None):
"""
Create a parent span for a Hindsight operation (retain, reflect, consolidation, etc.).
This creates the span hierarchy:
- hindsight.{operation} (parent)
- chat {model} (child LLM calls)
Args:
operation: Operation name (retain, reflect, consolidation, mental_model_refresh)
bank_id: Optional bank ID for context
Returns:
Span context manager
"""
if not _tracing_enabled or _tracer is None:
# Return a no-op context manager
from contextlib import nullcontext
return nullcontext()
span_name = f"hindsight.{operation}"
span = _tracer.start_as_current_span(span_name)
# Add operation-specific attributes
if span and hasattr(span, "set_attribute"):
span.set_attribute("hindsight.operation", operation)
if bank_id:
span.set_attribute("hindsight.bank_id", bank_id)
return span
def is_tracing_enabled() -> bool:
"""Check if tracing is enabled."""
return _tracing_enabled
# Maximum content length before truncation (to stay within span size limits)
MAX_CONTENT_LENGTH = 100_000 # characters
def _truncate_content(content: str) -> str:
"""Truncate content if too large for span."""
if len(content) > MAX_CONTENT_LENGTH:
return content[:MAX_CONTENT_LENGTH] + f"\n\n[TRUNCATED: {len(content) - MAX_CONTENT_LENGTH} chars omitted]"
return content
class LLMSpanRecorder:
"""
Records OpenTelemetry spans for LLM calls following GenAI semantic conventions.
"""
def __init__(self, tracer: trace.Tracer):
self.tracer = tracer
def record_llm_call(
self,
provider: str,
model: str,
scope: str,
messages: list[dict[str, str]],
response_content: Optional[str],
input_tokens: int,
output_tokens: int,
duration: float,
finish_reason: Optional[str] = None,
error: Optional[Exception] = None,
tool_calls: Optional[list[dict[str, Any]]] = None,
) -> None:
"""
Record a completed LLM call as a span with GenAI semantic conventions.
This creates a span AFTER the call completes, using timestamps to
set the correct start/end times. This approach works better with
the existing sync metrics recording pattern.
Args:
provider: Hindsight provider name
model: Model name
scope: Scope identifier (memory, reflect, consolidation, etc.)
messages: Input messages (chat history)
response_content: Response text from LLM
input_tokens: Input token count
output_tokens: Output token count
duration: Call duration in seconds
finish_reason: Reason the model stopped (stop, length, tool_calls, etc.)
error: Exception if call failed
tool_calls: List of tool calls made (for function calling)
"""
try:
# Map provider name to GenAI semantic convention
genai_provider = PROVIDER_NAME_MAPPING.get(provider.lower(), provider.lower())
# Determine operation name based on scope/context
operation_name = "chat" # Default for GenAI semantic conventions
# Create span name: "hindsight.{scope}" for consistency with parent spans
# Model info is available in span attributes (gen_ai.request.model)
if scope:
span_name = f"hindsight.{scope}"
else:
# Fallback to chat {model} if no scope provided
span_name = f"{operation_name} {model}"
# Calculate timestamps
end_time_ns = time.time_ns()
start_time_ns = end_time_ns - int(duration * 1_000_000_000)
# Create span with explicit timestamps
with self.tracer.start_as_current_span(
span_name,
start_time=start_time_ns,
end_on_exit=False, # We'll set end time manually
) as span:
# Set required attributes
span.set_attribute(GenAIAttributes.OPERATION_NAME, operation_name)
span.set_attribute(GenAIAttributes.PROVIDER_NAME, genai_provider)
span.set_attribute(GenAIAttributes.REQUEST_MODEL, model)
span.set_attribute(GenAIAttributes.RESPONSE_MODEL, model)
span.set_attribute(GenAIAttributes.USAGE_INPUT_TOKENS, input_tokens)
span.set_attribute(GenAIAttributes.USAGE_OUTPUT_TOKENS, output_tokens)
# Add custom attributes for Hindsight context
span.set_attribute("hindsight.scope", scope)
span.set_attribute("hindsight.provider.internal", provider)
# Add tool call information if present
if tool_calls:
span.set_attribute("gen_ai.tool_calls.count", len(tool_calls))
# Add tool names as comma-separated list
tool_names = [tc.get("name", "") for tc in tool_calls]
span.set_attribute("gen_ai.tool_calls.names", ",".join(tool_names))
# Format messages for GenAI conventions (as JSON)
input_messages_json = self._format_messages(messages)
output_messages_json = self._format_output(response_content, finish_reason)
# Extract system instructions if present
system_instructions = self._extract_system_instructions(messages)
# Add event with prompts/completions following v1.37 conventions
event_attrs = {}
if input_messages_json:
event_attrs[GenAIAttributes.INPUT_MESSAGES] = input_messages_json
if output_messages_json:
event_attrs[GenAIAttributes.OUTPUT_MESSAGES] = output_messages_json
if system_instructions:
event_attrs[GenAIAttributes.SYSTEM_INSTRUCTIONS] = system_instructions
if finish_reason:
event_attrs[GenAIAttributes.FINISH_REASONS] = json.dumps([finish_reason])
span.add_event(
"gen_ai.client.inference.operation.details",
attributes=event_attrs,
)
# Add individual tool call events with details
if tool_calls:
for i, tc in enumerate(tool_calls):
tool_event_attrs = {
"tool.name": tc.get("name", ""),
"tool.id": tc.get("id", ""),
"tool.arguments": json.dumps(tc.get("arguments", {})),
}
span.add_event(f"gen_ai.tool_call.{i}", attributes=tool_event_attrs)
# Handle errors
if error:
span.set_status(Status(StatusCode.ERROR, str(error)))
span.set_attribute(GenAIAttributes.ERROR_TYPE, type(error).__name__)
span.record_exception(error)
else:
span.set_status(Status(StatusCode.OK))
# Set end time
span.end(end_time=end_time_ns)
except Exception as e:
# Don't let tracing errors break LLM calls
logger.error(f"Failed to record LLM span: {e}", exc_info=True)
def _format_messages(self, messages: list[dict[str, str]]) -> str:
"""
Format messages into GenAI semantic convention format (JSON array).
Returns JSON string representation of message array.
"""
try:
formatted = []
for msg in messages:
content = msg.get("content", "")
# Truncate if needed
if isinstance(content, str):
content = _truncate_content(content)
formatted.append(
{
"role": msg.get("role", "user"),
"content": content,
}
)
return json.dumps(formatted)
except Exception as e:
logger.warning(f"Failed to format input messages: {e}")
return "[]"
def _format_output(
self,
content: Optional[str],
finish_reason: Optional[str],
) -> str:
"""Format output message into GenAI semantic convention format."""
try:
if content is None:
return "[]"
# Truncate if needed
if isinstance(content, str):
content = _truncate_content(content)
return json.dumps(
[
{
"role": "assistant",
"content": content,
}
]
)
except Exception as e:
logger.warning(f"Failed to format output message: {e}")
return "[]"
def _extract_system_instructions(self, messages: list[dict[str, str]]) -> Optional[str]:
"""Extract system instructions from messages if present."""
try:
for msg in messages:
if msg.get("role") == "system":
content = msg.get("content", "")
if isinstance(content, str):
return _truncate_content(content)
return str(content)
except Exception as e:
logger.warning(f"Failed to extract system instructions: {e}")
return None
class NoOpLLMSpanRecorder:
"""No-op span recorder for when tracing is disabled."""
def record_llm_call(self, **kwargs) -> None:
"""No-op."""
pass
# Global span recorder instance
_span_recorder: Optional[LLMSpanRecorder] = None
def get_span_recorder() -> LLMSpanRecorder | NoOpLLMSpanRecorder:
"""Get the global span recorder (NoOp if tracing disabled)."""
if _span_recorder is None:
return NoOpLLMSpanRecorder()
return _span_recorder
def create_span_recorder() -> LLMSpanRecorder:
"""Create and set the global span recorder."""
global _span_recorder
tracer = get_tracer()
if tracer is None:
raise RuntimeError("Tracing not initialized. Call initialize_tracing() first.")
_span_recorder = LLMSpanRecorder(tracer)
return _span_recorder
+13
View File
@@ -0,0 +1,13 @@
from urllib.parse import urlparse, urlunparse
def mask_network_location(url):
if not url:
return url
parsed_url = urlparse(url)
masked_network_location = parsed_url.hostname or ""
if parsed_url.port:
masked_network_location += f":{parsed_url.port}"
if parsed_url.username or parsed_url.password:
masked_network_location = f"***:***@{masked_network_location}"
return urlunparse(parsed_url._replace(netloc=masked_network_location))
+93 -7
View File
@@ -376,7 +376,12 @@ class WorkerPoller:
del self._in_flight_by_type[operation_type]
async def _execute_task_inner(self, task: ClaimedTask):
"""Inner task execution with error handling."""
"""Inner task execution with error handling.
Note: The executor (MemoryEngine.execute_task) handles status marking internally
(marking operations as completed/failed and handling retries). This method should
NOT override those status updates.
"""
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
@@ -386,12 +391,12 @@ class WorkerPoller:
if task.schema:
task.task_dict["_schema"] = task.schema
await self._executor(task.task_dict)
await self._mark_completed(task.operation_id, task.schema)
logger.debug(f"Task {task.operation_id} completed successfully")
logger.debug(f"Task {task.operation_id} execution finished")
except Exception as e:
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
logger.error(f"Task {task.operation_id} failed: {e}")
await self._retry_or_fail(task.operation_id, error_msg, task.schema)
# The executor should handle its own errors, but if an unexpected exception
# propagates (e.g., from schema setup), log it as a warning
logger.error(f"Task {task.operation_id} raised unexpected exception: {e}")
traceback.print_exc()
async def recover_own_tasks(self) -> int:
"""
@@ -401,6 +406,8 @@ class WorkerPoller:
On startup, we reset any tasks stuck in 'processing' for this worker_id
back to 'pending' so they can be picked up again.
Also recovers batch API operations that were in-flight.
If tenant_extension is configured, recovers across all tenant schemas.
Returns:
@@ -413,11 +420,16 @@ class WorkerPoller:
try:
table = fq_table("async_operations", schema)
# First, recover batch API operations (before resetting worker tasks)
batch_count = await self._recover_batch_operations(schema)
total_count += batch_count
# Then reset normal worker tasks
result = await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE status = 'processing' AND worker_id = $1
WHERE status = 'processing' AND worker_id = $1 AND result_metadata->>'batch_id' IS NULL
""",
self._worker_id,
)
@@ -434,6 +446,80 @@ class WorkerPoller:
logger.info(f"Worker {self._worker_id} recovered {total_count} stale tasks from previous run")
return total_count
async def _recover_batch_operations(self, schema: str | None) -> int:
"""
Recover batch API operations that were in-flight when worker crashed.
Finds operations with batch_id in metadata and re-submits them as tasks
so polling can resume.
Args:
schema: Database schema to recover from
Returns:
Number of batch operations recovered
"""
table = fq_table("async_operations", schema)
try:
# Find operations with batch_id in metadata (batch API operations)
rows = await self._pool.fetch(
f"""
SELECT operation_id, task_payload, result_metadata
FROM {table}
WHERE status = 'processing'
AND result_metadata ? 'batch_id'
AND task_payload IS NOT NULL
"""
)
if not rows:
return 0
recovered = 0
for row in rows:
operation_id = str(row["operation_id"])
task_payload = row["task_payload"]
result_metadata = row["result_metadata"]
# Parse metadata
if isinstance(result_metadata, str):
result_metadata = json.loads(result_metadata)
batch_id = result_metadata.get("batch_id")
batch_provider = result_metadata.get("batch_provider", "openai")
logger.info(
f"Recovering batch operation: operation_id={operation_id}, batch_id={batch_id}, provider={batch_provider}"
)
# Parse task_payload
if isinstance(task_payload, str):
task_dict = json.loads(task_payload)
else:
task_dict = task_payload
# Mark operation as ready for re-processing
# Reset to pending with task_payload intact so worker picks it up again
await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
WHERE operation_id = $1
""",
operation_id,
)
recovered += 1
logger.info(f"Batch operation {operation_id} reset to pending for re-processing")
return recovered
except Exception as e:
schema_display = f'"{schema}"' if schema else str(schema)
logger.error(f"Failed to recover batch operations for schema {schema_display}: {e}")
return 0
async def run(self):
"""
Main polling loop with fire-and-forget task execution.
+9 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.4.8"
version = "0.4.12"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -25,6 +25,7 @@ dependencies = [
"psycopg2-binary>=2.9.11",
"tiktoken>=0.12.0",
"httpx>=0.27.0",
"PyJWT[crypto]>=2.8.0",
"fastmcp>=2.14.0", # CVE-2025-66416
"pg0-embedded>=0.11.0",
"python-dateutil>=2.8.0",
@@ -32,6 +33,8 @@ dependencies = [
"opentelemetry-sdk>=1.20.0",
"opentelemetry-instrumentation-fastapi>=0.41b0",
"opentelemetry-exporter-prometheus>=0.41b0",
"opentelemetry-exporter-otlp-proto-http>=1.20.0",
"opentelemetry-semantic-conventions>=0.41b0",
"dateparser>=1.2.2",
"google-genai>=1.0.0",
"google-auth>=2.0.0",
@@ -39,6 +42,9 @@ dependencies = [
"typer>=0.9.0",
"cohere>=5.0.0",
"flashrank>=0.2.0",
"litellm>=1.0.0",
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
# Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false
"sentence-transformers>=3.3.0",
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
@@ -61,6 +67,7 @@ test = [
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.0.0",
"filelock>=3.20.1", # TOCTOU race condition fix
"testcontainers>=4.0.0",
]
[project.scripts]
@@ -110,6 +117,7 @@ dev = [
"filelock>=3.20.1", # TOCTOU race condition fix
"ruff>=0.8.0",
"ty>=0.0.1",
"testcontainers>=4.0.0",
]
[tool.ruff]

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