* perf: add GIN index on source_memory_ids for observation lookup
Addresses a 927x performance regression (45ms → 0.049ms) reported by a
user with ~77k observations. The array overlap operator (&&) on
source_memory_ids was doing a full sequential scan over all observations,
causing recall timeouts (57-64s) and slow user recall (18-27s avg).
The partial GIN index reduces consolidation recall from timeout to ~15s
and user recall to ~6s.
* fix: use pre-bounded memory_links for observation graph expansion
Replace raw unit_entities join in _expand_observations() with the same
memory_links entity graph used by non-observation fact types. The previous
approach joined unit_entities twice (seeds→entities→connected_sources),
which explodes at scale (30-70s at 100k observations). The LIMIT 500
workaround was non-deterministic and dropped valid results.
Using memory_links (pre-bounded to MAX_LINKS_PER_ENTITY=50 at retain time)
is algorithmically identical to the non-observation entity expansion and
keeps graph retrieval at ~2s p50 even at 100k observations.
Also fix migration down_revision (z1u2v3w4x5y6 → d2e3f4a5b6c7) and add
observation generation + fact-type filtering to the recall perf benchmark.
FastMCP 3.x replaced _tool_manager.get_tools() with a provider pattern
(LocalProvider._list_tools via _components). The existing wrapper on
_tool_manager.get_tools() silently failed (caught AttributeError) since
_tool_manager no longer exists in v3.
Now wraps FastMCP.list_tools() and FastMCP.get_tool() for v3, while
preserving the _tool_manager approach for v2 compatibility.
- Rename shadowed `max_retries` variable to `llm_max_retries` and move
config resolution outside the loop; the old code captured `range(2)`
then overwrote `max_retries` inside the loop, so comparisons used a
different value than the loop bound — causing `continue` on the final
iteration, exhausting the loop, and reaching `raise last_error` where
`last_error` was still None → TypeError
- Add fallback `raise RuntimeError(...)` after the retry loop so that if
`last_error` is None a descriptive error is raised instead of None
- Add unit tests covering non-dict JSON responses with various retry counts
* doc: update cookbook
* fix(cookbook): preserve tag keys during sync, strip local .md links
- Fix extract_tags_from_readme/notebook to return dict[str,str] preserving
sdk/topic keys instead of bare values, preventing topics like
"Customer Service" from being misclassified as SDK
- Add strip_local_md_links() to remove relative .md references that
would cause broken link errors in Docusaurus build
* ci: run test-doc-examples independently without waiting for test-rust-cli
Build the CLI directly in the job instead of downloading the artifact,
so test-doc-examples can start at the beginning in parallel with all other jobs.
* feat: webhook system with task-owned retry, retain.completed event, and UI
- New webhook system: register per-bank webhooks with HMAC signing, configurable
HTTP method/timeout/headers/params (http_config JSONB), and PATCH support
- Webhook deliveries run as async_operations (webhook_delivery type) with
task-owned retry via RetryTaskAt exception and exponential backoff
(60s / 5m / 30m / 2h / 8h, max 6 attempts)
- New retain.completed event fires per-document for both sync and async retain
- Delivery debug info (status code, response body) stored in result_metadata
- Control plane UI: webhooks tab per bank with create/edit/delete and a
deliveries table with cursor pagination and expandable response details
- 28 webhook tests covering HMAC signing, delivery retries, CRUD endpoints,
PATCH update, and retain.completed queuing
- Docs page at developer/api/webhooks documenting event payloads and delivery
- OpenAPI spec and all client SDKs (Python, TypeScript, Rust, Go) regenerated
* fix: update tests for task-owned retry model and guard _webhook_manager attribute
- test_worker.py: test_executor_exception_triggers_retry now raises RetryTaskAt
(plain exceptions are immediate failures in the new system); rename
test_executor_exception_marks_failed_after_max_retries to
test_executor_exception_marks_failed_immediately to reflect new semantics
- test_batch_api.py: remove max_retries kwarg from WorkerPoller constructor
- memory_engine.py: use getattr for _webhook_manager in _fire_retain_webhook
to avoid AttributeError when engine is created without __init__ (tests)
* fix: remove max_retries from benchmark WorkerPoller call
* fix(webhooks): transactional outbox, observations_deleted tracking, sidebar
- Queue webhook delivery rows atomically with the primary operation using the
transactional outbox pattern — prevents lost events on process crash:
- Retain (sync + async): outbox_callback passed into orchestrator.retain_batch
and called inside the DB transaction, replacing the post-commit fire call
- Consolidation: new _mark_operation_completed_and_fire_webhook combines the
status UPDATE and webhook INSERT in one transaction
- Added fire_event_with_conn() to WebhookManager for in-connection delivery
- Track observations_deleted count in consolidation stats and expose it in the
consolidation.completed webhook payload (was always None)
- Add Webhooks page to docs sidebar
- Document at-least-once delivery guarantee with operation_id dedup guidance
* fix(ui): add retain.completed to available webhook event types
* feat(ui): add delete confirmation dialog for webhooks
* fix(webhooks): include operation_id in task_payload so delivery is marked completed
The task_payload JSON was missing the operation_id field, causing execute_task
to see operation_id=None and skip _mark_operation_completed — leaving every
delivery row stuck in 'pending' forever.
Added a test that inserts a real async_operations row and verifies the status
transitions to 'completed' after a successful execute_task call.
* style: fix prettier formatting in webhooks-view
* Add LiteLLM persistent memory blog post
* doc: add blog image for LiteLLM post
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* doc: update cookbook
* fix(cookbook): preserve tag keys during sync, strip local .md links
- Fix extract_tags_from_readme/notebook to return dict[str,str] preserving
sdk/topic keys instead of bare values, preventing topics like
"Customer Service" from being misclassified as SDK
- Add strip_local_md_links() to remove relative .md references that
would cause broken link errors in Docusaurus build
* ci: run test-doc-examples independently without waiting for test-rust-cli
Build the CLI directly in the job instead of downloading the artifact,
so test-doc-examples can start at the beginning in parallel with all other jobs.
* refactor: replace set_gemini_safety_settings() with LLMProvider.with_config()
Removes the fragile ContextVar-setter pattern where callers had to remember
to call set_gemini_safety_settings() at every operation entry point.
Instead, LLMProvider.with_config(resolved_config) returns a
ConfiguredLLMProvider wrapper that:
- injects per-bank settings (Gemini safety settings) on every call via
token-based ContextVar set/reset — properly scoped, no leakage
- proxies all attribute access to the underlying provider via __getattr__
- requires zero changes to LLMInterface or any provider implementations
Call sites (retain, reflect, consolidation) now pass
llm_config.with_config(resolved_config) to sub-components instead of
setting a global context var and hoping nothing else runs in between.
This pattern also composes naturally with a future per-bank provider
factory: callers always receive something with a .call() method.
* fix: pass messages/tools as kwargs in ConfiguredLLMProvider to preserve class-level patch compatibility
* fix(ts-sdk): send null instead of undefined when includeEntities is false
When `includeEntities: false` was passed, the client serialized `entities`
as `undefined`, which is stripped from JSON. The API then applied its
default (`EntityIncludeOptions()` — enabled), silently ignoring the flag.
Fix: send `null` explicitly when `includeEntities === false` so the API
correctly interprets it as "disable entities".
chunks and source_facts are unaffected since their API defaults are null
(disabled), so omitting them from JSON produces the correct behaviour.
Also adds integration tests covering all three states of includeEntities.
* fix(ts-sdk): use toBeFalsy for null entity check in test
Replace the multi-round-trip while-loop in step 5.5 of recall_async with a
single WHERE chunk_id = ANY($1) query covering all candidate chunk IDs.
Token-budget accounting happens in Python after the single fetch.
Measured on a 97K-unit / 98M-link bank (budget=HIGH, include_chunks,
include_entities):
p50: 1.209s → 0.611s (−49%)
mean: 1.534s → 0.772s (−50%)
p95: 3.366s → 2.316s (−31%)
Also update recall_perf.py benchmark to use Budget.HIGH, include_chunks,
include_entities, and a realistic mixed fact_type distribution.
Adds per-bank configurable safety settings for Gemini/Vertex AI:
- New `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` env var (JSON array)
- Hierarchical config field so banks can override via Config API
- ContextVar pattern for zero-signature-change per-request override
- All 6 thresholds supported: UNSPECIFIED, OFF, BLOCK_NONE, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH
- UI: Models > Gemini/Vertex AI section with per-category threshold selectors and link to Google docs
- Graceful handling when bank_config_api feature is disabled
- 12 new tests covering config parsing, GeminiLLM behaviour, and context var override
Replace ~73 console.log calls with a debug() helper that is silent by default.
Debug output is now controlled via plugin config param (debug: true) instead of
environment variables, making it easier for users to configure.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add OAuth extension hooks for MCP authentication
Add extension points in core that allow cloud extensions to support
OAuth 2.1 (RFC 9728 / RFC 7591) for MCP server authentication:
- HttpExtension.get_root_router() for well-known endpoint mounting
- AuthenticationError.headers for WWW-Authenticate propagation
- MCP middleware forwards auth error headers to clients
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: document get_root_router and AuthenticationError.headers
Add documentation for the new extension points introduced in the
OAuth extension hooks commit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove OAuth-specific wording from extension docs
Make the AuthenticationError headers example generic instead of
OAuth-specific, since these are general-purpose extension hooks.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add Pydantic AI integration to CI, release pipeline, and docs
- Add test-pydantic-ai-integration job to CI (test.yml)
- Add build, publish, and artifact steps to release workflow (release.yml)
- Add hindsight-integrations/pydantic-ai to release.sh version bumping
- Add Pydantic AI documentation page (sdks/integrations/pydantic-ai.md)
- Add Pydantic AI entry to sidebar with icon
* docs: remove Requirements section from pydantic-ai integration page
* feat: add tags filtering and fix offset pagination docs for list documents API
- Add `tags` and `tags_match` query params to GET /banks/{bank_id}/documents
- Supports any, all, any_strict, all_strict matching modes (default: any_strict)
- Fix `q` param description — it's a case-insensitive substring match on document ID only
- Add tests for offset pagination and all tags_match modes
- Regenerate OpenAPI spec and Python/TypeScript/Go clients
- Document the new filtering options in docs/developer/api/documents.mdx
* fix(cli): pass new tags/tags_match args to list_documents
* feat: add Pydantic AI integration to CI, release pipeline, and docs
- Add test-pydantic-ai-integration job to CI (test.yml)
- Add build, publish, and artifact steps to release workflow (release.yml)
- Add hindsight-integrations/pydantic-ai to release.sh version bumping
- Add Pydantic AI documentation page (sdks/integrations/pydantic-ai.md)
- Add Pydantic AI entry to sidebar with icon
* docs: remove Requirements section from pydantic-ai integration page
* feat: add Pydantic AI integration for persistent agent memory
Adds hindsight-pydantic-ai package providing Hindsight-backed memory
tools for Pydantic AI agents. Since Pydantic AI is async-native, tools
use the hindsight-client async API directly (no thread-pool compat layer).
- create_hindsight_tools(): factory returning retain/recall/reflect Tool instances
- memory_instructions(): auto-injects relevant memories via Agent instructions
- Global configure()/get_config()/reset_config() following existing integration pattern
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* doc: add README for Pydantic AI integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* docs: move entity labels detail to memory-banks, simplify retain overview
* docs: move entity labels blurb under entity-recognition section in retain
* docs: update metadata filtering FAQ to cover entity graph retrieval and entity labels tag option
* docs: enable TOC and fix missing separators in FAQ
* docs: add benchmarks leaderboard screenshot and link to models page
* docs: add 'Which model should I use?' FAQ entry with leaderboard screenshot
* docs: fix leaderboard description to cover retain, reflect, and observations
* feat: entity labels
* feat: entity labels — optional, free_values, multi_value, UI polish
Completes the entity labels system:
**Schema & extraction**
- Dynamic Pydantic Labels model per fact: each group becomes a typed
field (Literal | None, list[Literal], str | None, or list[str])
- `optional: bool` flag per group — non-optional enum fields appear in
JSON schema required array so structured-output providers enforce them
- `free_values: bool` flag per group — accepts any LLM-generated string
instead of a predefined enum; example values shown as hints in prompt
- New `is_label_entity()` helper for labels-only mode filtering that
handles both enum lookup and free_values key-prefix matching
- Sentinel rejection: "None"/"null"/"n/a" strings dropped in post-processing
**BM25 / dense retrieval**
- `text_signals` column on memory_units: entity names + date tokens for
enriched BM25 indexing without polluting stored fact text
- Dense embedding includes occurred_end when it differs from occurred_start
- Alembic migration z1u2v3w4x5y6 (merge revision fixing two heads)
**UI (bank-config-view)**
- Shadcn Switch replaces custom Toggle for both entity-labels and observations
- Shadcn Checkbox for multi/optional/free_values per group
- Input heights bumped to h-8 throughout the editor
- "Label Groups" → "Entity Labels", "Free-form entities" → "Entities"
- Free-text groups show "Example hints" banner in values section
**Tests (45 unit + 3 LLM integration)**
- build_labels_model: single, multi, mixed, free_values optional/required/multi
- is_label_entity: enum match, free_values prefix match, no false positives
- Post-processing: null/absent/string-None/free_values/sentinels/multi-value
- Schema: labels in required, structured object, no labels when unconfigured
- LLM integration: single-value enum, multi-value enum, free_values retain
**Docs**
- retain.md: new Entity Labels section covering groups, flags, examples
- configuration.md: retain_free_form_entities env var + entity_labels note
* fix(tests): update hierarchical fields count for entity_labels additions
entity_labels and retain_free_form_entities are hierarchical fields,
bumping the expected count from 11 to 13.
* fix(migration): rename text_signals revision to avoid collision with main
Main branch claimed z1u2v3w4x5y6 for observation_scopes. Rename our
text_signals migration to a2b3c4d5e6f7, chaining after z1u2v3w4x5y6.
* refactor(entity-labels): simplify free_values — always str|None, no multi
- free_values groups always produce str | None (multi_value and optional
flags are ignored for free text groups — always optional, never multi)
- Prompt section for free_values groups shows only key + description,
no values list (users put examples in the description instead)
- UI: section title "Entities", toggle "Free Form Entities", replace
per-group checkboxes with a type dropdown (Enum / Free text); only
show multi checkbox and values list when type is Enum
- Update tests to reflect new behaviour
* refactor(entity-labels): replace free_values/multi_value booleans with type field
- LabelGroup now uses type: "value" | "multi-values" | "text" instead of
free_values/multi_value boolean pair
- Backward-compat migration converts legacy dicts automatically
- Rename retain_free_form_entities → entities_allow_free_form throughout
- Update UI dropdown to show Single value / Multi-values / Free text
- Remove separate multi checkbox (captured by type selection)
- Update docs examples and configuration.md
- Update all tests to use new field names
* fix(migration): backfill observation_scopes column for DBs with swapped z1u2v3w4x5y6
Local DBs that had z1u2v3w4x5y6 applied when it referred to the old
text_signals migration (before it was renamed to a2b3c4d5e6f7) won't have
observation_scopes in their memory_units table. This migration adds the
column with IF NOT EXISTS so it's a no-op on clean installs.
* feat(entity-labels): add tag field to auto-populate memory unit tags from labels
When a LabelGroup has tag=True, extracted key:value entities for that group
are automatically written to the memory unit's tags array. This lets entity
labels double as tags, enabling immediate filtering via the existing
tags/tags_match API params with no extra infrastructure.
- Add tag: bool = False to LabelGroup
- _inject_label_tags() helper called in both sync and batch extraction paths
- UI: add Tag checkbox per label group row
- Docs: document the new tag field
- Tests: 4 new unit tests covering all tag injection paths
* style: ruff format migration file
* fix(migration): fix multiple alembic heads after rebase — point text_signals after nullable_event_date
* fix(clients): update timestamp field to use Timestamp wrapper type after timestamp=unset feature
* style: ruff format agent.py
* fix(docs): update Go quickstart example to use NullableTimestamp for timestamp field
* feat: support timestamp="unset" to retain content without a date
When callers retain timeless content (e.g. fictional documents, static
reference material), passing timestamp="unset" now skips the utcnow()
default so mentioned_at is stored as NULL instead of an artificial date.
- HTTP: validate_timestamp recognises "unset" sentinel and threads it
through api_retain as event_date=None (key present, value None), which
the orchestrator distinguishes from key-absent (still defaults to now)
- Orchestrator: new branching logic separates "key absent" → utcnow()
from "key present but None" → no date
- types.py: RetainContent.event_date and ProcessedFact.mentioned_at are
now datetime | None; removed the unused _now_utc factory
- fact_extraction.py: all event_date params accept datetime | None;
_build_user_message emits "Event Date: Unknown" when None; removed
mentioned_at from the Fact LLM response model (LLM never sets it)
- embedding_processing: skip date suffix when fact_date is None
- entity_resolver: COALESCE(event_date, now()) for first_seen/last_seen
so entities table NOT NULL constraint is preserved
- link_utils: skip temporal linking for units without event_date
- Migration aa2b3c4d5e6f: DROP NOT NULL on memory_units.event_date
- Tests: test_retain_no_timestamp and test_retain_omit_timestamp_defaults_to_now
- Docs + OpenAPI + TypeScript client updated
* refactor: replace _TIMESTAMP_UNKNOWN sentinel with plain string comparison
The sentinel object() was only needed to distinguish "unset" from None
at the boundary — but since the field type is datetime | str | None,
"unset" can pass through the validator unchanged and be compared directly.
* chore: regenerate OpenAPI spec and clients after timestamp type change
timestamp field is now datetime | str | None to accept the "unset" sentinel value.
* fix(reflect): prevent context_length_exceeded on large memory banks (#457)
The reflect agent's agentic loop accumulated tool-call messages across
iterations with no upper bound on token count, causing
context_length_exceeded errors on banks with 19K+ nodes.
Changes:
- Add proactive token-budget guard: before each call_with_tools, count
accumulated message tokens via tiktoken; if >= max_context_tokens and
evidence has been gathered, immediately synthesize from what was found
- Detect context-overflow errors specifically (_is_context_overflow_error)
and skip the retry path — retrying after overflow only makes it worse
- Truncate context_history in build_final_prompt to a 60K-token budget
so the fallback synthesis prompt itself cannot overflow
- Add HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS config (default 100000)
wired through config.py → main.py → memory_engine → run_reflect_agent
- Tests: unit tests for helpers + mock-LLM behavior tests + an
end-to-end integration test using a real LLM with max_context_tokens=1
* fix(reflect): derive final prompt context budget from max_context_tokens
Replace the hardcoded _FINAL_PROMPT_CONTEXT_BUDGET (60K tokens) with
a fraction of max_context_tokens (80%), so the fallback synthesis prompt
automatically scales with whatever context window is configured.
* fix: resolve consolidation deadlock caused by zombie 'processing' tasks on retry
When a task failed and was rescheduled for retry, submit_task() only updated
task_payload without resetting status/worker_id/claimed_at. The task stayed
permanently in 'processing', blocking all future consolidation for that bank
via the NOT EXISTS guard in claim_batch().
Fix: remove the duplicate payload-based retry mechanism from execute_task().
Retryable failures now re-raise so the poller handles them via _retry_or_fail(),
which already correctly resets status='pending', worker_id=NULL, claimed_at=NULL
and uses the DB retry_count column as single source of truth.
Non-retryable tasks (file_convert_retain) continue to mark themselves failed
and return normally — no exception reaches the poller.
Tests: add regression tests for the retry path (status reset to pending) and
the max-retries exhaustion path (status set to failed).
* ci: re-trigger CI
* fix: zeroentropy rerank URL missing /v1 prefix and MCP routing tests
- Fix ZeroEntropy reranker URL: /models/rerank -> /v1/models/rerank (#453)
- Fix test_mcp_routing tests: update assertions to use submit_async_retain
instead of the non-existent async_processing=False/retain_batch_async pattern
* fix(openclaw): pass retainEveryNTurns through getPluginConfig and set it to 1 in tests
getPluginConfig was not forwarding retainEveryNTurns from the raw config,
so pluginConfig.retainEveryNTurns was always undefined (defaulting to 10).
The integration tests use retainEveryNTurns: 1 so retain fires every turn.
- Replace json.dumps(result) with result.model_dump_json() for Pydantic models to fix TypeError during consolidation
- Wrap record_llm_call tracing block in try/except so logging failures never propagate to retry handler
- Fix test_llm_provider.py to use _get_raw_config() for bank-configurable enable_observations field
* feat: add bank-scoped validation to engine methods and HTTP handlers
Add validate_bank_read/validate_bank_write hooks to all bank-scoped
engine methods so the operation validator can enforce per-bank API key
restrictions. Add OperationValidationError handling to HTTP handlers
and MCP tools to return proper 403 responses. Add allowed_bank_ids
field to RequestContext.
* Add OperationValidationError handling to mental model GET and DELETE endpoints
* feat: observation_scopes field to drive observations granularity
* fix(migration): make a2b3c4d5e6f7 a no-op to fix CI on fresh DB
The z1u2v3w4x5y6 migration already creates observation_scopes directly,
so the rename migration fails on fresh installs where observation_tags
never existed.
* chore: remove no-op migration a2b3c4d5e6f7
* feat: regenerate clients with observation_scopes field
- Add observation_scopes to OpenAPI spec and all generated clients
- Fix Rust build.rs to handle anyOf with >2 variants containing null
(previously only handled 2-item anyOf, causing progenitor to panic
on the observation_scopes union type)
* fix(rust): add observation_scopes: None to MemoryItem struct literals
* fix(api): add title to observation_scopes Field for deterministic client generation
Adding title="ObservationScopes" makes the inline anyOf schema use
the explicit name instead of deriving it from the field name, which
was non-deterministic between arm64 (macOS) and amd64 (CI) Docker.
Also fixes description: "each entity" -> "each tag".
* fix(scripts): use linux/amd64 Docker for client generation to ensure reproducibility
Both Python and Go client generation now use --platform linux/amd64
Docker, ensuring identical output on macOS arm64 (local) and Linux
amd64 (CI). Also switches Go from JAR+Java to Docker to eliminate
Java version variability.
* chore: update generated clients to API v0.4.14
* fix(test): add retry logic to test_retain_chinese_content to handle non-deterministic LLM output
* fix(test): mark test_retain_chinese_content as xfail due to non-deterministic LLM translation
Adds @vectorize-io/hindsight-chat, a wrapper for the Vercel Chat SDK
that gives any chat bot (Slack, Discord, Teams, etc.) long-term memory
via Hindsight. Includes withHindsightChat() handler wrapper with
auto-recall, auto-retain, and memoriesAsSystemPrompt() formatting.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Instead of silently skipping HNSW index creation for embeddings > 2000
dimensions, raise a RuntimeError with an actionable message suggesting
pgvectorscale/DiskANN as an alternative.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
PostgreSQLFileStorage was initialized once at startup with a static
schema value. Since get_current_schema() returns the default schema at
init time, multi-tenant requests always queried the wrong schema,
causing "relation file_storage does not exist" errors.
Replace static schema with schema_getter callable (same pattern used
by BrokerTaskBackend since #208) so the schema is resolved dynamically
per-request via contextvars.
The datetime.strptime() call can only raise ValueError on format
mismatch. Bare except catches KeyboardInterrupt and SystemExit,
which masks real errors.
Co-authored-by: haosenwang1018 <haosenwang1018@users.noreply.github.com>