Compare commits

...
Author SHA1 Message Date
Ben 73bbe5f736 Merge branch 'main' into docs-chat-cloud-first 2026-05-22 09:53:48 -04:00
Ben 806fbcd41c docs(nemoclaw): add Cloud API URL to quickstart and config default (#1700)
* docs(nemoclaw): prioritize Hindsight Cloud with callout banners

* feat(nemoclaw): default --api-url to Hindsight Cloud, make it optional
2026-05-22 09:52:58 -04:00
Ben a75c3c85ad docs(paperclip): add Cloud API URL to quickstart and config default (#1699)
* docs(paperclip): prioritize Hindsight Cloud in setup docs and config default

* style(paperclip): align table columns after linter reformat
2026-05-22 09:37:02 -04:00
Ben 0db9f3da19 docs(dify): add Cloud Recommended callout (#1698)
dify already led with Cloud signup — adds the explicit  Recommended
banner for visual consistency.
2026-05-22 09:36:15 -04:00
Ben 8940710c72 docs(n8n): add Cloud Recommended callout (#1697)
n8n already led with Cloud signup — adds the explicit  Recommended
banner to README and docs page Setup sections for visual consistency
with the other cloud-first integrations.
2026-05-22 09:35:29 -04:00
Ben 6252643de0 docs(agno): prioritize Hindsight Cloud in quickstart (#1696)
Lead README + docs Quick Start with Cloud sign-up + Cloud API URL.
Bulk-replace localhost:8888 examples with Cloud URL. Demote
self-hosted to a 'Self-hosting (local development)' section below.
Update docstring examples in __init__.py and tools.py.
2026-05-22 09:33:44 -04:00
Ben 3fce309c0d docs(agentcore): add Cloud Recommended callout in quickstart (#1694)
Adds  Recommended Hindsight Cloud callout to README + docs + guide
Quick Start sections. agentcore already led with Cloud URL in code
examples — this just makes the recommendation explicit.
2026-05-22 09:32:33 -04:00
Ben 3fc361aabd docs(codex): prioritize Hindsight Cloud over local daemon (#1693)
Add Cloud Recommended callouts to README + docs + guide. Reframe the
'Local Daemon' section as the self-hosting alternative rather than a
peer option. No code default changes — codex still defaults to empty
hindsightApiUrl (local daemon) to avoid breaking existing local users.
2026-05-22 09:31:43 -04:00
Ben f559ae1649 docs(smolagents): prioritize Hindsight Cloud in quickstart (#1692)
Lead README/docs/guide Quick Start with Cloud sign-up + Cloud API
URL example; demote self-hosted localhost:8888 to a 'Self-hosting
(local development)' section below. Update docstring example.
2026-05-22 09:17:39 -04:00
Ben 8ed9a4ebb2 docs(pydantic-ai): prioritize Hindsight Cloud in quickstart (#1691)
Lead README/docs/guide Quick Start with Cloud sign-up + Cloud
base_url example; demote self-hosted localhost:8888 to a
'Self-hosting (local development)' section below. Update docstring
example in __init__.py.
2026-05-22 09:17:07 -04:00
Ben 722aa902fb Regenerate hindsight-docs skill references (#1686)
Adds opencode-go to the integration lists in the generated skill
references. Picked up by the generate-docs-skill.sh pre-commit hook
as drift from the hindsight-docs sources on main.
2026-05-22 09:16:27 -04:00
Ben 5c7e783e18 docs(pipecat): prioritize Hindsight Cloud in quickstart (#1695)
Lead README/docs/guide Quick Start with Cloud, demote self-hosted to
its own section. Updates configure() global example to Cloud default.
2026-05-22 09:15:48 -04:00
Ben e779f10fc7 docs(strands): prioritize Hindsight Cloud in quickstart (#1690)
Lead README/docs/guide Quick Start with Hindsight Cloud sign-up and
Cloud API URL example; demote self-hosted localhost:8888 to a
'Self-hosting (local development)' section below. Update docstring
example in __init__.py to show Cloud-first usage.

Includes 2-line incidental skills/hindsight-docs/ regeneration drift.
2026-05-22 09:15:24 -04:00
Ben 1b2c9f63aa docs(crewai): prioritize Hindsight Cloud in quickstart (#1689)
- Lead README/docs/guide Quick Start with Hindsight Cloud sign-up
  and the Cloud API URL example; demote self-hosted localhost:8888
  to a "Self-hosting (local development)" section below.
- Fix unconfigured-fallback inconsistency in HindsightStorage and
  HindsightReflectTool: previously fell back to localhost:8888
  even though the documented default is Cloud. Now both fallbacks
  use DEFAULT_HINDSIGHT_API_URL.
- Update docstring examples in __init__.py and storage.py to reflect
  the Cloud-first default.
- Update fallback assertion in tests/test_storage.py.
2026-05-22 09:14:56 -04:00
Ben 6bdb1a08f5 docs(chat): add Hindsight Cloud setup callout to README and docs 2026-05-21 14:36:48 -04:00
Ben 7ffe6a104b style: apply ruff format to openai_compatible_llm.py (#1703) 2026-05-21 14:36:17 -04:00
Ben 113d7da987 Blog: Agent Memory Consolidation framework (#1672)
* Add blog post: Agent Memory Consolidation framework
2026-05-21 10:42:41 -04:00
Chandler bd86e7ead0 fix(typescript-client): update repository URL to correct repo (#1657) 2026-05-19 16:51:46 -04:00
Ben 795c081d9f fix(api): auto-refresh openai-codex OAuth access_token (#1637) (#1661)
The openai-codex provider was a startup-only credential loader: it read
~/.codex/auth.json once at __init__ and used the cached access_token
forever. ChatGPT OAuth tokens are short-lived (hours), so any
long-running deployment 401d on every request once the cached token
expired. The only recovery was an external cron + container restart.

This change makes the provider refresh tokens itself, mirroring the
canonical @openai/codex CLI (codex-rs/login/src/auth/manager.rs):

- Loads tokens.refresh_token from auth.json (previously discarded).
- Proactive refresh: decodes the access_token JWT's exp claim and
  refreshes ~60s before expiry. Cheap when the token is fresh.
- Reactive refresh: on a 401/403 from the codex backend, refreshes
  once and retries the request without consuming a normal-retry budget
  slot.
- Single-flight: serializes through asyncio.Lock so concurrent callers
  produce one network refresh, not N. Re-checks under the lock by
  comparing the cached token before/after wait to handle the case
  where another coroutine rotated mid-wait.
- Atomic persistence: writes auth.json via tempfile + os.replace with
  mode 0600. The upstream Rust CLI uses truncate-and-overwrite, which
  a concurrent reader can catch mid-write; tempfile+rename is strictly
  safer.
- Terminal error handling: refresh_token_expired/reused/invalidated
  (and any 401 from the refresh endpoint) raise CodexRefreshExpiredError
  with a clear "run codex auth login" remediation, and do not loop.
- No secrets in logs: refresh logs the reason and outcome but not the
  token values themselves.

OAuth request shape (POST https://auth.openai.com/oauth/token, JSON
body with hardcoded client_id app_EMoamEEZ73f0CkXaXp7hrann,
grant_type=refresh_token) matches the upstream Rust CLI exactly. The
endpoint is overridable via the CODEX_REFRESH_TOKEN_URL_OVERRIDE env
var the same way the upstream CLI supports it.

Tests: 23 new in test_codex_oauth_refresh.py covering JWT exp decode,
staleness with skew, refresh_token loading, atomic persistence with
0600 mode, request shape, in-memory + on-disk update, refresh_token
rotation, terminal-error classification, network error wrapping,
no-secrets-in-logs, single-flight under 10 concurrent callers,
proactive refresh before request, reactive 401-then-retry, and the
no-refresh-when-fresh case. Existing test_codex_tool_choice.py still
passes.

Caveat: all tests are mocked. The OAuth request shape has not been
verified against the real auth.openai.com endpoint - it is grounded
in the upstream codex-rs source on github.com/openai/codex.
Reviewers with a ChatGPT Plus subscription should validate the
end-to-end path before merge.
2026-05-19 16:49:03 -04:00
Minghao Xiao 9c161e4e59 fix(api): preserve tag group or triggers (#1655) 2026-05-19 16:35:01 -04:00
Minghao Xiao 9643e66e77 fix(api): lazy load reflect tiktoken encoding (#1654) 2026-05-19 16:34:24 -04:00
Teven Feng c29c76e3fa feat: add opencode-go LLM provider (#1652) 2026-05-19 16:34:11 -04:00
Minghao Xiao c16d9978e8 fix(api): strip Gemma thought tags (#1653) 2026-05-19 16:33:59 -04:00
Ben 943dfee624 docs: add HINDSIGHT_API_WORKER_ID tip to API quickstart (#1617)
* docs: add HINDSIGHT_API_WORKER_ID tip to API quickstart

Mirrors the tip already present in installation.md so users who follow
the API quickstart's Docker tab see the same guidance about pinning a
stable worker ID. Closes #1616.

* docs: mirror WORKER_ID tip to versioned_docs v0.6 (from #1648)

Folding in xmh1011's strict-improvement hunk from #1648: the
versioned snapshot for v0.6 should carry the same production tip
as the live doc. Same prose, same `:::tip` block. Includes the
auto-regenerated skills/ reference.
2026-05-19 16:28:17 -04:00
Ben ab0caa658e docs(changelog): correct openai-agents v0.1.1 entry (#1639)
Replaces the auto-generated entry, which credited #1123 (a core-engine
consolidation config, not openai-agents-specific) to the v0.1.1 release.
The actual openai-agents-specific work in v0.1.1 was #1134 by @DK09876:
docs/test polish — corrected SDK version requirement, added
memory_instructions() to README and API reference, added Production
Patterns section, and added test_config.py.
2026-05-19 16:27:56 -04:00
Chandler 2cb65e09e3 feat(typescript-client): replace Promise<any> with concrete generated types (#1640) 2026-05-19 16:27:14 -04:00
Ben d1903f3c9f blog: What's New in Hindsight Cloud (#1636)
* blog: What's New in Hindsight Cloud — Going Global
2026-05-19 10:50:42 -04:00
Ben 9784f6573a release(openclaw): v0.7.7 2026-05-15 15:11:52 -04:00
Ben f02e037bc7 release(openai-agents): v0.1.1 2026-05-15 14:50:39 -04:00
Ben 87734b3a44 release(litellm): v0.5.3 2026-05-15 14:46:54 -04:00
Ben f613f005a6 release(strands): v0.1.3 2026-05-15 14:22:37 -04:00
Ben 6a5e2d1800 release(claude-code): v0.6.5 2026-05-15 14:21:30 -04:00
Ben 6d495290bc release(paperclip): v0.2.2 2026-05-15 14:12:33 -04:00
Ben f94e840fe8 docs: attribute 0.6.2 release post to benfrank241 (#1635) 2026-05-14 17:07:12 -04:00
Ben 25052a56d0 docs: add 0.6.2 changelog and release blog post (#1633)
Documents the security/maintenance release: dependency CVE bumps,
mental_models.subtype migration repair, embedding-dimension OID
handling, and integration fixes for Claude Code, Agent SDK, CLI,
and Paperclip.
2026-05-14 16:55:48 -04:00
Ben 8b10231b8b Release v0.6.2
- Update version to 0.6.2 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.6
2026-05-14 16:00:16 -04:00
Ben 5a7996a649 blog: onboarding a new engineer onto five months of OpenCode memory (#1628)
* blog: add OpenCode onboarding use-case post
2026-05-14 14:57:37 -04:00
dependabot[bot] 059d1c3e94 chore(deps): bump the uv group across 3 directories with 8 updates (#1630) 2026-05-14 10:57:24 -04:00
Derek Bouius b20c0d8f67 fix(ci): set UV_FROZEN=1 on verify-generated-files job (#1629)
Set UV_FROZEN=1 as a job-level env var so all uv commands (sync, run,
lock) respect the committed lockfile without re-resolving. This is the
idiomatic uv approach for CI and prevents spurious uv.lock diffs that
blocked every Dependabot PR.

Reverts the lint.sh CI-specific --frozen logic from #1618 since the
env var covers it globally.
2026-05-14 10:24:47 -04:00
Ben debbd91961 fix(migrations): repair mental_models.subtype at current head (#1553) (#1627)
Three production deployments (issue #1553, plus confirmations from
@4Lienau and @khanhduyvt0101) report `column "subtype" of relation
"mental_models" does not exist` on `create_mental_model`, despite their
alembic_version showing the current head `m3rg3h3ad5f6`.

Both h3c4d5e6f7g8_mental_models_v4 (which uses `CREATE TABLE IF NOT EXISTS`
and is a no-op on databases that came through the reflections rename) and
d5y6z7a8b9c0_backfill_mental_models_subtype were meant to ensure the
column exists, but on these specific deployments neither fired
successfully — likely a casualty of the divergent-heads reorganization
that put d5y6z7a8b9c0 on a branch the affected DBs bypassed.

Add a new migration at the current head so every stuck deployment picks
it up on next container start. Idempotent (`ADD COLUMN IF NOT EXISTS`),
guarded by an existence check on the table, and matches the canonical v4
column set and CHECK allowlist from d5y6z7a8b9c0.

PG-only: Oracle's baseline creates mental_models with a different
topology and constraint shape, so this repair does not apply there.
2026-05-14 10:14:48 -04:00
Evo ed35894f55 docs(cli): document --timestamp flag on memory retain (#1622) (#1623)
* docs(cli): document new --timestamp flag on memory retain (#1622)

* docs(cli): mirror --timestamp flag in skills CLI reference
2026-05-14 09:32:09 -04:00
Evo 190c31f543 docs(claude-code): document requestTimeoutSeconds option from #1591 (#1626) 2026-05-14 09:31:11 -04:00
Chris Latimer 5a2c138779 Updated benchmark scores 2026-05-14 05:30:57 -06:00
Ben 51ea9aa286 fix(cli, control-plane): make retain Event Date / timestamp actually reach the API (#1622)
* fix(cli, control-plane): make Event Date / timestamp actually reach the API

- CLI `hindsight memory retain` now accepts `-t/--timestamp <ISO>`. The
  internal MemoryItem.timestamp was hardcoded to None, so retains from the
  CLI lost any caller-supplied event date even though the Python/Node/Go
  SDKs accept one. Add a flag and pass it through; regression test asserts
  --help advertises the option.
- Control plane "Event Date" inputs in the new-document and per-file flows
  used `<input type="datetime-local">`, which only commits a value when the
  user enters both date AND time. Typing a date alone silently left the
  value empty, so `item.timestamp` was never sent and the resulting
  operation payload had no event_date. Switch to `type="date"` and pad
  with `T00:00:00` before sending, so date-only entries reach the API as
  valid ISO datetimes.

* fix(cli): decode --timestamp into MemoryItemTimestamp enum

MemoryItem.timestamp is generated as Option<MemoryItemTimestamp>
(progenitor's anyOf wrapper), not Option<String>. Round-trip the
flag value through serde_json so the right variant is selected for
both ISO datetimes and the 'unset' sentinel. Fixes CI build break.
2026-05-13 16:51:21 -04:00
Ben f9fbfe55c2 fix(docs): use real GitHub handle for ContextForge integration author (#1621)
The `by` field was set to `omarouldali`, which is not a real GitHub user
(github.com/omarouldali returns 404). As a result the avatar request to
`github.com/omarouldali.png?size=40` failed and the integrations hub card
showed a broken-image placeholder next to the author name. The actual
GitHub handle of the contributor (author of PRs #961 and #1254) is
`ooa-andera`, which resolves cleanly.
2026-05-13 15:50:19 -04:00
Derek Bouius 5c7aea4717 fix(ci): use frozen lockfile in lint.sh during CI (#1618)
lint.sh runs `uv sync` without --frozen at the repo root, which
re-resolves uv.lock. In CI's verify-generated-files job this causes
spurious 1-line diffs on every Dependabot PR, blocking them from
merging.

Use --frozen when $CI is set so the lockfile is never modified by
the lint step. Local development keeps the non-frozen sync to handle
version bumps gracefully.
2026-05-13 13:58:48 -04:00
Derek Bouius 9dfbfb4bd0 fix: handle transient OID errors in embedding dimension migration (#1612)
The DO $$ block that drops vector indexes iterates pg_indexes via a
cursor. When concurrent pytest-xdist workers drop schemas (CASCADE),
the OID references in the cursor become stale, causing
'could not open relation with OID' errors.

Fix the root cause in migrations.py by adding EXCEPTION WHEN
internal_error handling to the PL/pgSQL DO block. Also add
defense-in-depth retry logic to the two test cases that previously
called ensure_embedding_dimension() without the retry wrapper.
2026-05-13 13:05:51 -04:00
Evo b593d40fdf fix(agent-sdk): agent_knowledge_get_page request detail=content (sister of #1543) (#1557)
* fix(agent-sdk): agent_knowledge_get_page request detail=content (sister of #1543)

* fix(agent-sdk): flatten throw to single line for prettier (printWidth 100)
2026-05-13 10:16:36 -04:00
Rogerio Saulo 55ef70679c feat(claude-code): expose configurable MCP request timeout (#1591)
Adds requestTimeoutSeconds (env: HINDSIGHT_REQUEST_TIMEOUT_SECONDS) to
the claude-code plugin config. When set, overrides the hardcoded per-call
HTTP timeouts (10s recall, 15s retain, 10-15s in knowledge MCP tools).
When unset (default), per-call defaults are preserved — fully backward
compatible.

The health check timeout (5s) is intentionally left alone, since bumping
it would degrade UX when the server is genuinely unreachable.

Fixes #1575
2026-05-13 10:08:22 -04:00
Derek Bouius fd05bdab51 security: bump litellm to >=1.83.14 in root lockfile (#1610)
Fixes 4 remaining Dependabot alerts (1 critical, 3 high) for litellm
vulnerabilities including GHSA-pq44-5pcq-4r5g and GHSA-8cjq-wjmh-q42r
that were missed in the #1609 squash merge.
2026-05-13 08:54:53 -04:00
Derek Bouius a6cd28a3b5 security: bump remaining high/critical deps across all lockfiles (#1609)
* security: bump remaining high/critical deps across all lockfiles

Root uv.lock:
- GitPython 3.1.45 → 3.1.50 (HIGH: multiple traversal/RCE fixes)
- langchain-core 1.2.23 → 1.4.0 (HIGH: path traversal)
- lxml 6.0.2 → 6.1.0 (HIGH)
- Mako 1.3.10 → 1.3.12 (HIGH)
- pillow 12.1.1 → 12.2.0 (HIGH: OOB write)
- python-multipart 0.0.22 → 0.0.28 (HIGH: arbitrary file write)
- litellm 1.83.0 → 1.83.14 (CRITICAL: multiple CVEs)

Integration lockfiles (ag2, agentcore, agno, autogen, crewai, dify,
langgraph, litellm, llamaindex, openai-agents, smolagents, strands,
pipecat, pydantic-ai, integration-tests):
- urllib3 2.6.3 → 2.7.0
- python-multipart, pillow, GitPython, langchain-core, banks, litellm
  bumped where present

Rust (hindsight-clients/rust):
- openssl 0.10.75 → 0.10.79 (HIGH: multiple CVEs)
- rustls-webpki 0.103.10 → 0.103.13 (HIGH)

crewai pinned to <1.10 — 1.10+ renamed Storage → StorageBackend;
migration tracked separately.

* fix: pin pipecat-ai <1.0 to avoid breaking module restructure

pipecat-ai 1.0+ restructured modules (removed
pipecat.processors.aggregators.openai_llm_context), breaking all tests.
Pin to <1.0 and track migration separately.
2026-05-13 07:40:10 -04:00
Derek Bouius 9533107612 security: bump urllib3 to 2.7.0 in integration lockfiles (#1603)
Fixes remaining Dependabot alerts for urllib3 decompression-bomb bypass
and sensitive header forwarding across 6 integration lockfiles:
strands, smolagents, pydantic-ai, pipecat, openai-agents, llamaindex.
2026-05-12 22:26:24 -04:00
Derek Bouius 26c5028c94 security: bump vulnerable dependencies across npm and pip (#1600) 2026-05-12 18:34:19 -04:00
Derek Bouius 9b8d8b5632 fix(ci): paperclip lint formatting + openclaw hook test expectations (#1601)
- paperclip: commit trailing whitespace and line-length fixes that the
  lint hook produces, fixing verify-generated-files on every PR
- openclaw: update agent_end hook tests to expect the system-role
  context message prepended by includeSenderContext (default: true)
2026-05-12 16:55:17 -04:00
Evo d9dd14995c fix(agent-sdk): rename agent_knowledge_recall max_results to max_tokens; bump default 10 to 1024 (#1552) 2026-05-12 16:03:08 -04:00
Evo 378097ba3d docs(paperclip): align integration guide + README with #1560 lifecycle (#1596)
* docs(paperclip): align integration guide with #1560 lifecycle (issue.comment.created retain)

* docs(paperclip): mirror integration README lifecycle after #1560
2026-05-12 15:23:15 -04:00
EvoandEvo 69703e5a31 docs(strands): document FastAPI lifecycle pattern from #1547 (#1581)
Co-authored-by: Evo <[email protected]>
2026-05-12 15:22:25 -04:00
Ben 771922cd70 docs: add Windows/China deployment guidance for embeddings config (#1549) 2026-05-12 15:22:03 -04:00
Ben 61730d5924 blog: the case against external vector DBs for agent memory (#1594)
* blog: add post on the case against external vector DBs for agent memory
2026-05-12 15:14:30 -04:00
Amir Moradi be908d5b2c fix(paperclip): align with Paperclip's actual event payloads (#1560)
* fix(paperclip): align with Paperclip's actual event payloads

The plugin's `agent.run.started` and `agent.run.finished` handlers
destructured fields (`issueTitle`, `issueDescription`, `output`, `result`)
that Paperclip's host does not publish. Paperclip emits a thin lifecycle
payload — `{runId, agentId, status, invocationSource, triggerDetail,
error, errorCode, issueId, startedAt, finishedAt}` — so both handlers
silently early-returned and the plugin never recalled or retained
anything despite registering successfully.

Changes:

- `agent.run.started` now uses `payload.issueId` to look up the issue
  via `ctx.issues.get` and builds the recall query from the issue's
  title + description.

- New `issue.comment.created` subscription replaces the
  `agent.run.finished` retain path. Comments are the durable record of
  agent + user output and the existing payload only carries a 120-char
  snippet, so we fetch the full body via `ctx.issues.listComments`.
  Bank attribution falls back to the issue's assignee when a comment
  has no agent author (e.g. user comments).

- `agent.run.finished` is kept as a debug no-op so the subscription
  stays visible and can be reused if Paperclip ever embeds output in
  the lifecycle payload.

- Manifest gains `issues.read` and `issue.comments.read` capabilities,
  required by the new SDK calls.

- Tests updated to seed issues/comments via the harness, exercise the
  new comment-created path, and cover the assignee-fallback for
  unauthored comments.

Verified end-to-end against a local Paperclip + self-hosted Hindsight:
the patched plugin retains real comment bodies to the correct bank
and Hindsight's recall API returns them on subsequent queries.

Related: vectorize-io/hindsight tracking issue (Paperclip ODIAA-84).

* Log skip retain due to missing agent attribution

Add logging for skipping retain when no agent attribution is available.

* Add test for skipping retain with no agent and assignee
2026-05-12 10:23:51 -04:00
Ben 2471f01107 blog: add category filter to blog landing page (#1580)
Replaces the Hindsight Cloud preview section with a pill-strip filter
(All / Hindsight Cloud / Deep Dives / Announcements & Releases /
Tutorials & Integrations) that filters the chronological grid by
canonical category tag via a ?cat=<slug> URL param.

Backfills the canonical category tag (release / tutorial / deep-dive)
onto the 49 existing posts that needed one. The hindsight-cloud tag is
already in use and stays unchanged.

Extends BlogTagsPostsPage with friendly titles for the new category
tags so /blog/tags/{release,tutorial,deep-dive} render like the
existing /blog/tags/hindsight-cloud page.

No existing post permalinks or tag-archive URLs change.
2026-05-11 16:07:13 -04:00
Ben 2bfd77477a fix(strands): close internally owned hindsight clients (#1547) 2026-05-11 15:40:25 -04:00
Nicolò Boschi 6aab6c89dc release(claude-code): v0.6.4 2026-05-08 16:54:53 +02:00
Offending Commit 909a4fd400 fix(claude-code-mcp): rename recall max_results→max_tokens (#1544)
The MCP tool exposed `max_results: int = 10` but piped that value
straight into the server's `max_tokens` budget. The server has no
`max_results` concept — recall returns whatever fits in the token
budget — so 10 tokens truncated every recall to an empty result set,
making the tool look like a connection failure even though the bank
contained thousands of nodes.

Rename the parameter to match server semantics and bump the default
to 1024 (same as `client.recall`'s default), so callers can request
deeper recalls by raising the budget honestly.
2026-05-08 16:54:02 +02:00
Ben 4d486b9262 blog: add cover image for How Hindsight Scales (#1545)
* blog: add cover image for How Hindsight Scales
2026-05-08 10:23:35 -04:00
Nicolò Boschi 43c4015afa docs: add 0.6.1 changelog and release blog post (#1542)
* docs: add 0.6.1 changelog and release blog post

* docs(blog): add bank dropdown memory stats section + screenshot
2026-05-08 16:13:43 +02:00
Chris Bartholomew b2a693ab7a fix(claude-code): get_page detail=content + handle tool-result spillover (#1543)
Two related changes addressing the same class of issue PR #1528 fixed
for list_pages — but on the get_page surface and on the agent prompt.

1. agent_knowledge_get_page now requests detail=content instead of
   detail=full. Measured on real banks, reflect_response is 70-95% of
   the response bytes; the actual `content` field is 1-2%. At realistic
   page sizes (200-280 KB at full) the response overflows the MCP host's
   per-tool-result token cap and spills to disk where the agent cannot
   consume it inline. Switching to detail=content drops every page to
   ~5 KB. Sample measurements:

     page                 total    content   reflect_response
     Pre-push gate        276 KB   2.8 KB    201 KB
     Local test stack     282 KB   4.0 KB    205 KB
     CI failure triage    266 KB   2.8 KB    194 KB

   The docstring promises "full synthesized content" — exactly what the
   `content` projection returns.

2. The create-agent SKILL template now tells the agent how to recover
   when get_page does spill (rare after this fix, but possible on
   genuinely large pages): Read the spill file, parse the JSON wrapper,
   or fall back to agent_knowledge_recall.

Adds a focused regression test pinning the content projection.
2026-05-08 16:12:54 +02:00
Nicolò Boschi 0dcd605751 Update 2026-05-08-how-hindsight-scales.md 2026-05-08 15:59:11 +02:00
Nicolò Boschi 838ddb0f30 blog: How Hindsight Scales (#1539)
* blog: add "How Hindsight Scales" technical deep dive

Covers performance, quality, and cost scaling across all 4 core
operations: retain, recall, consolidation, and reflect.

* blog: finalize "How Hindsight Scales" post + blog styling

Architecture-focused scaling analysis covering retain, recall,
consolidation, reflect, and mental models. Fact-checked against
codebase. Also switches blog body font to Space Grotesk and adds
colored underline treatment for bold text.
2026-05-08 15:48:14 +02:00
Nicolò Boschi 0f7c5e895b Release v0.6.1
- Update version to 0.6.1 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.6
2026-05-08 15:00:38 +02:00
Nicolò Boschi 98f33cbf6c feat(api): add litellmrouter provider for LLM fallback chains (#1537)
* feat(api): add litellmrouter provider for LLM fallback chains

Closes #1464.

New "litellmrouter" provider wraps LiteLLM Router with ordered fallback
across a configurable chain of deployments. On transient errors
(rate-limit, timeout, 5xx) the Router falls back to the next deployment
in declared order; auth errors (401/403) are not retried so a
misconfigured key cannot silently cascade through the chain.

Configuration is provider-scoped (one-word LITELLMROUTER namespace to
avoid clashing with the existing LITELLM_* settings used by the
embeddings/reranker layers):

  HINDSIGHT_API_LLM_PROVIDER=litellmrouter
  HINDSIGHT_API_LLM_LITELLMROUTER_CHAIN=<json list of deployments>

Per-operation chains are supported via the same pattern that already
exists for retain/reflect/consolidation:

  HINDSIGHT_API_RETAIN_LLM_LITELLMROUTER_CHAIN=...
  HINDSIGHT_API_REFLECT_LLM_LITELLMROUTER_CHAIN=...
  HINDSIGHT_API_CONSOLIDATION_LLM_LITELLMROUTER_CHAIN=...

Each per-op chain falls back to the default chain when unset, mirroring
the existing per-op provider/model overrides.

Chain entries are tagged as credential fields and are never exposed via
the bank-config API. Batch APIs are intentionally unsupported in router
mode; users that need batch retain should configure a single provider.

* refactor(api): dedup litellmrouter on top of LiteLLMLLM, accept arbitrary chain keys, add CI matrix entry

The retry/parse/metrics loop in LiteLLMRouterLLM was a near-verbatim copy of
LiteLLMLLM. Extract three small hooks on the base class
(_acompletion, _resolve_completion_model, _stage_label) and have the Router
provider inherit + override only what differs.

Drop strict validation of chain entries. The parser now requires only
'provider' and 'model'; everything else passes through to LiteLLM Router
unchanged. Top-level keys (rpm, tpm, weight, model_info, ...) flow to the
deployment record; an optional 'litellm_params' sub-object merges into the
inner params dict. Documented and tested.

Add a litellmrouter row to the LLM acceptance matrix using a single OpenAI
deployment in the chain. The chain JSON is built from secrets in a
dedicated step and masked in logs before being written to GITHUB_ENV.

* refactor(api): pure pass-through to litellm.Router, drop translation layer

Replace the chain-with-Hindsight-shape API with a thin pass-through to
litellm.Router. The HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG env var is now
a JSON object forwarded verbatim to Router(**config). Hindsight's only
imposed rules: model_list is non-empty, each entry has a model_name, and
requests route against the first entry's model_name.

This removes _LITELLM_PROVIDER_PREFIX (provider→prefix translation),
_build_model_list (flat→nested rewrite), and _build_fallbacks (auto-wired
ordered fallback). Users now write LiteLLM-native configs and pick their
own routing strategy — ordered fallback via 'fallbacks', load-balancing
via shared model_name + 'routing_strategy', rate-limit awareness via rpm/
tpm, and so on. The docs link to LiteLLM's reference rather than
recapitulating it.

Renames:
  ENV_LLM_LITELLMROUTER_CHAIN  -> ENV_LLM_LITELLMROUTER_CONFIG
  llm_litellmrouter_chain      -> llm_litellmrouter_config
  _parse_llm_router_chain      -> _parse_llm_router_config
  LLMProvider(litellmrouter_chain=) -> LLMProvider(litellmrouter_config=)

The dataclass fields change shape from list[dict] to dict (JSON object).

Net reduction across the touched files: ~165 lines.

* docs: regenerate hindsight-docs skill from updated configuration.md

* refactor(api): drop all shape validation on litellmrouter config, use fixed 'default' entrypoint

The previous version still inspected the user's config in two places:
the parser checked model_list/model_name shape, and __init__ pulled
primary_model_name out of model_list[0]. Both are gone.

The parser now only verifies the env var is parseable JSON. Whatever the
user supplies — dict, list, missing keys, weird shapes — flows through.
LiteLLM Router is authoritative about the shape and raises its own
errors at construction time if something's wrong.

The provider no longer extracts a 'primary' name from the input. Instead
it always issues completions against model_name='default' — the single
Hindsight-imposed convention. Users put one entry with that name in
their model_list as the entrypoint and use any names they want for
fallback/load-balance/weighted-pool members. This avoids both pre-
validation footguns and any dependence on Router's internal API
(model_names, model_list attributes) that could shift between versions.

Docs and tests updated to match. The CI matrix already used 'default'.

* docs: regenerate hindsight-docs skill

* ci(test): cap retain max_completion_tokens for litellmrouter matrix row

gpt-4.1-nano caps OpenAI completion at 32768 tokens, but Hindsight's
default DEFAULT_RETAIN_MAX_COMPLETION_TOKENS is 64000. The 'openai'
matrix row passes because OpenAICompatibleLLM has model-specific token
capping; LiteLLMLLM (and the new LiteLLMRouterLLM by inheritance) don't.
That's a pre-existing limitation orthogonal to this PR — the cap-aware
behaviour lives in OpenAICompatibleLLM and intentionally doesn't apply
to LiteLLM-routed calls.

Lower retain max_completion_tokens via env in the litellmrouter job so
CI exercises the Router path end-to-end instead of dying on a
provider-side BadRequestError that's not the thing we're testing.

* fix(api): cap LiteLLM-routed max_completion_tokens to model registry limit

Hindsight defaults retain_max_completion_tokens to 64000 — fine for
high-capacity models, but breaks against models with smaller caps
(gpt-4.1-nano: 32768; gpt-4o-mini: 16384). OpenAICompatibleLLM already
caps via a hardcoded string-match table; LiteLLMLLM and the new Router
provider didn't, so a default Hindsight install pointed at a small
model would fail with provider BadRequestError.

Cap pre-emptively using LiteLLM's own per-model registry
(litellm.get_max_tokens). For LiteLLMLLM the cap is self.model. For
LiteLLMRouterLLM the cap is the min across all configured deployments,
computed once at __init__ — this way a single max_completion_tokens
value works no matter which deployment Router picks (primary,
fallback, weighted-pool member). Unknown models contribute no cap.

Reverts the temporary CI workaround that lowered HINDSIGHT_API_RETAIN_
MAX_COMPLETION_TOKENS=32000 for the litellmrouter row — Hindsight
should work out of the box.

* docs: shorten litellmrouter config section, add models.mdx pointer

Move the discoverability pointer into models.mdx alongside the existing
LiteLLM tip, where users browsing for model options will find it. Strip
the configuration page entry to its essentials: env-var table, one
ordered-fallback example, and the three short caveats. Defer routing
details to LiteLLM's docs rather than recapitulating them.
2026-05-08 14:50:18 +02:00
Nicolò Boschi ab8cc3e605 fix(typescript-client): derive CLIENT_VERSION via tsup define (#1540)
The hardcoded `CLIENT_VERSION = "0.5.1"` in src/index.ts has fallen
behind npm releases through 0.5.6 / 0.5.7 / 0.6.0 — every published
release since 0.5.1 ships a stale constant, mis-attributing User-Agent
in server-side telemetry and foreclosing client-side feature gating.

Substitute `__CLIENT_VERSION__` with `pkg.version` via tsup's `define`
at build time. Source has no JSON import, so the fix is uniform across
runtimes (Node CJS/ESM, Deno via npm:, Deno via raw src) — unlike a
direct `import pkg from "../package.json"`, which Deno rejects without
`with { type: "json" }`, and which would in turn cascade into tsconfig
+ ts-jest reconfiguration (see #1535 for that path).

A `typeof` guard with a `0.0.0-dev` sentinel keeps raw-source loads
(jest, `npm run test:deno`) from throwing ReferenceError when the
build-time substitution hasn't run.

Verified locally: build, jest 6/6, Node CJS/ESM, Deno (dist), Deno
(raw src) all report the substituted version (or the dev sentinel
where appropriate). dist no longer inlines the full package.json
(devDependencies, scripts, repository url) — only the version string.

Closes #1535.
2026-05-08 12:51:12 +02:00
Nicolò Boschi da72f5da44 perf(locomo): scope CI to a 3-conversation curated subset (#1536)
The scheduled LoComo job has been failing on most recent runs with
``TimeoutError: Consolidation did not complete within 3000.0s`` from
``benchmark_runner._wait_for_consolidation``. The offender is
``locomo_conv-44``, the largest bank in the dataset (463 unconsolidated
items at ingestion peak), whose per-bank consolidation regularly grazes
or exceeds the hardcoded 50-minute wait budget under CI load. Because
``Publish LoComo to dashboard`` is gated on ``success()``, every such
failure also drops the entire run from the dashboard, so no LoComo
metrics have been published since the dashboard was set up.

Rather than chase the timeout up, narrow what the scheduled run
exercises. Pick three conversations that bracket accuracy on the last
clean full run (May 5):

- ``conv-26`` — best (90.79%)
- ``conv-30`` — middle (86.42%)
- ``conv-43`` — worst (82.02%)

This deliberately omits ``conv-44``: it sits at median accuracy but
carries the largest unconsolidated set in the dataset, and the goal here
is to keep the trend signal (best/median/worst spread, ingest+recall
behavior) without dragging in the bank that has been blowing the
per-bank timeout.

To plumb this through:

- ``--conversation`` becomes ``nargs="+"`` so it accepts a list of IDs
  (single-ID form still works). Help text and runner docstring updated.
- ``BenchmarkRunner.run`` widens ``specific_item`` to
  ``str | Iterable[str]`` and filters via set membership; longmemeval's
  single-string usage is unaffected.
- The workflow swaps ``locomo_max_conversations`` for
  ``locomo_conversations``: a space-separated string of IDs that
  defaults to the curated set but can be overridden at
  ``workflow_dispatch`` time.

Lint clean (``./scripts/hooks/lint.sh``); argparse ``--help`` verified.
2026-05-08 11:45:23 +02:00
Nicolò Boschi 48295b06e0 chore: fix formatting in llm_wrapper.py to pass verify-generated-files (#1534)
* chore: fix formatting in llm_wrapper.py to pass verify-generated-files

* chore: format n8n and openclaw files to pass verify-generated-files

* fix(openclaw): add missing includeSenderContext to plugin configSchema and uiHints
2026-05-08 11:12:35 +02:00
Nicolò Boschi 3f08b115f8 docs(zai): document z.ai provider and add default model (#1532)
* docs(zai): document z.ai provider and add default model

Follow-up to #1529. Adds z.ai (Zhipu GLM series) to the provider list,
example blocks, default-model table, and `.env.example`. Also wires
`zai` into `PROVIDER_DEFAULT_MODELS` so the new docs entry actually
matches what the engine resolves when only the provider is set.

* docs(zai): use glm-4.5-flash as default (free tier)

glm-4.5-air requires a paid balance on z.ai; flash is on the free
tier and works as a sensible default. Air is still listed in the
example as the paid-tier upgrade.
2026-05-08 10:50:40 +02:00
Nicolò Boschi b628716f15 fix(cp): improve access-key auth UX and harden middleware (#1533)
* fix(cp): improve access-key auth UX and harden middleware

- Move logout button from sidebar to header bar (next to GitHub icon),
  shown only when access-key auth is configured
- Remove redundant status bar from dashboard page
- Return 401 JSON for unauthenticated API requests instead of HTML redirect
- Redirect to /login on 401 in the API client (skip if already on /login)
- Allow /logo.png through middleware for the login page
- Replace brain emoji with Hindsight logo on login page
- Fix error message visibility in dark mode
- Add loading spinner for bank selector while banks are fetching
- Expose access_key_auth as a feature flag via version endpoint
- Document HINDSIGHT_CP_ACCESS_KEY in configuration and installation docs

* fix(cp): spread default features to handle unknown fields from API

* fix(cp): wrap login page in Suspense for useSearchParams
2026-05-08 10:40:50 +02:00
Rodolfo Hansen be696b0d38 feat(openclaw): prepend session-context block to retained transcripts (#1439)
When `dynamicBankGranularity` does not include `"user"`, every speaker
in an agent's bank ends up indistinguishable in similarity search --
memories from John look the same as memories from Peter, so recall can
mix them up. Bumping granularity to per-user is one fix, but it forces
fragmented banks and forfeits cross-user shared context (e.g. for an
ops/sprint-driver bot).

Add an opt-out `includeSenderContext` flag (default true) and a new
optional `sessionContext` parameter to `prepareRetentionTranscript`.
When provided, a small `[context] sender / channel / provider [/context]`
block is prepended to the transcript -- as a system-role message in the
JSON formats, or as a literal text block in the legacy text format.

That single header gives vector recall a strong, model-agnostic signal
to attribute and disambiguate memories without changing the bank
scheme. Filtered providers and missing fields collapse cleanly to null,
so the change is invisible when there's nothing useful to say.

Tests cover both formats, opt-out, missing-fields fallback, and the
no-context default.
2026-05-08 10:11:47 +02:00
Burgunthy 4c75cd9e37 feat: add z.ai (智谱) as first-class LLM provider (#1529)
Add z.ai (https://api.z.ai) as a supported provider in OpenAICompatibleLLM,
following the same pattern as deepseek, minimax, and openrouter.

Changes:
- openai_compatible_llm.py: add zai to valid_providers, base_url, api_key validation
- llm_wrapper.py: add zai to create_llm_provider routing, LLMConfig

Verified: retain (3276 in / 922 out tokens) + recall working with glm-4.5-air
2026-05-08 10:00:59 +02:00
Ariel AI c0ff87ea10 feat(cp): add optional access-key login for Control Plane (#1530)
Add HINDSIGHT_CP_ACCESS_KEY env var to enable a lightweight
shared-secret authentication gate for the Control Plane UI.

Features:
- Login page at /login with access key input form
- /api/auth/login endpoint validates key and sets HttpOnly session cookie
- /api/auth/logout endpoint clears session cookie
- Middleware protects all routes except /login, /api/auth/*, /api/health,
  /api/version, static assets, and _next
- returnTo query param preserves redirect after login
- Constant-time comparison for access key to prevent timing attacks
- Logout button in sidebar (when a bank is selected) and dashboard header
- Updated .env.example and docker-compose docs

Security:
- HttpOnly, SameSite=lax, Secure (production only) cookie
- 24-hour session lifetime
- Constant-time string comparison to prevent timing attacks
2026-05-08 09:37:30 +02:00
Chris Bartholomew 6c6ee73c56 fix(claude-code): use detail=metadata for agent_knowledge_list_pages (#1528)
agent_knowledge_list_pages was hitting GET /mental-models with no detail
parameter, so the API returned its default (detail=full) — synthesized
content + reflect_response for every page in the bank. On a bank with
many pages this produces a single JSON-RPC response that exceeds the
Claude Code MCP client's 16 MB without-newline-boundary buffer ceiling
and triggers a deterministic disconnect.

Reproduced locally driving the MCP server end-to-end:
  unpatched: 20,054,285 bytes in one JSON-RPC message → disconnect
  patched:   44,987 bytes, two messages → clean

The tool's docstring already promises "IDs and names only" — this aligns
the wire call with the documented contract. Agents that need the
synthesized content already use agent_knowledge_get_page, which keeps
detail=full and is unaffected.

Adds a focused regression test pinning the metadata projection.
2026-05-08 09:36:04 +02:00
Chris Bartholomew 7b82d05b77 fix(worker): propagate child error_message to failed batch_retain parent (#1527)
When a batch_retain parent transitions to 'failed' because at least one
child sub-batch failed, the parent's error_message was hardcoded to the
generic string "One or more sub-batches failed". Any consumer that
classifies failures by error_message (dashboards, alert filters, log
aggregators) loses signal once a batch grows children -- a class of
failures that all share the same root reason at the child level becomes
indistinguishable at the parent level.

Pull error_message in the siblings query and pick the most-common
non-empty failed-child message as the parent's error_message. When all
siblings failed for the same reason (the common case) the parent
inherits that reason verbatim; when reasons vary the most-common one is
still a useful representative. Falls back to the legacy generic string
only when no failed sibling carries an error_message at all, preserving
backward compat for that edge case.

Same change applied to both the worker poller's fallback path and the
memory engine's in-transaction path so the propagation behavior is
consistent regardless of which surface finalises the parent.

6 new unit tests for the helper plus an inheritance assertion added to
the existing integration test.
2026-05-08 09:35:38 +02:00
Chris f2a2f9fe40 fix(reflect): read document metadata from retain params (#1523) 2026-05-08 09:32:43 +02:00
Ben 8c6be6de6c blog: n8n Workflows Are Stateless. Hindsight Makes Them Compound. (#1511)
* blog: n8n Workflows Are Stateless. Hindsight Makes Them Compound.
2026-05-07 13:10:10 -04:00
Nicolò Boschi 8d77976ae9 fix(daemon): replace os.fork() with subprocess.Popen to fix MPS on macOS (#1519)
On macOS, os.fork() without exec() corrupts Apple framework state
(XPC, Metal/MPS, ObjC runtime). The daemon's double-fork pattern
caused SIGBUS crashes when PyTorch auto-selected the MPS backend
for local embeddings/reranker models.

Replace the double-fork in daemonize() with subprocess.Popen
(which uses posix_spawn on macOS), giving the daemon a clean
process where MPS works correctly. The re-exec'd child is
identified by the _HINDSIGHT_DAEMON_CHILD env var.

This also removes the macOS FORCE_CPU workaround from
hindsight-embed, since MPS now works natively in daemon mode.

Fixes #270, #1394, #1497
2026-05-07 18:58:34 +02:00
Nicolò Boschi 312bde1b4d docs: surface stable worker_id guidance and zombie-operation recovery (#1522)
* docs: surface stable worker_id guidance and zombie-operation recovery

Worker identity defaults to the container hostname, which Docker rotates
on every restart. That stranded several real deployments' consolidation
queues (issue #1470 and the related closed tickets #991 / #696 / #624).
Move the guidance from the configuration reference table — where it
only gets read after the bug bites — into the install path and add a
recovery section next to the decommission commands.

* docs(faq): add zombie-operations entry
2026-05-07 18:31:22 +02:00
Nicolò Boschi a22e8bdd22 fix(engine): remove multiplicative retry layers in fact extraction (#1412) (#1516)
Structured-output extraction had three nested retry loops that
multiplied on deterministic failures, burning up to 36 LLM calls
per chunk (inner 4 × middle 3 × outer 3).

- Remove outermost _extract_chunk_with_retry wrapper: its broad
  except-Exception added a 3× multiplier on top of already-bounded
  inner retries.
- Remove json_validate_failed retry from middle layer: the inner
  provider loop already retries 400 errors; re-entering the full
  LLM call for the same schema failure is wasted quota.
- Fix claude_code_llm.py: ValidationError was caught by a broad
  except-Exception and retried instead of raising immediately.
  Same input produces the same schema-violating output.
2026-05-07 18:26:18 +02:00
Nicolò Boschi 4088af369a fix(openclaw): backfill plugins.allow with hindsight-openclaw in setup wizard (#1521)
OpenClaw 2026.2.19+ logs a startup WARN whenever `plugins.allow` is
empty and non-bundled plugins are discovered:

  [plugins] plugins.allow is empty; discovered non-bundled plugins
            may auto-load: hindsight-openclaw (...). Set plugins.allow
            to explicit trusted ids.

Cosmetic — the plugin still loads — but the warning fires on every
gateway start and is the kind of noise users justifiably ask about.

`ensurePluginConfig` now adds `hindsight-openclaw` to `plugins.allow`
so the warning goes away. Conservative wrt user-curated lists:

- Undefined → set to `["hindsight-openclaw"]`.
- Existing array → append our id only when missing (idempotent).
- Existing array already containing our id → no-op.
- Non-array value (deliberate weirdness) → leave alone.

Four regression tests cover all four cases.
2026-05-07 18:18:52 +02:00
Nicolò Boschi be53972ab2 release(claude-code): v0.6.3 2026-05-07 18:14:38 +02:00
Nicolò Boschi 691c65acf8 feat(claude-code): resolve git worktrees + explicit directory-bank mapping (#1520)
* feat(claude-code): resolve git worktrees + explicit directory→bank mapping

Adds two new bank-resolution features so that working in a git worktree
or across multiple project directories doesn't accidentally fragment
memory across separate banks.

- resolveWorktrees (default true): detects git worktrees via
  `git rev-parse --git-common-dir` and resolves the project field to the
  main repository basename, so all worktrees of the same repo share one
  bank. Falls back to cwd basename if git is unavailable.
- directoryBankMap: explicit cwd → bankId mapping that takes priority
  over both static and dynamic modes, for users who want full control.

20 new tests cover worktree resolution, directory mapping, prefix
interaction, and graceful fallback paths.

* docs(claude-code): declare resolveWorktrees + directoryBankMap settings

Add the two new bank-resolution fields to the plugin's settings.json so
they show up in the canonical defaults, and document them in the
integration docs (Memory Bank table + a "Worktrees and explicit
mapping" subsection with a config example).
2026-05-07 18:14:04 +02:00
Nicolò Boschi bfa3115579 release(openclaw): v0.7.6 2026-05-07 17:48:43 +02:00
Nicolò Boschi ef600683c9 fix(openclaw): reuse existing token + URL when re-running setup wizard (#1518)
The wizard re-prompted for the API token / API key on every run even
when one was already stored in openclaw.json — confusing for users
(re-typing a long secret) and wasteful when running setup just to
backfill new fields like hooks.allowConversationAccess.

Now: if pluginConfig has an inline string secret (cloud token, api
token, llm api key), the wizard offers to reuse it (showing the last
4 chars masked, e.g. "Reuse the existing token (ends in …***1234)?").
Saying yes keeps the existing secret; saying no falls back to the
masked password prompt as before. SecretRef objects (env-var refs)
aren't pasteable so they keep the previous prompt path.

URL handling tightened up too:
- Cloud: prompt label adapts ("Reuse the configured Cloud URL X?" vs
  "Use the default Hindsight Cloud URL?") and reuses the existing URL
  on confirm.
- API: text prompt seeded with the existing URL via initialValue so
  the user can just press enter.
- API token confirm now defaults to "yes, needs token" when one is
  already configured, instead of always defaulting to no.

Adds a pure maskSecret helper in setup-lib.ts (testable without a
TTY) and three regression tests covering long token / very-short
input / surrounding whitespace.
2026-05-07 17:48:17 +02:00
Nicolò Boschi a34c0e7be3 release(openclaw): v0.7.5 2026-05-07 17:02:53 +02:00
Nicolò Boschi abf8487248 fix(openclaw): write hooks.allowConversationAccess in setup wizard (#1514)
* fix(openclaw): write hooks.allowConversationAccess in setup wizard

OpenClaw 2026.4.24 added a security gate (#71221) that silently drops
"conversation hooks" — including `agent_end`, which the plugin uses
to retain the transcript on every turn — for non-bundled plugins
unless `plugins.entries.<id>.hooks.allowConversationAccess` is
explicitly set to `true` in user config.

Symptom: openclaw logs `typed hook "agent_end" blocked because
non-bundled plugins must set ... allowConversationAccess=true`, the
plugin appears registered, retain count stays at 0, banks stay empty.
Affects every user on openclaw ≥ 2026.4.24 who installed via the
standard `hindsight-openclaw-setup` flow.

Fix: ensurePluginConfig (the helper every wizard mode calls before
saveConfig) now backfills `hooks.allowConversationAccess: true` when
the field is unset. Idempotent — re-running the wizard fixes existing
configs that pre-date the gate. We never override an explicit `false`,
since that's a deliberate user override.

Also extends the PluginEntry shape to include `hooks` and adds four
regression tests covering fresh, backfill, explicit-false, and
foreign-hooks-key cases.

* fix(openclaw): declare contracts.tools in plugin manifest

OpenClaw 2026.5.x added a second gate (loader.js:1448-1455): when a
plugin calls api.registerTool, the loader checks `record.contracts.tools`
(populated from the plugin manifest's `contracts.tools` array). If the
manifest doesn't declare the tool names, openclaw logs:

  ERROR [plugins] plugin must declare contracts.tools before registering
        agent tools (plugin=hindsight-openclaw, ...)

…and the registerTool call no-ops. Result on 2026.5.x: even with
enableKnowledgeTools=true, none of the agent_knowledge_* tools are
exposed to agents.

Fix: declare the seven agent_knowledge_* names in
openclaw.plugin.json's `contracts.tools` array so openclaw recognises
them at manifest-load time. Pure manifest change — runtime behavior is
still gated by `enableKnowledgeTools` in user config; this just lets
openclaw allow the registration when the runtime flag is on.

Verified locally on openclaw 2026.5.6 with the patched manifest copied
into the installed extension dir + a fresh gateway start: log goes
from "knowledge tools registered" + ERROR plugin-must-declare-contracts
→ "knowledge tools registered" with no error.

This is a pure manifest update — no code changes, no test changes
required.
2026-05-07 17:02:28 +02:00
Nicolò Boschi 5d867826a1 release(n8n): v0.1.3 2026-05-07 16:57:57 +02:00
BenandClaude Opus 4.7 e18ab10ac4 fix(n8n): drop hindsight-client runtime dep, inline HTTP calls (#1513)
* fix(n8n): drop hindsight-client runtime dep, inline HTTP calls

n8n's verified-node review (`npx @n8n/scan-community-package
@vectorize-io/[email protected]`) auto-rejects packages with
runtime dependencies via @n8n/community-nodes/no-restricted-imports.
The Hindsight node imported @vectorize-io/hindsight-client, which
triggered the rule.

Replaces the SDK calls with direct HTTP via n8n's built-in
`requestWithAuthentication` helper. The Bearer header is applied
automatically from the existing IAuthenticateGeneric credential — no
credential changes needed.

Endpoints used (verified against the SDK source we removed):
- Retain:  POST {apiUrl}/v1/default/banks/{bank_id}/memories
- Recall:  POST {apiUrl}/v1/default/banks/{bank_id}/memories/recall
- Reflect: POST {apiUrl}/v1/default/banks/{bank_id}/reflect

Body shapes match HindsightClient.retain/recall/reflect line-for-line
so server-side behavior is unchanged.

Test changes:
- Swapped the vi.mock() of @vectorize-io/hindsight-client for a mock
  of helpers.requestWithAuthentication on IExecuteFunctions
- All 22 tests still pass (8 in node-execute, 14 elsewhere)
- Added a new test asserting trailing-slash apiUrl is stripped before
  URL concatenation

Package changes:
- Drop @vectorize-io/hindsight-client from dependencies
- Bump 0.1.2 → 0.1.3

After this lands, run ./scripts/release-integration.sh n8n 0.1.3 to
publish 0.1.3 with provenance, then re-run the scan and submit at
creators.n8n.io.

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

* fix(n8n): use httpRequestWithAuthentication (deprecated rename)

n8n's @n8n/community-nodes ESLint plugin flags requestWithAuthentication
as deprecated in favor of httpRequestWithAuthentication. Caught by
running the full plugin ruleset locally against the dist before publish:

  no-deprecated-workflow-functions errors in Hindsight.node.js at
  lines 217, 241, 258 (the three operation HTTP calls)

Same signature, same auth behavior — just the modern helper name.
After this rename, all 25 community-nodes lint rules pass clean.

All 22 vitest tests still pass with the helper rename.

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

* chore(n8n): leave version at 0.1.2 — release pipeline owns the bump

Per Nicolo: the release-integration tooling owns version bumps. This
PR should ship the code change only (drop hindsight-client dep, switch
to httpRequestWithAuthentication, retarget tests). Version 0.1.2 →
0.1.3 will happen automatically when release-integration.sh runs.

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

* chore(n8n): match main's package-lock.json version field

main's package-lock.json has version "0.1.0" (out of sync with
package.json's "0.1.2", but that's the state on main). The previous
revert overshot to "0.1.2" — restoring to "0.1.0" so the lockfile
diff vs main no longer touches the version field.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-07 16:56:39 +02:00
Nicolò Boschi 39d31ad259 perf(worker): scope progress-stats fanout to schemas with pending work (#1509)
The progress logger (_log_progress_if_due) previously ran two heavy
COUNT/GROUP BY queries against every tenant schema on every stats cycle
(every 30s). With N tenants and W workers that's 2*N*W queries per cycle.

Reuse _scan_active_schemas() — which already calls the optional
schemas_with_pending_work() routine when installed (O(1) marker-table
read) or falls back to per-schema EXISTS checks — to pre-filter schemas
before the expensive breakdown queries. Union with schemas that have
locally-tracked in-flight tasks so processing worker counts stay accurate.

Also wraps per-schema queries in try/except for partially-provisioned
tenants and caps the schema list in log output to 20 entries.
2026-05-07 16:26:48 +02:00
Nicolò Boschi 0f2499c7cb release(opencode): v0.2.0 2026-05-07 14:41:34 +02:00
Nicolò Boschi 72ff603ff1 release(codex): v0.3.0 2026-05-07 14:41:14 +02:00
Evo 42ad17f679 docs(entity-labels): document type="map" structured entity groups (#1508)
* docs(entity-labels): document type="map" structured entity groups

* docs(entity-labels): mirror type="map" docs into sidecar reference
2026-05-07 14:37:35 +02:00
Nicolò Boschi 0dfbf3fae7 release(openclaw): v0.7.4 2026-05-07 13:47:45 +02:00
Nicolò Boschi c95206e0eb fix(openclaw): pass enableKnowledgeTools through getPluginConfig (#1507)
* fix(claude-code): bootstrap Python deps via venv in CLAUDE_PLUGIN_DATA

Install Python deps into ${CLAUDE_PLUGIN_DATA}/venv on demand, and
launch the MCP server through that venv's interpreter — no global
pip install, isolated to the plugin, survives plugin updates.

How it works:
- requirements.txt declares deps (mcp>=1.0.0)
- scripts/run_mcp.sh creates the venv on first run (or when
  requirements.txt changes vs the cached copy in plugin data),
  pip-installs into it, and execs ${VENV}/bin/python on mcp_server.py
- .mcp.json now points at the wrapper instead of bare 'python3', so
  the MCP server always runs with the plugin's pinned interpreter
  (avoids version mismatches: e.g. system /usr/bin/python3 was 3.9
  but venv was built with 3.11)

Tested locally: cold start ~25s (venv + pip), warm start ~0.4s,
all 9 agent_knowledge_* tools register correctly.

* docs(claude-code): document knowledge tools and subagent skill

The Claude Code integration now ships an MCP server with
agent_knowledge_* tools and a /hindsight-memory:create-agent skill
for scaffolding memory-backed subagents. Document both, plus the
new enableKnowledgeTools config flag and venv bootstrap behavior.

* fix(openclaw): pass enableKnowledgeTools through getPluginConfig

The flag was declared on PluginConfig and read at the
agent_knowledge_* tool registration site, but never copied through
getPluginConfig — so the runtime value was always undefined and the
if-branch never entered, regardless of what users (or the SDA CLI)
wrote into openclaw.json. Live since the feature was added on
Apr 29 2026.

Adds the field to the whitelist (defaulting to false on missing or
non-boolean values, matching the type definition) plus a regression
test in getPluginConfig.
2026-05-07 13:47:14 +02:00
Nicolò Boschi 2b725bc448 feat: add map-type entity labels for structured entity extraction (#1370) (#1505)
Add a new `type="map"` option to entity_labels that lets users define
structured entity types with named fields. Each field is stored as a
flat `key:field:value` entity string (e.g. `person:name:Alice`,
`person:role:Engineer`), reusing the existing entity storage and
co-occurrence mechanisms with no DB changes.

Fields support all types recursively: text, value, multi-values, and
nested map — enabling schemas like `person:address:city:New York`.

Control plane UI updated with a recursive MapFieldsEditor component
that renders all label types (top-level and nested) using the same
shared component with tree-style visual nesting.
2026-05-07 13:32:08 +02:00
Nicolò Boschi b20bcb62c1 follow-up to #1459: AlloyDB ScaNN docs + review-nit cleanups (#1506)
* docs: document AlloyDB ScaNN vector extension

Follow-up to #1459. Adds `scann` to the supported vector-extension
list in installation.md and configuration.md, with installation
hints, the 10k-row deferred-build caveat, AlloyDB Omni compose
pointer, and the relaxed switching rules (switching *to* scann is
allowed with existing data).

* refactor(_vector_index): address review nits from #1459

- Lift `from sqlalchemy import text` (and add `Connection`) to module
  top in `_vector_index.py`; both helpers now have proper type hints.
- Make `pg_diskann` a first-class entry in a new `RESOLVED_EXTENSIONS`
  tuple via `_normalize_resolved`. The configurable boundary stays
  strict (`validate_extension` rejects `pg_diskann`); the resolved
  helpers (`index_using_clause`, `index_type_keyword`,
  `minimum_rows_for_index`, `uses_per_bank_vector_indexes`) accept it
  without per-call special-case branches. Behavior is identical.
- Harden `test_alembic_vector_migrations_freeze_vector_sql_locally`
  to resolve the migrations dir from `__file__` so the test no longer
  depends on cwd.
- Add a one-liner explaining why `_drop_per_bank_vector_indexes`
  inlines identifiers instead of using bound parameters (DDL).

Tests: tests/test_vector_index.py (10), tests/test_migration_shape.py
+ tests/test_migrations_thread_safety.py (64). Lint and ty clean.
2026-05-07 13:12:03 +02:00
Nicolò Boschi 095f397770 docs(installation): bake custom models into image instead of PVC (#1504)
* docs(installation): bake custom models into image instead of PVC

Add a runnable example under `docker/docker-compose/custom-models/` that
extends the slim image and pre-downloads non-default embedder/reranker
models at build time. Document this as the recommended pattern for
production over enabling the Helm `modelCache` PVC: image layers cache
per node for free, while a PVC adds storage cost, pins pods to a node,
and needs lifecycle management on uninstall/upgrade. Add pointers from
the api/worker `modelCache` values in the chart to the new section.

Refs vectorize-io/hindsight#1383

* fix(docker/custom-models): install local-ml deps via uv into the venv

The slim image's venv at /app/api/.venv was created by uv sync and does
not ship its own pip, so a bare `pip install` falls through to the
system pip and lands the packages in /home/hindsight/.local — invisible
to the venv python that runs hindsight-api at runtime. Use
`uv pip install --python /app/api/.venv/bin/python` to install into the
venv directly. Verified the resulting image loads both baked-in models
with HF_HUB_OFFLINE=1.

* docs(installation): trim custom-models section to a tip and pointer

The Dockerfile/compose example in docker/docker-compose/custom-models/
already has its own README explaining when to use it and why it beats
the modelCache PVC. The installation page only needs to point readers
there.
2026-05-07 12:59:19 +02:00
Nicolò Boschi a1c1b7decd fix(worker): probe pg_proc before calling optional schemas_with_pending_work() (#1503)
* fix(worker): probe pg_proc before calling optional schemas_with_pending_work() (#1408)

The poller called the optional PL/pgSQL routine `schemas_with_pending_work()`
unconditionally on every cycle. When the routine isn't installed (the default
for fresh deployments), Postgres logs a server-side `function does not exist`
error every ~30s even though the Python code silently caught the exception.

This adds a small `OptionalRoutines` registry/cache in
`hindsight_api/engine/db/optional_routines.py` that probes `pg_proc` once on
first lookup and memoises the result for the life of the process. The poller
now calls the routine only when it's actually installed and falls back to the
per-schema EXISTS path otherwise — without any spurious server-side errors.

The registry also carries the canonical install SQL for each routine inline,
so anyone touching the optimisation has a single source of truth (the previous
docstring lived only on `_scan_active_schemas`).

Tradeoffs:
- Probe is permanently cached: installing the routine on a running cluster
  requires a worker restart. Acceptable because these routines are expected
  to be installed once at deploy time, and a probe-per-poll would defeat the
  optimisation.
- Non-PG backends short-circuit to False without touching the DB.

* refactor(worker): drop routine body from registry; document contract instead

Hindsight never installs schemas_with_pending_work() — operators do. Keeping
the SQL body in the API repo would drift from whatever is actually deployed
and falsely imply ownership. Replace the install_sql field on OptionalRoutine
with a contract docstring describing the expected signature, return shape,
and semantic constraints, so any operator-supplied implementation is
interchangeable as long as it matches.

The test installs a minimal contract-satisfying stub locally rather than
relying on a registry-supplied body.
2026-05-07 12:46:09 +02:00
Can Bölük e4422a9b40 Add AlloyDB ScaNN vector index support (#1459)
* feat: add AlloyDB ScaNN vector index support

* fix(hindsight_api): resolved SCANN index mismatch by deferring creation

- Added SCANN-aware vector index helpers with a 10k minimum-row threshold.
- Updated bank index generation to skip per-bank clauses and index creation when unsupported.
- Updated vector migrations to validate extension names and skip SCANN-specific index creation or drops.
- Updated migration reconciliation to use row counts and defer SCANN index recreation instead of mismatch errors.
- Added tests for SCANN deferral, per-bank index ineligibility, and migration SQL freeze behavior.

* docs: add AlloyDB Omni compose example
2026-05-07 12:35:35 +02:00
Nicolò Boschi e63100b6a2 ci: cosign-sign release images + document verification (#1502)
* ci: cosign-sign release images + document verification

Folds the now-proven keyless cosign signing flow into the release
workflow so future releases sign automatically alongside the build,
and adds a "Verifying image signatures" subsection to the Docker
installation docs so downstream consumers know how to verify.

The verification regex accepts signatures from both sign-images.yml
(used to backfill 0.6.0) and release.yml (future releases) so a
single documented command covers all signed tags.

Closes #1484

* docs: tighten cosign verification section
2026-05-07 12:08:01 +02:00
Nicolò Boschi 59ffb1d4a3 ci: add manual workflow to cosign-sign published GHCR images (#1495)
Standalone workflow_dispatch path that resolves a published tag to its
manifest digest, signs it with keyless OIDC via cosign, and verifies the
signature in the same job. Decoupled from release.yml so we can backfill
v0.6.0 (and prior) without coupling supply-chain signing to the release
cut. Once proven, the same sign step will fold into release.yml.

Refs #1484
2026-05-07 11:37:21 +02:00
Evo 976a4e54c6 docs(env): document HINDSIGHT_API_READ_DATABASE_URL in .env.example (#1496) 2026-05-07 11:04:16 +02:00
Evo 1af0907d14 docs(cli): document --strategy flag for memory retain-files (#1499)
* docs(cli): document --strategy flag for memory retain-files

* docs(cli/skills): mirror --strategy flag example for memory retain-files
2026-05-07 11:03:59 +02:00
Ben a3465dd1fe blog: Your Claude Code Subagents Don't Share What They Learn (#1456)
* blog: Your Claude Code Subagents Don't Share What They Learn
2026-05-06 15:13:19 -04:00
Nicolò Boschi 375747f516 feat(cli): add --strategy flag to memory retain-files (#1494)
Allows callers to pick a named retain strategy when bulk-importing files,
overriding the bank's default. The API already accepts a per-file strategy
in FileRetainMetadata; this just wires a CLI flag through to the multipart
metadata.

Closes #1492
2026-05-06 17:52:41 +02:00
Nicolò Boschi a5cef602bc fix(docker): chmod 755 /home/hindsight to support --user UID:GID overrides (#1493)
The default 0700 on /home/hindsight blocks traversal when running with
--user UID:GID for bind-mount ownership matching. This adds chmod 755
in both api-only and standalone stages so non-owner UIDs can traverse
the home directory.

Closes #1481
2026-05-06 17:37:06 +02:00
Nicolò Boschi 2161c4e815 chore: stabilize CI — docs-skill pre-commit hook + retain dict-mutation fix (#1490)
* ci: add pre-commit hook to keep skills/hindsight-docs in sync

The CI verify-generated-files job has been failing on ~82% of recent
runs because PRs touch hindsight-docs/src/pages/changelog/ or
hindsight-docs/static/openapi.json without re-running
./scripts/generate-docs-skill.sh, leaving the committed
skills/hindsight-docs/references/ copy stale.

Catch the drift locally instead. The hook regenerates and, if the
working tree diverges from the index after regen, fails the commit
with a clear message pointing the author at `git add skills/hindsight-docs/`.

The pre-commit dispatcher (.githooks/pre-commit) already iterates every
*.sh in scripts/hooks/, so the new file is picked up automatically.

* fix(retain): stop mutating caller-provided content dicts

PR #1398 (memory pressure) added an in-place pop of the "content" key
on contents_dicts after building combined_content, to release per-item
strings the engine no longer needs. Because the engine forwarded the
caller's dict objects all the way through (memory_engine →
_retain_batch_async_internal → orchestrator.retain_batch), the pop
reached back through the same references and stripped the key from
the caller's input. Any code path that holds onto the contents list
after retain_batch_async returns then trips KeyError: 'content'.

This is what was making test_extensions.py::TestOperationHooksParameters::
test_retain_pre_hook_receives_all_parameters fail intermittently on
main (the streaming path triggers the pop; non-streaming paths skip it).

Fix:
- memory_engine.py: take an engine-owned shallow copy of contents
  after the validator hook so the orchestrator can mutate freely
  without leaking to the caller. Strings are shared by reference,
  so the copy adds only ~150 bytes of dict overhead per item —
  negligible vs the multi-MB strings.
- orchestrator.py (_streaming_retain_batch): clear combined_content
  immediately after handle_document_tracking / upsert_document_metadata
  in all three first-batch paths (no-facts skip, mini-batch DB work,
  post-loop fallback). Once tracking persists the document, nothing
  reads combined_content again, so releasing it shrinks the lifetime
  of the per-document text from "until function returns" to "until DB
  write completes" — recovering the bulk of #1398's memory savings
  without the caller-mutation side effect. nonlocal declarations on
  _process_db_batch and _run_mini_batch_db_work are required because
  Python infers combined_content as local once any branch assigns to it.

Memory profile vs PR #1398:
- #1398 benchmark shape (caller releases its reference at call time):
  identical sustained, brief 2x peak during the combined_content +
  per-item-strings overlap window before tracking completes. Other
  PR #1398 savings (chunks, batch lists, sanitized_content) untouched.
- HTTP / FastAPI callers (request body holds strings until the handler
  returns): no observable change — those strings were going to live
  through the request anyway.
2026-05-06 17:18:52 +02:00
Nicolò Boschi 22f5fcf414 release(n8n): v0.1.2 2026-05-06 17:02:45 +02:00
Ben 8ea68bfbc2 ci(release): add --provenance to npm publish for n8n Verified (#1491) 2026-05-06 16:55:11 +02:00
Nicolò Boschi e32c951957 docs(claude-code): document knowledge tools and subagent skill (#1487)
* fix(claude-code): bootstrap Python deps via venv in CLAUDE_PLUGIN_DATA

Install Python deps into ${CLAUDE_PLUGIN_DATA}/venv on demand, and
launch the MCP server through that venv's interpreter — no global
pip install, isolated to the plugin, survives plugin updates.

How it works:
- requirements.txt declares deps (mcp>=1.0.0)
- scripts/run_mcp.sh creates the venv on first run (or when
  requirements.txt changes vs the cached copy in plugin data),
  pip-installs into it, and execs ${VENV}/bin/python on mcp_server.py
- .mcp.json now points at the wrapper instead of bare 'python3', so
  the MCP server always runs with the plugin's pinned interpreter
  (avoids version mismatches: e.g. system /usr/bin/python3 was 3.9
  but venv was built with 3.11)

Tested locally: cold start ~25s (venv + pip), warm start ~0.4s,
all 9 agent_knowledge_* tools register correctly.

* docs(claude-code): document knowledge tools and subagent skill

The Claude Code integration now ships an MCP server with
agent_knowledge_* tools and a /hindsight-memory:create-agent skill
for scaffolding memory-backed subagents. Document both, plus the
new enableKnowledgeTools config flag and venv bootstrap behavior.
2026-05-06 15:51:57 +02:00
Nicolò Boschi a86d5381d5 fix(packaging): hard-pin meta packages to matching hindsight-api-slim (#1486)
The meta packages (hindsight-api, hindsight-all, hindsight-all-slim,
hindsight-dev) are pure entry-point shims — all real code, including
__version__ shown on the startup banner, lives in hindsight-api-slim.
Their dependency on slim was a stale floor (>=0.4.17), so
`pip install -U hindsight-api==0.6.0` left an older slim in place and
the server reported the previous version.

Hard-pin each meta package to the matching slim/api version, and teach
scripts/release.sh to rewrite the pin alongside the existing
`version = "..."` bumps so future releases stay in sync.
2026-05-06 15:27:38 +02:00
Nicolò Boschi efe5ff8494 feat(perf): publish perf-test results to external dashboard (#1474)
* feat(perf): publish perf-test results to external dashboard repo

Adds `--benchmark-output-dir` to perf-test, which emits two JSON files
in github-action-benchmark format: latency.json (smaller-is-better:
durations + recall p50/p95/p99/mean) and throughput.json (bigger-is-
better: items/queries/memories per sec). The Performance Tests workflow
now publishes both to vectorize-io/hindsight-continuous-performance-
monitor's gh-pages branch on each scheduled run.

Iteration mode (TEMP — search "TEMP" to revert before merge):
push trigger on this branch, default scale=small, locomo skipped
unless manually dispatched.

Setup needed (one-time):
- PAT with Contents:write on the dashboard repo, stored as secret
  PERF_DASHBOARD_TOKEN.
- After the first run creates gh-pages there, enable Pages on that
  repo (Settings → Pages → gh-pages branch).

* fix(perf): wipe benchmark working dir between latency and throughput publishes

github-action-benchmark clones the dashboard repo into a fixed
./benchmark-data-repository directory and doesn't clean up, so the
second invocation in the same job fails with 'destination path already
exists'.

* feat(perf): replace github-action-benchmark with custom dashboard publisher

Drops the two benchmark-action steps (and the dead `--benchmark-output-dir`
flag + `_to_benchmark_entries` helper in system_perf.py) in favour of a
single `scripts/benchmarks/publish-perf-results.sh` step. The script:

1. Reads the perf-test JSON output.
2. Enriches it with commit metadata (subject, author, author_date,
   commit URL, PR URL via `gh api commits/<sha>/pulls`).
3. Clones the dashboard repo's gh-pages branch using PERF_DASHBOARD_TOKEN.
4. Writes data/<timestamp>-<short_sha>.json and prepends the run to
   data/index.json (newest first).
5. Commits and pushes (with one rebase-retry on push rejection).

The matching custom static site lives on gh-pages of
vectorize-io/hindsight-continuous-performance-monitor (separate commit
in that repo).

* perf(workflow): publish dashboard on workflow_dispatch too

* feat(perf): publish workflow run URL and LoComo results to dashboard

Perf script now embeds workflow_run.{id,url} in each enriched run JSON
and the manifest entry, sourced from default GitHub Actions env vars
(GITHUB_RUN_ID + GITHUB_REPOSITORY).

LoComo gets its own publish script (publish-locomo-results.sh) and a
new step in the locomo job. The script strips per-question
detailed_results (kept in the workflow artifact) before pushing — keeps
each run small enough for git. Output lands at:
  data/locomo/<timestamp>-<short_sha>.json
  data/locomo-index.json
The matching dashboard page (locomo.html) is in the dashboard repo.

* perf(workflow): revert iteration-mode TEMP markers

Restores the production defaults that were temporarily flipped while
iterating on the dashboard:
- drop the push trigger on feat/perf-dashboard
- default scale: small → large
- default locomo_skip: true → false
- locomo job condition: workflow_dispatch-only → inputs.locomo_skip != true

Scheduled cron now runs the full suite + LoComo daily and publishes
to the dashboard.
2026-05-06 15:12:11 +02:00
Nicolò Boschi a3e20f995f release(claude-code): v0.6.2 2026-05-06 15:02:27 +02:00
Nicolò Boschi 012c100ebf fix(claude-code): bootstrap Python deps via venv in CLAUDE_PLUGIN_DATA (#1485)
Install Python deps into ${CLAUDE_PLUGIN_DATA}/venv on demand, and
launch the MCP server through that venv's interpreter — no global
pip install, isolated to the plugin, survives plugin updates.

How it works:
- requirements.txt declares deps (mcp>=1.0.0)
- scripts/run_mcp.sh creates the venv on first run (or when
  requirements.txt changes vs the cached copy in plugin data),
  pip-installs into it, and execs ${VENV}/bin/python on mcp_server.py
- .mcp.json now points at the wrapper instead of bare 'python3', so
  the MCP server always runs with the plugin's pinned interpreter
  (avoids version mismatches: e.g. system /usr/bin/python3 was 3.9
  but venv was built with 3.11)

Tested locally: cold start ~25s (venv + pip), warm start ~0.4s,
all 9 agent_knowledge_* tools register correctly.
2026-05-06 15:01:41 +02:00
Chris BartholomewandNicolò Boschi cf9b1f59a9 feat(engine): optional read-only backend for recall queries (#1460)
* feat(engine): optional read-only backend for recall queries

Add a second `DatabaseBackend` (`MemoryEngine._read_backend`) that is
populated when the new `HINDSIGHT_API_READ_DATABASE_URL` env var is set.
The recall search path (`_search_with_retries`, which orchestrates the
parallel semantic + BM25 + graph + temporal retrievers) acquires this
backend via the new `_get_read_backend()` accessor, so all of recall's
heavy SELECT traffic flows through it. Reflect benefits transparently
because it composes recall via its agent-loop tools.

When the env var is unset, `_read_backend` is the same object as
`_backend`. All call sites are unconditional and behaviour is
bit-identical to before this change. Verified by
`test_read_backend_aliases_primary_when_url_unset`.

Intended deployment: front the read URL with a pgbouncer-style pooler
that routes to read-only standbys. Operators can then enable read
offload for individual workloads (e.g. async workers where slight
replication lag is acceptable) by setting the env var on those pods,
while keeping API pods on the primary URL for read-after-write
correctness on synchronous user requests.

Constraints:
- PostgreSQL backend only. The Oracle backend's abstraction layer does
  not yet model a second pool, so the engine silently falls back to the
  primary backend when the URL is set with `database_backend=oracle`.
- The read backend MUST NOT be used for writes — there is no guarantee
  the underlying server is the primary. Only the recall retrieval
  pipeline is wired to use it. All other call sites continue to use
  `_backend` / `_get_backend()`.
- Cleanup in `MemoryEngine.close()` shuts down the read backend only
  when it is a distinct object from `_backend`, so the alias case is
  not double-closed.

Tests:
- `test_config_validation.py`: read_database_url defaults to None when
  unset, loads when set, treats empty string as unset, and is masked in
  startup logs alongside the primary URL.
- `test_read_backend.py`: alias semantics when unset, distinct backend
  with separate pool when set, accessor returns the right backend in
  both cases, close() terminates the distinct read backend.

`uv run ruff check` clean. `uv run ruff format` clean. `uv run ty check`
clean. New tests pass; existing config tests still pass.

* refactor: add independent read pool knobs and clean up read backend init

- Add HINDSIGHT_API_READ_DB_POOL_MIN_SIZE / READ_DB_POOL_MAX_SIZE env
  vars so the read pool can be sized independently from the primary.
- Store read_database_url in __init__ from config instead of re-reading
  the global config singleton in initialize().
- Trim redundant comments and docstrings.

* fix: document read-replica env vars and fix test hygiene

- Add READ_DATABASE_URL, READ_DB_POOL_MIN_SIZE, READ_DB_POOL_MAX_SIZE
  to configuration.md.
- Remove unused `import os` from test_read_backend.py.
- Use monkeypatch instead of os.environ in test_log_config_masks_read_database_url.

* chore: regenerate docs skill and openapi spec

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-05-06 14:51:25 +02:00
Nicolò Boschi 5a0cb4a517 feat(control-plane): enrich bank dropdown with memory stats (#1479)
* feat(control-plane): enrich bank dropdown with memory stats and activity

Add fact_count and last_document_at to the bank list API response so the
control plane dropdown can show at-a-glance stats for each bank: a
proportional background bar for relative memory volume, compact count
(k/M), and time since last document ingestion. Banks are sorted by most
recently active first. Popover border color softened globally.

* test: assert bank list returns fact_count and last_document_at
2026-05-06 14:46:55 +02:00
Nicolò Boschi 17b4b2f86e release(openclaw): v0.7.3 2026-05-06 14:33:51 +02:00
Nicolò Boschi d37940a9e1 feat(openclaw): drop redundant before_agent_start + add debugPerfTiming (#1477)
* feat(openclaw): drop redundant before_agent_start hook + add debugPerfTiming

Two unrelated-but-tiny openclaw improvements:

- #1354: Stop registering `before_agent_start`. Its body only called
  `resolveAndCacheIdentity()` + emitted a debug log. The same identity
  resolution already happens in `before_dispatch` (earlier in the
  inbound path), `before_prompt_build` (re-resolves before recall, can
  infer senderId from prompt content), and `agent_end` (re-resolves
  before retain). Subscribing here was duplicate work on the hot path.

- #1406: Add `debugPerfTiming?: boolean` plugin config flag (default
  false). When enabled, the plugin emits one info-level perf line per
  recall path and per retain path:

    perf: before_prompt_build hook_total=4200ms recall_main=3800ms source=fresh results=3
    perf: agent_end hook_total=1200ms retain=1100ms outcome=ok bank=main messages=4

  Lets users diagnose latency without patching the dist. The
  `source=fresh|reused` field reflects in-flight recall dedup; the
  `outcome=ok|queued|error` field reflects whether retain succeeded
  inline, was queued for retry, or failed outright.

Also fixes a stale comment that referenced before_agent_start where the
actual lifecycle stage is before_prompt_build.

* fix(openclaw): sync manifest with PluginConfig type + add parity test

OpenClaw's plugin loader runs configSchema validation with
`additionalProperties: false`, so any PluginConfig field not declared
in openclaw.plugin.json is silently rejected at config-set time. The
manifest had drifted from the type:

- retainMission, observationsMission (added in #1473) — never declared
- debugPerfTiming (added earlier in this PR) — never declared
- retainDocumentScope — pre-existing gap, declared now
- enableKnowledgeTools — was in configSchema but missing from uiHints

All five are now in both configSchema.properties and uiHints. Also
fixed the bankMission description to match the corrected README from
#1353 (only affects /reflect, not retain).

Added a manifest.test.ts parity test that compares the type's keys to
the manifest's declared keys and fails on either side of drift. This
is the same class of bug as #1443 (whitelist drift) — having a test
prevents the next round.
2026-05-06 14:31:33 +02:00
Nicolò Boschi d6b7fad43a chore(deps): bump pg0-embedded to >=0.14.0 (#1476)
pg0 0.14.0 bundles libxml2.so.2 + libicu70 inside the binary and
extracts them next to the embedded postgres at first run, so the host
no longer needs libxml2/libicu installed system-wide.

Unblocks embedded mode on:
- Ubuntu 25.10 (Plucky) and the upcoming 26.04 LTS, where libxml2
  bumped to .so.16 and the .so.2 SONAME is gone (#1361)
- Modern Arch / EndeavourOS, where libxml2 was split out into the
  optional `extra/libxml2-legacy` package (#919)
- Other modern glibc distros where the bundled theseus-rs postgres
  failed with "error while loading shared libraries: libxml2.so.2"

The runtime lib bundle ships only on linux-*-gnu builds; macOS,
Windows, and the musl Linux wheel get an empty bundle (their lib
story is unchanged).

Note: this does not fix the second half of #1361 (hindsight-openclaw
strips HINDSIGHT_EMBED_API_DATABASE_URL when regenerating the profile
env file) — that bug lives in hindsight-integrations/openclaw and
needs a separate fix.

Release notes: https://github.com/vectorize-io/pg0/releases/tag/v0.14.0
2026-05-06 14:28:31 +02:00
Nicolò Boschi 64f430bc71 release(claude-code): v0.6.1 2026-05-06 12:51:46 +02:00
Nicolò Boschi 0231094df1 feat(claude-code): create-agent skill understands SDA layout (#1475)
* feat(claude-code): create-agent skill understands SDA directory layout

When invoked as /hindsight-memory:create-agent <name> from <path>, the skill
now knows the directory was prepared by the SDA installer and contains:
- Content files (.md, .txt, etc.) to ingest
- Optional bank-template.json with exact mental model definitions

The skill ingests files via agent_knowledge_ingest_file, then either:
- Creates the exact mental models from bank-template.json, or
- Creates 3 pages that make sense based on content (no template)

* fix(claude-code): retainToolCalls default false, remove agentName empty override

- Default retainToolCalls to false. Tool calls inflate retained content
  significantly and are mostly noise for memory extraction.
- Remove "agentName": "" from settings.json so the Python DEFAULTS value
  ("claude-code") wins. Empty string in settings.json was overriding
  the proper default, producing bank IDs like "::my-project".

* chore: regenerate docs skill
2026-05-06 12:50:47 +02:00
Nicolò Boschi aca03832f8 fix(openclaw): mission semantics + retainQueue config whitelist (#1473)
Addresses three triaged issues against the openclaw plugin:

- #1270: Stop substituting a default `bankMission` when none is configured.
  Previously every gateway restart re-stamped the default text via
  `createBank({reflectMission})`, clobbering per-bank missions written
  out-of-band via `PATCH /banks/{id}`. Empty/unset is now a true opt-out.

- #1353: Expose `retainMission` and `observationsMission` plugin config
  fields. They each map to the matching bank-config column on first use,
  so users can steer retain extraction and observation consolidation
  declaratively in `openclaw.json` instead of patching the bank API
  out-of-band. README clarified that `bankMission` only affects reflect.

- #1443: Add `retainQueuePath`, `retainQueueMaxAgeMs`, and
  `retainQueueFlushIntervalMs` to the `getPluginConfig()` whitelist.
  These keys were declared in the plugin schema and read by queue init,
  but the strict whitelist silently dropped them — so the queue always
  used the hardcoded default path regardless of user config.

Mission stamping is now centralised in `applyConfiguredMissions()` and
gated by `hasConfiguredMissions()`, replacing six ad-hoc `setMission`
call sites with a single helper that no-ops when nothing is configured.
2026-05-06 12:05:39 +02:00
Nicolò Boschi c9145805e2 fix(retain): reduce memory pressure by clearing content references after use (#1455)
The streaming retain pipeline held multiple redundant copies of document
content in memory for the entire duration of processing.

Changes:
- Clear contents[].content after chunking (chunks are the working set)
- Pop contents_dicts["content"] after building combined_content
- Clear sanitized_content after hash computation
- Clear all_pre_chunks[i] after each chunk is extracted and queued
- Clear batch_contents/extracted/processed/chunk_meta after DB commit

Benchmark (50MB document, 16,666 chunks, mock LLM):
                Baseline    With Fix
  Facts:        148,575     148,600   (identical)
  RSS Growth:   1,190MB     61MB      (19.5x reduction)
  Ratio:        24.9x       1.3x content size
2026-05-06 09:26:21 +02:00
Nicolò Boschi c124b0f28b chore: remove self-driving-agents CLI — moved to vectorize-io/self-driving-agents (#1461)
The CLI source, tests, and CI have been moved to
https://github.com/vectorize-io/self-driving-agents and published
as @vectorize-io/[email protected] from that repo.

Removed:
- hindsight-tools/self-driving-agents/ (source + tests)
- CI job test-self-driving-agents from test.yml
- Workspace entry from root package.json
- Tool entry from release-tool.sh
2026-05-06 09:12:30 +02:00
Nicolò Boschi 0b38269a8f docs: add 0.6.0 changelog and release blog post (#1458)
* docs: add 0.6.0 changelog and release blog post

- Generate changelog entry for 0.6.0 (Oracle 23ai, self-driving agents, Dify, n8n, SmolAgents, AgentCore)
- Add "What's new in Hindsight 0.6.0" blog post
- Fix package-lock.json sync for docs workspace

* docs: remove self-driving agents from 0.6.0 changelog and blog post

* docs: remove Claude Code changes from 0.6.0 changelog and blog post
2026-05-05 20:15:30 +02:00
Nicolò Boschi b967e1c8e2 fix: sync package-lock.json for [email protected]
The release script bumped package.json versions but didn't regenerate
the lockfile, causing npm ci to fail in CI for workspaces that depend
on @vectorize-io/hindsight-client.
2026-05-05 18:14:24 +02:00
Nicolò Boschi 05f52b0811 Release v0.6.0
- Update version to 0.6.0 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Create documentation version-0.6
2026-05-05 17:49:48 +02:00
Nicolò Boschi 23dc07b0d4 fix: regenerate docs skill to sync with removed config (#1454)
* fix: resolve CI failures in verify-generated-files, deno tests, and LLM acceptance

- Format n8n integration files with prettier (out of sync on main)
- Format postgresql.py (ruff reformatting)
- Format self-driving-agents tool files with prettier
- Skip jest.spyOn-based abort signal tests when running under Deno
  (jest global is not available in the Deno test runner)
- Upgrade bedrock LLM acceptance model from nova-2-lite to nova-2-pro
  (lite model too weak for fact extraction quality assertions)

* fix: revert bedrock model back to nova-2-lite for LLM acceptance tests
2026-05-05 17:40:33 +02:00
Nicolò Boschi 0e344a24ff release(claude-code): v0.6.0 2026-05-05 17:21:03 +02:00
Nicolò Boschi 9cf3890e0f docs(claude-code): update README for v0.6.0 (#1457)
* docs(claude-code): update README for v0.6.0 — knowledge tools, MCP server, subagents

* fix(claude-code): cross-platform Python fallback in hooks (#1413)

Hook commands now try python3 first, falling back to python if
python3 is not found (e.g. Windows where python3 is a Microsoft
Store stub that returns "Permission denied").

All hook scripts exit 0 on errors (graceful degradation), so the
|| fallback only triggers on "command not found" (exit 127) or
"permission denied" from the Windows python3 stub.
2026-05-05 17:19:55 +02:00
Nicolò Boschi 1b9d6d9160 feat(claude-code): knowledge tools, subagents, and create-agent skill (#1450)
* refactor(claude-code): simplify subagent — no hardcoded bank_id, no Stop hook

The subagent no longer hardcodes bank_id or has its own Stop hook.
Instead:
- inject_bank_id.py PreToolUse hook derives bank_id at runtime from
  the plugin config (supports dynamicBankId, per-repo via cwd, etc.)
- The main plugin's Stop hook retains the full conversation (including
  user input) to the derived bank

This means:
- Multiple subagents share the same bank (derived from plugin config)
- Per-repo isolation works via dynamicBankGranularity: ["agent", "project"]
- User input from the main thread is retained (not lost in subagent context)
- Subagent template is simpler — just tool instructions, no bank plumbing

* fix(self-driving-agents): don't overwrite plugin config on subsequent installs

If ~/.hindsight/claude-code.json already has a Hindsight connection
configured, use it as-is. Only prompt for Cloud/Self-hosted setup on
first install. This prevents installing a second agent from clobbering
the shared config (agentName, bankId, etc.) that the plugin uses at
runtime.

* feat(self-driving-agents): auto-approve hindsight MCP tools in user settings

* fix(self-driving-agents): use plugin bank derivation for content ingestion

* fix(self-driving-agents): resolve bank with project dimension from cwd

resolveFromClaudeCode now includes all dimensions (agent, project,
session, channel, user) matching the plugin's bank.py logic. The
project dimension uses basename(process.cwd()), so running the
installer from a repo directory ingests content into the correct
per-project bank that the plugin will use at runtime.

* fix(self-driving-agents): use plugin's agentName for bank derivation, not CLI agentId

* fix(self-driving-agents): fail if subagent already exists in claude-code

* feat(claude-code): add /create-agent skill for in-session agent creation

* refactor(claude-code): remove agent-knowledge skill — subagent body is self-contained

* refactor(self-driving-agents): simplify claude-code harness — just save content + print prompt

The CLI no longer writes subagent files, resolves banks, or patches
permissions for --harness claude-code. Instead it:
1. Fetches content from GitHub
2. Saves it to ~/.self-driving-agents/claude-code/<agent-id>/
3. Prints the exact prompt to give Claude Code

Claude handles everything via /hindsight-memory:create-agent skill:
- Creates the subagent
- Ingests the seed docs
- Creates initial knowledge pages based on the content

This eliminates all bank derivation issues (bank resolved at runtime
by the plugin) and keeps one code path for agent creation (the skill).

* feat(claude-code): auto-approve bash for .self-driving-agents dir in create-agent skill

* docs(claude-code): clarify ingest steps in create-agent skill

* feat(claude-code): add ingest_file tool + auto-approve MCP tools in skill

- Add agent_knowledge_ingest_file(file_path) — reads file server-side,
  no need to pass content inline. Avoids permission prompts for large
  content and keeps tool calls clean.
- Add mcp__hindsight__* to create-agent skill's allowed-tools
- Update skill instructions to prefer ingest_file for disk files

* feat(self-driving-agents): auto-approve MCP tools, skill, and bash for claude-code

* refactor(claude-code): remove bank_id from MCP tool params

bank_id is no longer exposed as a parameter on any MCP tool. The
server resolves it once at startup from plugin config (derive_bank_id).
This prevents Claude from trying to override it or getting confused
about which bank to use.

Removed inject_bank_id.py PreToolUse hook — no longer needed since
bank resolution is server-side only.

* feat(self-driving-agents): copy bank-template.json and instruct Claude to create mental models from it

* feat(claude-code): add get_current_bank tool so Claude can tell user which bank is active

* chore: regenerate docs skill

* chore: trigger CI
2026-05-05 14:53:17 +02:00
Ling Li b322b0c5eb fix(search): correct vchord BM25 score direction (#1453)
The `<&>` operator returns a distance metric where lower values mean
higher relevance, but the code was using DESC ordering, causing the
least relevant results to appear first. Negate the distance to get a
proper score (higher = more relevant), matching pg_textsearch behavior.
2026-05-05 14:27:54 +02:00
Nicolò Boschi e06bbf6ba7 chore: LLM minimum acceptance tests with CI-managed model matrix (#1445)
* chore: add LLM minimum acceptance test workflow with CI-managed model matrix

Move LLM provider/model selection from Python-level pytest.mark.parametrize
to a GitHub Actions matrix. Each provider/model combo runs as a separate CI
job for clear per-model failure visibility.

- Rewrite test_llm_provider.py to read LLM_TEST_PROVIDER/LLM_TEST_MODEL
  from env vars instead of hardcoded MODEL_MATRIX
- Mark with pytest.mark.llm, excluded from test-api via -m "not llm"
- Add test-llm-acceptance.yml workflow (daily cron, manual, or 'llm-tests' label)
  with matrix of 14 provider/model combinations

* chore: LLM minimum acceptance tests as CI matrix job in test.yml

Replace the Python-level MODEL_MATRIX in test_llm_provider.py with a
CI-managed matrix job (test-api-llm-acceptance) in test.yml.

- Add hs_llm_mat pytest marker for tests that should run across LLM providers
- Tag 6 tests across 5 files covering all core operations:
  - test_llm_provider.py: API methods + memory operations (fact extraction, reflect)
  - test_retain.py: test_retain_with_chunks (multi-paragraph retain)
  - test_fact_extraction_quality.py: test_comprehensive_multi_dimension
  - test_reflections.py: test_reflect_searches_mental_models_when_available
  - test_consolidation.py: test_consolidation_merges_only_redundant_facts
- test-api excludes hs_llm_mat tests via -m "not hs_llm_mat"
- New test-api-llm-acceptance job runs only -m "hs_llm_mat" with matrix:
  vertexai (gemini-2.5-flash, gemini-2.5-flash-lite), openai (gpt-4.1-mini),
  anthropic (claude-sonnet-4, claude-haiku-4), deepseek (deepseek-chat)

* fix: update LLM acceptance matrix to available CI providers

Matrix: vertexai/gemini-2.5-flash-lite, gemini/gemini-2.5-flash-lite,
openai/gpt-4.1-nano, groq/openai-gpt-oss-20b, bedrock/nova-2-lite.
Set HINDSIGHT_API_LLM_API_KEY from matrix-provided secret name.
2026-05-05 14:19:54 +02:00
Nicolò Boschi ffd6418efc release(dify): v0.1.1 2026-05-05 12:49:02 +02:00
Nicolò Boschi 19ca59f710 chore: add dify to changelog generator and create changelog page 2026-05-05 12:48:12 +02:00
Nicolò Boschi 197fd1c290 fix(dify): rename package to hindsight-dify (#1451)
* fix(dify): rename package from hindsight-dify-plugin to hindsight-dify

Align with the naming convention used by other integrations
(hindsight-crewai, hindsight-litellm, etc.).

* style(dify): apply ruff formatting
2026-05-05 12:47:09 +02:00
Nicolò Boschi 6c55dbde64 feat(claude-code): add knowledge tools via Python MCP server + claude/claude-code harnesses (#1428)
Plugin changes (hindsight-integrations/claude-code/):
- Add scripts/mcp_server.py — Python FastMCP stdio server exposing 7
  agent_knowledge_* tools (list/get/create/update/delete pages, recall,
  ingest). Each tool accepts optional bank_id parameter.
- Add scripts/inject_bank_id.py — PreToolUse hook that intercepts
  mcp__hindsight__agent_knowledge_* calls and injects bank_id from
  session context (cwd, agentName) via updatedInput.
- Add .mcp.json — plugin MCP server config (stdio transport)
- Add skills/agent-knowledge/SKILL.md
- Add enableKnowledgeTools config flag (MCP server exits if disabled)
- Make client.request() public (was _request)
- Bump plugin to v0.5.0

CLI changes (hindsight-tools/self-driving-agents/):
- Re-add --harness claude (Chat/Cowork skill zip generation, lost in
  hermes PR merge)
- Add --harness claude-code (marketplace install, config, knowledge tools)
- Add tests for both harnesses
2026-05-05 10:50:03 +02:00
BenandNicolò Boschi bc23750b29 feat(dify): add Dify integration with Hindsight memory tools (#1434)
* feat(dify): add Dify integration with Hindsight memory tools

Adds a Dify Tool Plugin under hindsight-integrations/dify/ exposing three
tools — Retain, Recall, Reflect — that can drop into any Dify workflow,
chatflow, or agent app alongside other LLM and tool nodes.

- Provider with API URL + optional API key credentials, validated via
  Hindsight /health
- 15 unit tests (pytest + pytest-mock)
- test-dify-integration CI job, dify added to release-integration.sh
- Docs page at /sdks/integrations/dify, integrations.json listing,
  placeholder icon
- Live-tested end-to-end against local Hindsight: Retain → fact extraction
  → Recall → Reflect synthesis all pass via Dify workflow

Distributed via GitHub for now; Dify Marketplace submission to follow.

* chore(dify): use real Dify logo for integrations listing

Replaces the placeholder blue-D SVG with the actual Dify icon on the
integrations listing page.

* docs(dify): add author + contact info to plugin README

Required by the Dify Marketplace submission checklist.

* fix(dify): address review feedback — add tool tests, error handling, cleanup

- Add 14 tests for RetainTool, RecallTool, ReflectTool _invoke() methods
- Add try/except around client calls with user-friendly error messages
- Simplify urljoin to f-string in provider health check
- Remove deprecated Pydantic v1 dict() fallback in _memory_to_dict
- Remove emoji from build_package.sh output
- Add comment explaining reflect's lower default budget

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-05-05 10:41:20 +02:00
youchi1 8507095ab8 fix(recall): inherit observation entities through source_memory_ids (#1397)
* fix(recall): inherit observation entities through source_memory_ids

`include_entities=True` returns `entities: null` for every observation in
the recall response, even when those observations are linked through
`source_memory_ids` to facts whose entities are populated. The
per-memory endpoint (`get_memory_unit`) already handles this case: if an
observation has no rows in `unit_entities`, it inherits the union of
entities from its source memories. The recall path queried
`unit_entities` directly and stopped there, so observation results lost
both their per-result `entities` field and their contribution to the
top-level aggregate map.

The asymmetry made observation-only recall hostile to clients that
needed entity context (URL recovery, entity-aware ranking). The
documented workaround was to add `world` and `experience` to the
`types` filter and rely on those facts to carry the entity payload.

Mirror `get_memory_unit`'s fallback inside the recall entity-fetching
block: for observation result IDs that produced no direct
`unit_entities` rows, look up their `source_memory_ids`, fetch entities
for the union of source IDs in a single batched query, and project the
results back onto the original observation IDs (deduped by entity_id,
preserving source-memory order). The downstream code that derives
per-result `entities` and the top-level aggregate map both consume
`fact_entity_map`, so the inheritance flows through both paths
automatically.

Add a regression test that seeds an observation linked via
`source_memory_ids` to a fact carrying two entities, plus a second
observation with its own direct `unit_entities` link, then asserts
recall projects both per-result entity lists and the top-level map.

* refactor(recall): consolidate observation entity inheritance in one SQL helper

The first commit on this branch fixed the recall projection by mirroring
get_memory_unit's procedural fallback in Python: query unit_entities,
detect observations that came back empty, separately fetch
source_memory_ids, separately fetch entities for the union of source
IDs, then dedupe and merge in Python. That worked but had two issues
worth fixing before the PR lands.

First, the inheritance edge ("observation linked through its source
memories") is dialect-shaped: PG stores it on `memory_units.source_memory_ids`,
Oracle keeps it in the `observation_sources` junction table. The
procedural patch reached for `source_memory_ids` directly, which made
recall observation-entity inheritance silently PG-only.

Second, the same fallback already existed inline in get_memory_unit, so
shipping a second copy in recall left two places that had to stay in
sync forever, by hand.

Introduce `_entity_rows_for_units_sql`, a private engine helper that
returns a single dialect-correct UNION SELECT producing
`(unit_id, entity_id, canonical_name)` rows. Direct rows come from
`unit_entities`; observations that have no direct row inherit through
`source_memory_ids` (PG) or `observation_sources` (Oracle), guarded by
NOT EXISTS so the inheritance only fires when the direct path is empty.
This is the same conceptual shape as `_observations_via_source_match_sql`
on the document view fix branch — both are SQL primitives over the
observation-source edge.

Use the helper in two places that previously hand-rolled the same
inheritance logic:

- The recall entity-fetch block collapses from three queries plus a
  Python dedupe loop to one fetch into the same `fact_entity_map`.
- get_memory_unit's two-query "fetch direct, fall back to sources"
  pattern collapses to one fetch, with identical observable behavior.

Add a get_memory_unit assertion to the existing regression test so the
shared helper is exercised through both call sites and any future drift
between recall and the per-memory endpoint trips a test, not a
production report.
2026-05-05 10:40:14 +02:00
DK09876andClaude Opus 4.6 f3b3fa2edb fix: repair 4 broken tests on main (#1437)
* fix: repair 4 broken tests on main

1. Merge divergent alembic heads (9f8e7d6c5b4a + b5d4e3f2a1c9) that
   were created when deferrable FK and cooccurrence backfill migrations
   both targeted the same parent without a merge revision.

2. Fix openrouter null-content mock tests — MagicMock auto-generates
   truthy values for .error and .model_dump().get(), triggering the
   ProviderResponseError path before reaching null-content handling.
   Explicitly set response.error=None and response.model_dump to return
   a clean dict. Also update the expected exception from JSONDecodeError
   to ProviderResponseError to match current behavior.

3. Fix worker test isolation — clean_operations fixture only cleaned
   test-worker-* prefixed operations, but WorkerPoller.claim_batch scans
   all pending operations in the schema. Stale consolidation tasks from
   other xdist workers caused spurious assertion failures.

4. Add retry to custom embedding dimension schema teardown — pg0
   embedded postgres can race with concurrent xdist workers during
   DROP SCHEMA CASCADE, causing 'could not open relation with OID'.

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

* Fix merge migration run_for_dialect and embedding dimension OID race

- Add run_for_dialect pattern to merge migration (required by test_migration_shape)
- Add retry wrapper for ensure_embedding_dimension to handle pg0 OID race
  condition when concurrent xdist workers do DROP SCHEMA CASCADE

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-05-05 10:34:59 +02:00
Nicolò Boschi d9da3f262d ci: stop re-running test workflow on pull_request_review (#1446)
The CI workflow used pull_request_review to re-run secret-requiring jobs
after a maintainer approved a fork PR. But pull_request_review fires on
every review, so approving an internal PR triggered a duplicate CI run on
the same SHA.

Drop the pull_request_review trigger and all the conditional gating it
required. CI now runs once per push on pull_request. Fork PRs run only
the jobs that don't need secrets (gated by has_secrets); to run the full
suite on a fork branch, push it to an internal branch or use
workflow_dispatch.
2026-05-05 10:21:59 +02:00
Nicolò Boschi fd91e3d34e release(n8n): v0.1.1 2026-05-05 10:18:46 +02:00
Nicolò Boschi 62b141318f chore: add n8n to changelog generator and create changelog page 2026-05-05 10:18:24 +02:00
Nicolò Boschi ad4c0e7755 fix(n8n): add missing index.ts, fix auth header, add execution tests (#1444)
- Add index.ts entry point (package.json "main" points to dist/index.js)
- Fix credential auth header: use empty string instead of undefined to
  avoid sending literal "undefined" header for unauthenticated instances
- Use SVG icon instead of PNG for crisper rendering
- Remove unsafe `as IDataObject` casts on client call options, use
  proper Budget type import
- Add node-execute.test.ts with mocked HindsightClient verifying all
  three operations (retain, recall, reflect) are called correctly
2026-05-05 10:16:56 +02:00
Ben c1eaf7110d feat(n8n): add n8n community-node package for Hindsight memory (#1364)
* feat(n8n): add n8n community-node package for Hindsight memory

Adds @vectorize-io/n8n-nodes-hindsight — an n8n community node package
that exposes Hindsight retain / recall / reflect as workflow operations.
Drop the Hindsight node into any workflow alongside Slack, Sheets,
OpenAI, etc. and you have persistent memory across runs.

Package layout (n8n community-node convention):
- credentials/HindsightApi.credentials.ts: credential class
  (apiUrl + optional apiKey, /health test, Bearer auth)
- nodes/Hindsight/Hindsight.node.ts: single node with operation parameter
  exposing retain / recall / reflect (matches Slack-style multi-op nodes)
- nodes/Hindsight/hindsight.svg: node icon
- 14 unit tests (vitest) covering credential metadata, node properties,
  per-operation field gating, budget enums

Wiring:
- detect-changes filter + test-n8n-integration job in test.yml
  (cloned from test-opencode-integration shape)
- Added n8n to VALID_INTEGRATIONS in scripts/release-integration.sh
- New /sdks/integrations/n8n docs page
- Entry in integrations.json so n8n appears on the listing
- n8n.svg icon (placeholder; replace with brand-approved version)

Verified: tsc + vitest both clean (npm run build, npm test).

* feat(n8n): use Hindsight iris logo as node icon

Replaces the placeholder mark with the actual brand logo (PNG).
Updates copy-icons to ship any hindsight.* file with the build, and
ignores npm-pack tarballs.
2026-05-05 10:01:43 +02:00
Ben 7b671f0c1c docs(guides): add framework memory guides batch (#1432) 2026-05-04 14:37:31 -04:00
Ben 0b0f417df8 blog: Your Agent Harness Has Tools. It Still Needs Memory. (#1429)
* blog: Your Agent Harness Has Tools. It Still Needs Memory.
2026-05-04 14:05:15 -04:00
aliu-ronin fc624cbf40 fix(entity-resolver): stamp cooccurrences with event_date, not now() (#1247)
* fix(entity-resolver): stamp cooccurrences with event_date, not now()

`entity_cooccurrences.last_cooccurred` was always set to `datetime.now(UTC)`
at flush time. For real-time retains that's fine — event time ≈ ingest
time — but any corpus **backfilled in a single session** (for example,
migrating from another memory system) collapses every co-occurrence
onto the import moment. The dashboard's entity graph recency heat then
shows a one-or-two-day range regardless of how far the underlying
knowledge actually spans, and downstream consumers of the column lose
the timeline dimension entirely.

The tuples flowing into `_link_units_to_entities_batch_impl` already
carried the per-unit `fact_date` alongside `(unit_id, entity_id)` — it
was just being discarded at the call site (`_fact_date` underscore).
This change wires the event date through:

- `_CooccurrencePair` grows an `event_date` field.
- `link_units_to_entities_batch` accepts both the legacy
  `(unit_id, entity_id)` tuples and the new
  `(unit_id, entity_id, event_date)` form, so external callers aren't
  forced to migrate in lockstep.
- `_link_units_to_entities_batch_impl` builds a per-unit event-date map
  and attaches the unit's date to every co-occurrence pair emitted from
  that unit.
- `flush_pending_stats` aggregates per-pair event dates and INSERTs the
  observed maximum, falling back to `now()` only when no event date was
  carried (preserves the pre-fix semantics for real-time retains).
- Both in-repo callers (`retain/orchestrator.py` and
  `retain/link_utils.py`) pass the `fact_date` they were already
  holding.

A new Alembic migration repairs historical rows by recomputing
`last_cooccurred` from `MAX(COALESCE(mentioned_at, occurred_start,
created_at))` over `unit_entities × memory_units`, so operators don't
have to run a manual backfill to see the fix in their dashboards.
Regression coverage added in `test_entity_resolver.py` asserts a
historical `event_date` survives the link → flush round-trip.

* chore(docs-skill): pick up HINDSIGHT_API_LLM_DEFAULT_HEADERS row from #1389

Incidental docs-skill regen — `generate-docs-skill.sh` produces a 1-line
diff because #1389 (`feat(anthropic): env-driven max_retries +
default_headers knobs`) added the env var to the source documentation
without re-running the skill exporter at merge time.

Has nothing to do with the entity-cooccurrence fix in the previous
commit, but `verify-generated-files` checks the whole tree, so the row
needs to be in this branch for CI to go green.
2026-05-04 18:05:08 +02:00
Nicolò Boschi 7e830f1f99 feat(self-driving-agents): add Hermes Agent harness support (#1431)
- Add --harness hermes to the CLI
- Creates a Hermes profile per agent for isolation
- Installs standalone Python tool plugin (hindsight-sda) that registers
  7 agent_knowledge_* tools via ctx.register_tool
- Plugin coexists with bundled hindsight memory provider: bundled handles
  auto-retain/recall, our plugin adds knowledge page management
- Both read from the same hindsight/config.json in the profile — single
  source of truth, static bank_id with empty bank_id_template
- Prompts for Hindsight credentials (pre-fills from hermes/openclaw config)
- Prompts for agent name (pre-fills from path)
- Adds plugin to plugins.enabled in profile config.yaml
- 43 tests (5 new for hermes)
2026-05-04 17:13:51 +02:00
Nicolò Boschi 73ea0aae75 feat(self-driving-agents): add Claude Chat/Cowork harness (#1427)
* feat(self-driving-agents): add Claude Chat/Cowork harness

Add --harness claude support to the self-driving-agents CLI. Generates
a self-contained skill zip that can be uploaded to Claude Chat or Cowork
via Customize → Skills → Upload.

The generated skill:
- Has the agent's Hindsight API URL, bank ID, and token baked in
- Uses curl to call the Hindsight REST API (no external deps)
- Instructs Claude to load knowledge pages at startup
- Includes commands for creating pages, searching memories, ingesting docs
- Tells Claude to self-retain user preferences/feedback (no hooks in Chat/Cowork)

Setup flow prompts for Cloud vs Self-hosted, warns about public
accessibility for self-hosted servers, and includes allowlist
instructions in the next steps.

* test(self-driving-agents): add unit tests for claude harness

Tests cover skill generation (frontmatter, API URL/bank/token baking,
zip structure), config validation (localhost rejection, cloud URL),
harness validation, and all API operations in the generated skill.
2026-05-04 16:24:24 +02:00
fa4bf70005 feat(anthropic): env-driven max_retries + default_headers knobs (#1389)
* feat(anthropic): env-driven max_retries + default_headers knobs

Add two opt-in env vars to AnthropicLLM.__init__:

- HINDSIGHT_API_LLM_MAX_RETRIES (int): when set, passes through to
  AsyncAnthropic to override the SDK's default retry count. Useful when
  the deployment has its own outer retry layer (Hindsight already does
  2s→300s exponential backoff in call()) and the SDK's auto-retry would
  stack unnecessarily, producing request bursts that compound 429s.

- HINDSIGHT_API_LLM_DEFAULT_HEADERS (JSON string): when set, parsed and
  passed as default_headers to AsyncAnthropic. Useful when routing
  through a proxy that needs custom headers (component attribution,
  client-fingerprint markers, etc).

Both no-op when unset; existing deployments unaffected.

Real-world driver: routing Hindsight through Switchboard (a custom
HTTP proxy that handles retries + needs X-Component-Id for attribution
+ X-SB-Impersonate-CC for fingerprint compat). Without these env knobs,
operators have to volume-mount a patched anthropic_llm.py into the
container, which is fragile across image upgrades.

* refactor(anthropic): route default_headers + max_retries through config.py per reviewer feedback

Addresses @nicoloboschi's review on PR #1389: "can we use the usual
path for using config.py? pls check other providers".

Changes:
- config.py: add ENV_LLM_DEFAULT_HEADERS + DEFAULT_LLM_DEFAULT_HEADERS
  constants and a static llm_default_headers field on HindsightConfig,
  parsed in from_env() the same way llm_extra_body / llm_gemini_safety_settings
  already are. Static (not in _CONFIGURABLE_FIELDS) — infrastructure-level.
- anthropic_llm.py: drop the inline os.environ.get() reads and the new
  import os. Accept default_headers as a typed __init__ kwarg (sourced from
  config). Hardcode max_retries=0 on the SDK client to mirror
  OpenAICompatibleLLM (line 179) — wrapper-level retry loop in `call()` already
  handles backoff, so SDK retries are double work. Drops our custom
  HINDSIGHT_API_LLM_MAX_RETRIES env knob entirely; the existing same-named
  variable still controls Hindsight's wrapper retry count via
  HindsightConfig.llm_max_retries.
- llm_wrapper.py: thread default_headers through create_llm_provider() and
  LLMProvider.__init__/from_env. Falls back to _get_raw_config().llm_default_headers
  when not explicitly passed (mirrors the gemini_safety_settings pattern).
- memory_engine.py: pass config.llm_default_headers to all four LLMConfig
  constructors (memory / retain / reflect / consolidation), parallel to how
  config.llm_extra_body is already passed.
- configuration.md: document HINDSIGHT_API_LLM_DEFAULT_HEADERS in the LLM
  variables table.

Behavior:
- Default behavior with HINDSIGHT_API_LLM_DEFAULT_HEADERS unset is unchanged
  (None → no headers added).
- SDK-level max_retries change: was Anthropic SDK default (2) when the env
  var was unset, now hardcoded 0. Users who relied on SDK retries will get
  the same retry semantics from the wrapper retry loop, which the rest of
  the providers already use.

Verified: ruff check + ruff format both clean on hindsight-api-slim.

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

---------

Co-authored-by: TuftyBruno <[email protected]>
Co-authored-by: cortex <[email protected]>
2026-05-04 15:46:08 +02:00
Nicolò Boschi 0f15f76a41 fix(hindsight-embed): use sysconfig to find scripts dir in daemon start (#1425)
* fix(hindsight-embed): use sysconfig to find scripts dir in _find_api_command (#1401)

`Path(__file__).parent.parent` resolves to site-packages/ in stock pip
venvs, missing the actual scripts dir (<venv>/bin or <venv>/Scripts).
Use `sysconfig.get_path("scripts")` which works across pip venvs, conda,
and --target installs.

* fix(typescript-client): add jest.spyOn/fn shim to deno_setup.ts

The TestAbortSignal tests use jest.spyOn which doesn't exist under Deno.
Add a mock implementation (matching the pattern in the AI SDK's
vitest-compat.ts) so these tests pass with deno test.

* fix(typescript-client): skip TestAbortSignal under Deno

Deno freezes ES module namespace objects, so jest.spyOn cannot patch
sdk exports. Skip these spy-based unit tests under Deno (they're
already covered by the Jest suite).

* fix(hindsight-embed): restore __file__-relative fallback for --target installs

sysconfig.get_path("scripts") correctly fixes stock venv installs
(#1401) but doesn't cover `pip install --target` layouts where the
binary sits alongside site-packages contents. Keep the original
Path(__file__)-based lookup as a second fallback before uvx (#1240).
2026-05-04 15:45:17 +02:00
aliu-ronin 3ec98a4c37 chore(generated): regenerate openapi spec + clients post #1246 (#1426)
#1246 added the `time_field` query parameter to
`/v1/{tenant}/banks/{bank_id}/stats/memories-timeseries` and the
corresponding `MemoriesTimeseriesResponse` field, but the generated
artefacts weren't refreshed at merge time. As a result `verify-generated-files`
fails on every PR opened against `main` until the spec + clients
catch up.

Regenerated by running:
  ./scripts/generate-openapi.sh
  ./scripts/generate-bank-template-schema.sh  (no diff)
  ./scripts/generate-clients.sh               (rust skipped — built at compile time)
  ./scripts/generate-docs-skill.sh            (no diff)
  ./scripts/hooks/lint.sh

The diff is purely the `time_field` query parameter and response field
propagated into the openapi spec and the python / typescript / go clients.
Rust client is auto-generated via `build.rs` (progenitor) so it doesn't
appear in the diff.
2026-05-04 15:44:58 +02:00
Nicolò Boschi b948b574de fix(mcp): expose tag_groups parameter on recall tool (#1396) (#1424)
The MCP recall tool's schema omitted tag_groups, so MCP clients passing
e.g. {"not": {"tags": ["closeout"]}} for negative filtering had it
silently dropped — recall executed without the filter. The REST API
already exposed it; this brings the MCP tool in line.

Validates incoming dicts via TypeAdapter(list[TagGroup]) and enforces
the same tags/tag_groups mutual-exclusivity check as RecallRequest.
2026-05-04 15:22:43 +02:00
Nicolò Boschi 35e06b6f85 fix(self-driving-agents): fail fast when nemoclaw sandbox is missing or destroyed (#1365) 2026-05-04 15:09:36 +02:00
Nicolò Boschi 08b56fdc33 fix(worker): handle NotImplementedError from add_signal_handler on Windows (#1423)
asyncio.AbstractEventLoop.add_signal_handler is Unix-only and raises
NotImplementedError on the Windows ProactorEventLoop. The worker would
crash silently ~30s into startup while the API process kept serving reads,
masking the failure (pending operations accumulate, consolidation never
runs).

Wrap the SIGINT/SIGTERM registration in a helper that swallows the
exception and reports back. On Windows we log a warning that the in-loop
two-stage shutdown is disabled; default Python SIGINT behavior still
terminates the process on Ctrl+C.

Fixes #1411
2026-05-04 13:15:10 +02:00
Nicolò Boschi 178a721ab2 fix(recall): preserve original exception in recall_async error path (#1421)
Closes #1384. The previous handler used `{e}` (which collapses to an empty
string for exceptions whose __str__ is blank) and re-raised as bare
`Exception(...)`, dropping the original class and traceback. Operations
rows ended up with an opaque `Failed to search memories: ` and worker
logs carried no traceback.

- Use `{e!r}` so exceptions with empty __str__ still produce a
  discriminating class+args string.
- `logger.error(..., exc_info=True)` so worker logs carry the full trace.
- `raise RuntimeError(...) from e` preserves the cause chain.
2026-05-04 12:38:41 +02:00
715 changed files with 46646 additions and 19251 deletions
+33 -2
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, volcano
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, volcano
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -30,6 +30,11 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-deepseek-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash # or deepseek-v4-pro / deepseek-chat / deepseek-reasoner
# Example: z.ai configuration (Zhipu GLM series, https://z.ai)
# HINDSIGHT_API_LLM_PROVIDER=zai
# HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
# HINDSIGHT_API_LLM_MODEL=glm-4.5-flash # or glm-4.5-air for the paid tier
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
@@ -49,6 +54,7 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
@@ -59,12 +65,25 @@ HINDSIGHT_API_LOG_LEVEL=info
# 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)
# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# For OpenAI-compatible embeddings:
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxx
# HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small
# HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=https://api.openai.com/v1
#
# IMPORTANT: Embedding keys require provider-specific names:
# HINDSIGHT_API_EMBEDDINGS_{PROVIDER}_{PARAMETER}
# (for example, HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL).
#
# DeepSeek note: DeepSeek is supported for LLM calls, but not for embeddings.
# If using DeepSeek as LLM provider, keep embeddings on local/openai/cohere/google/etc.
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
@@ -88,3 +107,15 @@ HINDSIGHT_API_LOG_LEVEL=info
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
# -----------------------------------------------------------------------------
# Control Plane (Optional)
# -----------------------------------------------------------------------------
# Dataplane API URL - where the CP proxies requests to
# HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
# Optional: Require a shared access key to view the Control Plane UI.
# When set, visitors see a login page and must enter the key before
# accessing the dashboard or any /api/* routes (except /api/health).
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key
+40 -37
View File
@@ -25,10 +25,10 @@ on:
- recall-with-observations
- consolidation
default: ""
locomo_max_conversations:
description: "LoComo max conversations (0 = skip, blank = all)"
type: number
default: 0
locomo_conversations:
description: "LoComo conversation IDs (space-separated). Blank = curated set (conv-26 conv-30 conv-43)."
type: string
default: ""
locomo_skip:
description: "Skip LoComo job"
type: boolean
@@ -83,46 +83,36 @@ jobs:
run: |
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: "Suite: retain"
if: inputs.suite == '' || inputs.suite == 'retain'
- name: Run perf-test
run: |
SUITE_ARG=""
if [ -n "${{ inputs.suite }}" ]; then
SUITE_ARG="--suite ${{ inputs.suite }}"
fi
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
--suite retain \
--output perf-results-retain.json
- name: "Suite: recall"
if: inputs.suite == '' || inputs.suite == 'recall'
run: |
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
--suite recall \
--output perf-results-recall.json
- name: "Suite: recall-with-observations"
if: inputs.suite == '' || inputs.suite == 'recall-with-observations'
run: |
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
--suite recall-with-observations \
--output perf-results-recall-with-observations.json
- name: "Suite: consolidation"
if: inputs.suite == '' || inputs.suite == 'consolidation'
run: |
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
--suite consolidation \
--output perf-results-consolidation.json
$SUITE_ARG \
--output perf-results.json
- name: Upload perf results
if: always()
uses: actions/upload-artifact@v7
with:
name: perf-results-${{ github.sha }}
path: hindsight-dev/perf-results-*.json
path: hindsight-dev/perf-results.json
retention-days: 90
# Publish enriched results (perf JSON + commit metadata) to the dashboard
# repo's gh-pages branch. The static site at
# https://vectorize-io.github.io/hindsight-continuous-performance-monitor/
# reads data/index.json + data/<run>.json and renders charts client-side.
- name: Publish to dashboard
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-perf-results.sh hindsight-dev/perf-results.json
locomo:
if: inputs.locomo_skip != true
runs-on: ubuntu-latest
@@ -179,14 +169,20 @@ jobs:
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run LoComo benchmark
# Curated 3-conversation subset (best/middle/worst by accuracy on the
# last successful full run): conv-26 (best), conv-30 (middle), conv-43
# (worst). Excludes conv-44, the bank with the largest unconsolidated
# set that has been pushing scheduled runs over the per-bank
# _wait_for_consolidation timeout. Override via workflow_dispatch with
# the locomo_conversations input.
run: |
MAX_CONV_ARG=""
if [ "${{ inputs.locomo_max_conversations }}" != "0" ] && [ -n "${{ inputs.locomo_max_conversations }}" ]; then
MAX_CONV_ARG="--max-conversations ${{ inputs.locomo_max_conversations }}"
CONVERSATIONS="${{ inputs.locomo_conversations }}"
if [ -z "$CONVERSATIONS" ]; then
CONVERSATIONS="conv-26 conv-30 conv-43"
fi
uv run python hindsight-dev/benchmarks/locomo/locomo_benchmark.py \
--wait-consolidation \
$MAX_CONV_ARG
--conversation $CONVERSATIONS
- name: Upload LoComo results
if: always()
@@ -195,3 +191,10 @@ jobs:
name: locomo-results-${{ github.sha }}
path: hindsight-dev/benchmarks/locomo/results/
retention-days: 90
- name: Publish LoComo to dashboard
if: success() && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-locomo-results.sh hindsight-dev/benchmarks/locomo/results/benchmark_results.json
+1 -1
View File
@@ -117,7 +117,7 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
OUTPUT=$(npm publish --access public --provenance 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
+27
View File
@@ -314,6 +314,7 @@ jobs:
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
include:
@@ -410,6 +411,7 @@ jobs:
# Build multi-platform and push to release tags
- name: Build and push release images
id: build
uses: docker/build-push-action@v7
with:
context: .
@@ -421,6 +423,31 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign published images
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
refs=()
while IFS= read -r tag; do
[[ -z "${tag}" ]] && continue
refs+=("${tag}@${DIGEST}")
done <<< "${TAGS}"
cosign sign --yes "${refs[@]}"
- name: Verify signature on primary tag
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign verify "${IMAGE}@${DIGEST}" \
--certificate-identity-regexp "^https://github\.com/${{ github.repository }}/\.github/workflows/release\.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
release-helm-chart:
runs-on: ubuntu-latest
permissions:
+71
View File
@@ -0,0 +1,71 @@
name: Sign published images
on:
workflow_dispatch:
inputs:
version:
description: 'Version to sign (without leading v, e.g. 0.6.0)'
required: true
type: string
default: '0.6.0'
permissions:
contents: read
packages: write
id-token: write
jobs:
sign:
name: Sign ${{ matrix.image }}:${{ inputs.version }}${{ matrix.suffix }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { image: hindsight-api, suffix: '' }
- { image: hindsight-api, suffix: '-slim' }
- { image: hindsight-control-plane, suffix: '' }
- { image: hindsight, suffix: '' }
- { image: hindsight, suffix: '-slim' }
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Resolve image digest
id: resolve
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}
TAG: ${{ inputs.version }}${{ matrix.suffix }}
run: |
set -euo pipefail
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ -z "${DIGEST}" || "${DIGEST}" != sha256:* ]]; then
echo "Failed to resolve digest for ${IMAGE}:${TAG} (got: ${DIGEST})" >&2
exit 1
fi
echo "Resolved ${IMAGE}:${TAG} -> ${DIGEST}"
echo "ref=${IMAGE}@${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Sign image
env:
REF: ${{ steps.resolve.outputs.ref }}
run: cosign sign --yes "${REF}"
- name: Verify signature
env:
REF: ${{ steps.resolve.outputs.ref }}
run: |
cosign verify "${REF}" \
--certificate-identity-regexp "^https://github\.com/${{ github.repository }}/\.github/workflows/sign-images\.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
+199 -118
View File
@@ -3,8 +3,6 @@ name: CI
on:
pull_request:
branches: [ main ]
pull_request_review:
types: [ submitted ]
workflow_dispatch:
concurrency:
@@ -13,10 +11,6 @@ concurrency:
jobs:
detect-changes:
# Skip non-approved pull_request_review events
if: >-
github.event_name != 'pull_request_review' ||
github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
pull-requests: read
@@ -47,24 +41,22 @@ jobs:
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }}
integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }}
integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }}
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
integrations-dify: ${{ steps.filter.outputs.integrations-dify }}
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
tools-self-driving-agents: ${{ steps.filter.outputs.tools-self-driving-agents }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
# Secrets are available for internal PRs, pull_request_review, and workflow_dispatch.
# Secrets are available for internal PRs and workflow_dispatch.
# Fork PRs via pull_request event do NOT have access to secrets.
has_secrets: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- uses: actions/checkout@v6
with:
# For pull_request_review, checkout the PR head (not the base branch)
ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || '' }}
- uses: dorny/paths-filter@v4
id: filter
@@ -133,6 +125,8 @@ jobs:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-lockfiles:
@@ -147,10 +141,10 @@ jobs:
- 'hindsight-integrations/agentcore/**'
integrations-smolagents:
- 'hindsight-integrations/smolagents/**'
integrations-dify:
- 'hindsight-integrations/dify/**'
tools-agent-sdk:
- 'hindsight-tools/hindsight-agent-sdk/**'
tools-self-driving-agents:
- 'hindsight-tools/self-driving-agents/**'
dev:
- 'hindsight-dev/**'
ci:
@@ -166,7 +160,6 @@ jobs:
check-integration-lockfiles:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-lockfiles == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -182,7 +175,6 @@ jobs:
build-api-python-versions:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -213,7 +205,6 @@ jobs:
build-typescript-client:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -240,7 +231,6 @@ jobs:
build-hindsight-all-npm:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.all-npm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -270,7 +260,6 @@ jobs:
build-openclaw-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
@@ -326,7 +315,6 @@ jobs:
smoke-openclaw-install:
needs: [detect-changes, build-openclaw-integration]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
@@ -384,7 +372,6 @@ jobs:
test-claude-code-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-claude-code == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -410,7 +397,6 @@ jobs:
test-codex-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-codex == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -436,7 +422,6 @@ jobs:
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -467,7 +452,6 @@ jobs:
test-ai-sdk-integration-deno:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -499,7 +483,6 @@ jobs:
test-opencode-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-opencode == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -527,10 +510,39 @@ jobs:
working-directory: ./hindsight-integrations/opencode
run: npm run build
test-n8n-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-n8n == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/n8n
run: npm install --no-fund --no-audit
- name: Run tests
working-directory: ./hindsight-integrations/n8n
run: npm test
- name: Build
working-directory: ./hindsight-integrations/n8n
run: npm run build
test-hindsight-agent-sdk:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.tools-agent-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -560,43 +572,9 @@ jobs:
- name: Build
run: npm run build --workspace=hindsight-tools/hindsight-agent-sdk
test-self-driving-agents:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.tools-self-driving-agents == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install root workspace dependencies
run: npm ci
- name: Build hindsight-client (self-driving-agents dep)
run: npm run build --workspace=hindsight-clients/typescript
- name: Run tests
run: npm test --workspace=hindsight-tools/self-driving-agents
- name: Build
run: npm run build --workspace=hindsight-tools/self-driving-agents
test-cloudflare-oauth-proxy-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cloudflare-oauth-proxy == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -627,7 +605,6 @@ jobs:
build-chat-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-chat == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -658,7 +635,6 @@ jobs:
test-paperclip-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-paperclip == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -689,7 +665,6 @@ jobs:
test-pipecat-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-pipecat == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -727,7 +702,6 @@ jobs:
build-control-plane:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
@@ -788,7 +762,6 @@ jobs:
build-docs:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.docs == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -816,8 +789,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -945,7 +917,6 @@ jobs:
lint-helm-chart:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.helm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -968,8 +939,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.docker == 'true' ||
needs.detect-changes.outputs.control-plane == 'true' ||
@@ -1062,8 +1032,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
@@ -1133,7 +1102,111 @@ jobs:
- name: Run tests
working-directory: ./hindsight-api-slim
run: uv run pytest tests -v
run: uv run pytest tests -v -m "not hs_llm_mat"
test-api-llm-acceptance:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- provider: vertexai
model: google/gemini-2.5-flash-lite
- provider: gemini
model: gemini-2.5-flash-lite
api_key_secret: GEMINI_API_KEY
- provider: openai
model: gpt-4.1-nano
api_key_secret: OPENAI_API_KEY
- provider: groq
model: openai/gpt-oss-20b
api_key_secret: GROQ_API_KEY
- provider: bedrock
model: us.amazon.nova-2-lite-v1:0
- provider: litellmrouter
model: gpt-4.1-nano
# Single-deployment chain over OpenAI — verifies the Router-backed
# call path works end-to-end. Built from secrets in the step below.
name: LLM acceptance (${{ matrix.provider }}/${{ matrix.model }})
env:
HINDSIGHT_API_LLM_PROVIDER: ${{ matrix.provider }}
HINDSIGHT_API_LLM_MODEL: ${{ matrix.model }}
HINDSIGHT_API_LLM_API_KEY: ${{ matrix.api_key_secret && secrets[matrix.api_key_secret] || '' }}
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION_NAME: ${{ secrets.AWS_REGION_NAME }}
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install dependencies
working-directory: ./hindsight-api-slim
run: uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
SentenceTransformer('BAAI/bge-small-en-v1.5')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
"
- name: Build litellmrouter config
if: matrix.provider == 'litellmrouter'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ROUTER_MODEL: ${{ matrix.model }}
run: |
cfg=$(jq -nc \
--arg model "openai/$ROUTER_MODEL" \
--arg key "$OPENAI_API_KEY" \
'{model_list: [{model_name: "default", litellm_params: {model: $model, api_key: $key}}]}')
echo "::add-mask::$cfg"
echo "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG=$cfg" >> "$GITHUB_ENV"
- name: Run LLM acceptance tests
working-directory: ./hindsight-api-slim
run: uv run pytest tests -v -m "hs_llm_mat" --timeout 600
test-api-oracle:
needs: [detect-changes]
@@ -1142,8 +1215,7 @@ jobs:
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
contains(github.event.pull_request.labels.*.name, 'oracle-tests') &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
@@ -1261,8 +1333,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1373,8 +1444,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1503,8 +1573,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1663,8 +1732,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1823,8 +1891,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1945,7 +2012,6 @@ jobs:
build-rust-cli-arm64:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1978,8 +2044,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-rust == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2094,8 +2159,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-go == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2208,8 +2272,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
@@ -2341,8 +2404,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.integration-tests == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2447,7 +2509,6 @@ jobs:
test-ag2-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ag2 == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2484,7 +2545,6 @@ jobs:
test-smolagents-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-smolagents == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2518,10 +2578,43 @@ jobs:
working-directory: ./hindsight-integrations/smolagents
run: uv run pytest tests -v
test-crewai-integration:
test-dify-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-dify == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
# Dify plugin runtime targets Python 3.12 (see manifest.yaml)
python-version: '3.12'
- name: Install dependencies
working-directory: ./hindsight-integrations/dify
run: uv pip install --system -e . pytest pytest-mock
- name: Run tests
working-directory: ./hindsight-integrations/dify
run: pytest tests -v
test-crewai-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-crewai == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2558,7 +2651,6 @@ jobs:
test-litellm-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-litellm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2595,7 +2687,6 @@ jobs:
test-pydantic-ai-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-pydantic-ai == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2632,7 +2723,6 @@ jobs:
test-llamaindex-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-llamaindex == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2669,7 +2759,6 @@ jobs:
test-openai-agents-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openai-agents == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2706,7 +2795,6 @@ jobs:
test-agentcore-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-agentcore == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2744,8 +2832,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
@@ -2813,8 +2900,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2881,8 +2967,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3059,8 +3144,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.hindsight-all == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3144,8 +3228,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
@@ -3297,8 +3380,7 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.dev == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3380,8 +3462,9 @@ jobs:
done
verify-generated-files:
if: github.event_name != 'pull_request_review'
runs-on: ubuntu-latest
env:
UV_FROZEN: "1"
steps:
- uses: actions/checkout@v6
with:
@@ -3462,7 +3545,6 @@ jobs:
check-openapi-compatibility:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3514,7 +3596,6 @@ jobs:
check-cli-coverage:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
@@ -3585,6 +3666,7 @@ jobs:
- test-integration
- test-ag2-integration
- test-smolagents-integration
- test-dify-integration
- test-crewai-integration
- test-litellm-integration
- test-pydantic-ai-integration
@@ -3595,7 +3677,6 @@ jobs:
- test-embed-windows
- test-hindsight-all
- test-hindsight-agent-sdk
- test-self-driving-agents
- test-doc-examples
- test-upgrade
- verify-generated-files
@@ -3684,4 +3765,4 @@ jobs:
issue_number: prNumber,
body,
});
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ It eliminates the shortcomings of alternative techniques such as RAG and knowled
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
![Overview](./hindsight-docs/static/img/hindsight-benchmarks.png)
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
Generated
+33 -1
View File
@@ -42,6 +42,16 @@
},
"workspace": {
"members": {
"hindsight-all-npm": {
"packageJson": {
"dependencies": [
"npm:@types/node@22",
"npm:tsup@^8.5.1",
"npm:typescript@^5.7.0",
"npm:vitest@^4.1.2"
]
}
},
"hindsight-clients/typescript": {
"packageJson": {
"dependencies": [
@@ -58,6 +68,7 @@
"hindsight-control-plane": {
"packageJson": {
"dependencies": [
"npm:@chenglou/pretext@^0.0.3",
"npm:@eslint/eslintrc@^3.3.3",
"npm:@eslint/js@^9.39.2",
"npm:@radix-ui/react-alert-dialog@^1.1.15",
@@ -91,11 +102,12 @@
"npm:eslint@^9.39.1",
"npm:[email protected]",
"npm:next-themes@~0.4.6",
"npm:next@^16.1.6",
"npm:next@^16.1.7",
"npm:postcss@^8.5.6",
"npm:prettier@^3.7.4",
"npm:react-chrono@^2.9.1",
"npm:react-dom@^19.2.0",
"npm:react-is@^19.2.4",
"npm:react-markdown@^10.1.0",
"npm:react18-json-view@~0.2.9",
"npm:react@^19.2.0",
@@ -133,6 +145,26 @@
"npm:typescript@~5.6.2"
]
}
},
"hindsight-tools/hindsight-agent-sdk": {
"packageJson": {
"dependencies": [
"npm:@vectorize-io/hindsight-client@~0.5.6",
"npm:typescript@^5.4.0",
"npm:vitest@^4.1.2"
]
}
},
"hindsight-tools/self-driving-agents": {
"packageJson": {
"dependencies": [
"npm:@clack/prompts@^1.2.0",
"npm:@vectorize-io/hindsight-client@~0.5.6",
"npm:picocolors@^1.1.0",
"npm:typescript@^5.4.0",
"npm:vitest@^4.1.2"
]
}
}
}
}
@@ -0,0 +1,90 @@
name: hindsight
# Docker Compose file for Hindsight with AlloyDB Omni and ScaNN
# Uses Google's free AlloyDB Omni container image: https://hub.docker.com/r/google/alloydbomni
#
# Usage:
# docker compose -f docker/docker-compose/alloydb/docker-compose.yaml up -d
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: password for the AlloyDB Omni/PostgreSQL user
# - Configure LLM provider variables as needed (see the hindsight service below)
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_VERSION: AlloyDB Omni image tag (default: 17)
# - HINDSIGHT_DB_USER: database user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: database name (default: hindsight_db)
services:
db:
image: google/alloydbomni:${HINDSIGHT_DB_VERSION:-17}
container_name: hindsight-db-alloydb
restart: always
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:
- alloydb_data:/var/lib/postgresql/data
networks:
- hindsight-net
alloydb-init:
image: google/alloydbomni:${HINDSIGHT_DB_VERSION:-17}
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command:
- bash
- -c
- |
echo 'Waiting for AlloyDB Omni to be ready...'
until pg_isready -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user}; do
echo 'AlloyDB Omni is unavailable - sleeping'
sleep 2
done
echo 'AlloyDB Omni is ready - creating ${HINDSIGHT_DB_NAME:-hindsight_db} database'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -c 'CREATE DATABASE ${HINDSIGHT_DB_NAME:-hindsight_db};' 2>/dev/null || echo 'Database already exists'
echo 'Creating vector and alloydb_scann extensions'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -d ${HINDSIGHT_DB_NAME:-hindsight_db} -c 'CREATE EXTENSION IF NOT EXISTS vector;'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -d ${HINDSIGHT_DB_NAME:-hindsight_db} -c 'CREATE EXTENSION IF NOT EXISTS alloydb_scann 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: scann
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: native
depends_on:
db:
condition: service_started
alloydb-init:
condition: service_completed_successfully
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
alloydb_data:
@@ -0,0 +1,34 @@
# Example: custom Hindsight image with non-default local models baked in.
#
# Use this pattern in production when you run a non-default embedder or
# reranker. Baking models into the image removes the runtime dependency on
# HuggingFace and lets the container registry handle caching per node, so
# you don't need a model-cache PVC.
#
# Built on top of the slim image so only the deps and models you actually
# use end up in the final image.
FROM ghcr.io/vectorize-io/hindsight:latest-slim
# Install the local-ml deps required to load sentence-transformers /
# cross-encoder models at runtime. Pinned ranges mirror hindsight-api-slim's
# `local-ml` extra in hindsight-api-slim/pyproject.toml. Use `uv pip
# install` against the image's venv explicitly: the slim image's venv was
# created by `uv sync` and does not ship its own `pip`, so a bare
# `pip install` would fall back to user site-packages and not be visible
# to the runtime python.
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
'sentence-transformers>=3.3.0' \
'transformers>=4.53.0' \
'torch>=2.6.0'
# Pre-download the models you want to use. Replace these with your own.
# The defaults bundled in the full image are BAAI/bge-small-en-v1.5 and
# cross-encoder/ms-marco-MiniLM-L-6-v2; here we pick multilingual variants
# as a concrete non-default example.
ARG EMBEDDER=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
ARG RERANKER=cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
ENV HF_HUB_DOWNLOAD_TIMEOUT=600
RUN python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
SentenceTransformer('${EMBEDDER}'); \
CrossEncoder('${RERANKER}')"
@@ -0,0 +1,81 @@
# Hindsight with Custom Local Models
Example Docker Compose setup that builds a Hindsight image with **non-default
local embedder and reranker models baked in at build time**.
This is the recommended pattern for production when you use a non-default
local model: the container registry caches model layers per node, pod
startup is deterministic, and you don't need a model-cache PVC (or any
runtime dependency on HuggingFace).
## When to use this
- You override `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` or
`HINDSIGHT_API_RERANKER_LOCAL_MODEL` to a non-default model.
- You want pod startup to be deterministic and offline-capable.
- You'd otherwise reach for a Helm `modelCache` PVC just to avoid
re-downloading models.
If you're using the **default** local models, the published full image
(`ghcr.io/vectorize-io/hindsight:latest`) already bakes them in — you don't
need this example.
If you're using **external** providers (TEI, OpenAI, Cohere, ...) for
embeddings and reranking, use the slim image directly — no models are
needed in the image.
## Quick start
```bash
export OPENAI_API_KEY=sk-xxx
docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
```
- API: http://localhost:8888
- Control Plane: http://localhost:9999
## Using your own models
Override the build args to bake different models:
```bash
docker compose -f docker/docker-compose/custom-models/docker-compose.yaml build \
--build-arg EMBEDDER=your-org/your-embedder \
--build-arg RERANKER=your-org/your-reranker
```
Then update the matching `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` and
`HINDSIGHT_API_RERANKER_LOCAL_MODEL` values in `docker-compose.yaml` so the
runtime points at the same model IDs.
## Verifying the models are baked in
`docker-compose.yaml` sets `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1`
so that any attempt to download a model at runtime fails loudly instead of
silently re-downloading. If the container starts and serves recall queries
with these set, the models are correctly baked in.
You can also inspect the image directly:
```bash
docker run --rm --entrypoint sh hindsight-custom-models-hindsight \
-c 'ls ~/.cache/huggingface/hub/'
```
## Why not a model-cache PVC?
The Helm chart exposes an optional `api.persistence.modelCache` PVC for
caching downloaded models across pod restarts. Compared to baking models
into the image:
- A PVC adds storage cost — one PVC per worker replica with
`volumeClaimTemplates`.
- `ReadWriteOnce` (the default) pins pods to a node.
- The PVC needs lifecycle management on `helm uninstall` / `helm upgrade`
— without `helm.sh/resource-policy: keep` it is deleted on uninstall;
with it, storage keeps billing forever until manually cleaned up.
- Pod startup still depends on HuggingFace being reachable on first run.
Image layers, by contrast, are pulled once per node and cached for free by
the container runtime, with no orphaned-storage cleanup story.
@@ -0,0 +1,44 @@
name: hindsight-custom-models
# Example: run a custom Hindsight image with non-default local models baked
# in at build time, so pod startup does not depend on HuggingFace at runtime.
#
# Quick start:
# export OPENAI_API_KEY=sk-xxx
# docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
#
# Required environment variables:
# - OPENAI_API_KEY (or configure another LLM provider via HINDSIGHT_API_LLM_*)
services:
hindsight:
build:
context: .
dockerfile: Dockerfile
# Override at build time to bake different models:
# docker compose build --build-arg EMBEDDER=your-org/your-embedder
args:
EMBEDDER: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
RERANKER: cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
container_name: hindsight-custom-models
ports:
- "8888:8888"
- "9999:9999"
environment:
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Point Hindsight at the models baked into the image above.
HINDSIGHT_API_EMBEDDINGS_PROVIDER: local
HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
HINDSIGHT_API_RERANKER_PROVIDER: local
HINDSIGHT_API_RERANKER_LOCAL_MODEL: cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
# Fail fast if a model is missing from the image instead of silently
# falling back to a HuggingFace download at runtime.
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
volumes:
- pg_data:/home/hindsight/.pg0
volumes:
pg_data:
@@ -51,6 +51,8 @@ services:
# Control Plane config
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
# Optional: Require a shared access key for Control Plane UI access
# HINDSIGHT_CP_ACCESS_KEY: your-secret-key
volumes:
# Persist embedded pg0 database
- hindsight_data:/app/data
+8
View File
@@ -172,6 +172,10 @@ USER hindsight
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
# Make /home/hindsight traversable when running with --user UID:GID overrides
# (default 0700 blocks traversal by non-owner UIDs needed for bind-mount ownership matching)
RUN chmod 755 /home/hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
@@ -328,6 +332,10 @@ USER hindsight
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
# Make /home/hindsight traversable when running with --user UID:GID overrides
# (default 0700 blocks traversal by non-owner UIDs needed for bind-mount ownership matching)
RUN chmod 755 /home/hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.5.6
appVersion: "0.5.6"
version: 0.6.2
appVersion: "0.6.2"
keywords:
- ai
- memory
+10 -1
View File
@@ -70,6 +70,12 @@ api:
# Persistent volume for local model cache (reranker, embeddings)
# Models are downloaded to /home/hindsight/.cache on first use.
# Without persistence, models are re-downloaded on every pod restart.
#
# For production, prefer baking models into a custom image instead of
# enabling this PVC: image layers are pulled once per node and cached
# for free, while a PVC adds storage cost, pins pods to a node
# (ReadWriteOnce), and needs lifecycle management on uninstall/upgrade.
# See docs: developer/installation#bundling-custom-models-in-a-custom-image
persistence:
modelCache:
enabled: false
@@ -168,7 +174,10 @@ worker:
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Uses volumeClaimTemplates since worker is a StatefulSet.
# Uses volumeClaimTemplates since worker is a StatefulSet — one PVC per
# replica. For production, prefer baking models into a custom image; see
# api.persistence.modelCache above and docs:
# developer/installation#bundling-custom-models-in-a-custom-image
persistence:
modelCache:
enabled: false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.5.6",
"version": "0.6.2",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.5.6"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim>=0.4.17",
"hindsight-api-slim==0.6.2",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+3 -3
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.5.6"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]>=0.4.17",
"hindsight-api-slim[all]==0.6.2",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]>=0.4.17",
"hindsight-api-slim[local-llm]==0.6.2",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.5.6"
__version__ = "0.6.2"
@@ -0,0 +1,170 @@
"""Shared PostgreSQL vector-extension dispatch helpers."""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.engine import Connection
logger = logging.getLogger(__name__)
# Extensions a user can set via HINDSIGHT_API_VECTOR_EXTENSION.
CONFIGURABLE_EXTENSIONS = ("pgvector", "pgvectorscale", "vchord", "scann")
# Extensions detect_vector_extension() can return. pg_diskann is a runtime-only
# resolution from a configured "pgvectorscale" backend on Azure (uses a different
# WITH clause), never a value the user sets directly.
RESOLVED_EXTENSIONS = (*CONFIGURABLE_EXTENSIONS, "pg_diskann")
# Backwards-compatible alias for older imports.
VALID_EXTENSIONS = CONFIGURABLE_EXTENSIONS
SCANN_MIN_ROWS_FOR_AUTO_INDEX = 10_000
_EXTENSION_NAMES = {
"pgvector": "vector",
"pgvectorscale": "vectorscale",
"vchord": "vchord",
"scann": "alloydb_scann",
}
_INDEX_USING_CLAUSES = {
"pgvector": "USING hnsw (embedding vector_cosine_ops)",
"pgvectorscale": "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)",
"pg_diskann": "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)",
"vchord": "USING vchordrq (embedding vector_l2_ops)",
"scann": "USING scann (embedding cosine) WITH (mode = 'AUTO')",
}
_INDEX_TYPE_KEYWORDS = {
"pgvector": "hnsw",
"pgvectorscale": "diskann",
"pg_diskann": "diskann",
"vchord": "vchordrq",
"scann": "scann",
}
_EXTENSION_INSTALL_SQL = {
"pgvector": ("CREATE EXTENSION IF NOT EXISTS vector",),
"pgvectorscale": (
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE",
),
"vchord": ("CREATE EXTENSION IF NOT EXISTS vchord CASCADE",),
"scann": (
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE",
),
}
_INSTALL_HINTS = {
"pgvector": "CREATE EXTENSION vector;",
"pgvectorscale": "CREATE EXTENSION vector; then CREATE EXTENSION vectorscale CASCADE; (or pg_diskann on Azure)",
"vchord": "CREATE EXTENSION vchord CASCADE;",
"scann": "CREATE EXTENSION vector; then CREATE EXTENSION alloydb_scann CASCADE;",
}
def validate_extension(name: str) -> str:
"""Return a normalized configurable vector extension name or raise.
Used at the user-facing config boundary; pg_diskann is rejected here because
it is a detection-time alias, never a value the user sets directly.
"""
ext = name.lower()
if ext not in CONFIGURABLE_EXTENSIONS:
valid = ", ".join(CONFIGURABLE_EXTENSIONS)
raise ValueError(f"Invalid vector_extension: {name}. Must be one of: {valid}")
return ext
def _normalize_resolved(name: str) -> str:
"""Normalize either a user-configurable or detect-time extension name."""
ext = name.lower()
if ext not in RESOLVED_EXTENSIONS:
valid = ", ".join(RESOLVED_EXTENSIONS)
raise ValueError(f"Unknown vector extension: {name}. Must be one of: {valid}")
return ext
def pg_extension_name(ext: str) -> str:
"""Return the PostgreSQL extension name for a configured vector backend."""
return _EXTENSION_NAMES[validate_extension(ext)]
def index_using_clause(ext: str) -> str:
"""Return the CREATE INDEX USING clause for the vector backend."""
return _INDEX_USING_CLAUSES[_normalize_resolved(ext)]
def index_type_keyword(ext: str) -> str:
"""Return the keyword that identifies this index type in pg_indexes.indexdef."""
return _INDEX_TYPE_KEYWORDS[_normalize_resolved(ext)]
def minimum_rows_for_index(ext: str) -> int:
"""Return the minimum populated embedding rows before creating this index type."""
return SCANN_MIN_ROWS_FOR_AUTO_INDEX if _normalize_resolved(ext) == "scann" else 0
def should_defer_index_creation(ext: str, row_count: int) -> bool:
"""Return True when index creation should wait for more embeddings."""
minimum_rows = minimum_rows_for_index(ext)
return minimum_rows > 0 and row_count < minimum_rows
def uses_per_bank_vector_indexes(ext: str) -> bool:
"""Return whether the backend should create per-bank partial vector indexes."""
return _normalize_resolved(ext) != "scann"
def bootstrap_extension(conn: Connection, ext: str) -> None:
"""Install the configured vector extension and any prerequisites if possible."""
normalized = validate_extension(ext)
for statement in _EXTENSION_INSTALL_SQL[normalized]:
conn.execute(text(statement))
def detect_vector_extension(conn: Connection, vector_extension: str = "pgvector") -> str:
"""Validate the configured vector extension exists and return the index backend."""
configured_ext = validate_extension(vector_extension)
if configured_ext == "pgvectorscale":
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. "
f"Install it with: {_INSTALL_HINTS['pgvectorscale']}"
)
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"
if pg_diskann_check:
logger.debug("Using vector extension: pg_diskann (Azure DiskANN)")
return "pg_diskann"
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;"
)
extension_name = pg_extension_name(configured_ext)
extension_check = conn.execute(
text("SELECT 1 FROM pg_extension WHERE extname = :extension_name"),
{"extension_name": extension_name},
).scalar()
if not extension_check:
raise RuntimeError(
f"Configured vector extension '{configured_ext}' not found. "
f"Install it with: {_INSTALL_HINTS[configured_ext]}"
)
logger.debug("Using configured vector extension: %s", configured_ext)
return configured_ext
@@ -26,52 +26,67 @@ depends_on: str | Sequence[str] | None = None
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Detect or validate vector extension for this immutable migration revision.
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:
if 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":
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;"
)
if 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":
if vector_extension == "scann":
scann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'alloydb_scann'")).scalar()
if not scann_check:
raise RuntimeError(
"Configured vector extension 'scann' not found. Install it with: CREATE EXTENSION alloydb_scann CASCADE;"
)
return "scann"
if 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'"
)
raise ValueError(
"Invalid HINDSIGHT_API_VECTOR_EXTENSION: "
f"{vector_extension}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "pg_diskann":
return "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
def _detect_text_search_extension() -> str:
@@ -313,36 +328,11 @@ def _pg_upgrade() -> None:
)
# Create vector index - conditional based on available extension
vector_ext = _detect_vector_extension()
if vector_ext == "pgvectorscale":
# Use DiskANN index for pgvectorscale (disk-based, scalable)
op.execute("""
if vector_ext != "scann":
op.execute(f"""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
{_vector_index_using_clause(vector_ext)}
""")
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
@@ -0,0 +1,117 @@
"""Repair mental_models.subtype on databases stuck at m3rg3h3ad5f6
Three production deployments reported `column "subtype" of relation
"mental_models" does not exist` on `create_mental_model` even after their
container reported `Database migrations completed successfully` and
`alembic_version` advanced to `m3rg3h3ad5f6` (see issue #1553, #1553#1
confirmations from @4Lienau and @khanhduyvt0101).
Both `h3c4d5e6f7g8_mental_models_v4` and `d5y6z7a8b9c0_backfill_mental_models_subtype`
were meant to ensure `subtype` exists, but on databases that came through the
`reflections -> mental_models` rename chain *and* whose alembic_version
advanced past `d5y6z7a8b9c0` along an alternate path during the divergent-heads
reorganization, neither column-add actually fired. The result is a head-tagged
database with a v3-shaped `mental_models` table missing six columns:
``subtype``, ``description``, ``entity_id``, ``observations``, ``links``,
``last_updated``.
This migration sits at the current head (`m3rg3h3ad5f6`) so every affected
deployment will pick it up on next container start. It mirrors the column-add
block from `d5y6z7a8b9c0_backfill_mental_models_subtype` using
``ADD COLUMN IF NOT EXISTS`` so it is a no-op on databases where the columns
are already present.
Revision ID: 86f7a033d372
Revises: m3rg3h3ad5f6
Create Date: 2026-05-14
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "86f7a033d372"
down_revision: str | Sequence[str] | None = "m3rg3h3ad5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
"""Idempotently ensure mental_models has the v4 column set.
Safe to re-apply on databases that already received the columns via
`h3c4d5e6f7g8_mental_models_v4` or `d5y6z7a8b9c0_backfill_mental_models_subtype` —
every column-add uses ``IF NOT EXISTS`` and the constraint is recreated
from scratch with the canonical v4 allowlist.
"""
schema = _pg_schema_prefix()
bare_schema = schema.strip(".").strip('"') if schema else ""
schema_clause = f"AND table_schema = '{bare_schema}'" if bare_schema else ""
# Wrapped in a DO block so the existence check skips databases that
# predate the reflections -> mental_models rename chain (no table to
# repair). On those, every ALTER below would error.
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'mental_models'
{schema_clause}
) THEN
-- Add the six v4 columns idempotently.
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS subtype VARCHAR(32) NOT NULL DEFAULT 'structural';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS entity_id UUID;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS observations JSONB DEFAULT '{{"observations": []}}'::jsonb;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS links VARCHAR[];
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS last_updated TIMESTAMP WITH TIME ZONE;
-- Recreate the CHECK constraint with the canonical v4 allowlist.
-- Existing rows with subtype = 'directive' (possible on databases
-- that ran the o0j1k2l3m4n5 directive-only path) are rewritten to
-- 'structural' first so the constraint add succeeds.
UPDATE {schema}mental_models SET subtype = 'structural' WHERE subtype = 'directive';
ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype;
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'));
CREATE INDEX IF NOT EXISTS idx_mental_models_subtype
ON {schema}mental_models(bank_id, subtype);
END IF;
END$$;
"""
)
def _pg_downgrade() -> None:
"""No-op: dropping these columns would corrupt v4 application code."""
pass
def upgrade() -> None:
# PG-only: Oracle's baseline (o1a2b3c4d5e6) creates mental_models with its
# own subtype shape (chk_mm_subtype IN ('directive', 'pinned')) and a
# different table topology, so this PG-shaped repair does not apply.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -11,7 +11,8 @@ was configured.
This migration detects the mismatch and recreates the affected indexes with
the correct type. Skipped entirely when the configured extension is pgvector
(the default), since those indexes are already correct.
(the default) or scann. ScaNN uses global vector indexes because empty or tiny
per-bank indexes cannot be built safely on AlloyDB.
"""
import os
@@ -39,39 +40,47 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _target_index_type() -> str | None:
"""Return the target index type, or None if pgvector (no fix needed)."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
def _validate_extension(name: str) -> str:
ext = name.lower()
if ext not in {"pgvector", "pgvectorscale", "vchord", "scann"}:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {ext}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
return ext
def _index_type_keyword(ext: str) -> str:
if ext == "pgvectorscale":
return "diskann"
elif ext == "vchord":
if ext == "vchord":
return "vchordrq"
return None
if ext == "scann":
return "scann"
return "hnsw"
def _vector_index_using_clause() -> str:
"""Return the USING clause based on the configured vector extension."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
if ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else:
return "USING hnsw (embedding vector_cosine_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
def _pg_upgrade() -> None:
target = _target_index_type()
if target is None:
# pgvector — indexes are already HNSW, nothing to fix
ext = _validate_extension(os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector"))
if ext in {"pgvector", "scann"}:
return
target = _index_type_keyword(ext)
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
schema = _get_schema_prefix()
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
using_clause = _vector_index_using_clause()
using_clause = _vector_index_using_clause(ext)
pg_schema = schema_name or "public"
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
@@ -117,8 +126,8 @@ def _pg_upgrade() -> None:
def _pg_downgrade() -> None:
# Downgrade recreates indexes as HNSW (the original hardcoded behavior)
target = _target_index_type()
if target is None:
ext = _validate_extension(os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector"))
if ext in {"pgvector", "scann"}:
return
bind = op.get_bind()
@@ -0,0 +1,91 @@
"""Backfill entity_cooccurrences.last_cooccurred from memory_units event time
Revision ID: b5d4e3f2a1c9
Revises: o1a2b3c4d5e6
Create Date: 2026-04-24
The writer path in `entity_resolver.link_units_to_entities_batch` historically
stamped `entity_cooccurrences.last_cooccurred` with `datetime.now(UTC)` at
flush time, ignoring the source memory unit's event date. For normal online
retains that's fine (now ≈ event time), but for any corpus that was
backfilled in a single session — migrating from another memory system, for
example — every co-occurrence collapsed to the import moment, which hid the
underlying knowledge timeline from the dashboard's entity graph recency heat
and from any downstream consumer of the column.
The writer is fixed in the same change set to propagate the unit's event_date;
this migration repairs historical rows by reading the true event time off
`unit_entities × memory_units` (falling back to `created_at` when
`mentioned_at` / `occurred_start` are NULL, so rows never regress).
Oracle slot is intentionally absent: the Oracle baseline (`o1a2b3c4d5e6`)
landed days before this fix, so any Oracle deployment runs the corrected
writer against an effectively empty `entity_cooccurrences` — there is no
historical residue on Oracle to repair. PG-only matches the asymmetry of
the data, not negligence.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b5d4e3f2a1c9"
down_revision: str | Sequence[str] | None = "o1a2b3c4d5e6"
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 _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Recompute last_cooccurred from the true event time per entity pair.
# COALESCE picks the first non-null of mentioned_at / occurred_start /
# created_at so banks without event-time metadata still see a sane value
# (equivalent to the pre-fix behaviour) instead of NULL.
#
# The self-join on `unit_entities` is O(k²) per memory_unit in the number
# of distinct entities mentioned (k). For typical units k is small (single
# digits), but a bank with units containing hundreds of entities and tens
# of millions of co-occurrence rows may want to run this off-hours — the
# whole UPDATE is one statement, so it locks every targeted ec row for
# the duration. The migration is one-time; subsequent online writes
# already carry event time via the writer fix.
op.execute(
f"""
UPDATE {schema}entity_cooccurrences ec
SET last_cooccurred = sub.event_time
FROM (
SELECT
LEAST(ue1.entity_id, ue2.entity_id) AS e1,
GREATEST(ue1.entity_id, ue2.entity_id) AS e2,
MAX(COALESCE(mu.mentioned_at, mu.occurred_start, mu.created_at)) AS event_time
FROM {schema}memory_units mu
JOIN {schema}unit_entities ue1 ON ue1.unit_id = mu.id
JOIN {schema}unit_entities ue2 ON ue2.unit_id = mu.id AND ue1.entity_id <> ue2.entity_id
GROUP BY 1, 2
) sub
WHERE ec.entity_id_1 = sub.e1 AND ec.entity_id_2 = sub.e2
"""
)
def _pg_downgrade() -> None:
# No-op: the previous column value was `now()` at the time of write and
# isn't recoverable. Rolling back the code is sufficient — new writes will
# revert to the old behaviour for subsequent retains.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent — see header
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -6,10 +6,11 @@ Create Date: 2026-03-11
This migration:
1. Adds internal_id UUID column to banks (stable identifier for index naming)
2. Drops the global vector index (competes with per-bank partial indexes)
3. Creates per-(bank_id, fact_type) partial vector indexes for all existing banks
using the configured vector extension (HNSW for pgvector, DiskANN for
pgvectorscale, vchordrq for vchord).
2. For non-ScaNN backends, drops the global vector index (competes with
per-bank partial indexes)
3. For non-ScaNN backends, creates per-(bank_id, fact_type) partial vector
indexes for all existing banks using the configured vector extension
(HNSW for pgvector, DiskANN for pgvectorscale, vchordrq for vchord).
(new banks get indexes created at bank-creation time via bank_utils.create_bank_vector_indexes)
Why per-(bank, fact_type) indexes:
@@ -17,6 +18,8 @@ Why per-(bank, fact_type) indexes:
clause, because the idx_memory_units_bank_id B-tree index always wins at planning time.
- Per-(bank, fact_type) partial indexes have both predicates matching → planner selects them.
- The global vector index competes for larger partitions (world, observation) and must be dropped.
- AlloyDB ScaNN uses global vector indexes with filtered vector search instead
because empty or tiny per-bank indexes cannot be built safely.
"""
import os
@@ -39,22 +42,30 @@ _FACT_TYPES: dict[str, str] = {
}
def _configured_vector_extension() -> str:
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext not in {"pgvector", "pgvectorscale", "vchord", "scann"}:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {ext}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
return ext
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _vector_index_using_clause() -> str:
"""Return the USING clause based on the configured vector extension."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else:
return "USING hnsw (embedding vector_cosine_ops)"
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
@@ -64,6 +75,14 @@ def _pg_upgrade() -> None:
)
op.execute(f"ALTER TABLE {schema}banks ADD CONSTRAINT banks_internal_id_unique UNIQUE (internal_id)")
ext = _configured_vector_extension()
if ext == "scann":
# ScaNN should keep/use a global vector index. Per-bank partial indexes
# are created while banks are empty and can fail AlloyDB's ScaNN build
# requirements, so this migration leaves vector index reconciliation to
# runtime ensure_vector_extension once enough rows exist.
return
# 2. Drop any fact_type-only partial indexes that may exist from prior migrations
# (bank_id B-tree always wins over them when bank_id is in the WHERE clause)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_world")
@@ -79,7 +98,7 @@ def _pg_upgrade() -> None:
schema_name = context.config.get_main_option("target_schema")
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
using_clause = _vector_index_using_clause()
using_clause = _vector_index_using_clause(ext)
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
@@ -109,7 +128,7 @@ def _pg_downgrade() -> None:
rows = bind.execute(text(f"SELECT internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
internal_id = str(row[0]).replace("-", "")[:16]
for ft_short in _HNSW_FACT_TYPES.values():
for ft_short in _FACT_TYPES.values():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
bind.execute(text(f"DROP INDEX IF EXISTS {schema}{idx_name}"))
@@ -0,0 +1,31 @@
"""Merge divergent heads from deferrable FK and cooccurrence backfill
Revision ID: m3rg3h3ad5f6
Revises: 9f8e7d6c5b4a, b5d4e3f2a1c9
Create Date: 2026-05-04
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "m3rg3h3ad5f6"
down_revision: tuple[str, ...] = ("9f8e7d6c5b4a", "b5d4e3f2a1c9")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
pass
def _pg_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -32,53 +32,65 @@ def _get_schema_prefix() -> str:
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
"""Detect or validate vector extension for this immutable migration revision."""
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:
if 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":
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;"
)
if 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":
if vector_extension == "scann":
scann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'alloydb_scann'")).scalar()
if not scann_check:
raise RuntimeError(
"Configured vector extension 'scann' not found. Install it with: CREATE EXTENSION alloydb_scann CASCADE;"
)
return "scann"
if 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'"
)
raise ValueError(
"Invalid HINDSIGHT_API_VECTOR_EXTENSION: "
f"{vector_extension}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "pg_diskann":
return "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
def _detect_text_search_extension() -> str:
@@ -158,28 +170,12 @@ def _pg_upgrade() -> None:
# Indexes for learnings
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
# Create vector index based on detected extension. ScaNN is deferred because
# this table is empty during migration and AlloyDB rejects empty ScaNN builds.
if vector_ext != "scann":
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)
{_vector_index_using_clause(vector_ext)}
""")
op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
@@ -237,28 +233,12 @@ def _pg_upgrade() -> None:
# Indexes for pinned_reflections
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
# Create vector index based on detected extension. ScaNN is deferred because
# this table is empty during migration and AlloyDB rejects empty ScaNN builds.
if vector_ext != "scann":
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)
{_vector_index_using_clause(vector_ext)}
""")
op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
+5 -1
View File
@@ -997,7 +997,7 @@ class BackgroundResponse(BaseModel):
class BankListItem(BaseModel):
"""Bank list item with profile summary."""
"""Bank list item with profile summary and stats."""
bank_id: str
name: str | None = None
@@ -1005,6 +1005,8 @@ class BankListItem(BaseModel):
mission: str | None = None
created_at: str | None = None
updated_at: str | None = None
fact_count: int = 0
last_document_at: str | None = None
class BankListResponse(BaseModel):
@@ -1021,6 +1023,8 @@ class BankListResponse(BaseModel):
"mission": "I am a software engineer helping my team ship quality code",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-16T14:20:00Z",
"fact_count": 156,
"last_document_at": "2024-01-16T14:20:00Z",
}
]
}
+77 -10
View File
@@ -14,6 +14,7 @@ from typing import Any, Literal
from dotenv import find_dotenv, load_dotenv
from ._vector_index import validate_extension
from .utils import mask_network_location
# Load .env file, searching current and parent directories (overrides existing env vars)
@@ -121,6 +122,9 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
# Environment variable names
ENV_DATABASE_BACKEND = "HINDSIGHT_API_DATABASE_BACKEND"
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_READ_DATABASE_URL = "HINDSIGHT_API_READ_DATABASE_URL"
ENV_READ_DB_POOL_MIN_SIZE = "HINDSIGHT_API_READ_DB_POOL_MIN_SIZE"
ENV_READ_DB_POOL_MAX_SIZE = "HINDSIGHT_API_READ_DB_POOL_MAX_SIZE"
ENV_MIGRATION_DATABASE_URL = "HINDSIGHT_API_MIGRATION_DATABASE_URL"
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
@@ -135,11 +139,23 @@ 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"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
# falls back to the next on transient errors (5xx, rate-limit, timeout).
# Provider-scoped naming mirrors other provider-specific flags (e.g. llm_groq_*,
# llm_vertexai_*). Note the single token "LITELLMROUTER" — keeping it one word
# disambiguates from the embeddings/reranker LITELLM_* settings.
ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
# 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)
DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged into OpenAI extra_body
DEFAULT_LLM_DEFAULT_HEADERS = (
None # None = no extra headers; JSON dict passed as default_headers to provider SDK clients
)
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
@@ -151,6 +167,7 @@ ENV_RETAIN_LLM_MAX_RETRIES = "HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"
ENV_RETAIN_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"
ENV_RETAIN_LLM_MAX_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"
ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_RETAIN_LLM_LITELLMROUTER_CONFIG"
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
@@ -161,6 +178,7 @@ ENV_REFLECT_LLM_MAX_RETRIES = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"
ENV_REFLECT_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"
ENV_REFLECT_LLM_MAX_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"
ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_REFLECT_LLM_LITELLMROUTER_CONFIG"
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
@@ -171,6 +189,7 @@ ENV_CONSOLIDATION_LLM_MAX_RETRIES = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES
ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF"
ENV_CONSOLIDATION_LLM_MAX_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF"
ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
@@ -451,6 +470,8 @@ PROVIDER_DEFAULT_MODELS = {
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"deepseek": "deepseek-v4-flash",
"zai": "glm-4.5-flash",
"opencode-go": "deepseek-v4-flash",
"ollama": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
"lmstudio": "local-model",
@@ -488,7 +509,7 @@ DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety se
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_FORCE_CPU = False # Force CPU mode for local embeddings
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDINGS_OPENAI_BATCH_SIZE = 100
@@ -499,7 +520,7 @@ DEFAULT_EMBEDDING_DIMENSION = 384
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_FORCE_CPU = False # Force CPU mode for local reranker
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
@@ -529,8 +550,8 @@ DEFAULT_RERANKER_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
# Vector extension (pgvector, vchord, or pgvectorscale)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
# Vector extension (pgvector, vchord, pgvectorscale, or AlloyDB ScaNN)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale", "scann"
# Text search extension (native PostgreSQL, vchord BM25, or Timescale pg_textsearch)
DEFAULT_TEXT_SEARCH_EXTENSION = "native" # Options: "native", "vchord", "pg_textsearch"
@@ -800,6 +821,24 @@ def _get_default_model_for_provider(provider: str) -> str:
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
def _parse_llm_router_config(env_var: str) -> dict | None:
"""
Parse a LiteLLM Router configuration from a JSON env var.
The value is forwarded verbatim to ``litellm.Router(**config)``. We only
check that it parses as JSON; LiteLLM Router is authoritative about the
shape (``model_list``, ``fallbacks``, ``routing_strategy``, …). See
https://docs.litellm.ai/docs/routing.
"""
raw = os.getenv(env_var, "").strip()
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid {env_var}: invalid JSON: {e}") from e
def _parse_default_bank_template(raw: str | None) -> dict | None:
"""
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
@@ -827,9 +866,14 @@ class HindsightConfig:
# Database
database_backend: Literal["postgresql", "oracle"]
database_url: str
# Optional read-replica URL for recall queries. When set, the engine opens
# a second pool and routes recall SELECTs through it.
read_database_url: str | None
read_db_pool_min_size: int
read_db_pool_max_size: int
migration_database_url: str | None
database_schema: str
vector_extension: str # "pgvector" or "vchord"
vector_extension: str # "pgvector", "vchord", "pgvectorscale", or "scann"
text_search_extension: str # "native" or "vchord"
# LLM (default, used as fallback for per-operation config)
@@ -847,6 +891,15 @@ class HindsightConfig:
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
llm_default_headers: (
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
# Each entry: {"provider": str, "model": str, "api_key": str | None, "base_url": str | None}.
# Treated as a credential field because entries embed api keys.
llm_litellmrouter_config: dict | None
# Vertex AI configuration
llm_vertexai_project_id: str | None
@@ -874,6 +927,7 @@ class HindsightConfig:
retain_llm_initial_backoff: float | None
retain_llm_max_backoff: float | None
retain_llm_timeout: float | None
retain_llm_litellmrouter_config: dict | None
reflect_llm_provider: str | None
reflect_llm_api_key: str | None
@@ -884,6 +938,7 @@ class HindsightConfig:
reflect_llm_initial_backoff: float | None
reflect_llm_max_backoff: float | None
reflect_llm_timeout: float | None
reflect_llm_litellmrouter_config: dict | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
@@ -894,6 +949,7 @@ class HindsightConfig:
consolidation_llm_initial_backoff: float | None
consolidation_llm_max_backoff: float | None
consolidation_llm_timeout: float | None
consolidation_llm_litellmrouter_config: dict | None
# Embeddings
embeddings_provider: str
@@ -1133,6 +1189,11 @@ class HindsightConfig:
"retain_llm_api_key",
"reflect_llm_api_key",
"consolidation_llm_api_key",
# LiteLLM Router chains — entries embed api_keys and base_urls
"llm_litellmrouter_config",
"retain_llm_litellmrouter_config",
"reflect_llm_litellmrouter_config",
"consolidation_llm_litellmrouter_config",
# Base URLs (could expose infrastructure)
"llm_base_url",
"retain_llm_base_url",
@@ -1270,11 +1331,7 @@ class HindsightConfig:
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_extension(self.vector_extension)
# Validate text_search_extension
valid_text_search = ("native", "vchord", "pg_textsearch")
@@ -1352,6 +1409,9 @@ class HindsightConfig:
# Database
database_backend=os.getenv(ENV_DATABASE_BACKEND, DEFAULT_DATABASE_BACKEND).lower(),
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
read_database_url=os.getenv(ENV_READ_DATABASE_URL) or None,
read_db_pool_min_size=int(os.getenv(ENV_READ_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))),
read_db_pool_max_size=int(os.getenv(ENV_READ_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
migration_database_url=os.getenv(ENV_MIGRATION_DATABASE_URL) or None,
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
vector_extension=os.getenv(ENV_VECTOR_EXTENSION, DEFAULT_VECTOR_EXTENSION).lower(),
@@ -1369,6 +1429,8 @@ class HindsightConfig:
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),
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
# 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),
@@ -1407,6 +1469,7 @@ class HindsightConfig:
if os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF)
else None,
retain_llm_timeout=float(os.getenv(ENV_RETAIN_LLM_TIMEOUT)) if os.getenv(ENV_RETAIN_LLM_TIMEOUT) else None,
retain_llm_litellmrouter_config=_parse_llm_router_config(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG),
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL)
@@ -1431,6 +1494,7 @@ class HindsightConfig:
reflect_llm_timeout=float(os.getenv(ENV_REFLECT_LLM_TIMEOUT))
if os.getenv(ENV_REFLECT_LLM_TIMEOUT)
else None,
reflect_llm_litellmrouter_config=_parse_llm_router_config(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG),
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL)
@@ -1455,6 +1519,7 @@ class HindsightConfig:
consolidation_llm_timeout=float(os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT))
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
else None,
consolidation_llm_litellmrouter_config=_parse_llm_router_config(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG),
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
@@ -1880,6 +1945,8 @@ class HindsightConfig:
def log_config(self) -> None:
"""Log the current configuration (without sensitive values)."""
logger.info(f"Database: {mask_network_location(self.database_url)} (schema: {self.database_schema})")
if self.read_database_url:
logger.info(f"Read database (recall queries only): {mask_network_location(self.read_database_url)}")
if self.migration_database_url:
logger.info(f"Migration database: {mask_network_location(self.migration_database_url)}")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
+97 -35
View File
@@ -4,12 +4,20 @@ Daemon mode support for Hindsight API.
Provides idle timeout for running as a background daemon.
"""
from __future__ import annotations
import asyncio
import logging
import os
import platform
import subprocess
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import IO
logger = logging.getLogger(__name__)
@@ -20,6 +28,12 @@ DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own tim
# Allow override via environment variable for profile-specific logs
DAEMON_LOG_PATH = Path(os.getenv("HINDSIGHT_API_DAEMON_LOG", str(Path.home() / ".hindsight" / "daemon.log")))
# Internal env var: set by daemonize() in the re-exec'd child so the child
# skips re-exec and just redirects stdio. Also set by hindsight-embed's
# DaemonEmbedManager so the daemon launched via Popen skips re-exec entirely
# (hindsight-embed's Popen already provides a clean, detached process).
ENV_DAEMON_CHILD = "_HINDSIGHT_DAEMON_CHILD"
class IdleTimeoutMiddleware:
"""ASGI middleware that tracks activity and exits after idle timeout."""
@@ -58,57 +72,105 @@ class IdleTimeoutMiddleware:
os.kill(os.getpid(), signal.SIGTERM)
def daemonize():
def _detach_popen_kwargs(log_handle: "IO[bytes]") -> dict:
"""Cross-platform kwargs to spawn a subprocess detached from the caller.
On POSIX, ``start_new_session=True`` calls ``setsid(2)`` so the child
survives the parent's terminal. On Windows we use
``DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP``.
``log_handle`` receives the child's stdout/stderr so output never leaks
into the parent's terminal.
"""
Fork the current process into a background daemon.
if platform.system() == "Windows":
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0)
create_new_process_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
return {
"creationflags": detached_process | create_new_process_group,
"stdin": subprocess.DEVNULL,
"stdout": log_handle,
"stderr": subprocess.STDOUT,
"close_fds": True,
}
return {
"start_new_session": True,
"stdin": subprocess.DEVNULL,
"stdout": log_handle,
"stderr": log_handle,
}
Uses double-fork technique to properly detach from terminal.
On Windows there is no fork model: the spawning parent is expected to
detach us via `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS` and to
redirect stdout/stderr to HINDSIGHT_API_DAEMON_LOG before exec. We
still ensure the log directory exists so that any file handlers set
up by the calling app have a valid target.
def _redirect_stdio_to_log() -> None:
"""Redirect stdin/stdout/stderr to the daemon log file.
Called in the daemon child process after re-exec.
"""
if sys.platform == "win32":
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
return
# First fork - detach from parent
try:
pid = os.fork()
if pid > 0:
sys.exit(0)
except OSError as e:
sys.stderr.write(f"fork #1 failed: {e}\n")
sys.exit(1)
# Decouple from parent environment
os.chdir("/")
os.setsid()
os.umask(0)
# Second fork - prevent zombie
pid = os.fork()
if pid > 0:
sys.exit(0)
# Redirect standard file descriptors to log file
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
sys.stdout.flush()
sys.stderr.flush()
# Redirect stdin to /dev/null
with open("/dev/null", "r") as devnull:
with open(os.devnull, "r") as devnull:
os.dup2(devnull.fileno(), sys.stdin.fileno())
# Redirect stdout/stderr to log file
log_fd = open(DAEMON_LOG_PATH, "a")
os.dup2(log_fd.fileno(), sys.stdout.fileno())
os.dup2(log_fd.fileno(), sys.stderr.fileno())
def daemonize():
"""Detach the current process into a background daemon.
Uses ``subprocess.Popen`` (which maps to ``posix_spawn`` on macOS) to
re-exec the current command in a detached session. This replaces the
traditional double-fork pattern because ``os.fork()`` without ``exec()``
corrupts Apple framework state (XPC, Metal/MPS, ObjC runtime) on macOS,
causing SIGBUS crashes when PyTorch uses the MPS backend.
The function has two code paths controlled by the ``_HINDSIGHT_DAEMON_CHILD``
environment variable:
* **Parent** (env var not set): re-exec the same command via Popen with
``start_new_session=True``, stripping ``--daemon`` from argv and setting
``_HINDSIGHT_DAEMON_CHILD=1``. Then ``sys.exit(0)``.
* **Child** (env var set): redirect stdio to the daemon log file and return.
No fork, no re-exec.
On Windows there is no fork model: the spawning parent is expected to
detach us via ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` and to
redirect stdout/stderr to ``HINDSIGHT_API_DAEMON_LOG`` before exec.
We still ensure the log directory exists.
"""
if sys.platform == "win32":
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
return
# If we are already the daemon child (re-exec'd by a previous daemonize()
# call, or launched by hindsight-embed with the env var set), just redirect
# stdio and return — no re-exec needed.
if os.environ.get(ENV_DAEMON_CHILD) == "1":
_redirect_stdio_to_log()
return
# --- Parent path: re-exec ourselves as a detached background process ---
# Build child command: same Python, same module entry point, all args
# except --daemon (replaced by the env var).
child_args = [a for a in sys.argv[1:] if a != "--daemon"]
cmd = [sys.executable, "-m", "hindsight_api.main"] + child_args
env = os.environ.copy()
env[ENV_DAEMON_CHILD] = "1"
env["HINDSIGHT_API_DAEMON_LOG"] = str(DAEMON_LOG_PATH)
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(DAEMON_LOG_PATH, "ab") as log_handle:
subprocess.Popen(cmd, env=env, **_detach_popen_kwargs(log_handle))
sys.exit(0)
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Check if a daemon is running and responsive on the given port."""
import socket
@@ -133,7 +133,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
max_concurrent: Maximum concurrent reranking calls (default: 2).
Higher values may cause CPU thrashing under load.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
force_cpu: Force CPU mode for local inference.
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
@@ -0,0 +1,181 @@
"""Registry of optional PostgreSQL server-side routines.
Some hot paths (currently only the worker poller's per-cycle scan) can be
sped up by a server-side PL/pgSQL routine that the API never installs
itself — operators install it out-of-band (e.g. a Helm hook in
hindsight-cloud) when they want the optimisation. When the routine is not
installed, callers must fall back to a pure-Python implementation.
This module centralises that pattern so we don't sprinkle ad-hoc
``try / except`` blocks (which silently log a server-side error on every
call) around the codebase. Each registered entry carries:
* a ``schema`` and ``name`` (used to probe ``pg_proc``)
* a ``contract`` describing the expected signature and return shape
Bodies are deliberately not stored here. Hindsight never installs these
routines, so a body checked into this repo would (a) drift from whatever
operators actually deploy and (b) imply ownership we don't have. The
contract is the entire API surface: any operator-supplied implementation
that satisfies it is interchangeable.
Probe behaviour:
* On first ``is_installed()`` call per backend instance we issue a single
``SELECT EXISTS(...) FROM pg_proc`` and cache the boolean result in
memory for the life of the process.
* No TTL: if an operator installs a routine on a running cluster, workers
pick it up only after restart. This is intentional — these routines are
expected to be installed once at deploy time, and a probe-per-poll would
defeat the optimisation.
* Non-PostgreSQL backends short-circuit to ``False`` without touching the
database, so callers can use the same code path for Oracle.
PostgreSQL terminology note: ``CREATE FUNCTION ... RETURNS SETOF`` defines
a *function* (invoked via ``SELECT``); ``CREATE PROCEDURE`` defines a
*procedure* (invoked via ``CALL``). The SQL-standard umbrella term
covering both is *routine*, and the system catalog (``pg_proc``) stores
both. The module name uses "routine" so a future ``CREATE PROCEDURE``
entry slots in without a rename.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .base import DatabaseBackend, DatabaseConnection
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class OptionalRoutine:
"""One optional server-side routine Hindsight may call when present.
Hindsight never installs these — operators do, out-of-band. The fields
here describe the contract the API expects; any implementation that
matches is interchangeable.
Attributes:
name: Unqualified routine name as it appears in ``pg_proc.proname``.
schema: Schema the routine lives in (matched against
``pg_namespace.nspname``).
contract: Free-form description of the expected signature,
arguments, return type, and any semantic constraints. Read
this before installing a custom implementation.
"""
name: str
schema: str
contract: str
# Registry of known optional routines.
#
# Add new entries here when a hot path grows a server-side optimisation.
# Document the *contract* — not the implementation — so operator-supplied
# variants stay interchangeable and we don't pretend to own SQL we never
# install.
SCHEMAS_WITH_PENDING_WORK = OptionalRoutine(
name="schemas_with_pending_work",
schema="public",
contract="""
Signature: public.schemas_with_pending_work() RETURNS SETOF text
Called by the worker poller on every cycle to find schemas with
claimable async_operations rows. The poller then runs FOR UPDATE
SKIP LOCKED only against the returned schemas, so an empty set means
"nothing to do, skip the expensive claim query".
A schema is "claimable" iff at least one row matches:
status = 'pending' AND task_payload IS NOT NULL
in that schema's ``async_operations`` table.
Required semantics:
* No arguments.
* Returns a set of schema names (``text``); each must match a real
``pg_namespace.nspname``. The poller passes them straight into
the claim query — anything that isn't a valid schema will fail.
* Operators choose the search scope (e.g. ``tenant_%`` only, or
include ``public``). A schema omitted from the scan will *never*
be serviced by the poller, so the implementation must cover
every schema that holds an ``async_operations`` table in that
deployment.
* Should be cheap and idempotent — called every poll cycle (~30s).
Fallback when the routine is absent: per-schema ``EXISTS`` queries
from Python (~4ms per schema). The server-side path is a single-
round-trip optimisation worth ~200ms in deployments with thousands
of tenant schemas; everything else works correctly without it.
""",
)
_REGISTRY: dict[str, OptionalRoutine] = {
SCHEMAS_WITH_PENDING_WORK.name: SCHEMAS_WITH_PENDING_WORK,
}
class OptionalRoutines:
"""Per-backend cache of which optional routines are installed.
One instance per long-lived consumer (e.g. one per ``WorkerPoller``).
Probes ``pg_proc`` lazily on first lookup and caches the result in
memory until the process restarts.
"""
def __init__(self, backend: DatabaseBackend) -> None:
self._backend = backend
self._cache: dict[str, bool] = {}
async def is_installed(self, conn: DatabaseConnection, routine_name: str) -> bool:
"""Return True iff *routine_name* exists in ``pg_proc``.
On non-PostgreSQL backends always returns False without issuing a
query. Result is memoised for the life of this instance.
"""
if self._backend.backend_type != "postgresql":
return False
cached = self._cache.get(routine_name)
if cached is not None:
return cached
routine = _REGISTRY.get(routine_name)
if routine is None:
raise KeyError(f"Unknown optional routine: {routine_name!r}")
exists = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM pg_proc p "
"JOIN pg_namespace n ON p.pronamespace = n.oid "
"WHERE n.nspname = $1 AND p.proname = $2)",
routine.schema,
routine.name,
)
installed = bool(exists)
self._cache[routine_name] = installed
if installed:
logger.info(
"Optional PG routine %s.%s detected — using server-side path",
routine.schema,
routine.name,
)
else:
logger.debug(
"Optional PG routine %s.%s not installed — using fallback path",
routine.schema,
routine.name,
)
return installed
def invalidate(self, routine_name: str | None = None) -> None:
"""Drop cached probe results (test helper).
Without an argument, clears the entire cache.
"""
if routine_name is None:
self._cache.clear()
else:
self._cache.pop(routine_name, None)
@@ -104,7 +104,7 @@ class LocalSTEmbeddings(Embeddings):
Args:
model_name: Name of the SentenceTransformer model to use.
Default: BAAI/bge-small-en-v1.5
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
force_cpu: Force CPU mode for local inference.
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
@@ -12,7 +12,7 @@ from collections import defaultdict
from dataclasses import dataclass, field
from datetime import UTC, datetime
from difflib import SequenceMatcher
from typing import Any
from typing import Any, Final
from .db_utils import acquire_with_retry
from .memory_engine import fq_table
@@ -46,12 +46,38 @@ class _EntityStatAgg:
max_date: datetime | None = None
# Sentinel distinguishing "key not in dict" from "key present with value None".
# Needed when merging event_date across duplicate unit rows: legacy two-tuple
# callers surface `None`, which must not clobber a real datetime from another
# caller for the same unit.
_SENTINEL_MISSING: Final = object()
def _later_date(a: datetime | None, b: datetime | None) -> datetime | None:
"""Return whichever of ``a`` / ``b`` is later (None loses to any datetime).
Used to fold duplicate co-occurrence pairs across a retain batch: legacy
two-tuple callers surface ``None``, which must not clobber a real
datetime that arrived for the same pair from an aware caller.
"""
if a is None:
return b
if b is None:
return a
return a if a > b else b
@dataclass
class _CooccurrencePair:
"""A (entity_id_1, entity_id_2) pair observed in a retain batch (for post-txn flush)."""
entity_id_1: str
entity_id_2: str
# When the two entities co-occurred in the source content. For real-time
# retains this is ~now; for backfilled corpora it's the historical event
# time, so the cooccurrence cache reflects the underlying knowledge
# timeline instead of collapsing to the import moment.
event_date: datetime | None = None
# Load spaCy model (singleton)
@@ -143,11 +169,15 @@ class EntityResolver:
)
if cooccurrences:
# Aggregate: count occurrences per (entity_id_1, entity_id_2) pair.
coo_agg: dict[tuple[str, str], int] = {}
# Aggregate per (entity_id_1, entity_id_2): count occurrences and
# keep the latest event_date we saw. Using GREATEST(...) in the SQL
# already handles merging against the existing row; here we fold
# the batch so executemany doesn't send the same pair twice.
coo_agg: dict[tuple[str, str], tuple[int, datetime | None]] = {}
for c in cooccurrences:
pair = (c.entity_id_1, c.entity_id_2)
coo_agg[pair] = coo_agg.get(pair, 0) + 1
prev_count, prev_date = coo_agg.get(pair, (0, None))
coo_agg[pair] = (prev_count + 1, _later_date(prev_date, c.event_date))
now = datetime.now(UTC)
# Sort by (entity_id_1, entity_id_2) for consistent lock ordering.
@@ -161,7 +191,7 @@ class EntityResolver:
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + EXCLUDED.cooccurrence_count,
last_cooccurred = GREATEST({fq_table("entity_cooccurrences")}.last_cooccurred, EXCLUDED.last_cooccurred)
""",
sorted((e1, e2, count, now) for (e1, e2), count in coo_agg.items()),
sorted((e1, e2, count, event_date or now) for (e1, e2), (count, event_date) in coo_agg.items()),
)
@staticmethod
@@ -872,29 +902,45 @@ class EntityResolver:
entity_id_2,
)
async def link_units_to_entities_batch(self, unit_entity_pairs: list[tuple[str, str]], conn=None):
async def link_units_to_entities_batch(
self,
unit_entity_pairs: list[tuple[str, str]] | list[tuple[str, str, datetime | None]],
conn=None,
):
"""
Link multiple memory units to entities in batch (MUCH faster than sequential).
Also updates co-occurrence cache for entities that appear in the same unit.
Args:
unit_entity_pairs: List of (unit_id, entity_id) tuples
unit_entity_pairs: List of (unit_id, entity_id) or
(unit_id, entity_id, event_date) tuples. When `event_date` is
supplied, ``entity_cooccurrences.last_cooccurred`` for pairs
observed in that unit advances to the event time instead of
``now()``, which matters for backfilled corpora where ingest
time is a single spike unrelated to the underlying timeline.
Legacy two-tuples remain accepted.
conn: Optional connection to use (if None, acquires from pool)
"""
if not unit_entity_pairs:
return
# Normalize to 3-tuples internally so downstream code doesn't branch.
normalized: list[tuple[str, str, datetime | None]] = [
(t[0], t[1], t[2] if len(t) >= 3 else None) # type: ignore[misc]
for t in unit_entity_pairs
]
if conn is None:
async with acquire_with_retry(self.pool) as conn:
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
return await self._link_units_to_entities_batch_impl(conn, normalized)
else:
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
return await self._link_units_to_entities_batch_impl(conn, normalized)
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str]]):
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str, datetime | None]]):
# Sorted bulk insert to prevent deadlocks from inconsistent lock ordering
# across concurrent transactions on the unit_entities unique index.
sorted_pairs = sorted(unit_entity_pairs)
sorted_pairs = sorted(unit_entity_pairs, key=lambda t: (t[0], t[1]))
unit_ids = [p[0] for p in sorted_pairs]
entity_ids = [p[1] for p in sorted_pairs]
@@ -905,28 +951,42 @@ class EntityResolver:
entity_ids,
)
# Build map of unit -> entities for co-occurrence calculation
# Use sets to avoid duplicate entities in the same unit
unit_to_entities = {}
for unit_id, entity_id in unit_entity_pairs:
if unit_id not in unit_to_entities:
unit_to_entities[unit_id] = set()
unit_to_entities[unit_id].add(entity_id)
# Build maps keyed by unit_id:
# unit_to_entities: entity set per unit (for the co-occurrence cross-product)
# unit_event_date: event time per unit (propagated onto every pair from that unit)
# When a unit shows up more than once with conflicting event_dates (legacy
# callers passing None interleaved with aware callers), prefer the first
# non-None value so we don't accidentally erase an explicit timestamp.
unit_to_entities: dict[str, set[str]] = {}
unit_event_date: dict[str, datetime | None] = {}
for unit_id, entity_id, event_date in unit_entity_pairs:
unit_to_entities.setdefault(unit_id, set()).add(entity_id)
if event_date is not None and unit_event_date.get(unit_id) is None:
unit_event_date[unit_id] = event_date
elif unit_id not in unit_event_date:
unit_event_date[unit_id] = event_date
# Update co-occurrences for all pairs in each unit
cooccurrence_pairs = set() # Use set to avoid duplicates
# Update co-occurrences for all pairs in each unit. Carry the unit's
# event_date onto every pair so the flush step can stamp
# `last_cooccurred` with the correct time.
cooccurrence_pairs: dict[tuple[str, str], datetime | None] = {}
for unit_id, entity_ids in unit_to_entities.items():
entity_list = list(entity_ids) # Convert set to list for iteration
# For each pair of entities in this unit, create co-occurrence
entity_list = list(entity_ids)
event_date = unit_event_date.get(unit_id)
for i, entity_id_1 in enumerate(entity_list):
for entity_id_2 in entity_list[i + 1 :]:
# Skip if same entity (shouldn't happen with set, but be safe)
if entity_id_1 == entity_id_2:
continue
# Ensure consistent ordering (entity_id_1 < entity_id_2)
# Canonical ordering (entity_id_1 < entity_id_2) matches the
# entity_cooccurrences PK and check constraint.
if entity_id_1 > entity_id_2:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
cooccurrence_pairs.add((entity_id_1, entity_id_2))
key = (entity_id_1, entity_id_2)
prev = cooccurrence_pairs.get(key, _SENTINEL_MISSING)
if prev is _SENTINEL_MISSING:
cooccurrence_pairs[key] = event_date
else:
cooccurrence_pairs[key] = _later_date(prev, event_date)
# Accumulate co-occurrence pairs for post-transaction flush.
# The actual INSERT/UPDATE is deferred to flush_pending_stats() to avoid
@@ -935,7 +995,8 @@ class EntityResolver:
if cooccurrence_pairs:
key = self._task_key()
self._pending_cooccurrences.setdefault(key, []).extend(
_CooccurrencePair(entity_id_1=e1, entity_id_2=e2) for e1, e2 in cooccurrence_pairs
_CooccurrencePair(entity_id_1=e1, entity_id_2=e2, event_date=ed)
for (e1, e2), ed in cooccurrence_pairs.items()
)
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
@@ -129,6 +129,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"none",
"vertexai",
"litellm",
"litellmrouter",
"bedrock",
}
)
@@ -148,10 +149,12 @@ def create_llm_provider(
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
litellmrouter_config: dict[str, Any] | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -165,6 +168,9 @@ def create_llm_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).
extra_body: Extra body params merged into OpenAI-compatible API calls.
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients
(used by operators routing through proxies / request-tracing middleware). Currently
wired into the Anthropic provider; other providers may opt in as needed.
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).
@@ -179,6 +185,7 @@ def create_llm_provider(
CodexLLM,
GeminiLLM,
LiteLLMLLM,
LiteLLMRouterLLM,
LlamaCppLLM,
MockLLM,
NoneLLM,
@@ -243,6 +250,7 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
default_headers=default_headers,
)
elif provider_lower == "litellm":
@@ -254,6 +262,23 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower == "litellmrouter":
if not litellmrouter_config:
raise ValueError(
"Provider 'litellmrouter' requires a config object. "
"Set HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG (or the per-op variant) "
"to a JSON object accepted by litellm.Router. "
"See https://docs.litellm.ai/docs/routing."
)
return LiteLLMRouterLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
)
elif provider_lower == "bedrock":
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
@@ -283,7 +308,18 @@ def create_llm_provider(
extra_args=config.llamacpp_extra_args,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "deepseek", "volcano", "openrouter"):
elif provider_lower in (
"openai",
"groq",
"ollama",
"lmstudio",
"minimax",
"deepseek",
"volcano",
"openrouter",
"zai",
"opencode-go",
):
return OpenAICompatibleLLM(
provider=provider,
api_key=api_key,
@@ -317,6 +353,8 @@ class LLMProvider:
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
litellmrouter_config: dict[str, Any] | None = None,
):
"""
Initialize LLM provider.
@@ -331,12 +369,22 @@ class LLMProvider:
openai_service_tier: OpenAI service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra body params merged into OpenAI-compatible API calls.
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
Used by operators routing through proxies / request-tracing middleware. Falls
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
when ``None``.
litellmrouter_config: Provider-specific config for ``provider="litellmrouter"``.
JSON object passed verbatim to ``litellm.Router(**config)`` — see
https://docs.litellm.ai/docs/routing. Ignored unless ``provider == "litellmrouter"``.
When None and the provider is ``litellmrouter``, falls back to
``HindsightConfig.llm_litellmrouter_config``.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
self.litellmrouter_config = litellmrouter_config
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
@@ -344,6 +392,17 @@ class LLMProvider:
self.gemini_safety_settings = gemini_safety_settings
# Extra body params for OpenAI-compatible providers (e.g. chat_template_kwargs)
self.extra_body = extra_body
# Default headers passed to provider SDK clients (e.g. proxy auth, request tracing).
# Same pattern as ``gemini_safety_settings``: explicit override wins; otherwise read
# the static server-level default from ``HindsightConfig`` via ``_get_raw_config()``.
self.default_headers = default_headers
if self.default_headers is None:
from ..config import _get_raw_config
try:
self.default_headers = _get_raw_config().llm_default_headers
except Exception:
pass # Config may not be initialized in test environments
# Validate provider
valid_providers = [
@@ -362,9 +421,12 @@ class LLMProvider:
"minimax",
"deepseek",
"litellm",
"litellmrouter",
"bedrock",
"volcano",
"openrouter",
"zai",
"opencode-go",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -383,6 +445,10 @@ class LLMProvider:
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
@@ -438,6 +504,19 @@ class LLMProvider:
except Exception:
pass # Config may not be initialized in test environments
# For litellmrouter: prefer an explicit chain from the caller (per-op
# construction in MemoryEngine threads the right chain through). If the caller
# didn't supply one, fall back to the global ``llm_litellmrouter_config`` so
# ad-hoc constructions (e.g. ``LLMProvider.from_env()``) keep working.
router_config: dict[str, Any] | None = self.litellmrouter_config
if self.provider == "litellmrouter" and router_config is None:
from ..config import _get_raw_config
try:
router_config = _get_raw_config().llm_litellmrouter_config
except Exception:
router_config = None
# Create provider implementation using factory
self._provider_impl = create_llm_provider(
provider=self.provider,
@@ -448,10 +527,12 @@ class LLMProvider:
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
extra_body=self.extra_body,
default_headers=self.default_headers,
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
litellmrouter_config=router_config,
)
# Backward compatibility: Keep mock provider properties
@@ -759,13 +840,14 @@ class LLMProvider:
def from_env(cls) -> "LLMProvider":
"""Create provider from environment variables using config.py constants."""
from ..config import (
DEFAULT_LLM_MODEL,
DEFAULT_LLM_PROVIDER,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_MODEL,
ENV_LLM_PROVIDER,
_get_default_model_for_provider,
)
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
@@ -779,8 +861,9 @@ class LLMProvider:
)
base_url = os.getenv(ENV_LLM_BASE_URL, "")
model = os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL)
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
return cls(
provider=provider,
@@ -789,6 +872,7 @@ class LLMProvider:
model=model,
reasoning_effort="low",
extra_body=extra_body,
default_headers=default_headers,
)
@@ -18,7 +18,7 @@ import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, cast, overload
import asyncpg
import httpx
@@ -502,9 +502,15 @@ class MemoryEngine(MemoryEngineInterface):
self._dialect: SQLDialect | None = None
# Connection pool — set from backend.get_pool() for backward compatibility
self._pool = None
self._read_backend: DatabaseBackend | None = None
self._read_database_url: str | None = (
config.read_database_url if self._database_backend_type == "postgresql" else None
)
self._initialized = False
self._pool_min_size = pool_min_size if pool_min_size is not None else config.db_pool_min_size
self._pool_max_size = pool_max_size if pool_max_size is not None else config.db_pool_max_size
self._read_pool_min_size = config.read_db_pool_min_size
self._read_pool_max_size = config.read_db_pool_max_size
self._db_command_timeout = db_command_timeout if db_command_timeout is not None else config.db_command_timeout
self._db_acquire_timeout = db_acquire_timeout if db_acquire_timeout is not None else config.db_acquire_timeout
self._db_statement_timeout = config.db_statement_timeout
@@ -539,6 +545,8 @@ class MemoryEngine(MemoryEngineInterface):
base_url=memory_llm_base_url,
model=memory_llm_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.llm_litellmrouter_config,
)
# Store client and model for convenience (deprecated: use _llm_config.call() instead)
@@ -566,6 +574,8 @@ class MemoryEngine(MemoryEngineInterface):
base_url=retain_base_url,
model=retain_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Reflect LLM config - for think/observe operations (can use lighter models)
@@ -588,6 +598,8 @@ class MemoryEngine(MemoryEngineInterface):
base_url=reflect_base_url,
model=reflect_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Consolidation LLM config - for mental model consolidation (can use efficient models)
@@ -610,6 +622,8 @@ class MemoryEngine(MemoryEngineInterface):
base_url=consolidation_base_url,
model=consolidation_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Initialize cross-encoder reranker (cached for performance)
@@ -1644,11 +1658,16 @@ class MemoryEngine(MemoryEngineInterface):
# Parent doesn't exist (shouldn't happen)
return
# Get all sibling operations (including this one)
# This query runs in the same transaction, so it sees the current child's updated status
# Get all sibling operations (including this one).
# This query runs in the same transaction, so it sees the current
# child's updated status. Pull error_message too so a parent that
# fails can inherit a representative child reason -- otherwise
# downstream consumers (dashboards, alert filters) lose the actual
# cause once a batch has children. See the worker poller's
# _summarise_child_error_messages for the propagation rationale.
siblings = await conn.fetch(
f"""
SELECT status
SELECT status, error_message
FROM {fq_table("async_operations")}
WHERE bank_id = $1
AND result_metadata::jsonb @> $2::jsonb
@@ -1672,7 +1691,12 @@ class MemoryEngine(MemoryEngineInterface):
# All siblings are done - update parent status
if any_failed:
new_status = "failed"
# Set parent error message to indicate child failure
# Set parent error message to indicate child failure. Inherit
# the most-common failed-child error_message rather than a
# generic string so downstream filters can attribute the
# cause correctly.
from hindsight_api.worker.poller import _summarise_child_error_messages
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
@@ -1681,7 +1705,7 @@ class MemoryEngine(MemoryEngineInterface):
""",
uuid.UUID(parent_operation_id),
new_status,
"One or more sub-batches failed",
_summarise_child_error_messages(siblings),
)
elif all_completed:
new_status = "completed"
@@ -1925,6 +1949,23 @@ class MemoryEngine(MemoryEngineInterface):
# These will be migrated to use self._backend.acquire() over time.
self._pool = self._backend.get_pool()
if self._read_database_url:
logger.info(
f"Opening read backend against {mask_network_location(self._read_database_url)} for recall queries"
)
self._read_backend = create_database_backend(self._database_backend_type)
await self._read_backend.initialize(
self._read_database_url,
min_size=self._read_pool_min_size,
max_size=self._read_pool_max_size,
command_timeout=self._db_command_timeout,
acquire_timeout=self._db_acquire_timeout,
statement_cache_size=0,
init_callback=_init_connection,
)
else:
self._read_backend = self._backend
# Initialize entity resolver with pool and configured lookup strategy
self.entity_resolver = EntityResolver(
self._backend,
@@ -2013,6 +2054,15 @@ class MemoryEngine(MemoryEngineInterface):
await self.initialize()
return self._pool
async def _get_read_backend(self) -> DatabaseBackend:
"""Get the read-only backend (replica when configured, otherwise primary).
Writes MUST NOT be issued through this backend.
"""
if not self._initialized:
await self.initialize()
return self._read_backend
async def _get_backend(self) -> DatabaseBackend:
"""Get the database backend, auto-initializing if needed."""
if not self._initialized:
@@ -2069,7 +2119,11 @@ class MemoryEngine(MemoryEngineInterface):
await self._http_client.aclose()
self._http_client = None
# Close database backend (shuts down pool)
if self._read_backend is not None and self._read_backend is not self._backend:
await self._read_backend.shutdown()
self._read_backend = None
# Close primary database backend (shuts down pool)
if self._backend is not None:
await self._backend.shutdown()
self._backend = None
@@ -2294,6 +2348,12 @@ class MemoryEngine(MemoryEngineInterface):
if result and result.contents is not None:
contents = result.contents
# Engine-owned copy: the orchestrator clears per-item "content" strings
# after building the document's combined text (memory pressure
# optimization, see retain/orchestrator.py). Without an internal copy
# those mutations leak back to the caller's dicts.
contents = cast(list[RetainContentDict], [dict(c) for c in contents])
# Apply batch-level document_id to contents that don't have their own (backwards compatibility)
if document_id:
for item in contents:
@@ -2914,7 +2974,7 @@ class MemoryEngine(MemoryEngineInterface):
if tracer:
tracer.start()
backend = await self._get_backend()
backend = await self._get_read_backend()
recall_start = time.time()
# Buffer logs for clean output in concurrent scenarios.
@@ -2983,7 +3043,7 @@ class MemoryEngine(MemoryEngineInterface):
max_connections=effective_connection_budget,
operation_id=f"recall-{recall_id}",
) as op:
budgeted_pool = op.wrap_pool(self._backend)
budgeted_pool = op.wrap_pool(backend)
parallel_start = time.time()
multi_result = await retrieve_all_fact_types_parallel(
budgeted_pool,
@@ -3572,26 +3632,21 @@ class MemoryEngine(MemoryEngineInterface):
source_facts_dict[sid] = _make_source_fact(sid, r)
total_source_tokens += fact_tokens
# Get entities for each fact if include_entities is requested
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
# Get entities for each fact if include_entities is requested.
# _entity_rows_for_units_sql resolves both direct unit_entities rows
# and observation-via-source-memory inheritance in a single query.
fact_entity_map = {} # unit_id -> list of {entity_id, canonical_name}
if include_entities and top_scored:
unit_ids = [uuid.UUID(sr.id) for sr in top_scored]
if unit_ids:
async with acquire_with_retry(backend) as entity_conn:
entity_rows = await entity_conn.fetch(
f"""
SELECT ue.unit_id, e.id as entity_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
""",
self._entity_rows_for_units_sql(unit_ids_placeholder=1),
unit_ids,
)
for row in entity_rows:
unit_id = str(row["unit_id"])
if unit_id not in fact_entity_map:
fact_entity_map[unit_id] = []
fact_entity_map[unit_id].append(
fact_entity_map.setdefault(unit_id, []).append(
{"entity_id": str(row["entity_id"]), "canonical_name": row["canonical_name"]}
)
@@ -3681,12 +3736,67 @@ class MemoryEngine(MemoryEngineInterface):
)
except Exception as e:
# Use repr(e) so exceptions with empty __str__ (e.g. raise SomeError())
# still emit a discriminating class+args string into operations.error_message.
log_buffer.append(
f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {type(e).__name__}: {e}"
f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {type(e).__name__}: {e!r}"
)
if not quiet:
logger.error("\n" + "\n".join(log_buffer))
raise Exception(f"Failed to search memories: {type(e).__name__}: {e}")
logger.error("\n" + "\n".join(log_buffer), exc_info=True)
raise RuntimeError(f"Failed to search memories ({type(e).__name__}): {e!r}") from e
def _entity_rows_for_units_sql(self, unit_ids_placeholder: int) -> str:
"""SQL SELECT producing ``(unit_id, entity_id, canonical_name)`` rows for
the given unit IDs.
Direct rows come from ``unit_entities``. Observations rarely carry
direct rows there; their entity association lives transitively through
their source memories (``source_memory_ids`` on PG, the
``observation_sources`` junction on Oracle). When an observation has
no direct entity rows the SELECT inherits its source memories'
entities, so the result is the same set callers would get from
``get_memory_unit``.
``unit_ids_placeholder`` is the 1-based parameter index that holds the
``uuid[]`` of unit IDs. The placeholder is referenced twice both
sides of the UNION need it so callers should not reuse the slot.
"""
ue = fq_table("unit_entities")
ents = fq_table("entities")
mu = fq_table("memory_units")
p = unit_ids_placeholder
direct = (
f"SELECT ue.unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {ue} ue "
f"JOIN {ents} e ON e.id = ue.entity_id "
f"WHERE ue.unit_id = ANY(${p}::uuid[])"
)
if self._backend.ops.uses_observation_sources_table:
os_t = fq_table("observation_sources")
inherited = (
f"SELECT os.observation_id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {os_t} os "
f"JOIN {ue} src_ue ON src_ue.unit_id = os.source_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE os.observation_id = ANY(${p}::uuid[]) "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = os.observation_id)"
)
else:
inherited = (
f"SELECT obs.id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {mu} obs "
f"CROSS JOIN LATERAL unnest(obs.source_memory_ids) AS src_id "
f"JOIN {ue} src_ue ON src_ue.unit_id = src_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE obs.id = ANY(${p}::uuid[]) "
f"AND obs.fact_type = 'observation' "
f"AND obs.source_memory_ids IS NOT NULL "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = obs.id)"
)
return f"({direct}) UNION ({inherited})"
def _filter_by_token_budget(
self, results: list[dict[str, Any]], max_tokens: int
@@ -5086,31 +5196,15 @@ class MemoryEngine(MemoryEngineInterface):
if not row:
return None
# Get entity information
# Get entity information. _entity_rows_for_units_sql handles the
# observation→source_memory_ids inheritance fallback in SQL, so a
# single query covers direct rows and inherited ones.
entities_rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = $1
""",
row["id"],
self._entity_rows_for_units_sql(unit_ids_placeholder=1),
[row["id"]],
)
entities = [r["canonical_name"] for r in entities_rows]
# For observations with no direct entities, inherit from source memories
if not entities and row["fact_type"] == "observation" and row["source_memory_ids"]:
source_entities_rows = await conn.fetch(
f"""
SELECT DISTINCT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
""",
row["source_memory_ids"],
)
entities = [r["canonical_name"] for r in source_entities_rows]
result = {
"id": str(row["id"]),
"text": row["text"],
@@ -9,6 +9,7 @@ from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .litellm_llm import LiteLLMLLM
from .litellm_router_llm import LiteLLMRouterLLM
from .llamacpp_llm import LlamaCppLLM
from .mock_llm import MockLLM
from .none_llm import NoneLLM
@@ -21,6 +22,7 @@ __all__ = [
"GeminiLLM",
"LlamaCppLLM",
"LiteLLMLLM",
"LiteLLMRouterLLM",
"MockLLM",
"NoneLLM",
"OpenAICompatibleLLM",
@@ -37,6 +37,7 @@ class AnthropicLLM(LLMInterface):
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
default_headers: dict[str, str] | None = None,
**kwargs: Any,
):
"""
@@ -49,6 +50,10 @@ class AnthropicLLM(LLMInterface):
model: Model name (e.g., "claude-sonnet-4-20250514").
reasoning_effort: Reasoning effort level (not used by Anthropic).
timeout: Request timeout in seconds.
default_headers: Optional custom headers passed as ``default_headers`` to
the Anthropic SDK client. Used by operators routing through proxies
or request-tracing middleware. Sourced from ``llm_default_headers`` in
``HindsightConfig`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``).
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -60,11 +65,16 @@ class AnthropicLLM(LLMInterface):
try:
from anthropic import AsyncAnthropic
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
# SDK retries disabled — wrapper-level retry loop in ``call`` handles
# backoff (mirrors ``OpenAICompatibleLLM`` so the two providers behave
# consistently).
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
if timeout:
client_kwargs["timeout"] = timeout
if default_headers:
client_kwargs["default_headers"] = default_headers
self._client = AsyncAnthropic(**client_kwargs)
logger.info(f"Anthropic client initialized for model: {self.model}")
@@ -12,6 +12,8 @@ import logging
import time
from typing import Any
from pydantic import ValidationError
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -278,6 +280,12 @@ class ClaudeCodeLLM(LLMInterface):
return result
except ValidationError:
# Pydantic schema validation failure — retrying with the same
# input won't produce a different schema. Raise immediately
# instead of burning quota on identical calls (#1412).
raise
except Exception as e:
last_exception = e
@@ -4,14 +4,26 @@ OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
without separate OpenAI Platform API credits. It uses OAuth tokens from
~/.codex/auth.json and communicates with the ChatGPT backend API.
Tokens are refreshed automatically: the provider decodes the access_token
JWT's ``exp`` claim and proactively refreshes via
``POST https://auth.openai.com/oauth/token`` ~60s before expiry. It also
reactively refreshes once on a 401/403 from the Codex backend before giving
up. The refresh request shape mirrors the canonical ``@openai/codex`` CLI
implementation (codex-rs/login/src/auth/manager.rs on github.com/openai/codex)
so that future server-side changes affect both clients identically.
"""
import asyncio
import base64
import binascii
import json
import logging
import os
import tempfile
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -24,6 +36,36 @@ from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# OAuth refresh endpoint and client id, mirrored from the canonical
# ``@openai/codex`` CLI (codex-rs/login/src/auth/manager.rs on
# github.com/openai/codex). The endpoint is overridable via env var so that
# future Codex changes or staging environments can be pointed at without a
# code change — same env var name the upstream CLI uses.
_CODEX_REFRESH_TOKEN_URL = os.environ.get("CODEX_REFRESH_TOKEN_URL_OVERRIDE", "https://auth.openai.com/oauth/token")
_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
# Proactively refresh this many seconds before the JWT ``exp`` claim. The
# upstream Codex CLI uses no skew (it refreshes at ``exp <= now``); the
# extra window reduces races where a request leaves the client with a token
# that the server has already declared expired by the time it arrives.
_CODEX_TOKEN_REFRESH_SKEW_SECONDS = 60
# OAuth error codes that the refresh endpoint returns when the refresh_token
# itself is no longer usable. These are terminal — retrying refresh will not
# succeed; the user must re-run ``codex auth login``.
_CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated"}
)
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
The user must re-run ``codex auth login`` to obtain new credentials.
Callers should surface a clear remediation message and stop retrying.
"""
class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
@@ -44,9 +86,19 @@ class CodexLLM(LLMInterface):
"""Initialize Codex LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Path is fixed at ~/.codex/auth.json — matches the upstream CLI.
# Storing it on self lets the refresh path re-read after another
# process (e.g. a sidecar) rotates the file out from under us.
self._auth_file = Path.home() / ".codex" / "auth.json"
# Single-flight refresh lock. Multiple concurrent requests racing
# toward an expired token should produce one network refresh, not N.
self._auth_lock = asyncio.Lock()
# Load Codex OAuth credentials
try:
self.access_token, self.account_id = self._load_codex_auth()
self.refresh_token = self._load_codex_refresh_token()
logger.info(f"Loaded Codex OAuth credentials for account: {self.account_id}")
except Exception as e:
raise RuntimeError(
@@ -108,6 +160,290 @@ class CodexLLM(LLMInterface):
return access_token, account_id
def _load_codex_refresh_token(self) -> str | None:
"""Load ``tokens.refresh_token`` from ``~/.codex/auth.json``.
Returns None when the auth file is unreadable or omits the field —
the provider still functions as a one-shot loader in that case, it
just can't refresh when the access_token expires. This deliberately
does not raise so that ``__init__`` keeps the existing failure mode
of raising only on missing ``access_token``.
"""
try:
with open(self._auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(
f"Codex auth file unreadable when loading refresh_token: {type(e).__name__}. "
"Token refresh will not be available; the access_token in memory will be used until it expires."
)
return None
return data.get("tokens", {}).get("refresh_token")
@staticmethod
def _decode_jwt_exp_unixtime(token: str) -> int | None:
"""Return the JWT ``exp`` claim as a unix timestamp, or None on parse failure.
ChatGPT/Codex access_tokens are JWTs whose payload includes ``exp``
(RFC 7519). We need the expiry to schedule proactive refresh — the
``auth.json`` file does not persist a separate ``expires_at`` field
in the upstream CLI's shape, so decoding the JWT itself is the
canonical way to know when the token is stale.
We do not verify the signature — the server is the source of truth
on whether the token is actually accepted, and the only thing this
method affects is the *timing* of refresh, not whether to trust the
token contents.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
# JWT uses base64url without padding. Re-pad before decoding.
padding = "=" * (-len(payload_b64) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding)
payload = json.loads(payload_bytes.decode("utf-8"))
exp = payload.get("exp")
return int(exp) if exp is not None else None
except (ValueError, TypeError, json.JSONDecodeError, binascii.Error):
return None
def _token_is_stale(self, skew_seconds: int = _CODEX_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
"""True when the cached access_token is past expiry (with skew).
Returns False when expiry cannot be determined — we'd rather use a
possibly-expired token and recover via the reactive 401 path than
refresh aggressively on every request when ``exp`` parsing fails.
"""
exp = self._decode_jwt_exp_unixtime(self.access_token)
if exp is None:
return False
return exp <= int(time.time()) + skew_seconds
def _persist_auth_atomic(self, updated_tokens: dict[str, Any]) -> None:
"""Write the rotated tokens back to ``~/.codex/auth.json`` atomically.
Strategy: re-read the on-disk auth.json (so we don't clobber fields
another process may have added), patch ``tokens.*`` and
``last_refresh``, write to a tempfile in the same directory with
mode 0600, then ``os.replace`` onto the target. ``os.replace`` is
atomic within the same filesystem on POSIX and Windows, so a
concurrent reader will see either the old file or the fully-written
new file — never a partial truncate, which is the upstream CLI's
worst-case race.
On non-Unix platforms the chmod is a best-effort no-op; the parent
directory permissions still bound access.
"""
current: dict[str, Any]
try:
with open(self._auth_file) as f:
loaded = json.load(f)
# auth.json should always be a JSON object at the top level; if
# someone has hand-edited it into a non-object shape, fall back
# to the minimal default rather than crashing the refresh path.
current = loaded if isinstance(loaded, dict) else {"auth_mode": "chatgpt", "tokens": {}}
except (OSError, json.JSONDecodeError):
# If the file became unreadable between our last read and now,
# construct a minimal shape rather than refusing to persist.
current = {"auth_mode": "chatgpt", "tokens": {}}
existing_tokens = current.get("tokens")
tokens: dict[str, Any] = existing_tokens if isinstance(existing_tokens, dict) else {}
for key in ("access_token", "refresh_token", "id_token", "account_id"):
if key in updated_tokens and updated_tokens[key] is not None:
tokens[key] = updated_tokens[key]
current["tokens"] = tokens
current["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# Write to a sibling tempfile so the rename is same-filesystem.
parent = self._auth_file.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".auth.", suffix=".json.tmp", dir=str(parent))
try:
with os.fdopen(fd, "w") as f:
json.dump(current, f, indent=2)
f.flush()
os.fsync(f.fileno())
try:
os.chmod(tmp_path, 0o600)
except OSError:
pass # best-effort on platforms that don't support chmod
os.replace(tmp_path, self._auth_file)
except Exception:
# Clean up the orphaned tempfile if rename fails.
try:
os.unlink(tmp_path)
except OSError:
pass
raise
async def _refresh_oauth_tokens(self, reason: str = "", *, force: bool = False) -> None:
"""Refresh the OAuth access_token using the stored refresh_token.
Single-flight: serialized through ``self._auth_lock`` so concurrent
callers produce one network request. The first caller refreshes; the
rest wake up and observe that either (a) the in-memory token is no
longer stale (proactive case) or (b) the in-memory token has changed
since they entered (reactive case), and return without re-refreshing.
Args:
reason: Free-form string included in log lines for diagnostics.
force: When True, refresh even if the JWT exp claim looks fresh.
Used by the reactive 401 path — the server rejected the
token, so we cannot trust the JWT's self-reported expiry.
Raises:
CodexRefreshExpiredError: when the server returns a terminal
error code (refresh_token_expired/reused/invalidated) or any
401 on the refresh endpoint itself.
RuntimeError: for other refresh failures (network, 5xx, etc.).
"""
# Capture the token we'd be refreshing BEFORE acquiring the lock so
# that we can detect mid-wait rotation by another coroutine.
token_before_lock = self.access_token
async with self._auth_lock:
if force:
# Reactive: skip only if another coroutine already rotated
# the token while we were waiting on the lock.
if self.access_token != token_before_lock:
return
else:
# Proactive: skip if the token is no longer stale (the
# canonical "another coroutine refreshed first" check).
if not self._token_is_stale():
return
if not self.refresh_token:
raise RuntimeError(
"Codex access_token is expired but no refresh_token is available. "
"Run 'codex auth login' to re-authenticate."
)
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Codex OAuth access_token{log_reason}")
request_body = {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
}
try:
response = await self._client.post(
_CODEX_REFRESH_TOKEN_URL,
json=request_body,
headers={"Content-Type": "application/json"},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Codex OAuth refresh network error: {type(e).__name__}") from e
if response.status_code == 401:
# Classify by ``error.code`` (or top-level ``error`` string) — same
# mapping as the upstream Rust CLI's request_chatgpt_token_refresh.
error_code = self._extract_oauth_error_code(response)
if error_code in _CODEX_TERMINAL_REFRESH_ERROR_CODES:
raise CodexRefreshExpiredError(
f"Codex refresh_token is permanently invalid (error.code={error_code}). "
"Run 'codex auth login' to re-authenticate."
)
# Unknown 401 — treat as terminal too, matching the upstream classification.
raise CodexRefreshExpiredError(
f"Codex OAuth refresh returned 401 with unrecognized error code "
f"({error_code or 'none'}). Run 'codex auth login' to re-authenticate."
)
if response.status_code >= 400:
# 5xx and other 4xx are transient/retryable from the caller's
# perspective; surface as RuntimeError without leaking the
# request body in logs.
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
try:
body = response.json()
except json.JSONDecodeError as e:
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Codex OAuth refresh returned no access_token")
# The refresh_token may rotate on each refresh — adopt the new
# one if the server sent it, otherwise keep the existing.
new_refresh = body.get("refresh_token") or self.refresh_token
new_id_token = body.get("id_token")
# Update in-memory state first so callers waiting on the lock
# see fresh credentials immediately, even if disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted = {
"access_token": new_access,
"refresh_token": new_refresh,
}
if new_id_token:
persisted["id_token"] = new_id_token
try:
self._persist_auth_atomic(persisted)
except OSError as e:
# In-memory creds are valid; warn but don't fail the request
# path. Future process starts will fall back to the stale
# on-disk auth.json and immediately refresh.
logger.warning(
f"Codex OAuth refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are up to date; on-disk file is stale."
)
logger.info("Codex OAuth access_token refreshed successfully")
@staticmethod
def _extract_oauth_error_code(response: "httpx.Response") -> str | None:
"""Pull the OAuth error code out of a 4xx response body, if present.
The refresh endpoint returns shapes like
``{"error": "...", "error_code": "..."}`` or
``{"error": {"code": "..."}}``. We don't fail the call if the body
is unparseable — the caller falls back to a generic "unknown" error.
"""
try:
body = response.json()
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(body, dict):
return None
# Shape 1: error is a nested object with "code"
err = body.get("error")
if isinstance(err, dict):
code = err.get("code")
if isinstance(code, str):
return code
# Shape 2: top-level error_code string
code = body.get("error_code")
if isinstance(code, str):
return code
# Shape 3: error is itself a string code
if isinstance(err, str):
return err
return None
async def _ensure_fresh_token(self) -> None:
"""Refresh the access_token proactively if it is near or past expiry.
Called at the top of every API-bound method. Cheap when the token is
fresh (just decodes the JWT exp claim and returns).
"""
if self._token_is_stale():
try:
await self._refresh_oauth_tokens(reason="proactive (token near expiry)")
except CodexRefreshExpiredError:
# Surface to the caller as the same RuntimeError shape the
# request loop has historically raised, so existing error
# handling paths keep working.
raise
def _map_reasoning_effort(self, effort: str) -> str:
"""
Map standard reasoning effort to Codex reasoning summary format.
@@ -189,6 +525,15 @@ class CodexLLM(LLMInterface):
"""Make API call to Codex backend with SSE streaming."""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
# Cheap when fresh: a JWT exp decode + comparison.
await self._ensure_fresh_token()
# Tracks whether we've already attempted a reactive refresh in
# response to a 401 from the backend. Set once on the first auth
# failure so we retry exactly once after refresh, not in a loop.
attempted_refresh_after_auth_error = False
# Prepare system instructions
system_instruction = ""
user_messages = []
@@ -244,7 +589,12 @@ class CodexLLM(LLMInterface):
url = f"{self.base_url}/codex/responses"
last_exception = None
for attempt in range(max_retries + 1):
# Manual attempt tracking instead of ``for attempt in range(...)`` so
# that the reactive-refresh path can retry once without consuming a
# normal-retry budget slot. The refresh-retry is conceptually a
# separate auth-recovery attempt that shouldn't compete with backoff.
attempt = 0
while True:
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
@@ -269,6 +619,7 @@ class CodexLLM(LLMInterface):
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
attempt += 1
continue
raise
@@ -332,8 +683,38 @@ class CodexLLM(LLMInterface):
last_exception = e
status_code = e.response.status_code
# Fast fail on auth errors
# Auth error: try one OAuth refresh + retry before giving up.
# The proactive refresh at the top of this method catches most
# expiries, but a token can also become invalid mid-request if
# another process rotates auth.json out from under us, or if
# the JWT exp claim is unparseable and we never knew it was
# stale. Reactive refresh is the safety net.
if status_code in (401, 403):
if not attempted_refresh_after_auth_error:
attempted_refresh_after_auth_error = True
try:
await self._refresh_oauth_tokens(
reason=f"reactive (HTTP {status_code} from codex backend)",
force=True,
)
# Rebuild the Authorization header with the new
# token and retry without consuming a normal-retry
# budget slot — this is a dedicated auth-recovery
# attempt that shouldn't compete with backoff.
headers["Authorization"] = f"Bearer {self.access_token}"
logger.info("Codex auth refreshed after auth error; retrying request once")
continue
except CodexRefreshExpiredError as refresh_err:
logger.error("Codex refresh_token is permanently invalid; cannot recover from auth error")
raise RuntimeError(
"Codex authentication failed and the refresh_token is no longer valid.\n"
"Run 'codex auth login' to re-authenticate."
) from refresh_err
except Exception as refresh_err:
logger.error(
f"Codex token refresh attempt failed: {type(refresh_err).__name__}: {refresh_err}"
)
# Fall through to the original raise below.
logger.error(f"Codex auth error (HTTP {status_code}): {e.response.text[:200]}")
raise RuntimeError(
"Codex authentication failed. Your OAuth token may have expired.\n"
@@ -349,6 +730,7 @@ class CodexLLM(LLMInterface):
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
)
await asyncio.sleep(backoff)
attempt += 1
continue
else:
logger.error(
@@ -362,6 +744,7 @@ class CodexLLM(LLMInterface):
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
attempt += 1
continue
else:
logger.error(f"Codex connection error after {max_retries + 1} attempts: {e}")
@@ -462,6 +845,11 @@ class CodexLLM(LLMInterface):
"""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
# Same rationale as in ``call()`` — keeps the request from leaving
# the client carrying a token that's already past ``exp``.
await self._ensure_fresh_token()
# Prepare system instructions
system_instruction = ""
user_messages = []
@@ -534,9 +922,39 @@ class CodexLLM(LLMInterface):
# Debug logging for troubleshooting
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
# One reactive refresh attempt on auth failure, mirroring call().
# ``call_with_tools`` doesn't have a retry loop, so we hand-roll a
# single retry after refreshing the token. Any non-auth error still
# surfaces immediately to keep behavior identical for callers.
attempted_refresh_after_auth_error = False
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
if response.status_code in (401, 403) and not attempted_refresh_after_auth_error:
attempted_refresh_after_auth_error = True
try:
await self._refresh_oauth_tokens(
reason=f"reactive (HTTP {response.status_code} from codex backend in call_with_tools)",
force=True,
)
headers["Authorization"] = f"Bearer {self.access_token}"
logger.info("Codex auth refreshed after auth error; retrying tool-call request once")
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
except CodexRefreshExpiredError as refresh_err:
logger.error(
"Codex refresh_token is permanently invalid; cannot recover from auth error in tool-call path"
)
raise RuntimeError(
"Codex authentication failed and the refresh_token is no longer valid.\n"
"Run 'codex auth login' to re-authenticate."
) from refresh_err
except Exception as refresh_err:
logger.error(
f"Codex token refresh attempt failed in tool-call path: {type(refresh_err).__name__}: {refresh_err}"
)
# Fall through to the normal error path below.
# Log response details on error
if response.status_code != 200:
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
@@ -103,12 +103,58 @@ class LiteLLMLLM(LLMInterface):
if self.base_url:
kwargs["api_base"] = self.base_url
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = max_completion_tokens
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
# Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
# high-capacity models. When a configured deployment supports fewer
# completion tokens (e.g. gpt-4.1-nano caps at 32768), the call would
# otherwise be rejected. Cap pre-emptively using LiteLLM's per-model
# registry so things work out of the box across the supported model set.
def _cap_max_completion_tokens(self, value: int) -> int:
cap = self._get_model_output_cap()
if cap and value > cap:
logger.debug("capping max_completion_tokens %d -> %d for model %s", value, cap, self.model)
return cap
return value
def _get_model_output_cap(self) -> int | None:
"""Return the configured model's max output tokens, per LiteLLM's registry."""
try:
cap = self._litellm.get_max_tokens(self.model)
return int(cap) if cap else None
except Exception:
return None
# ── hooks for Router-style subclasses ────────────────────────────────────
# The retry+parse loop in call() / call_with_tools() is shared by every
# LiteLLM-backed provider. Subclasses override the small surface below to
# swap the completion fn (direct vs Router) and rename the deployment that
# actually answered the request.
@property
def _stage_label(self) -> str:
"""Stage breadcrumb label — overridden by subclasses (e.g. ``litellmrouter``)."""
return "litellm"
async def _acompletion(self, **kwargs: Any) -> Any:
"""Issue a chat completion. Subclasses override to route via ``litellm.Router``."""
return await self._litellm.acompletion(**kwargs)
def _resolve_completion_model(self, response: Any) -> str:
"""
Return the model name to record in metrics/tracing.
For Router-backed providers this can differ from ``self.model`` — the Router
may pick a different deployment than the primary. Default: ``self.model``.
"""
return self.model
async def call(
self,
messages: list[dict[str, str]],
@@ -143,12 +189,13 @@ class LiteLLMLLM(LLMInterface):
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.{scope}.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
response = await self._acompletion(**call_kwargs)
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
# Check for length-limited output
if finish_reason == "length":
@@ -184,7 +231,7 @@ class LiteLLMLLM(LLMInterface):
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
duration=duration,
input_tokens=input_tokens,
@@ -198,7 +245,7 @@ class LiteLLMLLM(LLMInterface):
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
@@ -211,7 +258,7 @@ class LiteLLMLLM(LLMInterface):
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"slow llm call: scope={scope}, model={self.provider}/{model_name}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
@@ -287,13 +334,14 @@ class LiteLLMLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.tools.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
response = await self._acompletion(**call_kwargs)
message = response.choices[0].message
content = message.content
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
# Extract tool calls
tool_calls: list[LLMToolCall] = []
@@ -319,7 +367,7 @@ class LiteLLMLLM(LLMInterface):
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
duration=duration,
input_tokens=input_tokens,
@@ -338,7 +386,7 @@ class LiteLLMLLM(LLMInterface):
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
model=model_name,
scope=scope,
messages=messages,
response_content=content,
@@ -0,0 +1,167 @@
"""
LiteLLM Router LLM provider — pure pass-through to ``litellm.Router``.
The full configuration object is forwarded verbatim. We do not translate model
names, infer fallbacks, validate shape, or introspect Router internals:
whatever the user puts in ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG`` becomes
``Router(**config)``. If the shape is wrong, LiteLLM Router raises.
The only Hindsight-imposed convention is that one entry in ``model_list``
must have ``model_name: "default"`` — that's the entrypoint we issue
completions against. Everything else (ordering, fallbacks, load-balancing,
weighted picks, rate limits, retries, cooldowns) is whatever the user
configures via LiteLLM's own keys.
See https://docs.litellm.ai/docs/routing for the supported keys (``model_list``,
``fallbacks``, ``context_window_fallbacks``, ``num_retries``, ``cooldown_time``,
``routing_strategy``, ``allowed_fails``, …).
The retry/parse/metrics loop is shared with ``LiteLLMLLM`` via inheritance:
this class only overrides the completion fn, the call kwargs, and the model
name reported in metrics.
Example ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG``::
{
"model_list": [
{"model_name": "default", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-..."}},
{"model_name": "fallback", "litellm_params": {"model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-..."}}
],
"fallbacks": [{"default": ["fallback"]}],
"num_retries": 0,
"cooldown_time": 60
}
"""
import logging
from typing import Any
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
logger = logging.getLogger(__name__)
# Hindsight always issues completions against this ``model_name``. Users must
# include at least one entry with ``model_name: "default"`` in their config's
# ``model_list``; that entry is the entrypoint, and any other entries become
# fallback / load-balance / weighted-pool members per the user's own
# ``fallbacks`` / ``routing_strategy`` settings.
_ENTRYPOINT_MODEL_NAME = "default"
class LiteLLMRouterLLM(LiteLLMLLM):
"""
LLM provider backed by ``litellm.Router``.
The full Router config is supplied by the caller. We pass it verbatim to
``Router(**config)`` and route requests against the first ``model_list``
entry's ``model_name``. Inherits the retry/parse/metrics loop from
``LiteLLMLLM``; only the completion fn and the call kwargs differ.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
config: dict[str, Any],
reasoning_effort: str = "low",
timeout: float = 300.0,
**kwargs: Any,
):
super().__init__(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
timeout=timeout,
**kwargs,
)
self.config = config
from litellm import Router
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
# Pure pass-through: whatever the user gave goes straight to LiteLLM Router.
# If the shape is invalid, Router raises its own error — we don't pre-validate
# or introspect Router internals.
self._router = Router(**config)
# Pre-compute the most conservative output-tokens cap across every configured
# deployment so a single max_completion_tokens value works no matter which
# deployment Router picks. Uses LiteLLM's own per-model registry; unknown
# models contribute no cap. See LiteLLMLLM._cap_max_completion_tokens.
self._router_output_cap = self._compute_router_output_cap(config)
logger.info("LiteLLM Router initialized; entrypoint model_name=%r", _ENTRYPOINT_MODEL_NAME)
def _compute_router_output_cap(self, config: dict[str, Any]) -> int | None:
caps: list[int] = []
for deployment in (config.get("model_list") or []) if isinstance(config, dict) else []:
if not isinstance(deployment, dict):
continue
params = deployment.get("litellm_params") or {}
model_str = params.get("model") if isinstance(params, dict) else None
if not model_str:
continue
try:
cap = self._litellm.get_max_tokens(model_str)
except Exception:
cap = None
if cap:
caps.append(int(cap))
return min(caps) if caps else None
# ── overrides for the shared retry/parse loop ───────────────────────────
@property
def _stage_label(self) -> str:
return "litellmrouter"
async def _acompletion(self, **kwargs: Any) -> Any:
return await self._router.acompletion(**kwargs)
def _resolve_completion_model(self, response: Any) -> str:
hidden = getattr(response, "_hidden_params", None) or {}
return hidden.get("model") or _ENTRYPOINT_MODEL_NAME
def _get_model_output_cap(self) -> int | None:
return self._router_output_cap
def _build_common_kwargs(
self,
messages: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
) -> dict[str, Any]:
# Always issue against the entrypoint group; Router handles deployment selection,
# cross-group fallbacks, retries, cooldowns — whatever the user configured.
kwargs: dict[str, Any] = {
"model": _ENTRYPOINT_MODEL_NAME,
"messages": messages,
}
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
async def verify_connection(self) -> None:
from hindsight_api.engine.llm_interface import OutputTooLongError
try:
await self.call(
messages=[{"role": "user", "content": "test"}],
max_completion_tokens=50,
temperature=0.0,
scope="verification",
max_retries=0,
)
logger.info("LiteLLM Router connection verified successfully")
except OutputTooLongError:
logger.info("LiteLLM Router connection verified successfully (response truncated)")
except Exception as e:
logger.error(f"LiteLLM Router connection verification failed: {e}")
raise RuntimeError(f"Failed to verify LiteLLM Router connection: {e}") from e
@@ -1,5 +1,6 @@
"""
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, and DeepSeek.
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, DeepSeek,
and Opencode Go.
This provider handles all OpenAI API-compatible models including:
- OpenAI: GPT-4, GPT-4o, GPT-5, o1, o3 (reasoning models)
@@ -8,6 +9,7 @@ This provider handles all OpenAI API-compatible models including:
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models with 1M context window
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via api.deepseek.com
- Opencode Go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
@@ -232,6 +234,7 @@ class OpenAICompatibleLLM(LLMInterface):
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via https://api.deepseek.com
- opencode-go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
"""
def __init__(
@@ -250,7 +253,7 @@ class OpenAICompatibleLLM(LLMInterface):
Initialize OpenAI-compatible LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama", "lmstudio").
provider: Provider name ("openai", "groq", "ollama", "lmstudio", "opencode-go", etc.).
api_key: API key (optional for ollama/lmstudio).
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
@@ -273,6 +276,8 @@ class OpenAICompatibleLLM(LLMInterface):
"deepseek",
"volcano",
"openrouter",
"zai",
"opencode-go",
]
if self.provider not in valid_providers:
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
@@ -291,13 +296,29 @@ class OpenAICompatibleLLM(LLMInterface):
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
# Validate API key for cloud providers
if self.provider in ("openai", "groq", "minimax", "deepseek", "openrouter") and not self.api_key:
if (
self.provider
in (
"openai",
"groq",
"minimax",
"deepseek",
"openrouter",
"zai",
"opencode-go",
)
and not self.api_key
):
raise ValueError(f"API key is required for {self.provider}")
# Service tier configuration (from config, not env vars)
@@ -558,10 +579,11 @@ class OpenAICompatibleLLM(LLMInterface):
)
# Strip reasoning model thinking tags
# Supports: <think>, <thinking>, <reasoning>, |startthink|/|endthink|
# Supports: <think>, <thinking>, <thought>, <reasoning>, |startthink|/|endthink|
original_len = len(content)
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
content = re.sub(r"<thought>.*?</thought>", "", content, flags=re.DOTALL)
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
content = content.strip()
@@ -14,8 +14,6 @@ import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
import tiktoken
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
@@ -23,6 +21,7 @@ from .prompts import (
build_final_system_prompt,
build_system_prompt_for_tools,
)
from .tokenization import count_cl100k_tokens
from .tools_schema import get_reflect_tools
@@ -266,25 +265,22 @@ OUTPUT:"""
return None, 0, 0
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
def _count_messages_tokens(messages: list[dict[str, Any]]) -> int:
"""Estimate the token count of the messages list using cl100k_base encoding."""
total = 0
for msg in messages:
content = msg.get("content") or ""
if isinstance(content, str):
total += len(_TIKTOKEN_ENCODING.encode(content))
total += count_cl100k_tokens(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
total += len(_TIKTOKEN_ENCODING.encode(part["text"]))
total += count_cl100k_tokens(part["text"])
# Tool call arguments and results also count
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
func = tc.get("function", {})
total += len(_TIKTOKEN_ENCODING.encode(func.get("arguments", "")))
total += count_cl100k_tokens(func.get("arguments", ""))
return total
@@ -672,7 +668,7 @@ async def run_reflect_agent(
# must respect max_tokens like the forced-final paths do. If it
# overshoots, run one extra capped call to rewrite it within
# the cap.
if max_tokens is not None and len(_TIKTOKEN_ENCODING.encode(answer)) > max_tokens:
if max_tokens is not None and count_cl100k_tokens(answer) > max_tokens:
rewrite_start = time.time()
rewritten, rewrite_usage = await llm_config.call(
messages=[
@@ -10,9 +10,7 @@ The reflect agent uses hierarchical retrieval:
import json
from typing import Any
import tiktoken
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
from .tokenization import count_cl100k_tokens
# Fraction of max_context_tokens reserved for tool results in the final synthesis prompt.
# The remainder covers the system prompt, question, bank context, and output tokens.
@@ -453,7 +451,7 @@ def build_final_prompt(
except (TypeError, ValueError):
output_str = str(output)
block = f"\n### From {tool}:\n```json\n{output_str}\n```"
block_tokens = len(_TIKTOKEN_ENCODING.encode(block))
block_tokens = count_cl100k_tokens(block)
if block_tokens > token_budget:
truncated = True
break
@@ -0,0 +1,17 @@
"""Token counting helpers for reflect prompts and agent control flow."""
from functools import lru_cache
import tiktoken
@lru_cache(maxsize=1)
def _get_cl100k_base_encoding() -> tiktoken.Encoding:
# tiktoken downloads this encoding on first lookup when it is not cached.
# Keep the lookup lazy so importing hindsight_api does not depend on network access.
return tiktoken.get_encoding("cl100k_base")
def count_cl100k_tokens(text: str) -> int:
"""Return the number of cl100k_base tokens in text."""
return len(_get_cl100k_base_encoding().encode(text))
@@ -7,6 +7,7 @@ Implements hierarchical retrieval:
3. recall - Raw facts as ground truth
"""
import json
import logging
import uuid
from dataclasses import replace
@@ -22,6 +23,21 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _document_metadata_from_retain_params(retain_params: Any) -> dict[str, Any] | None:
"""Return document metadata stored under retain_params.metadata."""
if isinstance(retain_params, str):
try:
retain_params = json.loads(retain_params)
except json.JSONDecodeError:
return None
if not isinstance(retain_params, dict):
return None
metadata = retain_params.get("metadata")
return metadata if isinstance(metadata, dict) else None
async def tool_search_mental_models(
memory_engine: "MemoryEngine",
conn: "Connection",
@@ -350,7 +366,7 @@ async def tool_expand(
if all_doc_ids:
docs = await conn.fetch(
f"""
SELECT id, original_text, metadata, retain_params
SELECT id, original_text, retain_params
FROM {fq_table("documents")}
WHERE id = ANY($1) AND bank_id = $2
""",
@@ -396,7 +412,7 @@ async def tool_expand(
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
"retain_params": doc["retain_params"],
}
elif memory["document_id"] and depth == "document" and memory["document_id"] in doc_map:
@@ -405,7 +421,7 @@ async def tool_expand(
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": doc["metadata"],
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
"retain_params": doc["retain_params"],
}
@@ -10,6 +10,7 @@ from typing import TypedDict
from pydantic import BaseModel, Field
from ..._vector_index import index_using_clause, uses_per_bank_vector_indexes
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table, get_current_schema
@@ -35,15 +36,12 @@ def _bank_index_name(ft: str, internal_id: str) -> str:
return f"idx_mu_emb_{_BANK_INDEX_FACT_TYPES[ft]}_{uid}"
def _vector_index_clause() -> str:
"""Return the USING clause for vector index creation based on the configured extension."""
def _vector_index_clause() -> str | None:
"""Return the USING clause for per-bank vector indexes, if this backend uses them."""
ext = get_config().vector_extension
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else: # pgvector (default)
return "USING hnsw (embedding vector_cosine_ops)"
if not uses_per_bank_vector_indexes(ext):
return None
return index_using_clause(ext)
async def create_bank_vector_indexes(conn, bank_id: str, internal_id: str, ops=None) -> None:
@@ -52,20 +50,26 @@ async def create_bank_vector_indexes(conn, bank_id: str, internal_id: str, ops=N
Respects the HINDSIGHT_API_VECTOR_EXTENSION config to use the appropriate
index type (HNSW for pgvector, DiskANN for pgvectorscale, vchordrq for vchord).
Called immediately after the bank row is first inserted. Safe on empty banks
(index build is instant). Idempotent via CREATE INDEX IF NOT EXISTS.
AlloyDB ScaNN uses global vector indexes with filtered vector search; it
cannot safely create per-bank indexes at bank-creation time because new
banks have no embedding rows.
bank_id is escaped for SQL literal safety (apostrophes doubled).
On Oracle 23ai, this is a no-op — Oracle uses a single global vector index
created during migrations. Partial indexes (WHERE clause) are not supported
for Oracle vector indexes.
"""
index_clause = _vector_index_clause()
if index_clause is None:
logger.debug("Skipping per-bank vector indexes for configured backend")
return
await ops.create_bank_vector_indexes(
conn,
fq_table("memory_units"),
bank_id,
internal_id,
_vector_index_clause(),
index_clause,
_BANK_INDEX_FACT_TYPES,
)
@@ -368,30 +372,49 @@ Merged mission:"""
async def list_banks(pool) -> list:
"""
List all banks in the system.
List all banks in the system with summary stats.
Args:
pool: Database connection pool
Returns:
List of dicts with bank_id, name, disposition, mission, created_at, updated_at
List of dicts with bank info and stats (document_count, fact_count, last_event_at)
"""
banks_table = fq_table("banks")
docs_table = fq_table("documents")
mu_table = fq_table("memory_units")
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT bank_id, name, disposition, mission, created_at, updated_at
FROM {fq_table("banks")}
ORDER BY updated_at DESC
SELECT
b.bank_id, b.name, b.disposition, b.mission,
b.created_at, b.updated_at,
COALESCE(m.fact_count, 0) AS fact_count,
d.last_document_at
FROM {banks_table} b
LEFT JOIN (
SELECT bank_id, MAX(created_at) AS last_document_at
FROM {docs_table}
GROUP BY bank_id
) d ON d.bank_id = b.bank_id
LEFT JOIN (
SELECT bank_id, COUNT(*) AS fact_count
FROM {mu_table}
GROUP BY bank_id
) m ON m.bank_id = b.bank_id
ORDER BY d.last_document_at DESC NULLS LAST, b.updated_at DESC
"""
)
result = []
for row in rows:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
last_doc = row["last_document_at"]
result.append(
{
"bank_id": row["bank_id"],
@@ -400,6 +423,8 @@ async def list_banks(pool) -> list:
"mission": row["mission"] or "",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
"fact_count": row["fact_count"],
"last_document_at": last_doc.isoformat() if last_doc else None,
}
)
@@ -6,7 +6,7 @@ Defines a controlled vocabulary of key:value classification labels
at retain time and stored as entities.
"""
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, Field, create_model
@@ -18,15 +18,28 @@ class LabelValue(BaseModel):
description: str = ""
class MapField(BaseModel):
"""A field within a map-type entity label group. Supports recursion via type='map'."""
type: Literal["text", "value", "multi-values", "map"] = "text"
description: str = ""
values: list[LabelValue] = []
fields: dict[str, "MapField"] = {}
MapField.model_rebuild()
class LabelGroup(BaseModel):
"""A label group (dimension) with its type and allowed values."""
key: str
description: str = ""
type: Literal["value", "multi-values", "text"] = "value"
type: Literal["value", "multi-values", "text", "map"] = "value"
optional: bool = True
tag: bool = False
values: list[LabelValue] = []
fields: dict[str, MapField] = {}
class EntityLabelsConfig(BaseModel):
@@ -91,6 +104,71 @@ def _migrate_label_group(raw: dict) -> dict:
return patched
def _build_map_fields_model(fields: dict[str, MapField], model_name: str) -> type[BaseModel] | None:
"""
Build a dynamic Pydantic model for a set of map fields (recursive).
Each field becomes a typed Pydantic field based on its type:
- text → str | None
- value → Literal[...] | None
- multi-values → list[Literal[...]]
- map → list[NestedModel] (recursive)
Returns:
Dynamic Pydantic model class, or None if no fields defined
"""
if not fields:
return None
model_fields: dict[str, Any] = {}
for field_name, map_field in fields.items():
description = map_field.description or field_name
if map_field.type == "map":
nested = _build_map_fields_model(map_field.fields, model_name + field_name.capitalize())
if nested is not None:
model_fields[field_name] = (
list[nested], # type: ignore[valid-type]
Field(default_factory=list, description=description),
)
elif map_field.type == "text":
model_fields[field_name] = (str | None, Field(default=None, description=description))
else:
# value / multi-values — enum-constrained
if not map_field.values:
model_fields[field_name] = (str | None, Field(default=None, description=description))
continue
values = tuple(v.value for v in map_field.values if v.value)
if not values:
model_fields[field_name] = (str | None, Field(default=None, description=description))
continue
literal_type = Literal[values] # type: ignore[valid-type]
if map_field.type == "multi-values":
model_fields[field_name] = (
list[literal_type], # type: ignore[valid-type]
Field(default_factory=list, description=description),
)
else:
model_fields[field_name] = (
literal_type | None, # type: ignore[valid-type]
Field(default=None, description=description),
)
if not model_fields:
return None
return create_model(model_name, **model_fields)
def _build_map_entity_model(group: LabelGroup) -> type[BaseModel] | None:
"""
Build a dynamic Pydantic model for a map-type entity label group.
Delegates to ``_build_map_fields_model`` which handles recursion.
"""
# Capitalize group key for the model name (e.g., "person" → "Person")
model_name = group.key.capitalize() + "Entity"
return _build_map_fields_model(group.fields, model_name)
def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None:
"""
Build a dynamic Pydantic model for structured label extraction.
@@ -100,6 +178,7 @@ def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None
- type="value", optional=True → Literal["v1","v2"] | None
- type="value", optional=False → Literal["v1","v2"] (required)
- type="multi-values" → list[Literal["v1","v2"]]
- type="map" → list[MapModel] (structured entity)
Args:
labels_cfg: Parsed EntityLabelsConfig
@@ -113,7 +192,14 @@ def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None
continue
description = group.description or group.key
if group.type == "text":
if group.type == "map":
map_model = _build_map_entity_model(group)
if map_model is not None:
fields[group.key] = (
list[map_model], # type: ignore[valid-type]
Field(default_factory=list, description=description),
)
elif group.type == "text":
# Free-form: any string value accepted, always optional
fields[group.key] = (str | None, Field(default=None, description=description))
else:
@@ -147,18 +233,35 @@ def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None
return create_model("Labels", **fields)
def _is_map_label_entity(text_lower: str, prefix: str, fields: dict[str, MapField]) -> bool:
"""Recursively check if text matches a map field path (e.g. 'person:address:city:...')."""
for field_name, map_field in fields.items():
field_prefix = f"{prefix}{field_name.lower()}:"
if map_field.type == "map" and map_field.fields:
if _is_map_label_entity(text_lower, field_prefix, map_field.fields):
return True
elif text_lower.startswith(field_prefix):
return True
return False
def is_label_entity(text: str, labels_cfg: EntityLabelsConfig, labels_lookup: set[str]) -> bool:
"""
Return True if entity text belongs to any configured label group.
For enum groups: checks the pre-built lookup set.
For text groups: checks that the text starts with a known key prefix.
For map groups: recursively checks ``key:field:...:value`` patterns.
"""
if text.lower() in labels_lookup:
return True
for group in labels_cfg.attributes:
if group.type == "text" and group.key and text.lower().startswith(f"{group.key.lower()}:"):
return True
if group.type == "map" and group.key and group.fields:
prefix = f"{group.key.lower()}:"
if _is_map_label_entity(text.lower(), prefix, group.fields):
return True
return False
@@ -186,8 +289,8 @@ def build_labels_lookup(labels_cfg: EntityLabelsConfig | list | None) -> set[str
valid = set()
for group in labels_cfg.attributes:
if group.type == "text":
continue # No fixed vocabulary — all values accepted in post-processing
if group.type in ("text", "map"):
continue # text: no fixed vocabulary; map: uses three-level key:field:value strings
for v in group.values:
if group.key and v.value:
valid.add(f"{group.key}:{v.value}".lower())
@@ -19,6 +19,7 @@ from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..response_models import TokenUsage
from .entity_labels import (
EntityLabelsConfig,
MapField,
build_labels_lookup,
build_labels_model,
is_label_entity,
@@ -26,6 +27,49 @@ from .entity_labels import (
)
def _extract_map_entities(
entity_obj: dict,
fields: dict[str, MapField],
prefix: str,
validated_entities: "list[Entity]",
existing_texts_lower: set[str],
) -> None:
"""Recursively extract key:field:value entity strings from a map entity dict."""
for field_name, map_field in fields.items():
field_val = entity_obj.get(field_name)
if field_val is None or field_val == "":
continue
if map_field.type == "map" and map_field.fields:
# Nested map: recurse into each sub-entity
sub_list = field_val if isinstance(field_val, list) else [field_val]
for sub_obj in sub_list:
if isinstance(sub_obj, dict):
_extract_map_entities(
sub_obj,
map_field.fields,
f"{prefix}{field_name}:",
validated_entities,
existing_texts_lower,
)
elif map_field.type == "multi-values":
vals = field_val if isinstance(field_val, list) else [field_val]
for v in vals:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
continue
label_str = f"{prefix}{field_name}:{v.strip()}"
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
else:
# text or value — single string
if not isinstance(field_val, str) or not field_val.strip() or field_val.lower() in ("none", "null", "n/a"):
continue
label_str = f"{prefix}{field_name}:{field_val.strip()}"
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
def _infer_temporal_date(fact_text: str, event_date: datetime | None) -> str | None:
"""
Infer a temporal date from fact text when LLM didn't provide occurred_start.
@@ -731,6 +775,25 @@ Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 2: Moved apartment, causal_relations: [{target_index: 1, relation_type: "caused_by"}]"""
def _append_map_fields_prompt(fields: dict[str, "MapField"], lines: list[str], indent: int = 4) -> None:
"""Recursively append map field descriptions to the prompt lines."""
pad = " " * indent
for field_name, map_field in fields.items():
field_desc = f": {map_field.description}" if map_field.description else ""
if map_field.type == "map" and map_field.fields:
lines.append(f"{pad}{field_name} (object){field_desc}")
_append_map_fields_prompt(map_field.fields, lines, indent + 4)
elif map_field.type == "multi-values":
vals = ", ".join(v.value for v in map_field.values if v.value)
type_hint = f"multi-values: {vals}" if vals else "multi-values"
lines.append(f"{pad}{field_name} ({type_hint}){field_desc}")
elif map_field.type == "value" and map_field.values:
vals = ", ".join(v.value for v in map_field.values if v.value)
lines.append(f"{pad}{field_name} (one of: {vals}){field_desc}")
else:
lines.append(f"{pad}{field_name} (text){field_desc}")
def _build_labels_prompt_section(labels_cfg: EntityLabelsConfig | list | None, free_form_entities: bool = True) -> str:
"""Build the entity labels classification section for the extraction prompt."""
if labels_cfg is None:
@@ -763,7 +826,14 @@ def _build_labels_prompt_section(labels_cfg: EntityLabelsConfig | list | None, f
"",
]
has_classification_attrs = False
has_map_attrs = False
for attr in labels_cfg.attributes:
if attr.type == "map":
has_map_attrs = True
continue
has_classification_attrs = True
if attr.type == "text":
# Free-text: no predefined values — LLM writes any relevant string or null
lines.append(f"- {attr.key} (free text or null): {attr.description}")
@@ -775,7 +845,31 @@ def _build_labels_prompt_section(labels_cfg: EntityLabelsConfig | list | None, f
lines.append(f'"{v.value}"{desc}')
lines.append("")
lines.append("Only assign labels when clearly applicable. Leave null/empty if the fact does not match.")
if has_classification_attrs:
lines.append("Only assign labels when clearly applicable. Leave null/empty if the fact does not match.")
lines.append("")
# Add structured entity types (map groups)
if has_map_attrs:
lines.append("")
lines.append("══════════════════════════════════════════════════════════════════════════")
lines.append("STRUCTURED ENTITY TYPES")
lines.append("══════════════════════════════════════════════════════════════════════════")
lines.append("")
lines.append("For each fact, extract structured entities into the corresponding list field in 'labels'.")
lines.append("Each structured entity type has defined fields. Return a list of objects, one per entity found.")
lines.append("")
for attr in labels_cfg.attributes:
if attr.type != "map" or not attr.fields:
continue
desc = f": {attr.description}" if attr.description else ""
lines.append(f"- {attr.key}{desc}")
_append_map_fields_prompt(attr.fields, lines, indent=4)
lines.append("")
lines.append(
"Only extract structured entities when clearly present in the text. Leave the list empty if none found."
)
return "\n".join(lines)
@@ -1164,6 +1258,19 @@ async def _extract_facts_from_chunk(
value = labels_data.get(group.key)
if not value:
continue
# Map-type groups: recursively extract key:field:value strings
if group.type == "map" and group.fields:
entities_list = value if isinstance(value, list) else [value]
for entity_obj in entities_list:
if isinstance(entity_obj, dict):
_extract_map_entities(
entity_obj,
group.fields,
f"{group.key}:",
validated_entities,
existing_texts_lower,
)
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
@@ -1275,14 +1382,9 @@ async def _extract_facts_from_chunk(
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}/{llm_max_retries} failed with JSON validation error: {e}"
)
if attempt < llm_max_retries - 1:
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
continue
# If it's not a JSON validation error or we're out of retries, re-raise
# Don't retry json_validate_failed here — the inner provider
# loop already retried the 400 error. Re-entering the LLM call
# with the same input just multiplies wasted calls.
raise
# If we exhausted all retries, raise the last error or a descriptive fallback
@@ -1455,47 +1557,25 @@ async def extract_facts_from_text(
f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
)
# Per-chunk retry wrapper: each chunk gets up to MAX_CHUNK_RETRIES attempts.
# This handles transient LLM failures (timeouts, rate limits, malformed responses)
# without discarding the entire batch. If a chunk still fails after all retries,
# the ENTIRE retain fails — we do not accept partial extraction.
MAX_CHUNK_RETRIES = 3
CHUNK_RETRY_BASE_DELAY = 2.0 # seconds, doubles each retry
async def _extract_chunk_with_retry(chunk: str, chunk_index: int) -> tuple:
"""Extract facts from a single chunk with retries on failure."""
last_exception = None
for attempt in range(MAX_CHUNK_RETRIES):
try:
return await _extract_facts_with_auto_split(
chunk=chunk,
chunk_index=chunk_index,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
except Exception as e:
last_exception = e
if attempt < MAX_CHUNK_RETRIES - 1:
delay = CHUNK_RETRY_BASE_DELAY * (2**attempt)
logger.warning(
f"Chunk {chunk_index}/{len(chunks)} extraction failed "
f"(attempt {attempt + 1}/{MAX_CHUNK_RETRIES}): "
f"{type(e).__name__}. Retrying in {delay:.0f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"Chunk {chunk_index}/{len(chunks)} extraction failed after "
f"{MAX_CHUNK_RETRIES} attempts: {type(e).__name__}: {e}"
)
raise last_exception
tasks = [_extract_chunk_with_retry(chunk, i) for i, chunk in enumerate(chunks)]
# Transient LLM failures (timeouts, rate limits) are already retried inside
# the provider's inner loop. Content-quality retries (malformed facts) are
# handled by the middle loop in _extract_facts_from_chunk. Adding a third
# retry layer here would multiply wasted calls on deterministic failures
# (see https://github.com/vectorize-io/hindsight/issues/1412).
tasks = [
_extract_facts_with_auto_split(
chunk=chunk,
chunk_index=i,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
for i, chunk in enumerate(chunks)
]
# return_exceptions=True so we can collect all results even if some chunks
# exhausted their retries. We check for failures below and fail the retain
@@ -1521,8 +1601,8 @@ async def extract_facts_from_text(
# hasn't committed yet. The worker poller will retry the entire task.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
raise RuntimeError(
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed "
f"after {MAX_CHUNK_RETRIES} retries each. First failures: {failed_summary}"
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}"
)
return all_facts, chunk_metadata, total_usage
@@ -1863,6 +1943,19 @@ async def extract_facts_from_contents_batch_api(
value = labels_data.get(group.key)
if not value:
continue
# Map-type groups: recursively extract key:field:value strings
if group.type == "map" and group.fields:
entities_list = value if isinstance(value, list) else [value]
for entity_obj in entities_list:
if isinstance(entity_obj, dict):
_extract_map_entities(
entity_obj,
group.fields,
f"{group.key}:",
validated_entities,
existing_texts_lower,
)
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
@@ -405,8 +405,10 @@ async def build_entity_links_from_resolved(
# Insert unit-entity links (used in fallback path where Phase 2 didn't do this)
substep_start = time.time()
unit_entity_pairs = []
for idx, (unit_id, _local_idx, _fact_date) in enumerate(entity_to_unit):
unit_entity_pairs.append((unit_id, resolved_entity_ids[idx]))
for idx, (unit_id, _local_idx, fact_date) in enumerate(entity_to_unit):
# Propagate the unit's fact_date so entity_cooccurrences.last_cooccurred
# reflects the event timeline, not the ingest moment.
unit_entity_pairs.append((unit_id, resolved_entity_ids[idx], fact_date))
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
_log(
@@ -284,10 +284,12 @@ async def _insert_facts_and_links(
)
# Update semantic_ann_links with remapped IDs for Phase 2
semantic_ann_links = remapped_semantic
# INSERT unit_entities (FK to memory_units, must be in transaction)
# INSERT unit_entities (FK to memory_units, must be in transaction).
# Pass fact_date alongside so entity_cooccurrences.last_cooccurred
# tracks the event timeline, not the ingest moment.
unit_entity_pairs = [
(unit_id, resolved_entity_ids[idx])
for idx, (unit_id, _local_idx, _fact_date) in enumerate(remapped_entity_to_unit)
(unit_id, resolved_entity_ids[idx], fact_date)
for idx, (unit_id, _local_idx, fact_date) in enumerate(remapped_entity_to_unit)
]
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
log_buffer.append(f" Insert unit_entities: {len(unit_entity_pairs)} pairs in {time.time() - step_start:.3f}s")
@@ -680,6 +682,14 @@ async def retain_batch(
all_pre_chunks.extend(content_chunks)
chunk_to_content.extend([content_idx] * len(content_chunks))
# Memory: after chunking, the original content bodies in RetainContent are
# no longer needed (all_pre_chunks holds the working set). Clear them so
# Python can reclaim the (potentially multi-MB) strings.
# Note: contents_dicts["content"] is still needed briefly for hash computation
# inside _streaming_retain_batch, but gets cleared there after use.
for content in contents:
content.content = ""
total_pre_chunks = len(all_pre_chunks)
num_batches = (total_pre_chunks + chunk_batch_size - 1) // chunk_batch_size if total_pre_chunks > 0 else 1
log_buffer.append(
@@ -882,9 +892,15 @@ async def _streaming_retain_batch(
# the producer can skip already-extracted chunks to avoid duplicate work.
existing_chunk_hashes: set[str] = set()
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Memory: contents_dicts content strings are now captured in combined_content.
# Clear them from the dicts to release the per-item copies (can be multi-MB each).
for d in contents_dicts:
d.pop("content", None)
# Sanitize before hashing to match what handle_document_tracking stores
sanitized_content = fact_extraction._sanitize_text(combined_content) or ""
new_content_hash = hashlib.sha256(sanitized_content.encode()).hexdigest()
# Memory: sanitized_content is only needed for the hash; free it immediately.
sanitized_content = ""
is_recovery = False
try:
@@ -969,12 +985,17 @@ async def _streaming_retain_batch(
schema,
)
await chunk_queue.put((global_idx, content, extracted, processed, chunk_meta, usage))
# Memory: release the chunk text from the shared list now that it's
# been extracted and queued. The queued RetainContent holds its own copy.
all_pre_chunks[global_idx] = ""
tasks: list[asyncio.Task] = []
skipped_total = 0
for i, chunk_text in enumerate(all_pre_chunks):
chunk_hash = chunk_storage.compute_chunk_hash(chunk_text)
if chunk_hash in existing_chunk_hashes:
# Memory: skipped chunks aren't needed either.
all_pre_chunks[i] = ""
skipped_total += 1
continue
tasks.append(asyncio.create_task(_extract_one(i, chunk_text)))
@@ -1035,6 +1056,9 @@ async def _streaming_retain_batch(
is_last: bool,
) -> None:
"""Run Phase 1 + Phase 2 + Phase 3 for a batch of pre-extracted chunks."""
# Allow clearing combined_content after the no-facts skip path runs
# doc tracking — see the assignment further below.
nonlocal combined_content
# Combine results from individual chunk extractions
batch_contents: list[RetainContent] = []
batch_extracted: list = []
@@ -1106,6 +1130,10 @@ async def _streaming_retain_batch(
ops=pool.ops,
)
doc_tracking_done[0] = True
# Memory: combined_content has been persisted; release
# it now so the rest of the consumer loop doesn't pin
# a multi-MB string. Nothing reads it after tracking.
combined_content = ""
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (0 facts in first batch)")
log_buffer.append(
f"[streaming] Consumer batch {consumer_batch_idx + 1}: "
@@ -1119,6 +1147,9 @@ async def _streaming_retain_batch(
)
async def _run_mini_batch_db_work() -> None:
# Allow clearing combined_content after the doc-tracking call so
# subsequent batches don't carry the per-document text in memory.
nonlocal combined_content
entity_resolver.discard_pending_stats()
mb_start = time.time()
@@ -1210,6 +1241,10 @@ async def _streaming_retain_batch(
)
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (full content)")
doc_tracking_done[0] = True
# Memory: combined_content is no longer needed after
# this first-batch tracking call. Release it so the
# remaining consumer batches don't pin the string.
combined_content = ""
else:
# --- Later batches: verify we still own the document ---
# If another request took over (cascade-deleted our doc and
@@ -1291,6 +1326,14 @@ async def _streaming_retain_batch(
else:
await _run_mini_batch_db_work()
# Memory: after DB write, clear the batch-local lists that hold extracted
# facts and embedding vectors. These can be large (384 floats per fact ×
# thousands of facts) and are no longer needed after commit.
batch_contents.clear()
batch_extracted.clear()
batch_processed.clear()
batch_chunk_meta.clear()
# ---------------------------------------------------------------------------
# Check if facts are already committed (recovery from previous crash).
# If so, skip extraction+writes and jump straight to final ANN pass.
@@ -1376,6 +1419,9 @@ async def _streaming_retain_batch(
ops=pool.ops,
)
doc_tracking_done[0] = True
# Memory: combined_content has been persisted and won't be
# read again — release the per-document text now.
combined_content = ""
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (no facts extracted)")
# Mark facts as committed in operation metadata (crash recovery checkpoint)
@@ -191,21 +191,21 @@ class TagGroupLeaf(BaseModel):
class TagGroupAnd(BaseModel):
"""Compound AND group: all child filters must match."""
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
filters: list[TagGroup] = Field(alias="and")
class TagGroupOr(BaseModel):
"""Compound OR group: at least one child filter must match."""
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
filters: list[TagGroup] = Field(alias="or")
class TagGroupNot(BaseModel):
"""Compound NOT group: child filter must NOT match."""
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
filter: TagGroup = Field(alias="not")
@@ -185,9 +185,8 @@ class PostgreSQLDialect(SQLDialect):
extra_where: str = "",
) -> str:
if text_search_extension == "vchord":
bm25_score_expr = (
f"search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2'))"
)
# <&> returns a distance (lower = more relevant), negate for score
bm25_score_expr = f"-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2')))"
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = ""
elif text_search_extension == "pg_textsearch":
+17 -8
View File
@@ -28,6 +28,7 @@ from .config import DEFAULT_WORKERS, ENV_HOST, ENV_WORKERS, HindsightConfig, _ge
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
ENV_DAEMON_CHILD,
IdleTimeoutMiddleware,
daemonize,
)
@@ -150,8 +151,15 @@ def main():
args = parser.parse_args()
# Daemon mode handling
if args.daemon:
# Daemon mode handling.
# is_daemon_child is True when we are the re-exec'd child spawned by
# daemonize() or by hindsight-embed's DaemonEmbedManager. The child
# does not have --daemon in its argv, but must still behave as a daemon
# (resolve host/port, enable idle timeout, suppress banner, etc.).
is_daemon_child = os.environ.get(ENV_DAEMON_CHILD) == "1"
is_daemon = args.daemon or is_daemon_child
if is_daemon:
args.host, args.port = resolve_daemon_host_port(
args_host=args.host,
args_port=args.port,
@@ -159,12 +167,13 @@ def main():
config_port=config.port,
)
# Fork into background
# No lockfile needed - port binding prevents duplicate daemons
# Detach into background (parent re-execs and exits; child redirects
# stdio to log file). No lockfile needed port binding prevents
# duplicate daemons.
daemonize()
# Print banner (not in daemon mode)
if not args.daemon:
if not is_daemon:
print()
print_banner()
@@ -173,7 +182,7 @@ def main():
if args.log_level != config.log_level:
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
config.configure_logging()
if not args.daemon:
if not is_daemon:
config.log_config()
# Register cleanup handlers
@@ -222,7 +231,7 @@ def main():
# Wrap with idle timeout middleware in daemon mode
idle_middleware = None
if args.daemon:
if is_daemon:
idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
app = idle_middleware
@@ -277,7 +286,7 @@ def main():
uvicorn_config["ssl_certfile"] = args.ssl_certfile
# Print startup info (not in daemon mode)
if not args.daemon:
if not is_daemon:
from .banner import print_startup_info
print_startup_info(
+30 -2
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from typing import Any, Callable
from fastmcp import FastMCP
from pydantic import TypeAdapter
from hindsight_api import MemoryEngine
from hindsight_api.config import (
@@ -21,9 +22,12 @@ from hindsight_api.config import (
from hindsight_api.engine.audit import AuditEntry, AuditLogger
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.engine.search.tags import TagGroup
from hindsight_api.extensions import OperationValidationError
from hindsight_api.models import RequestContext
_TAG_GROUP_LIST_ADAPTER = TypeAdapter(list[TagGroup])
# All tools available in the system (explicit list — no wildcards).
# Defined here (shared module) to avoid circular imports with api/mcp.py.
_ALL_TOOLS: frozenset[str] = frozenset(
@@ -773,6 +777,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
bank_id: str | None = None,
) -> str | dict:
@@ -782,8 +787,12 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
tags: Optional tags to filter results by (e.g., ['project:alpha'])
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
{"tags": [...], "match": "any_strict"} or compound {"and": [...]}, {"or": [...]}, {"not": {...}}.
Example: [{"not": {"tags": ["closeout"], "match": "any_strict"}}] excludes memories tagged closeout.
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories.
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
@@ -792,6 +801,11 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if target_bank is None:
return "Error: No bank_id configured"
if tags is not None and tag_groups is not None:
raise ValueError(
"'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering."
)
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.HIGH)
fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES)
@@ -807,6 +821,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if tags is not None:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
if tag_groups is not None:
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
@@ -832,6 +848,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
) -> dict:
"""
@@ -840,8 +857,12 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
tags: Optional tags to filter results by (e.g., ['project:alpha'])
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
{"tags": [...], "match": "any_strict"} or compound {"and": [...]}, {"or": [...]}, {"not": {...}}.
Example: [{"not": {"tags": ["closeout"], "match": "any_strict"}}] excludes memories tagged closeout.
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories.
"""
try:
@@ -849,6 +870,11 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if target_bank is None:
return {"error": "No bank_id configured", "results": []}
if tags is not None and tag_groups is not None:
raise ValueError(
"'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering."
)
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.HIGH)
fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES)
@@ -864,6 +890,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if tags is not None:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
if tag_groups is not None:
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
+138 -220
View File
@@ -27,6 +27,15 @@ from alembic.config import Config
from alembic.script.revision import ResolutionError
from sqlalchemy import Connection, create_engine, text
from ._vector_index import (
bootstrap_extension,
detect_vector_extension,
index_type_keyword,
index_using_clause,
minimum_rows_for_index,
should_defer_index_creation,
uses_per_bank_vector_indexes,
)
from .db_url import is_oracle_url, to_libpq_url
from .utils import mask_network_location
@@ -44,66 +53,28 @@ _alembic_lock = threading.Lock()
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
"""
Validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
"""Validate configured vector extension and preserve Azure DiskANN detection."""
return detect_vector_extension(conn, vector_extension)
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 _drop_per_bank_vector_indexes(conn: Connection, schema_name: str) -> None:
"""Drop per-bank partial memory_units vector indexes after global ScaNN is ready."""
rows = conn.execute(
text("""
SELECT indexname
FROM pg_indexes
WHERE schemaname = :schema_name
AND tablename = 'memory_units'
AND indexname LIKE 'idx_mu_emb_%'
AND indexdef LIKE '%embedding%'
"""),
{"schema_name": schema_name},
).fetchall()
# DDL identifiers cannot be passed as bound parameters, so escape inline.
safe_schema = schema_name.replace('"', '""')
for row in rows:
safe_index = row[0].replace('"', '""')
conn.execute(text(f'DROP INDEX IF EXISTS "{safe_schema}"."{safe_index}"'))
def _get_schema_lock_id(schema: str) -> int:
@@ -364,47 +335,8 @@ 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
bootstrap_extension(conn, vector_extension)
# Commit any pending transaction on the advisory-lock connection
# before running migrations. Some code paths above (e.g., the
@@ -545,7 +477,10 @@ def _migrate_table_embedding_dimension(
logger.info(f"Altering {table_name}.embedding column dimension from {current_dim} to {required_dimension}")
# Drop existing vector index (works for both HNSW and vchordrq)
# Drop existing vector index (works for HNSW, DiskANN, vchordrq, and ScaNN)
# The EXCEPTION block handles 'could not open relation with OID' errors that
# occur when concurrent sessions drop schemas (e.g. pytest-xdist workers),
# invalidating pg_indexes OID references mid-cursor-iteration.
conn.execute(
text(f"""
DO $$
@@ -555,11 +490,14 @@ def _migrate_table_embedding_dimension(
SELECT indexname FROM pg_indexes
WHERE schemaname = '{schema_name}'
AND tablename = '{table_name}'
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%' OR indexdef LIKE '%diskann%')
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%' OR indexdef LIKE '%diskann%' OR indexdef LIKE '%scann%')
AND indexdef LIKE '%embedding%'
LOOP
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
END LOOP;
EXCEPTION WHEN internal_error THEN
-- Stale OID from concurrent schema drop; nothing to drop anyway
NULL;
END $$;
""")
)
@@ -570,41 +508,34 @@ def _migrate_table_embedding_dimension(
conn.commit()
# Recreate index with appropriate type based on detected extension
if vector_ext == "pgvectorscale":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_diskann
ON {schema_name}.{table_name}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
if vector_ext == "pgvector" and required_dimension > 2000:
raise RuntimeError(
f"Embedding dimension {required_dimension} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN or AlloyDB ScaNN)."
)
logger.info(f"Created DiskANN index on {table_name} for {required_dimension}-dimensional embeddings")
elif vector_ext == "vchord":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_vchordrq
ON {schema_name}.{table_name}
USING vchordrq (embedding vector_l2_ops)
""")
index_type = index_type_keyword(vector_ext)
if should_defer_index_creation(vector_ext, row_count):
minimum_rows = minimum_rows_for_index(vector_ext)
logger.warning(
"Skipping %s index recreation on %s: AlloyDB ScaNN AUTO indexes need at least %s populated "
"embedding rows; table currently has %s",
vector_ext,
table_name,
minimum_rows,
row_count,
)
logger.info(f"Created vchordrq index on {table_name} for {required_dimension}-dimensional embeddings")
else: # pgvector
if required_dimension > 2000:
raise RuntimeError(
f"Embedding dimension {required_dimension} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
)
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_hnsw
ON {schema_name}.{table_name}
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
logger.info(f"Created HNSW index on {table_name} for {required_dimension}-dimensional embeddings")
return
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_{index_type}
ON {schema_name}.{table_name}
{index_using_clause(vector_ext)}
""")
)
logger.info(f"Created {index_type} index on {table_name} for {required_dimension}-dimensional embeddings")
conn.commit()
logger.info(f"Successfully changed {table_name}.embedding dimension to {required_dimension}")
@@ -628,7 +559,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")
vector_extension: Configured vector extension ("pgvector", "vchord", "pgvectorscale", or "scann")
Raises:
RuntimeError: If dimension mismatch with existing data
@@ -676,7 +607,7 @@ def ensure_vector_extension(
Args:
database_url: SQLAlchemy database URL
vector_extension: Configured vector extension ("pgvector" or "vchord")
vector_extension: Configured vector extension ("pgvector", "vchord", "pgvectorscale", or "scann")
schema: Target PostgreSQL schema name (None for public)
Raises:
@@ -697,13 +628,7 @@ def ensure_vector_extension(
("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"
target_index_type = index_type_keyword(target_ext)
mismatched_tables = []
tables_with_data = []
@@ -724,6 +649,10 @@ def ensure_vector_extension(
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
continue
row_count = conn.execute(
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
).scalar()
# Check current index type by querying pg_indexes
current_index_info = conn.execute(
text("""
@@ -737,30 +666,33 @@ def ensure_vector_extension(
).fetchone()
if not current_index_info:
# Check whether per-bank partial HNSW indexes already cover this table
# (created by the bank_utils lifecycle — no global index needed in that case)
per_bank_index_count = conn.execute(
text("""
SELECT COUNT(*)
FROM pg_indexes
WHERE schemaname = :schema
AND tablename = :table_name
AND indexname LIKE 'idx_mu_emb_%'
"""),
{"schema": schema_name, "table_name": table_name},
).scalar()
if per_bank_index_count and per_bank_index_count > 0:
logger.debug(
f"No global embedding index on {table_name}, but {per_bank_index_count} "
f"per-bank partial HNSW indexes exist — skipping global index creation"
)
continue
logger.warning(f"No embedding index found for {table_name}, will create it")
mismatched_tables.append((table_name, index_name, None))
if table_name == "memory_units" and uses_per_bank_vector_indexes(target_ext):
# Check whether per-bank partial vector indexes already cover this table
# (created by the bank_utils lifecycle — no global index needed in that case)
per_bank_index_count = conn.execute(
text("""
SELECT COUNT(*)
FROM pg_indexes
WHERE schemaname = :schema
AND tablename = :table_name
AND indexname LIKE 'idx_mu_emb_%'
"""),
{"schema": schema_name, "table_name": table_name},
).scalar()
if per_bank_index_count and per_bank_index_count > 0:
logger.debug(
f"No global embedding index on {table_name}, but {per_bank_index_count} "
f"per-bank partial vector indexes exist — skipping global index creation"
)
continue
logger.warning(f"No embedding index found for {table_name}, will create it if safe")
mismatched_tables.append((table_name, index_name, None, row_count))
continue
indexdef = current_index_info[0].lower()
if "diskann" in indexdef:
if "scann" in indexdef:
current_index_type = "scann"
elif "diskann" in indexdef:
current_index_type = "diskann"
elif "vchordrq" in indexdef:
current_index_type = "vchordrq"
@@ -775,30 +707,32 @@ def ensure_vector_extension(
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))
mismatched_tables.append((table_name, index_name, current_index_type, row_count))
# 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))
if row_count > 0 and target_ext != "scann":
tables_with_data.append((table_name, row_count, current_index_type))
else:
logger.debug(f"Index type OK for {table_name}: {current_index_type}")
if target_ext == "scann" and table_name == "memory_units":
_drop_per_bank_vector_indexes(conn, schema_name)
conn.commit()
# 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 there's data in any non-ScaNN mismatched table, raise error
if tables_with_data:
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
table_list = ", ".join([f"{table}({count} rows)" for table, count, _ in tables_with_data])
current_index_type = tables_with_data[0][2]
# 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
)
current_ext_name = {
"diskann": "pgvectorscale",
"vchordrq": "vchord",
"hnsw": "pgvector",
"scann": "scann",
}.get(current_index_type, current_index_type)
raise RuntimeError(
f"Cannot change vector extension from {current_index_type} to {target_index_type}: "
@@ -809,46 +743,28 @@ def ensure_vector_extension(
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}")
logger.info(f"Reconciling vector indexes for {target_ext}")
for table_name, index_name, current_type, row_count in mismatched_tables:
if should_defer_index_creation(target_ext, row_count):
minimum_rows = minimum_rows_for_index(target_ext)
logger.warning(
"Skipping %s index creation on %s: AlloyDB ScaNN AUTO indexes need at least %s populated "
"embedding rows; table currently has %s",
target_ext,
table_name,
minimum_rows,
row_count,
)
continue
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
if target_ext == "pgvector":
# Check embedding dimension — pgvector HNSW indexes only support up to 2000 dims
embed_dim = conn.execute(
text("""
@@ -865,20 +781,22 @@ def ensure_vector_extension(
raise RuntimeError(
f"Embedding dimension {embed_dim} on {table_name} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN or AlloyDB ScaNN)."
)
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)
""")
)
logger.info(f"Creating {target_index_type} index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
{index_using_clause(target_ext)}
""")
)
if target_ext == "scann" and table_name == "memory_units":
_drop_per_bank_vector_indexes(conn, schema_name)
conn.commit()
logger.info(f"Successfully migrated vector indexes to {target_ext}")
logger.info(f"Successfully reconciled vector indexes for {target_ext}")
def ensure_text_search_extension(
@@ -16,6 +16,7 @@ import signal
import socket
import sys
import warnings
from collections.abc import Callable
from ..config import get_config
from ..engine.task_backend import WorkerTaskBackend
@@ -31,6 +32,26 @@ os.environ["TOKENIZERS_PARALLELISM"] = "false"
logger = logging.getLogger(__name__)
def _install_shutdown_signal_handlers(
loop: asyncio.AbstractEventLoop,
handler: Callable[[], None],
) -> bool:
"""Register SIGINT/SIGTERM handlers on the asyncio loop.
Returns True when handlers were installed via ``loop.add_signal_handler``.
Returns False on platforms (Windows ProactorEventLoop) where asyncio
does not implement signal handlers; the caller falls back to Python's
default SIGINT behavior, which still terminates the process on Ctrl+C
but loses the in-loop two-stage graceful shutdown.
"""
try:
loop.add_signal_handler(signal.SIGINT, handler)
loop.add_signal_handler(signal.SIGTERM, handler)
except NotImplementedError:
return False
return True
def create_worker_app(poller: WorkerPoller, memory):
"""Create a minimal FastAPI app for worker metrics and health."""
from fastapi import FastAPI
@@ -243,6 +264,7 @@ def main():
# Setup signal handlers for graceful shutdown using asyncio
shutdown_requested = asyncio.Event()
force_exit = False
async_handlers_installed = False
loop = asyncio.get_event_loop()
@@ -253,17 +275,26 @@ def main():
print("\nReceived second signal, forcing immediate exit...")
force_exit = True
# Restore default handler so third signal kills process
loop.remove_signal_handler(signal.SIGINT)
loop.remove_signal_handler(signal.SIGTERM)
if async_handlers_installed:
loop.remove_signal_handler(signal.SIGINT)
loop.remove_signal_handler(signal.SIGTERM)
sys.exit(1)
else:
print("\nReceived shutdown signal, initiating graceful shutdown...")
print("(Press Ctrl+C again to force immediate exit)")
shutdown_requested.set()
# Use asyncio's signal handlers which work properly with the event loop
loop.add_signal_handler(signal.SIGINT, signal_handler)
loop.add_signal_handler(signal.SIGTERM, signal_handler)
async_handlers_installed = _install_shutdown_signal_handlers(loop, signal_handler)
if not async_handlers_installed:
# Windows ProactorEventLoop: asyncio.add_signal_handler is Unix-only
# and raises NotImplementedError. Default Python SIGINT handler still
# terminates the worker on Ctrl+C, just without the two-stage path.
print(
f"WARN: asyncio signal handlers unavailable on this platform "
f"({sys.platform}); graceful two-stage shutdown disabled, "
f"default Python SIGINT handler remains active.",
flush=True,
)
# Create uvicorn config and server
uvicorn_config = uvicorn.Config(
+111 -68
View File
@@ -14,7 +14,8 @@ import json
import logging
import time
import traceback
from collections.abc import Awaitable, Callable
from collections import Counter
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -37,6 +38,36 @@ STUCK_STACK_INITIAL_THRESHOLD_S = 300
STUCK_STACK_MAX_THRESHOLD_S = 3600 * 6 # cap doubling at 6h
def _summarise_child_error_messages(siblings: "Iterable[Any]") -> str:
"""Pick a representative error message for a parent whose children failed.
Used when a batch_retain parent transitions to 'failed' because at least
one child sub-batch failed. Without this, the parent gets a generic
"One or more sub-batches failed" string and any consumer that reasons
about errors via error_message (dashboards, alert filters, log
aggregators) loses the actual cause -- a class of failures that all
share the same root reason at the child level becomes indistinguishable
at the parent level.
Strategy: pick the most common non-empty error_message among failed
siblings. If they all failed for the same reason (the common case), the
parent inherits that reason verbatim. If they vary, the most-common one
is still a useful representative. Falls back to the legacy generic
string when no failed sibling carries an error_message at all.
"""
failed_errors: list[str] = []
for s in siblings:
if s["status"] != "failed":
continue
msg = (s["error_message"] or "").strip()
if msg:
failed_errors.append(msg)
if not failed_errors:
return "One or more sub-batches failed"
most_common, _count = Counter(failed_errors).most_common(1)[0]
return most_common
@dataclass
class ActiveTaskInfo:
"""Tracking info for an in-flight worker task.
@@ -137,6 +168,11 @@ class WorkerPoller:
self._slot_reservations: dict[str, int] = (
slot_reservations if slot_reservations is not None else {"consolidation": 2}
)
# Cache of which optional PG routines are installed on the server
# (probed once, memoised for the life of the poller).
from ..engine.db.optional_routines import OptionalRoutines
self._optional_routines = OptionalRoutines(self._backend)
self._shutdown = asyncio.Event()
self._current_tasks: set[asyncio.Task] = set()
self._in_flight_count = 0
@@ -162,46 +198,22 @@ class WorkerPoller:
async def _scan_active_schemas(self, schemas: list[str | None]) -> set[str | None]:
"""Find which schemas have pending work.
Tries a server-side PL/pgSQL function first (single DB round-trip,
~200ms for 1400+ schemas). Falls back to per-schema Python EXISTS
queries if the function is not installed (~4ms each).
Prefers a server-side PL/pgSQL routine (single DB round-trip,
~200ms for 1400+ schemas) when ``public.schemas_with_pending_work()``
is installed. The presence check goes through
``OptionalRoutines.is_installed`` which probes ``pg_proc`` once and
caches the result, so we don't generate a server-side error on
every poll cycle when the routine isn't installed.
The server-side function should be installed in the ``public``
schema as::
CREATE OR REPLACE FUNCTION public.schemas_with_pending_work()
RETURNS SETOF text AS $$
DECLARE
r RECORD; has_work BOOLEAN;
BEGIN
FOR r IN SELECT nspname FROM pg_namespace
WHERE nspname LIKE 'tenant_%' LOOP
BEGIN
EXECUTE format(
'SELECT EXISTS(SELECT 1 FROM %I.async_operations '
'WHERE status = ''pending'' '
'AND task_payload IS NOT NULL LIMIT 1)',
r.nspname) INTO has_work;
IF has_work THEN RETURN NEXT r.nspname; END IF;
EXCEPTION WHEN OTHERS THEN NULL;
END;
END LOOP;
END $$ LANGUAGE plpgsql STABLE;
In hindsight-cloud deployments this is installed by a Helm hook
job alongside ``total_pending_tasks()``.
Falls back to per-schema Python EXISTS queries (~4ms each) on
non-PostgreSQL backends or when the routine isn't installed. See
``hindsight_api.engine.db.optional_routines`` for the canonical
install SQL.
"""
async with self._backend.acquire() as conn:
# The schemas_with_pending_work() PL/pgSQL function is a
# PostgreSQL-specific optimisation installed by Helm hooks in
# hindsight-cloud. Skip on non-PG backends to avoid constant
# ORA-00904 / syntax errors on every poll cycle.
if self._backend.backend_type == "postgresql":
try:
rows = await conn.fetch("SELECT * FROM schemas_with_pending_work()")
return {r[0] for r in rows}
except Exception:
pass
if await self._optional_routines.is_installed(conn, "schemas_with_pending_work"):
rows = await conn.fetch("SELECT * FROM public.schemas_with_pending_work()")
return {r[0] for r in rows}
# Fallback: per-schema EXISTS checks from Python
active: set[str | None] = set()
@@ -514,10 +526,14 @@ class WorkerPoller:
if not parent_row:
return
# Check whether all siblings are done
# Check whether all siblings are done. Pull error_message too so a
# parent that fails can inherit a representative child reason --
# otherwise the parent's error_message is generic ("One or more
# sub-batches failed") and downstream consumers (dashboards, alerts,
# filters) lose the actual cause once a batch has children.
siblings = await conn.fetch(
f"""
SELECT status FROM {table}
SELECT status, error_message FROM {table}
WHERE bank_id = $1
AND result_metadata::jsonb @> $2::jsonb
""",
@@ -536,7 +552,7 @@ class WorkerPoller:
WHERE operation_id = $1
""",
uuid.UUID(parent_operation_id),
"One or more sub-batches failed",
_summarise_child_error_messages(siblings),
)
else:
await conn.execute(
@@ -959,15 +975,30 @@ class WorkerPoller:
if len(processing_info) > 10:
processing_str += f" +{len(processing_info) - 10} more"
# Get global stats from DB
# Get global stats from DB — scope the heavy COUNT/GROUP BY
# queries to schemas that actually have work. With N tenants the
# full fanout is 2*N queries every PROGRESS_LOG_INTERVAL; scoping
# via the routine (or per-schema EXISTS fallback) reduces this to
# 2*active_schemas which is typically << N.
schemas = await self._get_schemas()
total_schema_count = len(schemas)
# Schemas with pending async_operations (uses server-side
# routine when installed, falls back to per-schema EXISTS).
schemas_with_pending = await self._scan_active_schemas(schemas)
# Also include schemas that have in-flight tasks on this worker
# so the "processing" worker_id GROUP BY still reports correctly.
schemas_with_active_tasks = {info.schema for info in active_tasks.values()}
schemas_to_query = schemas_with_pending | schemas_with_active_tasks
global_pending = 0
all_worker_counts: dict[str, int] = {}
# operation_type -> aggregated bucket counts across schemas
pending_breakdown: dict[str, dict[str, int]] = {}
async with self._backend.acquire() as conn:
for schema in schemas:
for schema in schemas_to_query:
table = fq_table("async_operations", schema)
# Bucket pending rows by the same predicates the claim query
@@ -976,20 +1007,24 @@ class WorkerPoller:
# retry backoff, etc.).
# Use SUM(CASE WHEN ...) instead of COUNT(*) FILTER (WHERE ...)
# for Oracle compatibility — FILTER is PG-specific.
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
THEN 1 ELSE 0 END) AS retry_blocked,
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
try:
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
THEN 1 ELSE 0 END) AS retry_blocked,
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
except Exception:
# Schema may be partially provisioned (table missing).
breakdown_rows = []
for br in breakdown_rows:
op_type = br["operation_type"] or "unknown"
bucket = pending_breakdown.setdefault(
@@ -1001,14 +1036,17 @@ class WorkerPoller:
bucket["assigned"] += br["assigned"]
global_pending += br["total"]
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
try:
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
except Exception:
worker_rows = []
for wr in worker_rows:
wid = wr["worker_id"] or "unknown"
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
@@ -1024,14 +1062,19 @@ class WorkerPoller:
pool_str = self._format_pool_stats()
proc_str = self._format_proc_stats()
# Display None as "default" in logs
schemas_str = ", ".join(s if s else "default" for s in schemas)
queried_count = len(schemas_to_query)
# Display queried schemas (cap at 20 for readability)
queried_list = sorted(s if s else "default" for s in schemas_to_query)
schemas_str = ", ".join(queried_list[:20])
if len(queried_list) > 20:
schemas_str += f" +{len(queried_list) - 20} more"
logger.info(
f"[WORKER_STATS] worker={self._worker_id} "
f"slots={in_flight}/{self._max_slots} | "
f"reserved: [{reserved_str}] | "
f"shared={tasks_in_shared}/{shared_pool_size}(avail={shared_available}) | "
f"global: pending={global_pending} (schemas: {schemas_str}) | "
f"global: pending={global_pending} "
f"(queried={queried_count}/{total_schema_count} schemas: {schemas_str}) | "
f"others: {others_str} | "
f"pool: {pool_str} | "
f"proc: {proc_str} | "
+5 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.5.6"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -47,14 +47,14 @@ dependencies = [
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
"litellm>=1.83.0", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789
"litellm>=1.83.14", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789 / GHSA-pq44-5pcq-4r5g / GHSA-8cjq-wjmh-q42r
"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)
"winloop>=0.1.0; sys_platform == 'win32'",
"uvloop>=0.22.1; sys_platform != 'win32'",
# Transitive dependency security fixes
"pyasn1>=0.6.3", # DoS vulnerability fix
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
"urllib3>=2.7.0", # Decompression-bomb safeguards bypass + sensitive header forwarding fixes
"langchain-core>=1.2.22", # Path traversal in legacy load_prompt functions fix
"langsmith>=0.6.3", # SSRF via tracing header injection fix
"protobuf>=6.33.5", # JSON recursion depth bypass fix
@@ -93,7 +93,7 @@ local-llm = [
"huggingface-hub>=0.20.0",
]
embedded-db = [
"pg0-embedded>=0.13.0",
"pg0-embedded>=0.14.0",
]
oracle = [
"oracledb>=2.5.0",
@@ -141,6 +141,7 @@ log_cli_date_format = "%Y-%m-%d %H:%M:%S"
addopts = "--timeout 300 -n 8 --dist loadgroup --durations=10 -v"
markers = [
"oracle: Oracle 23ai integration tests (require ORACLE_TEST_DSN env var)",
"hs_llm_mat: LLM minimum acceptance tests — run in CI matrix across multiple providers",
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,535 @@
"""Tests for Codex OAuth token refresh (issue #1637).
The Codex provider was originally a startup-only credential loader: it read
``~/.codex/auth.json`` once and used the cached access_token forever. These
tests pin the new automatic-refresh behavior:
- ``refresh_token`` is now actually loaded from auth.json.
- The provider proactively refreshes ~60s before the JWT ``exp`` claim.
- It reactively refreshes once on a 401/403 from the Codex backend.
- The OAuth refresh request shape mirrors the canonical ``@openai/codex``
CLI (POST https://auth.openai.com/oauth/token, JSON body with hardcoded
client_id, grant_type=refresh_token).
- Terminal error codes (refresh_token_expired/reused/invalidated) raise a
permanent error and do not loop.
- Concurrent callers serialize through a single-flight lock.
- ``auth.json`` is persisted atomically via tempfile+rename with mode 0600.
Tests construct ``CodexLLM`` with ``_load_codex_auth`` mocked, then drive
JWT exp / network / persistence paths through targeted patches.
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import stat
import sys
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from hindsight_api.engine.providers.codex_llm import (
_CODEX_CLIENT_ID,
_CODEX_REFRESH_TOKEN_URL,
CodexLLM,
CodexRefreshExpiredError,
)
def _make_jwt(exp_unixtime: int | None) -> str:
"""Build a minimal JWT-shaped token with the given ``exp`` claim.
Signature segment is a placeholder we don't verify, we only decode
the payload to read ``exp``.
"""
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode()
payload_dict: dict[str, object] = {}
if exp_unixtime is not None:
payload_dict["exp"] = exp_unixtime
payload = base64.urlsafe_b64encode(json.dumps(payload_dict).encode()).rstrip(b"=").decode()
signature = "sig"
return f"{header}.{payload}.{signature}"
def _build_llm(refresh_token: str | None = "rt-initial", access_token: str | None = None) -> CodexLLM:
"""Construct a CodexLLM with patched auth-file reads."""
if access_token is None:
access_token = _make_jwt(int(time.time()) + 3600) # fresh by default
with (
patch.object(CodexLLM, "_load_codex_auth", return_value=(access_token, "acct-123")),
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=refresh_token),
):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# ---------------------------------------------------------------------------
# JWT exp decode
# ---------------------------------------------------------------------------
def test_jwt_exp_decode_returns_int_for_valid_token():
token = _make_jwt(1_800_000_000)
assert CodexLLM._decode_jwt_exp_unixtime(token) == 1_800_000_000
def test_jwt_exp_decode_returns_none_when_exp_missing():
token = _make_jwt(None)
assert CodexLLM._decode_jwt_exp_unixtime(token) is None
def test_jwt_exp_decode_returns_none_for_malformed_token():
assert CodexLLM._decode_jwt_exp_unixtime("not.a.real.jwt") is None
assert CodexLLM._decode_jwt_exp_unixtime("only-one-segment") is None
assert CodexLLM._decode_jwt_exp_unixtime("a.!!notbase64!!.c") is None
# ---------------------------------------------------------------------------
# Staleness
# ---------------------------------------------------------------------------
def test_token_is_stale_true_when_expired():
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(access_token=expired)
assert llm._token_is_stale() is True
def test_token_is_stale_true_within_skew_window():
# 30s before expiry, default skew is 60s → should be considered stale.
soon = _make_jwt(int(time.time()) + 30)
llm = _build_llm(access_token=soon)
assert llm._token_is_stale() is True
def test_token_is_stale_false_when_far_from_expiry():
far = _make_jwt(int(time.time()) + 3600)
llm = _build_llm(access_token=far)
assert llm._token_is_stale() is False
def test_token_is_stale_false_when_exp_unparseable():
# When we can't decide, we'd rather use a possibly-expired token and
# recover via the reactive 401 path than refresh aggressively.
llm = _build_llm(access_token="opaque-token-no-jwt-structure")
assert llm._token_is_stale() is False
# ---------------------------------------------------------------------------
# refresh_token loading
# ---------------------------------------------------------------------------
def test_refresh_token_loaded_from_auth_file(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": "at",
"refresh_token": "rt-from-disk",
"account_id": "acct",
},
}
)
)
with patch.object(CodexLLM, "_load_codex_auth", return_value=("at", "acct")):
llm = CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# Now point the auth_file at our tmp file and reload.
llm._auth_file = auth_file
assert llm._load_codex_refresh_token() == "rt-from-disk"
def test_refresh_token_returns_none_when_field_absent(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(json.dumps({"auth_mode": "chatgpt", "tokens": {"access_token": "at"}}))
llm = _build_llm()
llm._auth_file = auth_file
assert llm._load_codex_refresh_token() is None
def test_refresh_token_returns_none_when_file_missing(tmp_path: Path):
llm = _build_llm()
llm._auth_file = tmp_path / "definitely-not-here.json"
assert llm._load_codex_refresh_token() is None
# ---------------------------------------------------------------------------
# Atomic persistence
# ---------------------------------------------------------------------------
def test_persist_auth_atomic_writes_mode_0600_and_preserves_fields(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(
json.dumps(
{
"OPENAI_API_KEY": None,
"auth_mode": "chatgpt",
"tokens": {
"access_token": "old",
"refresh_token": "rt-old",
"account_id": "acct-keep",
"id_token": {"email": "[email protected]"},
},
"last_refresh": "2026-01-01T00:00:00Z",
}
)
)
llm = _build_llm()
llm._auth_file = auth_file
llm._persist_auth_atomic({"access_token": "new", "refresh_token": "rt-new"})
written = json.loads(auth_file.read_text())
assert written["tokens"]["access_token"] == "new"
assert written["tokens"]["refresh_token"] == "rt-new"
# Untouched fields are preserved (account_id, id_token, auth_mode).
assert written["tokens"]["account_id"] == "acct-keep"
assert written["tokens"]["id_token"] == {"email": "[email protected]"}
assert written["auth_mode"] == "chatgpt"
# last_refresh got bumped to a new ISO-8601 UTC timestamp.
assert written["last_refresh"] != "2026-01-01T00:00:00Z"
assert written["last_refresh"].endswith("Z")
if sys.platform != "win32":
mode = stat.S_IMODE(auth_file.stat().st_mode)
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
def test_persist_auth_atomic_does_not_leak_tempfile_on_success(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(json.dumps({"tokens": {"access_token": "old"}}))
llm = _build_llm()
llm._auth_file = auth_file
llm._persist_auth_atomic({"access_token": "new"})
# No sibling tempfile should remain — atomic rename consumed it.
siblings = [p.name for p in tmp_path.iterdir()]
assert siblings == ["auth.json"], f"unexpected leftover files: {siblings}"
# ---------------------------------------------------------------------------
# _refresh_oauth_tokens — request shape, in-memory update, rotation
# ---------------------------------------------------------------------------
def _refresh_response(status_code: int, body: dict | str) -> MagicMock:
response = MagicMock()
response.status_code = status_code
if isinstance(body, dict):
response.json.return_value = body
response.text = json.dumps(body)
else:
response.json.side_effect = json.JSONDecodeError("nope", body, 0)
response.text = body
return response
@pytest.mark.asyncio
async def test_refresh_sends_canonical_request_shape(tmp_path: Path):
"""POST JSON body with client_id + grant_type=refresh_token + refresh_token."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-current", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
fresh_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": fresh_access, "refresh_token": "rt-rotated"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp) as mock_post:
await llm._refresh_oauth_tokens()
call_args = mock_post.call_args
assert call_args.args[0] == _CODEX_REFRESH_TOKEN_URL
assert call_args.kwargs["headers"]["Content-Type"] == "application/json"
assert call_args.kwargs["json"] == {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": "rt-current",
}
@pytest.mark.asyncio
async def test_refresh_updates_in_memory_credentials(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-old", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-old"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
await llm._refresh_oauth_tokens()
assert llm.access_token == new_access
assert llm.refresh_token == "rt-new"
@pytest.mark.asyncio
async def test_refresh_keeps_existing_refresh_token_when_server_omits_one(tmp_path: Path):
"""If the OAuth response has no ``refresh_token`` field, keep the one we have."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-keep", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-keep"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
await llm._refresh_oauth_tokens()
assert llm.refresh_token == "rt-keep"
@pytest.mark.asyncio
async def test_refresh_raises_permanent_error_on_terminal_oauth_code(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
bad_resp = _refresh_response(401, {"error": {"code": "refresh_token_expired"}})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(CodexRefreshExpiredError):
await llm._refresh_oauth_tokens()
@pytest.mark.asyncio
async def test_refresh_raises_permanent_error_on_unknown_401(tmp_path: Path):
"""Any 401 from the refresh endpoint is treated as permanent — matches upstream Rust classification."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
bad_resp = _refresh_response(401, {"error": "something_else"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(CodexRefreshExpiredError):
await llm._refresh_oauth_tokens()
@pytest.mark.asyncio
async def test_refresh_raises_runtime_error_on_5xx(tmp_path: Path):
"""5xx is transient from the caller's perspective — surface as RuntimeError, not CodexRefreshExpiredError."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-current", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
bad_resp = _refresh_response(503, "service unavailable")
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(RuntimeError) as exc_info:
await llm._refresh_oauth_tokens()
assert not isinstance(exc_info.value, CodexRefreshExpiredError)
@pytest.mark.asyncio
async def test_refresh_does_not_log_token_values(tmp_path: Path, caplog):
expired = _make_jwt(int(time.time()) - 60)
secret_rt = "rt-DO-NOT-LEAK-THIS"
llm = _build_llm(refresh_token=secret_rt, access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": secret_rt}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-also-secret"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
with caplog.at_level("DEBUG"):
await llm._refresh_oauth_tokens()
log_text = "\n".join(record.getMessage() for record in caplog.records)
assert secret_rt not in log_text
assert new_access not in log_text
assert "rt-also-secret" not in log_text
# ---------------------------------------------------------------------------
# Single-flight under concurrent callers
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_ensure_fresh_token_calls_produce_one_refresh(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
call_count = 0
async def fake_post(*args, **kwargs):
nonlocal call_count
call_count += 1
# Simulate non-zero refresh latency so concurrent callers actually queue.
await asyncio.sleep(0.01)
return _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
with patch.object(llm._client, "post", new=fake_post):
await asyncio.gather(*(llm._ensure_fresh_token() for _ in range(10)))
assert call_count == 1, f"expected 1 network refresh under contention, got {call_count}"
# ---------------------------------------------------------------------------
# Reactive 401 retry on the request path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
"""A backend 401 triggers one refresh + retry instead of immediately raising."""
fresh = _make_jwt(int(time.time()) + 3600) # not stale; the 401 is the trigger
llm = _build_llm(refresh_token="rt", access_token=fresh)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
# First post → 401 (backend rejects the token). After refresh, second post → 200.
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
fail_response = MagicMock()
fail_response.status_code = 401
fail_response.text = "unauthorized"
fail_exc = httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
success_resp.raise_for_status = MagicMock(return_value=None)
post_responses = [fail_exc, success_resp]
async def fake_post(*args, **kwargs):
item = post_responses.pop(0)
if isinstance(item, Exception):
raise item
return item
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
call_count = {"refresh": 0, "post": 0}
async def counting_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_count["refresh"] += 1
return refresh_resp
call_count["post"] += 1
# First backend call fails with 401 wrapped in an HTTPStatusError-style response,
# second succeeds.
if call_count["post"] == 1:
raise httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
return success_resp
with (
patch.object(llm._client, "post", new=counting_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
result = await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert result == "ok"
assert call_count["refresh"] == 1
assert call_count["post"] == 2 # one 401, one success after refresh
assert llm.access_token == new_access
@pytest.mark.asyncio
async def test_call_proactively_refreshes_when_token_is_stale(tmp_path: Path):
"""A near-expiry token triggers refresh BEFORE the request is sent."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": expired, "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
call_order: list[str] = []
async def fake_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_order.append("refresh")
return refresh_resp
call_order.append("backend")
# Assert that by the time the backend is called, the new token is in use.
assert kwargs["headers"]["Authorization"] == f"Bearer {new_access}"
return success_resp
with (
patch.object(llm._client, "post", new=fake_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert call_order == ["refresh", "backend"], "expected proactive refresh BEFORE the backend call"
@pytest.mark.asyncio
async def test_call_does_not_refresh_when_token_is_fresh(tmp_path: Path):
fresh = _make_jwt(int(time.time()) + 3600)
llm = _build_llm(refresh_token="rt", access_token=fresh)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
call_count = {"refresh": 0, "backend": 0}
async def fake_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_count["refresh"] += 1
raise AssertionError("refresh endpoint should not be hit for a fresh token")
call_count["backend"] += 1
return success_resp
with (
patch.object(llm._client, "post", new=fake_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert call_count == {"refresh": 0, "backend": 1}
@@ -128,6 +128,74 @@ def test_log_config_masks_database_urls(caplog):
assert "postgresql://***:***@db-admin:5432/hindsight_db" in log_output
def test_read_database_url_defaults_to_none_when_unset(monkeypatch):
"""Without HINDSIGHT_API_READ_DATABASE_URL, the field is None — engine
will alias the read backend to the primary, preserving today's
single-pool behaviour byte-for-bit. This is the most important guarantee
of the change: zero-config means zero behaviour change.
"""
from hindsight_api.config import HindsightConfig
monkeypatch.delenv("HINDSIGHT_API_READ_DATABASE_URL", raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.read_database_url is None
def test_read_database_url_is_loaded_when_set(monkeypatch):
"""When HINDSIGHT_API_READ_DATABASE_URL is set, the value flows into
config so MemoryEngine.initialize() will open a second backend against
that URL for recall queries.
"""
from hindsight_api.config import HindsightConfig
read_url = "postgresql://reader:[email protected]:5432/hindsight"
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", read_url)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.read_database_url == read_url
def test_read_database_url_empty_string_is_treated_as_unset(monkeypatch):
"""Helm sometimes renders an unset env var as the empty string. Treat it
the same as unset so deployments that conditionally set the var don't
accidentally try to open a pool against `''`.
"""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", "")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.read_database_url is None
def test_log_config_masks_read_database_url(monkeypatch, caplog):
"""Read-replica URL credentials must be masked in startup logs, same as
the primary URL.
"""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_DATABASE_URL", "postgresql://hindsight:pw@primary:5432/db")
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", "postgresql://reader:replica-secret@replica:5432/db")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS", "64000")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_CHUNK_SIZE", "3000")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
caplog.set_level(logging.INFO, logger="hindsight_api.config")
config = HindsightConfig.from_env()
config.log_config()
log_output = "\n".join(record.getMessage() for record in caplog.records)
assert "reader" not in log_output
assert "replica-secret" not in log_output
assert "Read database" in log_output
assert "postgresql://***:***@replica:5432/db" in log_output
# Note: The BadRequestError wrapping is implemented in fact_extraction.py
# but requires a complex integration test setup. The functionality is
# straightforward: when a BadRequestError containing keywords like
@@ -335,6 +335,7 @@ class TestConsolidationIntegration:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_consolidation_merges_only_redundant_facts(self, memory: MemoryEngine, request_context):
"""Test that consolidation only merges truly redundant facts.
@@ -69,15 +69,76 @@ def create_isolated_schema(db_url: str, schema_name: str, dimension: int | None
# Adjust embedding dimension if specified
if dimension is not None:
ensure_embedding_dimension(db_url, dimension, schema=schema_name)
_ensure_embedding_dimension_with_retry(db_url, dimension, schema=schema_name)
def drop_schema(db_url: str, schema_name: str):
"""Drop an isolated schema."""
"""Drop an isolated schema.
Retries once on InternalError (e.g. 'could not open relation with OID')
which can happen when pg0 has concurrent connections referencing the schema.
"""
engine = create_engine(db_url)
with engine.connect() as conn:
conn.execute(text(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE"))
conn.commit()
for attempt in range(2):
try:
with engine.connect() as conn:
conn.execute(text(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE"))
conn.commit()
return
except Exception:
if attempt == 0:
import time
time.sleep(0.5)
# Best-effort teardown — don't fail the test over cleanup issues
def _ensure_embedding_dimension_with_retry(db_url: str, dimension: int, schema: str):
"""Wrapper around ensure_embedding_dimension with OID race retry.
pg0 with concurrent xdist workers can cause 'could not open relation with OID'
when one worker's DROP SCHEMA CASCADE invalidates pg_indexes references mid-query.
"""
import time
for attempt in range(3):
try:
ensure_embedding_dimension(db_url, dimension, schema=schema)
return
except Exception as e:
if "could not open relation with OID" in str(e) and attempt < 2:
time.sleep(0.5)
continue
raise
def _assert_raises_runtime_error_with_retry(
db_url: str,
dimension: int,
schema: str,
expected_messages: list[str],
):
"""Assert ensure_embedding_dimension raises RuntimeError, retrying on transient OID errors.
Concurrent xdist workers can cause 'could not open relation with OID' errors
that mask the expected RuntimeError. This retries to give the system a chance to
reach the actual dimension-mismatch check.
"""
import time
for attempt in range(3):
try:
ensure_embedding_dimension(db_url, dimension, schema=schema)
raise AssertionError("Expected RuntimeError but ensure_embedding_dimension succeeded")
except RuntimeError as e:
for msg in expected_messages:
assert msg in str(e), f"Expected '{msg}' in error message, got: {e}"
return
except Exception as e:
if "could not open relation with OID" in str(e) and attempt < 2:
time.sleep(0.5)
continue
raise
def get_column_dimension(db_url: str, schema: str = "public", table: str = "memory_units") -> int | None:
@@ -188,7 +249,7 @@ class TestEmbeddingDimension:
assert initial_dim == 384, f"Expected 384, got {initial_dim}"
# Call ensure_embedding_dimension with matching dimension
ensure_embedding_dimension(db_url, 384, schema=schema)
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
# Dimension should still be 384
assert get_column_dimension(db_url, schema) == 384
@@ -202,14 +263,14 @@ class TestEmbeddingDimension:
assert get_row_count(db_url, schema) == 0
# Change dimension to 768
ensure_embedding_dimension(db_url, 768, schema=schema)
_ensure_embedding_dimension_with_retry(db_url, 768, schema=schema)
# Verify dimension changed
new_dim = get_column_dimension(db_url, schema)
assert new_dim == 768, f"Expected 768, got {new_dim}"
# Change back to 384 for other tests
ensure_embedding_dimension(db_url, 384, schema=schema)
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema) == 384
def test_dimension_change_blocked_with_data(self, dimension_test_schema):
@@ -223,12 +284,12 @@ class TestEmbeddingDimension:
insert_test_embedding(db_url, schema, 384)
assert get_row_count(db_url, schema) == 1
# Try to change dimension - should raise error
with pytest.raises(RuntimeError) as exc_info:
ensure_embedding_dimension(db_url, 768, schema=schema)
assert "Cannot change embedding dimension" in str(exc_info.value)
assert "1 rows with embeddings" in str(exc_info.value)
# Try to change dimension - should raise RuntimeError.
# Retry on transient OID errors from concurrent xdist schema drops.
_assert_raises_runtime_error_with_retry(
db_url, 768, schema,
expected_messages=["Cannot change embedding dimension", "1 rows with embeddings"],
)
# Dimension should be unchanged
assert get_column_dimension(db_url, schema) == 384
@@ -243,7 +304,7 @@ class TestEmbeddingDimension:
initial_dim = get_column_dimension(db_url, schema, table="mental_models")
assert initial_dim == 384, f"Expected 384, got {initial_dim}"
ensure_embedding_dimension(db_url, 384, schema=schema)
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
@@ -253,12 +314,12 @@ class TestEmbeddingDimension:
clear_mental_model_embeddings(db_url, schema)
ensure_embedding_dimension(db_url, 768, schema=schema)
_ensure_embedding_dimension_with_retry(db_url, 768, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 768
# Change back for other tests
ensure_embedding_dimension(db_url, 384, schema=schema)
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
def test_mental_models_dimension_change_blocked_with_data(self, dimension_test_schema):
@@ -268,11 +329,12 @@ class TestEmbeddingDimension:
clear_mental_model_embeddings(db_url, schema)
insert_test_mental_model_embedding(db_url, schema, 384)
with pytest.raises(RuntimeError) as exc_info:
ensure_embedding_dimension(db_url, 768, schema=schema)
assert "Cannot change embedding dimension" in str(exc_info.value)
assert "mental_models" in str(exc_info.value)
# Try to change dimension - should raise RuntimeError.
# Retry on transient OID errors from concurrent xdist schema drops.
_assert_raises_runtime_error_with_retry(
db_url, 768, schema,
expected_messages=["Cannot change embedding dimension", "mental_models"],
)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
@@ -0,0 +1,88 @@
"""Tests for daemonize() — subprocess.Popen re-exec instead of os.fork()."""
import sys
from unittest.mock import MagicMock, patch
import pytest
def test_daemonize_parent_reexecs_via_popen(monkeypatch, tmp_path):
"""Parent path: daemonize() must spawn a child via subprocess.Popen and exit."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.delenv("_HINDSIGHT_DAEMON_CHILD", raising=False)
monkeypatch.setattr(sys, "argv", ["hindsight-api", "--daemon", "--port", "9999"])
log_path = tmp_path / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
captured: dict = {}
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
captured["kwargs"] = kwargs
proc = MagicMock()
proc.pid = 99999
return proc
with (
patch("hindsight_api.daemon.subprocess.Popen", side_effect=fake_popen),
pytest.raises(SystemExit) as exc_info,
):
from hindsight_api.daemon import daemonize
daemonize()
assert exc_info.value.code == 0
# Verify child command does NOT contain --daemon
assert "--daemon" not in captured["cmd"]
# Verify it uses the module entry point
assert "-m" in captured["cmd"]
assert "hindsight_api.main" in captured["cmd"]
# Verify remaining args are preserved
assert "--port" in captured["cmd"]
assert "9999" in captured["cmd"]
# Verify env has the daemon child marker
env = captured["kwargs"]["env"]
assert env["_HINDSIGHT_DAEMON_CHILD"] == "1"
# Verify detach kwargs
kwargs = captured["kwargs"]
assert kwargs.get("start_new_session") is True
def test_daemonize_child_does_not_reexec(monkeypatch, tmp_path):
"""Child path: when _HINDSIGHT_DAEMON_CHILD=1, daemonize() does NOT call
Popen it only redirects stdio."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("_HINDSIGHT_DAEMON_CHILD", "1")
log_path = tmp_path / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
with (
patch("hindsight_api.daemon.subprocess.Popen") as mock_popen,
patch("hindsight_api.daemon._redirect_stdio_to_log") as mock_redirect,
):
from hindsight_api.daemon import daemonize
daemonize()
mock_popen.assert_not_called()
mock_redirect.assert_called_once()
def test_daemonize_windows_noop(monkeypatch, tmp_path):
"""On Windows, daemonize() just creates the log directory."""
monkeypatch.setattr(sys, "platform", "win32")
log_path = tmp_path / "subdir" / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
with patch("hindsight_api.daemon.subprocess.Popen") as mock_popen:
from hindsight_api.daemon import daemonize
daemonize()
mock_popen.assert_not_called()
assert log_path.parent.exists()
@@ -17,7 +17,10 @@ from hindsight_api.engine.retain.entity_labels import (
EntityLabelsConfig,
LabelGroup,
LabelValue,
MapField,
build_labels_lookup,
build_labels_model,
is_label_entity,
parse_entity_labels,
)
@@ -1118,3 +1121,776 @@ async def test_retain_extracts_free_values_label(memory, request_context):
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_retain_extracts_map_type_entities(memory, request_context):
"""
End-to-end: retain content with a map-type entity_labels group.
Verify that structured entity fields are extracted as key:field:value entity strings.
"""
from hindsight_api.engine.memory_engine import fq_table
bank_id = f"test-labels-map-{uuid.uuid4().hex[:8]}"
try:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Configure a map-type entity label
await memory._config_resolver.update_bank_config(
bank_id=bank_id,
updates={
"entity_labels": [
{
"key": "person",
"type": "map",
"description": "A person mentioned in the text",
"fields": {
"name": {"type": "text", "description": "Full name of the person"},
"role": {"type": "text", "description": "Job title or role"},
"organization": {"type": "text", "description": "Company or organization"},
},
}
],
"entities_allow_free_form": False, # map entities only
},
context=request_context,
)
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=(
"Alice Johnson is a Senior Software Engineer at Google. "
"She leads the search infrastructure team and has been with the company for 5 years."
),
request_context=request_context,
)
assert len(unit_ids) > 0, "Should have extracted at least one fact"
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1::uuid[])
""",
[u for u in unit_ids],
)
entity_names = {r["canonical_name"].lower() for r in rows}
# Should have person:name:* entity
name_entities = {n for n in entity_names if n.startswith("person:name:")}
assert len(name_entities) > 0, (
f"Expected at least one person:name:* entity. Got: {entity_names}"
)
# Name should contain "alice" somewhere
assert any("alice" in n for n in name_entities), (
f"Expected person:name entity containing 'alice'. Got: {name_entities}"
)
# Should have person:organization:* entity mentioning google
org_entities = {n for n in entity_names if n.startswith("person:organization:")}
assert len(org_entities) > 0, (
f"Expected at least one person:organization:* entity. Got: {entity_names}"
)
assert any("google" in n for n in org_entities), (
f"Expected person:organization entity containing 'google'. Got: {org_entities}"
)
# In labels-only mode, free-form entities should be absent
non_person_entities = {n for n in entity_names if not n.startswith("person:")}
assert len(non_person_entities) == 0, (
f"Free-form entities should not appear in labels-only mode. Got: {non_person_entities}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ─── map-type entity labels ──────────────────────────────────────────────────
def test_parse_entity_labels_map_type():
"""Map-type label group with fields is parsed correctly."""
raw = [
{
"key": "person",
"type": "map",
"description": "A person entity",
"fields": {
"name": {"type": "text", "description": "Full name"},
"role": {"type": "text", "description": "Job title"},
"organization": {"type": "text", "description": "Company"},
},
}
]
result = parse_entity_labels(raw)
assert result is not None
assert len(result.attributes) == 1
group = result.attributes[0]
assert group.key == "person"
assert group.type == "map"
assert len(group.fields) == 3
assert "name" in group.fields
assert group.fields["name"].description == "Full name"
def test_parse_entity_labels_map_type_dict_format():
"""Map-type label group via dict format."""
raw = {
"attributes": [
{
"key": "company",
"type": "map",
"fields": {
"name": {"type": "text"},
"industry": {"type": "text"},
},
}
]
}
result = parse_entity_labels(raw)
assert result is not None
assert result.attributes[0].type == "map"
assert len(result.attributes[0].fields) == 2
def test_build_labels_model_map_type():
"""Map-type groups produce list[MapModel] fields in the Labels model."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(description="Full name"),
"role": MapField(description="Job title"),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
assert "person" in schema["properties"]
# Should be an array of objects
person_prop = schema["properties"]["person"]
assert person_prop["type"] == "array"
def test_build_labels_model_mixed_map_and_value():
"""Both map-type and value-type groups coexist in the same Labels model."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(description="Full name"),
},
),
LabelGroup(
key="topic",
type="value",
values=[LabelValue(value="math"), LabelValue(value="science")],
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
assert "person" in schema["properties"]
assert "topic" in schema["properties"]
def test_build_labels_model_map_type_no_fields():
"""Map-type group with no fields produces no field in the model."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(key="empty", type="map", fields={}),
]
)
model = build_labels_model(labels_cfg)
assert model is None
def test_build_labels_lookup_skips_map_type():
"""Map-type groups should not contribute to the two-level lookup set."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={"name": MapField()},
),
LabelGroup(
key="topic",
type="value",
values=[LabelValue(value="math")],
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert "topic:math" in lookup
# No map-type entries in the lookup
assert not any("person" in v for v in lookup)
def test_is_label_entity_map_type():
"""Three-level key:field:value strings are recognized as label entities."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(),
"role": MapField(),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:name:Alice", labels_cfg, lookup)
assert is_label_entity("person:role:Engineer", labels_cfg, lookup)
assert is_label_entity("Person:Name:Alice", labels_cfg, lookup) # case insensitive
assert not is_label_entity("person:unknown_field:value", labels_cfg, lookup)
assert not is_label_entity("person:Alice", labels_cfg, lookup) # two-level, not map
assert not is_label_entity("Alice", labels_cfg, lookup)
def test_build_labels_prompt_section_map_type():
"""Map-type groups appear in the STRUCTURED ENTITY TYPES prompt section."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person entity",
fields={
"name": MapField(description="Full name"),
"role": MapField(description="Job title"),
},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "STRUCTURED ENTITY TYPES" in result
assert "person" in result
assert "name" in result
assert "role" in result
assert "Full name" in result
def test_build_labels_prompt_section_mixed():
"""Mixed map-type and value-type groups both appear in the prompt."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="topic",
type="value",
description="Subject area",
values=[LabelValue(value="math")],
),
LabelGroup(
key="person",
type="map",
description="A person",
fields={"name": MapField(description="Full name")},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "CLASSIFICATION ATTRIBUTES" in result
assert "topic" in result
assert "STRUCTURED ENTITY TYPES" in result
assert "person" in result
def test_map_entity_post_processing():
"""Map-type labels are converted to key:field:value entity strings."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"role": MapField(type="text"),
"organization": MapField(type="text"),
}
entity_obj = {"name": "Alice", "role": "Senior Engineer", "organization": "Acme Corp"}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:role:Senior Engineer" in texts
assert "person:organization:Acme Corp" in texts
def test_map_entity_post_processing_null_fields_skipped():
"""Null/empty fields in map entities are skipped."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"role": MapField(type="text"),
}
entity_obj = {"name": "Bob", "role": None}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Bob" in texts
assert len(texts) == 1 # role was null, so only name
def test_map_entity_post_processing_multiple_entities():
"""Multiple map entities in a single fact produce separate entity strings."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"role": MapField(type="text"),
}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities({"name": "Alice", "role": "Engineer"}, fields, "person:", validated, existing)
_extract_map_entities({"name": "Bob", "role": "Manager"}, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:role:Engineer" in texts
assert "person:name:Bob" in texts
assert "person:role:Manager" in texts
assert len(texts) == 4
# ─── recursive map-type entity labels ────────────────────────────────────────
def test_parse_entity_labels_recursive_map():
"""Nested map fields parse correctly."""
raw = [
{
"key": "person",
"type": "map",
"fields": {
"name": {"type": "text"},
"address": {
"type": "map",
"fields": {
"city": {"type": "text", "description": "City name"},
"country": {"type": "text"},
},
},
},
}
]
result = parse_entity_labels(raw)
assert result is not None
group = result.attributes[0]
assert group.fields["address"].type == "map"
assert "city" in group.fields["address"].fields
assert group.fields["address"].fields["city"].description == "City name"
def test_build_labels_model_recursive_map():
"""Nested map fields produce nested list[Model] in the schema."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"address": MapField(
type="map",
fields={
"city": MapField(type="text"),
"country": MapField(type="text"),
},
),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
person_prop = schema["properties"]["person"]
assert person_prop["type"] == "array"
def test_is_label_entity_recursive_map():
"""Deeply nested key:field:subfield:value strings are recognized."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"address": MapField(
type="map",
fields={
"city": MapField(type="text"),
"country": MapField(type="text"),
},
),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:name:Alice", labels_cfg, lookup)
assert is_label_entity("person:address:city:New York", labels_cfg, lookup)
assert is_label_entity("person:address:country:US", labels_cfg, lookup)
assert not is_label_entity("person:address:zip:12345", labels_cfg, lookup)
assert not is_label_entity("person:address:New York", labels_cfg, lookup)
def test_recursive_map_post_processing():
"""Nested map entities produce deeply-joined key:field:subfield:value strings."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"address": MapField(
type="map",
fields={
"city": MapField(type="text"),
"country": MapField(type="text"),
},
),
}
entity_obj = {
"name": "Alice",
"address": [{"city": "New York", "country": "US"}],
}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:address:city:New York" in texts
assert "person:address:country:US" in texts
assert len(texts) == 3
def test_map_field_with_enum_values():
"""Map fields with value/multi-values types constrain extraction."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering"), LabelValue(value="sales")],
),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
# The model should exist and have the person field
assert "person" in schema["properties"]
def test_build_labels_prompt_section_recursive_map():
"""Nested map fields appear indented in the prompt."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(type="text", description="Full name"),
"address": MapField(
type="map",
description="Home address",
fields={
"city": MapField(type="text", description="City name"),
},
),
},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "name (text)" in result
assert "address (object)" in result
assert "city (text)" in result
# ─── map fields with value/multi-values types ────────────────────────────────
def test_map_field_value_post_processing():
"""Map field with type='value' extracts a single enum entity string."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering"), LabelValue(value="sales")],
),
}
entity_obj = {"name": "Alice", "department": "engineering"}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:department:engineering" in texts
assert len(texts) == 2
def test_map_field_multi_values_post_processing():
"""Map field with type='multi-values' extracts one entity per value."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"skills": MapField(
type="multi-values",
values=[LabelValue(value="python"), LabelValue(value="go"), LabelValue(value="rust")],
),
}
entity_obj = {"name": "Alice", "skills": ["python", "rust"]}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:skills:python" in texts
assert "person:skills:rust" in texts
assert "person:skills:go" not in texts
assert len(texts) == 3
def test_map_field_multi_values_null_skipped():
"""Null/sentinel values in multi-values are skipped."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"tags": MapField(type="multi-values"),
}
entity_obj = {"tags": ["valid", "none", "null", "", " ", "n/a", "also_valid"]}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "item:", validated, existing)
texts = {e.text for e in validated}
assert "item:tags:valid" in texts
assert "item:tags:also_valid" in texts
assert len(texts) == 2
def test_nested_map_with_enum_fields_post_processing():
"""Nested map containing value/multi-values fields produces correct paths."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"job": MapField(
type="map",
fields={
"title": MapField(type="text"),
"level": MapField(
type="value",
values=[LabelValue(value="junior"), LabelValue(value="senior")],
),
"languages": MapField(
type="multi-values",
values=[LabelValue(value="python"), LabelValue(value="java")],
),
},
),
}
entity_obj = {
"name": "Bob",
"job": [{"title": "Engineer", "level": "senior", "languages": ["python", "java"]}],
}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Bob" in texts
assert "person:job:title:Engineer" in texts
assert "person:job:level:senior" in texts
assert "person:job:languages:python" in texts
assert "person:job:languages:java" in texts
assert len(texts) == 5
def test_is_label_entity_map_with_enum_fields():
"""Entity strings from map fields with value/multi-values types are recognized."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering")],
),
"skills": MapField(type="multi-values"),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:name:Alice", labels_cfg, lookup)
assert is_label_entity("person:department:engineering", labels_cfg, lookup)
assert is_label_entity("person:skills:python", labels_cfg, lookup)
assert not is_label_entity("person:unknown:value", labels_cfg, lookup)
def test_is_label_entity_nested_map_with_enum():
"""Deeply nested paths with value/multi-values fields are recognized."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"job": MapField(
type="map",
fields={
"level": MapField(type="value"),
"languages": MapField(type="multi-values"),
},
),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:job:level:senior", labels_cfg, lookup)
assert is_label_entity("person:job:languages:python", labels_cfg, lookup)
assert not is_label_entity("person:job:salary:100k", labels_cfg, lookup)
def test_map_field_enum_schema_generation():
"""Map fields with value/multi-values generate correct JSON schema constraints."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering"), LabelValue(value="sales")],
),
"skills": MapField(
type="multi-values",
values=[LabelValue(value="python"), LabelValue(value="go")],
),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
# Resolve $defs to find the person entity schema
person_ref = schema["properties"]["person"]["items"]
if "$ref" in person_ref:
ref_name = person_ref["$ref"].split("/")[-1]
person_schema = schema["$defs"][ref_name]
else:
person_schema = person_ref
props = person_schema["properties"]
# department should be an enum
assert "department" in props
assert "enum" in props["department"] or "anyOf" in props["department"]
# skills should be an array
assert "skills" in props
assert props["skills"]["type"] == "array"
def test_prompt_section_map_with_all_field_types():
"""Prompt includes correct type hints for value/multi-values/map fields."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(type="text", description="Full name"),
"department": MapField(
type="value",
description="Department",
values=[LabelValue(value="eng"), LabelValue(value="sales")],
),
"skills": MapField(
type="multi-values",
description="Skills list",
values=[LabelValue(value="python"), LabelValue(value="go")],
),
"address": MapField(
type="map",
description="Home address",
fields={"city": MapField(type="text")},
),
},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "name (text)" in result
assert "one of: eng, sales" in result
assert "multi-values: python, go" in result
assert "address (object)" in result
assert "city (text)" in result
def test_duplicate_entity_strings_deduplicated():
"""Same entity string from multiple nested objects is only added once."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
}
# Two entities with the same name
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities({"name": "Alice"}, fields, "person:", validated, existing)
_extract_map_entities({"name": "Alice"}, fields, "person:", validated, existing)
texts = [e.text for e in validated]
assert texts == ["person:name:Alice"] # only once
@@ -326,3 +326,88 @@ class TestOracleFuzzyEntityResolution:
# The candidate IDs should be passed as bind parameter
cooc_bind_args = mock_conn.fetch.call_args_list[1].args[1:]
assert "eid-1" in cooc_bind_args[0], "Co-occurrence query must receive candidate IDs"
@pytest.mark.asyncio
async def test_link_units_carries_event_date_into_cooccurrences(pg0_db_url):
"""
`link_units_to_entities_batch` must propagate each unit's event_date onto the
accumulated _CooccurrencePair entries, so flush_pending_stats() stamps
entity_cooccurrences.last_cooccurred with the event time instead of "now".
This protects banks that were backfilled from another memory system
without it, every pair collapses to the import moment and the UI's entity
graph recency heat loses the underlying knowledge timeline.
"""
resolved_url = await resolve_database_url(pg0_db_url)
backend = create_database_backend("postgresql")
await backend.initialize(resolved_url, min_size=1, max_size=2, command_timeout=30)
bank_id = f"test-cooccurrence-evt-{uuid.uuid4().hex[:8]}"
resolver = EntityResolver(pool=backend, entity_lookup="full")
historical = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
try:
async with backend.acquire() as conn:
unit_id = await conn.fetchval(
"""
INSERT INTO memory_units
(bank_id, text, fact_type, mentioned_at, occurred_start, created_at)
VALUES ($1, 'paired-entity unit', 'experience', $2, $2, now())
RETURNING id
""",
bank_id,
historical,
)
e1 = await conn.fetchval(
"INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count) "
"VALUES ($1, 'alpha', $2, $2, 1) RETURNING id",
bank_id,
historical,
)
e2 = await conn.fetchval(
"INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count) "
"VALUES ($1, 'beta', $2, $2, 1) RETURNING id",
bank_id,
historical,
)
await resolver.link_units_to_entities_batch(
[(str(unit_id), str(e1), historical), (str(unit_id), str(e2), historical)],
conn=conn,
)
# Flush accumulates to entity_cooccurrences on a fresh connection, as
# the production flush runs post-transaction to avoid lock contention.
await resolver.flush_pending_stats()
async with backend.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT last_cooccurred
FROM entity_cooccurrences
WHERE entity_id_1 = LEAST($1::uuid, $2::uuid)
AND entity_id_2 = GREATEST($1::uuid, $2::uuid)
""",
e1,
e2,
)
assert row is not None, "co-occurrence row should exist"
assert row["last_cooccurred"] == historical, (
f"expected last_cooccurred == {historical}, got {row['last_cooccurred']}"
)
finally:
async with backend.acquire() as conn:
await conn.execute(
"DELETE FROM unit_entities WHERE unit_id IN (SELECT id FROM memory_units WHERE bank_id = $1)", bank_id
)
await conn.execute(
"DELETE FROM entity_cooccurrences WHERE entity_id_1 IN "
"(SELECT id FROM entities WHERE bank_id = $1) "
"OR entity_id_2 IN "
"(SELECT id FROM entities WHERE bank_id = $1)",
bank_id,
)
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
await backend.shutdown()
@@ -305,6 +305,7 @@ Family is the most important thing to her.
f"Found: {found_evaluative}"
)
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_comprehensive_multi_dimension(self):
"""Test a realistic scenario with multiple dimensions in one fact."""
@@ -336,7 +337,21 @@ I prefer presenting in person rather than virtually because I can read the room
has_emotional = any(term in all_facts_text for term in [
"thrilled", "positive feedback", "positive", "feedback", "enthusiastic"
])
assert has_emotional, "Should preserve emotional dimension"
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "in-person", "virtually",
"read the room", "face-to-face", "face to face", "remote",
])
# MAT bar: at least one of emotional or preferential must be preserved.
# Smaller models (e.g. nova-2-lite) may compress both sentences into a
# single fact that only captures one dimension — that's acceptable for
# a minimum-acceptance test.
assert has_emotional or has_preference, (
f"Should preserve at least one of emotional or preferential dimension. "
f"Extracted facts: {all_facts_text}"
)
# Check no vague temporal terms
prohibited_terms = ["recently", "soon", "lately"]
@@ -344,12 +359,6 @@ I prefer presenting in person rather than virtually because I can read the room
assert len(found_prohibited) == 0, \
f"Should NOT use vague temporal terms. Found: {found_prohibited}"
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "virtually", "read the room"
])
assert has_preference, "Should preserve preferential dimension"
# =============================================================================
# TEMPORAL CONVERSION TESTS
@@ -158,6 +158,14 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert "total_nodes" in stats
assert stats["total_nodes"] > 0
# Verify bank list returns stats (fact_count, last_document_at)
response = await api_client.get("/v1/default/banks")
assert response.status_code == 200
banks_after = response.json()["banks"]
our_bank = next(b for b in banks_after if b["bank_id"] == test_bank_id)
assert our_bank["fact_count"] > 0, "fact_count should reflect retained memories"
assert our_bank["last_document_at"] is not None, "last_document_at should be set after retain"
# List memory units
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
+108 -348
View File
@@ -1,60 +1,37 @@
"""
Test LLM provider with different models using actual Hindsight memory operations.
LLM Minimum Acceptance Tests provider API surface.
Tests validate that providers work correctly with:
1. Retain (memory ingestion with fact extraction)
2. Reflect (memory retrieval with tool calling)
3. Mental models (consolidated knowledge generation)
Validates that a given LLM provider/model works correctly with Hindsight's
low-level LLM API methods: plain text, structured output, and tool calling.
The provider/model under test comes from HINDSIGHT_API_LLM_PROVIDER /
HINDSIGHT_API_LLM_MODEL env vars, which are set by the CI matrix in the
test-api-llm-acceptance job.
These tests are excluded from the regular test-api CI job via the
hs_llm_mat marker.
"""
import os
from datetime import datetime
import pytest
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.utils import extract_facts
from hindsight_api.engine.search.think_utils import reflect
pytestmark = pytest.mark.hs_llm_mat
# Model matrix: (provider, model)
MODEL_MATRIX = [
# OpenAI models
("openai", "gpt-4o-mini"),
("openai", "gpt-4.1-mini"),
("openai", "gpt-4.1-nano"),
("openai", "gpt-5-mini"),
("openai", "gpt-5-nano"),
("openai", "gpt-5"),
("openai", "gpt-5.2"),
# Anthropic models
("anthropic", "claude-sonnet-4-20250514"),
("anthropic", "claude-opus-4-5-20251101"),
("anthropic", "claude-haiku-4-20250514"),
# Groq models
("groq", "openai/gpt-oss-120b"),
("groq", "openai/gpt-oss-20b"),
# DeepSeek models
("deepseek", "deepseek-v4-flash"),
("deepseek", "deepseek-chat"),
# Gemini models
("gemini", "gemini-2.5-flash"),
("gemini", "gemini-2.5-flash-lite"),
("gemini", "gemini-3.1-pro-preview"),
("gemini", "gemini-3.1-flash-lite-preview"),
# Ollama models (local)
("ollama", "gemma3:12b"),
("ollama", "gemma3:1b"),
# Claude Code (uses Claude Agent SDK with Claude models)
("claude-code", "claude-sonnet-4-20250514"),
# OpenAI Codex (uses MCP with Codex-specific models)
("openai-codex", "gpt-5.4-mini"),
# Bedrock models (via LiteLLM)
("bedrock", "us.amazon.nova-2-lite-v1:0"),
# Mock provider (for testing)
("mock", "mock"),
]
_PROVIDER = os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "")
_MODEL = os.environ.get("HINDSIGHT_API_LLM_MODEL", "")
def get_api_key_for_provider(provider: str) -> str | None:
"""Get API key for provider from environment variables."""
def _get_api_key() -> str:
"""Get API key from HINDSIGHT_API_LLM_API_KEY (CI) or provider-specific env var."""
key = os.environ.get("HINDSIGHT_API_LLM_API_KEY", "")
if key:
return key
# Fallback to provider-specific env vars for local dev
provider_key_map = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
@@ -62,50 +39,24 @@ def get_api_key_for_provider(provider: str) -> str | None:
"gemini": "GEMINI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
}
env_var = provider_key_map.get(provider)
return os.getenv(env_var) if env_var else None
env_var = provider_key_map.get(_PROVIDER, "")
return os.environ.get(env_var, "") if env_var else ""
def should_skip_provider(provider: str, model: str = "") -> tuple[bool, str]:
"""Check if provider should be skipped and return reason."""
# Never skip mock provider
if provider == "mock":
return False, ""
# Skip claude-code and openai-codex in CI (require local auth)
if os.getenv("CI") and provider in ("claude-code", "openai-codex"):
return True, f"{provider} not available in CI (requires local authentication)"
# Skip Ollama in CI (no models available)
if provider == "ollama" and os.getenv("CI"):
return True, "Ollama not available in CI"
# Skip Ollama gemma models (don't support tool calling)
if provider == "ollama" and "gemma" in model.lower():
return True, f"Ollama {model} does not support tool calling"
# Bedrock needs AWS credentials
if provider == "bedrock":
if not os.getenv("AWS_ACCESS_KEY_ID"):
return True, "No AWS credentials available (set AWS_ACCESS_KEY_ID)"
return False, ""
# Other providers need an API key
if provider not in ("ollama", "claude-code", "openai-codex", "mock"):
api_key = get_api_key_for_provider(provider)
if not api_key:
return True, f"No API key available (set {provider.upper()}_API_KEY)"
return False, ""
def _make_llm() -> LLMProvider:
return LLMProvider(
provider=_PROVIDER,
api_key=_get_api_key(),
base_url=os.environ.get("HINDSIGHT_API_LLM_BASE_URL", ""),
model=_MODEL,
)
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
@pytest.mark.timeout(300) # Increase timeout for slow models like groq gpt-oss-120b
async def test_llm_provider_api_methods(provider: str, model: str):
@pytest.mark.timeout(300)
async def test_llm_api_methods():
"""
Test all LLM API methods used by Hindsight at runtime.
This validates that the provider correctly implements the LLMInterface.
Tests:
1. verify_connection() - Connection verification
@@ -113,184 +64,108 @@ async def test_llm_provider_api_methods(provider: str, model: str):
3. call() with response_format - Structured output (used in fact extraction)
4. call_with_tools() - Tool calling (used in reflect agent)
"""
# Skip mock provider - it's a test stub, not a real LLM implementation
if provider == "mock":
pytest.skip("Mock provider is a test stub, not a real LLM")
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
api_key = get_api_key_for_provider(provider)
llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
)
print(f"\n{provider}/{model} - API methods test:")
llm = _make_llm()
# Test 1: verify_connection()
try:
await llm.verify_connection()
print(" ✓ verify_connection()")
except Exception as e:
pytest.fail(f"{provider}/{model} verify_connection() failed: {e}")
await llm.verify_connection()
# Test 2: call() with plain text
try:
response = await llm.call(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is 2+2? Answer in one word."},
],
max_completion_tokens=50,
)
assert response is not None, "call() returned None"
assert len(response) > 0, "call() returned empty string"
print(f" ✓ call() plain text: {response[:50]}")
except Exception as e:
pytest.fail(f"{provider}/{model} call() plain text failed: {e}")
response = await llm.call(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is 2+2? Answer in one word."},
],
max_completion_tokens=50,
)
assert response is not None, "call() returned None"
assert len(response) > 0, "call() returned empty string"
# Test 3: call() with response_format (structured output)
# Skip for models that don't support structured output
skip_structured_output = (provider == "groq" and "gpt-oss-120b" in model.lower())
if skip_structured_output:
print(f" ⊘ call() structured output: skipped (model doesn't support response_format)")
else:
try:
from pydantic import BaseModel
from pydantic import BaseModel
class TestResponse(BaseModel):
answer: str
confidence: str
class TestResponse(BaseModel):
answer: str
confidence: str
response = await llm.call(
messages=[
{"role": "system", "content": "You are a math assistant."},
{"role": "user", "content": "What is the capital of France?"},
],
response_format=TestResponse,
max_completion_tokens=100,
)
assert isinstance(response, TestResponse), f"Expected TestResponse, got {type(response)}"
assert hasattr(response, "answer"), "Structured output missing 'answer' field"
assert hasattr(response, "confidence"), "Structured output missing 'confidence' field"
print(f" ✓ call() structured output: answer={response.answer}, confidence={response.confidence}")
except Exception as e:
pytest.fail(f"{provider}/{model} call() structured output failed: {e}")
structured = await llm.call(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of France?"},
],
response_format=TestResponse,
max_completion_tokens=100,
)
assert isinstance(structured, TestResponse), f"Expected TestResponse, got {type(structured)}"
assert structured.answer, "Structured output missing 'answer'"
assert structured.confidence, "Structured output missing 'confidence'"
# Test 4: call_with_tools() (tool calling)
try:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
}
]
},
}
]
result = await llm.call_with_tools(
messages=[
{"role": "system", "content": "You are a helpful assistant with access to tools."},
{"role": "user", "content": "What's the weather like in Paris?"},
],
tools=tools,
max_completion_tokens=500, # Increased from 200 to give models enough space for tool calls
)
assert result is not None, "call_with_tools() returned None"
assert hasattr(result, "tool_calls"), "Result missing 'tool_calls' attribute"
# Nano models may hit token limits before making tool calls - that's acceptable
is_nano_model = "nano" in model.lower()
if is_nano_model and len(result.tool_calls) == 0:
# Check if it hit length limit (expected for nano models)
if hasattr(result, "finish_reason") and result.finish_reason == "length":
print(f" ✓ call_with_tools(): nano model hit token limit (expected)")
else:
pytest.fail(f"Nano model made 0 tool calls but didn't hit length limit (finish_reason={getattr(result, 'finish_reason', 'unknown')})")
else:
assert len(result.tool_calls) > 0, f"Expected at least 1 tool call, got {len(result.tool_calls)}"
# Verify tool call structure
tool_call = result.tool_calls[0]
assert hasattr(tool_call, "name"), "Tool call missing 'name'"
assert hasattr(tool_call, "arguments"), "Tool call missing 'arguments'"
assert tool_call.name == "get_weather", f"Expected 'get_weather', got '{tool_call.name}'"
assert "location" in tool_call.arguments, "Tool call arguments missing 'location'"
print(f" ✓ call_with_tools(): {tool_call.name}({tool_call.arguments})")
except Exception as e:
pytest.fail(f"{provider}/{model} call_with_tools() failed: {e}")
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
@pytest.mark.timeout(600) # 600s: some providers (e.g., bedrock via litellm) need extra time for fact extraction
async def test_llm_provider_memory_operations(provider: str, model: str):
"""
Test LLM provider with actual memory operations: fact extraction and reflect.
All models must pass this test.
"""
# Skip mock provider - it's a test stub, not designed for real operations
if provider == "mock":
pytest.skip("Mock provider is a test stub, not designed for real operations")
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
api_key = get_api_key_for_provider(provider)
llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
result = await llm.call_with_tools(
messages=[
{"role": "system", "content": "You are a helpful assistant with access to tools."},
{"role": "user", "content": "What's the weather like in Paris?"},
],
tools=tools,
max_completion_tokens=500,
)
# Test 1: Fact extraction (structured output)
assert result is not None, "call_with_tools() returned None"
assert hasattr(result, "tool_calls"), "Result missing 'tool_calls' attribute"
assert len(result.tool_calls) > 0, f"Expected at least 1 tool call, got {len(result.tool_calls)}"
tool_call = result.tool_calls[0]
assert tool_call.name == "get_weather", f"Expected 'get_weather', got '{tool_call.name}'"
assert "location" in tool_call.arguments, "Tool call arguments missing 'location'"
@pytest.mark.asyncio
@pytest.mark.timeout(600)
async def test_llm_memory_operations():
"""
Test fact extraction and reflect with the configured LLM provider.
"""
llm = _make_llm()
# Fact extraction (structured output)
test_text = """
User: I just got back from my trip to Paris last week. The Eiffel Tower was amazing!
Assistant: That sounds wonderful! How long were you there?
User: About 5 days. I also visited the Louvre and saw the Mona Lisa.
"""
event_date = datetime(2024, 12, 10)
facts, chunks = await extract_facts(
text=test_text,
event_date=event_date,
event_date=datetime(2024, 12, 10),
context="Travel conversation",
llm_config=llm,
)
print(f"\n{provider}/{model} - Fact extraction:")
print(f" Extracted {len(facts)} facts from {len(chunks)} chunks")
assert facts is not None, "fact extraction returned None"
assert len(facts) > 0, "should extract at least one fact"
for fact in facts:
print(f" - {fact.fact}")
assert fact.fact, "fact missing text"
assert fact.fact_type in ["world", "experience"], f"invalid fact_type: {fact.fact_type}"
assert facts is not None, f"{provider}/{model} fact extraction returned None"
assert len(facts) > 0, f"{provider}/{model} should extract at least one fact"
# Verify facts have required fields
for fact in facts:
assert fact.fact, f"{provider}/{model} fact missing text"
assert fact.fact_type in ["world", "experience"], f"{provider}/{model} invalid fact_type: {fact.fact_type}"
# Test 2: Reflect (actual reflect function)
# Reflect
response = await reflect(
llm_config=llm,
query="What was the highlight of my Paris trip?",
@@ -307,120 +182,5 @@ async def test_llm_provider_memory_operations(provider: str, model: str):
name="Traveler",
)
print(f"\n{provider}/{model} - Reflect response:")
print(f" {response[:200]}...")
assert response is not None, f"{provider}/{model} reflect returned None"
assert len(response) > 10, f"{provider}/{model} reflect response too short"
@pytest.mark.parametrize("provider,model", [
("claude-code", "claude-sonnet-4-20250514"),
("openai-codex", "gpt-5.4-mini"),
])
@pytest.mark.asyncio
async def test_llm_provider_consolidation(memory_no_llm_verify, request_context, provider: str, model: str):
"""
Test LLM provider with consolidation (automatic mental model generation from observations).
This validates that the provider can generate synthesized knowledge from raw memories.
This test is limited to claude-code and codex since they're the critical providers
that needed tool calling fixes for reflect and consolidation operations.
"""
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
# Use provider-specific LLM for this test
api_key = get_api_key_for_provider(provider)
memory_no_llm_verify._consolidation_llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
)
# Also need retain LLM for ingesting data
memory_no_llm_verify._retain_llm = memory_no_llm_verify._consolidation_llm
test_bank_id = f"llm_test_consolidation_{provider}_{model}_{datetime.now().timestamp()}"
# Enable observations for this bank
from hindsight_api.config import _get_raw_config
config = _get_raw_config()
original_value = config.enable_observations
config.enable_observations = True
try:
# Retain memories to consolidate
test_content = """
Bob prefers functional programming with Rust and Haskell.
He emphasizes immutability and pure functions in code reviews.
Bob advocates for type safety and compile-time guarantees.
He avoids mutable state and prefers declarative code patterns.
"""
await memory_no_llm_verify.retain_async(
bank_id=test_bank_id,
content=test_content,
context="Team coding preferences",
event_date=datetime(2024, 12, 1),
request_context=request_context,
)
print(f"\n{provider}/{model} - Consolidation test:")
# Run consolidation to generate observations (mental models)
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=test_bank_id,
request_context=request_context,
)
print(f" Processed: {result.get('memories_processed', 0)} memories")
print(f" Created: {result.get('observations_created', 0)} observations")
print(f" Updated: {result.get('observations_updated', 0)} observations")
# Verify consolidation ran successfully
assert result["status"] in ["success", "no_new_memories"], f"{provider}/{model} consolidation failed"
# If observations were created, verify they contain relevant content
if result.get("observations_created", 0) > 0:
observations = await memory_no_llm_verify.list_mental_models_consolidated(
bank_id=test_bank_id,
request_context=request_context,
)
assert len(observations) > 0, f"{provider}/{model} consolidation created 0 observations"
# Check first observation contains relevant information
obs_content = observations[0].get("content", "").lower()
relevant_terms = ["bob", "functional", "rust", "immutab", "type"]
matches = [term for term in relevant_terms if term in obs_content]
print(f" Observation preview: {observations[0].get('content', '')[:200]}...")
print(f" Found {len(matches)} relevant terms: {matches}")
assert len(matches) >= 2, (
f"{provider}/{model} consolidated observation doesn't contain relevant info. "
f"Expected at least 2 of {relevant_terms}, found {len(matches)}: {matches}"
)
finally:
# Restore original config
config.enable_observations = original_value
# NOTE: The tests above validate the critical Hindsight operations:
#
# test_llm_provider_memory_operations (ALL providers):
# - Fact extraction (retain): tests structured output generation
# - Reflect: tests memory retrieval and reasoning (uses tool calling for claude-code/codex)
#
# test_llm_provider_consolidation (claude-code and codex only):
# - Consolidation: tests automatic mental model generation from observations
# - Requires MemoryEngine fixture with working LLM (from .env or env vars)
# - Run your local LLM server OR set HINDSIGHT_API_LLM_PROVIDER/API_KEY/MODEL env vars
#
# For full end-to-end integration tests using the HTTP API, see tests/test_http_api_integration.py
assert response is not None, "reflect returned None"
assert len(response) > 10, "reflect response too short"
@@ -0,0 +1,345 @@
"""
Tests for the LiteLLM Router LLM provider config parsing, factory dispatch,
and the Router-backed call paths (plain text, structured output, tool calls,
retry on transient failure).
The provider is a thin pass-through to ``litellm.Router``. The chain config
shape mirrors LiteLLM's API; we don't translate model names or impose
fallbacks. See https://docs.litellm.ai/docs/routing.
"""
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.config import (
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_PROVIDER,
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG,
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG,
HindsightConfig,
_parse_llm_router_config,
)
from hindsight_api.engine.llm_wrapper import create_llm_provider
from hindsight_api.engine.providers.litellm_router_llm import LiteLLMRouterLLM
@pytest.fixture
def two_step_config() -> dict[str, Any]:
"""Raw LiteLLM Router config: two deployments wired for ordered fallback.
Hindsight always issues completions against ``model_name="default"``;
additional groups become fallback / load-balance pool members per the
user's ``fallbacks`` / ``routing_strategy`` settings.
"""
return {
"model_list": [
{
"model_name": "default",
"litellm_params": {
"model": "openai/MiniMax-M2.7",
"api_key": "sk-primary",
"api_base": "https://api.minimax.io/v1",
},
},
{
"model_name": "fallback",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-fallback"},
},
],
"fallbacks": [{"default": ["fallback"]}],
"num_retries": 0,
}
@pytest.fixture
def mock_router_response() -> MagicMock:
response = MagicMock()
choice = MagicMock()
choice.message.content = "ok"
choice.message.tool_calls = None
choice.finish_reason = "stop"
response.choices = [choice]
response.usage.prompt_tokens = 12
response.usage.completion_tokens = 3
response._hidden_params = {"model": "openai/gpt-4o-mini"}
return response
# --- config parsing ----------------------------------------------------------
class TestParseRouterConfig:
def test_unset_returns_none(self, monkeypatch):
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
def test_empty_string_returns_none(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, " ")
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
def test_valid_config_passes_through(self, monkeypatch, two_step_config):
"""Whatever the user provides round-trips verbatim — no translation."""
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == two_step_config
def test_invalid_json(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, "{not json")
with pytest.raises(ValueError, match="invalid JSON"):
_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG)
def test_no_shape_validation(self, monkeypatch):
"""We don't validate the shape — anything that parses as JSON gets passed through.
LiteLLM Router is authoritative for shape errors; we let them surface at
Router construction time rather than pre-validating.
"""
# A list, a string, an object with junk keys — all accepted by the parser.
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps([{"hello": "world"}]))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == [{"hello": "world"}]
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps({"only": "garbage"}))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == {"only": "garbage"}
class TestFromEnvLoadsConfig:
def test_loaded_when_provider_is_litellmrouter(self, monkeypatch, two_step_config):
monkeypatch.setenv(ENV_LLM_PROVIDER, "litellmrouter")
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
cfg = HindsightConfig.from_env()
assert cfg.llm_provider == "litellmrouter"
assert cfg.llm_litellmrouter_config == two_step_config
def test_unset_keeps_default_provider(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
cfg = HindsightConfig.from_env()
assert cfg.llm_provider == "openai"
assert cfg.llm_litellmrouter_config is None
def test_per_op_configs_independent(self, monkeypatch):
"""Per-op env vars populate per-op fields without touching the default."""
retain_config = {
"model_list": [{"model_name": "r", "litellm_params": {"model": "openai/retain", "api_key": "rk"}}]
}
reflect_config = {
"model_list": [{"model_name": "f", "litellm_params": {"model": "anthropic/claude", "api_key": "ak"}}]
}
consol_config = {
"model_list": [{"model_name": "c", "litellm_params": {"model": "openai/consol", "api_key": "ck"}}]
}
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
monkeypatch.setenv(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG, json.dumps(retain_config))
monkeypatch.setenv(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG, json.dumps(reflect_config))
monkeypatch.setenv(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG, json.dumps(consol_config))
cfg = HindsightConfig.from_env()
assert cfg.llm_litellmrouter_config is None
assert cfg.retain_llm_litellmrouter_config == retain_config
assert cfg.reflect_llm_litellmrouter_config == reflect_config
assert cfg.consolidation_llm_litellmrouter_config == consol_config
# --- factory dispatch --------------------------------------------------------
class TestFactoryDispatch:
def test_router_provider_requires_config(self):
with pytest.raises(ValueError, match="config object"):
create_llm_provider(
provider="litellmrouter",
api_key="",
base_url="",
model="unused",
reasoning_effort="low",
litellmrouter_config=None,
)
def test_router_provider_returns_router_impl(self, two_step_config):
with patch.dict("sys.modules", {"litellm": MagicMock()}):
with patch(
"hindsight_api.engine.providers.litellm_router_llm.LiteLLMRouterLLM.__init__",
return_value=None,
) as mock_init:
impl = create_llm_provider(
provider="litellmrouter",
api_key="",
base_url="",
model="unused",
reasoning_effort="low",
litellmrouter_config=two_step_config,
)
assert isinstance(impl, LiteLLMRouterLLM)
_, kwargs = mock_init.call_args
assert kwargs["config"] == two_step_config
# --- Router-backed call paths ------------------------------------------------
def _make_router_provider(config: dict[str, Any], mock_router: Any) -> LiteLLMRouterLLM:
"""Construct a LiteLLMRouterLLM with the inner Router replaced by a mock."""
fake_litellm = MagicMock()
fake_litellm.Router = MagicMock(return_value=mock_router)
with patch.dict("sys.modules", {"litellm": fake_litellm}):
# Bypass the heavy ctor chain by injecting state directly.
provider = LiteLLMRouterLLM.__new__(LiteLLMRouterLLM)
provider.provider = "litellmrouter"
provider.api_key = ""
provider.base_url = ""
provider.model = "unused"
provider.reasoning_effort = "low"
provider.timeout = 300.0
provider.config = config
provider._litellm = fake_litellm
provider._router = mock_router
provider._router_output_cap = None # tests that exercise the cap override this directly
return provider
class TestRouterCall:
@pytest.mark.asyncio
async def test_plain_text_call_targets_default_entrypoint(self, two_step_config, mock_router_response):
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=50,
max_retries=0,
)
assert result == "ok"
# Hindsight always issues against model_name="default"; Router handles fallback,
# load-balancing, and routing strategy from there.
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["model"] == "default"
@pytest.mark.asyncio
async def test_structured_output(self, two_step_config):
class MySchema(BaseModel):
answer: str
response = MagicMock()
choice = MagicMock()
choice.message.content = '{"answer": "42"}'
choice.message.tool_calls = None
choice.finish_reason = "stop"
response.choices = [choice]
response.usage.prompt_tokens = 5
response.usage.completion_tokens = 5
response._hidden_params = {"model": "openai/gpt-4o-mini"}
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "q"}],
response_format=MySchema,
max_retries=0,
)
assert isinstance(result, MySchema)
assert result.answer == "42"
@pytest.mark.asyncio
async def test_retry_on_transient_then_success(self, two_step_config, mock_router_response):
mock_router = MagicMock()
# First call raises a 503-style error, second call returns ok.
mock_router.acompletion = AsyncMock(side_effect=[Exception("503 Service Unavailable"), mock_router_response])
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=2,
initial_backoff=0.0,
max_backoff=0.0,
)
assert result == "ok"
assert mock_router.acompletion.await_count == 2
@pytest.mark.asyncio
async def test_auth_error_does_not_retry(self, two_step_config):
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(side_effect=Exception("401 Unauthorized: bad key"))
provider = _make_router_provider(two_step_config, mock_router)
with pytest.raises(Exception, match="401"):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=5,
initial_backoff=0.0,
)
assert mock_router.acompletion.await_count == 1
@pytest.mark.asyncio
async def test_caps_max_completion_tokens_to_litellm_registry(self, two_step_config, mock_router_response):
"""Cap max_completion_tokens to the most conservative deployment limit.
Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
high-capacity models. When a configured deployment has a smaller cap
(gpt-4.1-nano = 32768), the call would otherwise be rejected apply
the cap silently using LiteLLM's per-model registry.
"""
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
provider._router_output_cap = 32768 # what _compute_router_output_cap would yield
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=64000, # over the cap
max_retries=0,
)
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["max_completion_tokens"] == 32768
@pytest.mark.asyncio
async def test_no_cap_when_litellm_registry_has_no_data(self, two_step_config, mock_router_response):
"""If LiteLLM doesn't know any of the deployment models, pass the requested value through."""
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
provider._router_output_cap = None
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=64000,
max_retries=0,
)
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["max_completion_tokens"] == 64000
@pytest.mark.asyncio
async def test_call_with_tools(self, two_step_config):
response = MagicMock()
choice = MagicMock()
choice.message.content = None
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "lookup"
tool_call.function.arguments = '{"q": "x"}'
choice.message.tool_calls = [tool_call]
choice.finish_reason = "tool_calls"
response.choices = [choice]
response.usage.prompt_tokens = 5
response.usage.completion_tokens = 2
response._hidden_params = {"model": "openai/gpt-4o-mini"}
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call_with_tools(
messages=[{"role": "user", "content": "use tool"}],
tools=[{"type": "function", "function": {"name": "lookup", "parameters": {}}}],
max_retries=0,
)
assert result.content is None
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "lookup"
assert result.tool_calls[0].arguments == {"q": "x"}
@@ -949,6 +949,51 @@ class TestRecallNewParams:
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["question_date"] == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
async def test_recall_with_tag_groups_negative_filter(self, mock_memory):
"""tag_groups with NOT should pass through to engine after Pydantic validation."""
from hindsight_api.engine.search.tags import TagGroupNot
mcp = _make_mcp_server(mock_memory, {"recall"})
await _tools(mcp)["recall"].fn(
query="test",
tag_groups=[{"not": {"tags": ["closeout"], "match": "any_strict"}}],
)
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" in call_kwargs
assert len(call_kwargs["tag_groups"]) == 1
group = call_kwargs["tag_groups"][0]
assert isinstance(group, TagGroupNot)
assert group.filter.tags == ["closeout"]
async def test_recall_without_tag_groups_no_kwarg(self, mock_memory):
"""tag_groups omitted should not appear in engine kwargs."""
mcp = _make_mcp_server(mock_memory, {"recall"})
await _tools(mcp)["recall"].fn(query="test")
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" not in call_kwargs
async def test_recall_tags_and_tag_groups_mutually_exclusive(self, mock_memory):
"""Passing both tags and tag_groups returns an error and does not call engine."""
mcp = _make_mcp_server(mock_memory, {"recall"})
result = await _tools(mcp)["recall"].fn(
query="test",
tags=["project:x"],
tag_groups=[{"tags": ["closeout"], "match": "any_strict"}],
)
assert "mutually exclusive" in result
mock_memory.recall_async.assert_not_called()
async def test_recall_tag_groups_single_bank(self, mock_memory):
"""tag_groups should also work in single-bank mode."""
mcp = _make_mcp_server(mock_memory, {"recall"}, include_bank_id=False)
await _tools(mcp)["recall"].fn(
query="test",
tag_groups=[{"tags": ["scope:work"], "match": "all_strict"}],
)
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" in call_kwargs
assert len(call_kwargs["tag_groups"]) == 1
@pytest.mark.asyncio
class TestReflectNewParams:
@@ -0,0 +1,48 @@
from hindsight_api.api.http import MentalModelTrigger
from hindsight_api.engine.search.tags import TagGroupOr
def test_mental_model_trigger_model_dump_preserves_or_tag_group():
trigger = MentalModelTrigger.model_validate(
{
"tag_groups": [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
}
)
dumped = trigger.model_dump()
assert dumped["tag_groups"] == [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
def test_mental_model_trigger_or_tag_group_survives_storage_round_trip():
trigger = MentalModelTrigger.model_validate(
{
"tag_groups": [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
}
)
round_tripped = MentalModelTrigger.model_validate(trigger.model_dump())
assert isinstance(round_tripped.tag_groups[0], TagGroupOr)
assert round_tripped.model_dump()["tag_groups"] == trigger.model_dump()["tag_groups"]
@@ -1,5 +1,5 @@
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
@@ -52,6 +52,24 @@ async def test_json_object_call_adds_json_hint_to_user_message():
assert sent_messages[0]["content"].startswith("Return valid json only.")
@pytest.mark.asyncio
async def test_json_object_call_strips_gemma_thought_tags_before_parsing():
llm = _llm()
create = AsyncMock(
return_value=_response(content='<thought>\nI should return a compact JSON object.\n</thought>\n{"ok": true}')
)
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
result = await llm.call(
messages=[{"role": "user", "content": "Return whether this worked."}],
response_format=SimpleJsonResponse,
max_retries=0,
)
assert result.ok is True
@pytest.mark.asyncio
async def test_error_payload_with_no_choices_raises_clear_provider_error_without_retry():
llm = _llm()
@@ -0,0 +1,80 @@
"""Tests for the opencode-go OpenAI-compatible LLM provider."""
import pytest
def test_opencode_go_config_has_expected_default_model(monkeypatch):
"""HindsightConfig should default opencode-go to the DeepSeek v4 flash model."""
from hindsight_api.config import PROVIDER_DEFAULT_MODELS, HindsightConfig, clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
clear_config_cache()
try:
assert PROVIDER_DEFAULT_MODELS["opencode-go"] == "deepseek-v4-flash"
config = HindsightConfig.from_env()
assert config.llm_provider == "opencode-go"
assert config.llm_model == "deepseek-v4-flash"
finally:
clear_config_cache()
def test_opencode_go_llm_provider_from_env_has_expected_default_model(monkeypatch):
"""LLMProvider.from_env should use the opencode-go provider default model."""
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.llm_wrapper import LLMProvider
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "test-key")
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
monkeypatch.delenv("HINDSIGHT_API_LLM_BASE_URL", raising=False)
clear_config_cache()
try:
llm = LLMProvider.from_env()
assert llm.provider == "opencode-go"
assert llm.model == "deepseek-v4-flash"
assert llm.base_url == "https://opencode.ai/zen/go/v1"
finally:
clear_config_cache()
def test_opencode_go_requires_api_key_like_zai():
"""opencode-go is a cloud provider and should require an API key."""
from hindsight_api.engine.llm_wrapper import requires_api_key
assert requires_api_key("opencode-go") is True
def test_opencode_go_uses_openai_compatible_provider_with_default_base_url():
"""The provider factory should route opencode-go to OpenAICompatibleLLM."""
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
llm = LLMProvider(
provider="opencode-go",
api_key="test-key",
base_url="",
model="deepseek-v4-flash",
)
assert llm.provider == "opencode-go"
assert llm.model == "deepseek-v4-flash"
assert llm.base_url == "https://opencode.ai/zen/go/v1"
assert not llm.base_url.endswith("/")
assert isinstance(llm._provider_impl, OpenAICompatibleLLM)
assert llm._provider_impl.base_url == "https://opencode.ai/zen/go/v1"
def test_opencode_go_rejects_missing_api_key():
"""opencode-go should fail fast without an API key, matching zai behavior."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with pytest.raises(ValueError, match="API key is required for opencode-go"):
LLMProvider(
provider="opencode-go",
api_key="",
base_url="",
model="deepseek-v4-flash",
)
@@ -10,13 +10,12 @@ retry hits the same unhandled error so the entire retry budget is wasted.
See https://github.com/vectorize-io/hindsight/issues/1334.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM, ProviderResponseError
class _Response(BaseModel):
@@ -33,25 +32,38 @@ def _make_llm() -> OpenAICompatibleLLM:
def _make_chat_response(content: str | None) -> MagicMock:
"""Build a mock that matches the shape expected by _first_choice_or_error.
Key fields that must be explicitly set (not left as auto-MagicMock):
- response.error = None (otherwise truthy MagicMock triggers error path)
- response.model_dump() (returns dict without 'error' key)
- choice.message.tool_calls/refusal (otherwise truthy MagicMock in error msg)
"""
choice = MagicMock()
choice.finish_reason = "stop"
choice.message.content = content
choice.message.tool_calls = None
choice.message.refusal = None
response = MagicMock()
response.error = None
response.model_dump.return_value = {}
response.usage.prompt_tokens = 10
response.usage.completion_tokens = 0 if content is None else 5
response.usage.total_tokens = 10 if content is None else 15
response.choices[0].finish_reason = "stop"
response.choices[0].message.content = content
response.choices[0].message.tool_calls = None
response.choices = [choice]
return response
@pytest.mark.asyncio
async def test_null_content_raises_after_retries_exhausted():
"""All retries return null content -> JSONDecodeError, not TypeError."""
"""All retries return null content -> ProviderResponseError, not TypeError."""
llm = _make_llm()
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = _make_chat_response(None)
with pytest.raises(json.JSONDecodeError):
with pytest.raises(ProviderResponseError, match="empty message content"):
await llm.call(
messages=[{"role": "user", "content": "extract facts"}],
response_format=_Response,
@@ -0,0 +1,135 @@
"""Tests for the optional read-only backend for recall queries."""
from __future__ import annotations
import pytest
import pytest_asyncio
from hindsight_api import MemoryEngine
from hindsight_api.engine.task_backend import SyncTaskBackend
def _make_engine(pg0_db_url: str, embeddings, cross_encoder, query_analyzer) -> MemoryEngine:
"""Build a MemoryEngine for a single test. Tiny pool, no migrations,
SyncTaskBackend so async tasks resolve inline. Mirrors conftest's
``memory`` fixture but lets each test build its own with custom env.
"""
return MemoryEngine(
db_url=pg0_db_url,
memory_llm_provider="none", # No LLM calls in these tests
memory_llm_api_key="unused",
memory_llm_model="unused",
embeddings=embeddings,
cross_encoder=cross_encoder,
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=2,
run_migrations=False,
task_backend=SyncTaskBackend(),
)
@pytest_asyncio.fixture
async def engine_no_read_url(monkeypatch, pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""Engine built with READ_DATABASE_URL explicitly unset."""
from hindsight_api.config import clear_config_cache
monkeypatch.delenv("HINDSIGHT_API_READ_DATABASE_URL", raising=False)
clear_config_cache()
mem = _make_engine(pg0_db_url, embeddings, cross_encoder, query_analyzer)
await mem.initialize()
yield mem
try:
await mem.close()
except Exception:
pass
clear_config_cache()
@pytest_asyncio.fixture
async def engine_with_read_url(monkeypatch, pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""Engine built with READ_DATABASE_URL set to the same DB. The engine
can't tell it's the same server it just sees a second URL and opens
a second backend, which is what we want to verify.
"""
from hindsight_api.config import clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", pg0_db_url)
clear_config_cache()
mem = _make_engine(pg0_db_url, embeddings, cross_encoder, query_analyzer)
await mem.initialize()
yield mem
try:
await mem.close()
except Exception:
pass
clear_config_cache()
@pytest.mark.asyncio
async def test_read_backend_aliases_primary_when_url_unset(engine_no_read_url):
"""Without HINDSIGHT_API_READ_DATABASE_URL, _read_backend is _backend.
This is the back-compat invariant every call site that uses
_get_read_backend() resolves to the same object _get_backend() returns,
so nothing observable changes.
"""
assert engine_no_read_url._read_backend is engine_no_read_url._backend
@pytest.mark.asyncio
async def test_read_backend_is_separate_instance_when_url_set(engine_with_read_url):
"""With HINDSIGHT_API_READ_DATABASE_URL set, a SECOND backend object is
created. Even if both URLs point at the same DB (as in this test), the
two backends own independent connection pools connections taken from
one don't drain the other, and shutting one down doesn't close the
other's pool.
"""
primary = engine_with_read_url._backend
read = engine_with_read_url._read_backend
assert read is not primary
# Both backends are independently initialized (each owns a pool)
assert primary.get_pool() is not None
assert read.get_pool() is not None
assert primary.get_pool() is not read.get_pool()
@pytest.mark.asyncio
async def test_get_read_backend_returns_read_backend(engine_with_read_url):
"""The accessor used by recall (`_get_read_backend`) returns the dedicated
read backend, not the primary. Without this, the env var would have no
effect.
"""
backend = await engine_with_read_url._get_read_backend()
assert backend is engine_with_read_url._read_backend
assert backend is not engine_with_read_url._backend
@pytest.mark.asyncio
async def test_get_read_backend_returns_primary_when_unset(engine_no_read_url):
"""The accessor falls through to the primary when no read URL is set,
so callers don't need to handle a None case.
"""
backend = await engine_no_read_url._get_read_backend()
assert backend is engine_no_read_url._backend
@pytest.mark.asyncio
async def test_close_terminates_distinct_read_backend(engine_with_read_url):
"""When the read backend is distinct, close() must shut it down too,
not just the primary. Otherwise we leak the read pool when the engine
is recycled (e.g. across pytest sessions or in app shutdown).
"""
primary_before = engine_with_read_url._backend
read_before = engine_with_read_url._read_backend
assert read_before is not primary_before
await engine_with_read_url.close()
# Both backends should be cleared on close. The exact post-close state
# is "primary _backend cleared, _read_backend cleared". The shutdown
# method on the read backend is called — we verify by checking the
# engine no longer references either.
assert engine_with_read_url._backend is None
assert engine_with_read_url._read_backend is None
@@ -0,0 +1,56 @@
"""Tests that recall_async surfaces a non-opaque error message when the
underlying retrieval pipeline raises an exception with empty __str__.
Regression for issue #1384: ``raise Exception(f"Failed to search memories: ...{e}")``
collapsed to ``Failed to search memories: `` for any exception whose __str__()
returns blank, dropping the original class name and traceback chain.
"""
from unittest.mock import patch
import pytest
from hindsight_api import MemoryEngine, RequestContext
RC = RequestContext(tenant_id="default")
class _SilentError(Exception):
"""Mimics asyncpg.exceptions.ConnectionDoesNotExistError() and similar
exceptions whose __str__() returns blank when raised with no args."""
async def _raise_silent(*_args, **_kwargs):
raise _SilentError()
async def test_recall_async_error_preserves_original(memory_no_llm_verify: MemoryEngine):
engine = memory_no_llm_verify
bank_id = "test-error-propagation"
await engine.get_bank_profile(bank_id, request_context=RC)
try:
with patch(
"hindsight_api.engine.memory_engine.embedding_utils.generate_embeddings_batch",
side_effect=_raise_silent,
):
with pytest.raises(RuntimeError) as excinfo:
await engine.recall_async(
bank_id=bank_id,
query="anything",
request_context=RC,
)
# The wrapping message must include the original exception class name —
# the symptom in #1384 was an empty trailer like "Failed to search memories: ".
message = str(excinfo.value)
assert "Failed to search memories" in message
assert "_SilentError" in message, (
f"wrapper message dropped the original exception class: {message!r}"
)
# `from e` chain must be preserved so worker logs / debuggers can walk
# back to the real cause.
assert isinstance(excinfo.value.__cause__, _SilentError)
finally:
await engine.delete_bank(bank_id, request_context=RC)
@@ -0,0 +1,214 @@
"""Recall projects entities for observations through source_memory_ids.
Observations don't carry rows in `unit_entities`; their entity association
lives transitively via `memory_units.source_memory_ids`. The per-memory
endpoint (`get_memory_unit`) follows that chain, but recall used to query
`unit_entities` directly and silently dropped entities for every observation
result, even when `include_entities=True` was set.
This test seeds an observation linked through `source_memory_ids` to a fact
with entities, runs an observation-only recall, and asserts both the
per-result `entities` field and the top-level aggregate map carry the
inherited entities.
No LLM required.
"""
import uuid
import pytest
import pytest_asyncio
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api.engine.retain import embedding_utils
# Tests in this file insert memory_units with shared hardcoded UUIDs and
# memory_units.id is a global PK; share an xdist group so parallel workers
# don't collide on the same row IDs.
pytestmark = pytest.mark.xdist_group("recall_observation_entities")
ID_FACT = "11111111-0000-0000-0000-000000000001"
ID_OBS_INHERITED = "11111111-0000-0000-0000-000000000002"
ID_OBS_DIRECT = "11111111-0000-0000-0000-000000000003"
RC = RequestContext(tenant_id="default")
def _to_str(emb: list[float]) -> str:
return "[" + ",".join(str(v) for v in emb) + "]"
@pytest_asyncio.fixture
async def seeded(memory_no_llm_verify: MemoryEngine):
engine = memory_no_llm_verify
bank_id = f"test-recall-obs-ent-{uuid.uuid4().hex[:8]}"
await engine.get_bank_profile(bank_id, request_context=RC)
embeddings = await embedding_utils.generate_embeddings_batch(
engine.embeddings,
[
"HeadClaw waitlist tracked in Google Sheets",
"HeadClaw users sign up via the waitlist",
"Reddit thread mentions HeadClaw waitlist signups",
],
)
pool = await engine._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"DELETE FROM memory_units WHERE id IN ($1, $2, $3)",
ID_FACT,
ID_OBS_INHERITED,
ID_OBS_DIRECT,
)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
# Source fact with two entities. The observation that lacks direct
# entity rows must inherit both through source_memory_ids.
headclaw_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, mention_count)
VALUES ($1, $2, 1) RETURNING id
""",
bank_id,
"HeadClaw",
)
waitlist_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, mention_count)
VALUES ($1, $2, 1) RETURNING id
""",
bank_id,
"waitlist users",
)
# Independent entity attached directly to the second observation —
# exercises the existing direct-link path so we don't regress it.
reddit_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, mention_count)
VALUES ($1, $2, 1) RETURNING id
""",
bank_id,
"Reddit",
)
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date)
VALUES ($1, $2, $3, 'world', $4::vector, now())
""",
ID_FACT,
bank_id,
"HeadClaw waitlist tracked in Google Sheets",
_to_str(embeddings[0]),
)
await conn.execute(
"""
INSERT INTO unit_entities (unit_id, entity_id) VALUES ($1, $2), ($1, $3)
""",
ID_FACT,
headclaw_id,
waitlist_id,
)
# Observation with NO direct unit_entities — must inherit HeadClaw +
# waitlist users from source_memory_ids.
await conn.execute(
"""
INSERT INTO memory_units (
id, bank_id, text, fact_type, embedding, event_date,
source_memory_ids, history, proof_count
)
VALUES ($1, $2, $3, 'observation', $4::vector, now(), $5::uuid[], '[]'::jsonb, 1)
""",
ID_OBS_INHERITED,
bank_id,
"HeadClaw users sign up via the waitlist",
_to_str(embeddings[1]),
[ID_FACT],
)
# Observation with a DIRECT unit_entities link — must keep its own entity.
await conn.execute(
"""
INSERT INTO memory_units (
id, bank_id, text, fact_type, embedding, event_date,
source_memory_ids, history, proof_count
)
VALUES ($1, $2, $3, 'observation', $4::vector, now(), NULL, '[]'::jsonb, 1)
""",
ID_OBS_DIRECT,
bank_id,
"Reddit thread mentions HeadClaw waitlist signups",
_to_str(embeddings[2]),
)
await conn.execute(
"INSERT INTO unit_entities (unit_id, entity_id) VALUES ($1, $2)",
ID_OBS_DIRECT,
reddit_id,
)
yield engine, bank_id
await engine.delete_bank(bank_id, request_context=RC)
@pytest.mark.asyncio
async def test_recall_includes_inherited_entities_for_observations(seeded):
"""Observation-only recall must surface entities inherited from source memories."""
engine, bank_id = seeded
result = await engine.recall_async(
bank_id=bank_id,
query="HeadClaw waitlist users",
fact_type=["observation"],
request_context=RC,
max_tokens=4000,
include_entities=True,
max_entity_tokens=2000,
)
by_id = {str(r.id): r for r in result.results}
assert ID_OBS_INHERITED in by_id, f"Expected inherited-entity observation in results, got {list(by_id)}"
assert ID_OBS_DIRECT in by_id, f"Expected direct-entity observation in results, got {list(by_id)}"
inherited = by_id[ID_OBS_INHERITED].entities or []
assert "HeadClaw" in inherited, f"Observation entities must inherit through source_memory_ids; got {inherited}"
assert "waitlist users" in inherited, f"All source entities should propagate; got {inherited}"
direct = by_id[ID_OBS_DIRECT].entities or []
assert "Reddit" in direct, f"Direct unit_entities link must still project on observation results; got {direct}"
assert result.entities is not None, "Top-level entities map must populate when include_entities=True"
aggregate_names = set(result.entities.keys())
assert {"HeadClaw", "waitlist users", "Reddit"}.issubset(aggregate_names), (
f"Top-level entities map must include inherited + direct entities; got {aggregate_names}"
)
@pytest.mark.asyncio
async def test_get_memory_unit_inherits_observation_entities(seeded):
"""get_memory_unit shares the recall helper, so observation inheritance
must keep working through the per-memory endpoint as well.
"""
engine, bank_id = seeded
inherited = await engine.get_memory_unit(
memory_id=ID_OBS_INHERITED,
bank_id=bank_id,
request_context=RC,
)
assert inherited is not None
assert set(inherited["entities"]) >= {"HeadClaw", "waitlist users"}, (
f"Observation must inherit source-memory entities; got {inherited['entities']}"
)
direct = await engine.get_memory_unit(
memory_id=ID_OBS_DIRECT,
bank_id=bank_id,
request_context=RC,
)
assert direct is not None
assert "Reddit" in direct["entities"], (
f"Direct unit_entities link must still resolve via get_memory_unit; got {direct['entities']}"
)
@@ -0,0 +1,41 @@
import importlib
import sys
from unittest.mock import MagicMock, patch
def _drop_reflect_modules() -> None:
for name in list(sys.modules):
if name == "hindsight_api.engine.reflect" or name.startswith("hindsight_api.engine.reflect."):
sys.modules.pop(name)
def test_reflect_import_does_not_load_tiktoken_encoding():
_drop_reflect_modules()
with patch("tiktoken.get_encoding") as get_encoding:
reflect = importlib.import_module("hindsight_api.engine.reflect")
get_encoding.assert_not_called()
assert reflect.run_reflect_agent is not None
def test_reflect_token_counting_loads_tiktoken_encoding_when_used():
_drop_reflect_modules()
fake_encoding = MagicMock()
fake_encoding.encode.side_effect = lambda text: text.split()
with patch("tiktoken.get_encoding", return_value=fake_encoding) as get_encoding:
agent = importlib.import_module("hindsight_api.engine.reflect.agent")
prompts = importlib.import_module("hindsight_api.engine.reflect.prompts")
count = agent._count_messages_tokens([{"role": "user", "content": "one two"}])
final_prompt = prompts.build_final_prompt(
query="What happened?",
context_history=[{"tool": "recall", "output": {"answer": "three four"}}],
bank_profile={"name": "test"},
max_context_tokens=1000,
)
assert count == 2
assert "three four" in final_prompt
get_encoding.assert_called_once_with("cl100k_base")
@@ -0,0 +1,120 @@
"""Regression tests for reflect tool helpers."""
import re
import uuid
import pytest
from hindsight_api.engine.reflect.tools import _document_metadata_from_retain_params, tool_expand
class _FakeReflectConnection:
"""Tiny asyncpg-like connection for tool_expand query behavior."""
def __init__(self, bank_id: str, memory_id: uuid.UUID, document_id: str, chunk_id: str | None) -> None:
self.bank_id = bank_id
self.memory_id = memory_id
self.document_id = document_id
self.chunk_id = chunk_id
async def fetch(self, query: str, *args):
normalized_query = re.sub(r"\s+", " ", query).strip()
if "FROM public.memory_units" in normalized_query:
return [
{
"id": self.memory_id,
"text": "The user prefers test-first bug fixes.",
"chunk_id": self.chunk_id,
"document_id": self.document_id,
"fact_type": "experience",
"context": "preference",
}
]
if "FROM public.chunks" in normalized_query:
if self.chunk_id is None:
return []
return [
{
"chunk_id": self.chunk_id,
"chunk_text": "The user prefers test-first bug fixes.",
"chunk_index": 0,
"document_id": self.document_id,
}
]
if "FROM public.documents" in normalized_query:
select_clause = normalized_query.split(" FROM ", 1)[0]
assert " metadata," not in f" {select_clause},", (
"tool_expand must not query documents.metadata; that column was removed and "
"document metadata now lives in retain_params.metadata"
)
return [
{
"id": self.document_id,
"original_text": "The user prefers test-first bug fixes.",
"retain_params": {"metadata": {"source": "regression-test"}},
}
]
raise AssertionError(f"Unexpected query: {normalized_query}")
@pytest.mark.asyncio
async def test_tool_expand_document_depth_reads_metadata_from_retain_params() -> None:
"""Document expansion must work after documents.metadata has been dropped."""
bank_id = "test-reflect-expand-retain-params-metadata"
memory_id = uuid.uuid4()
document_id = "doc-reflect-expand"
chunk_id = "chunk-reflect-expand"
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id)
result = await tool_expand(
conn=conn,
bank_id=bank_id,
memory_ids=[str(memory_id)],
depth="document",
)
assert result["count"] == 1
document = result["results"][0]["document"]
assert document["metadata"] == {"source": "regression-test"}
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
@pytest.mark.asyncio
async def test_tool_expand_document_depth_without_chunk_reads_metadata_from_retain_params() -> None:
"""Direct document expansion follows the same metadata source contract."""
bank_id = "test-reflect-expand-direct-retain-params-metadata"
memory_id = uuid.uuid4()
document_id = "doc-reflect-expand-direct"
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id=None)
result = await tool_expand(
conn=conn,
bank_id=bank_id,
memory_ids=[str(memory_id)],
depth="document",
)
assert result["count"] == 1
document = result["results"][0]["document"]
assert document["metadata"] == {"source": "regression-test"}
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
def test_document_metadata_from_retain_params_accepts_json_strings() -> None:
"""asyncpg JSONB codecs may return retain_params as a dict or JSON string."""
retain_params = '{"metadata": {"source": "json-string"}}'
assert _document_metadata_from_retain_params(retain_params) == {"source": "json-string"}
@pytest.mark.parametrize(
"retain_params",
[None, [], "not json", {"metadata": ["not", "a", "dict"]}],
)
def test_document_metadata_from_retain_params_ignores_invalid_values(retain_params) -> None:
"""Malformed retain_params should not break reflect expansion."""
assert _document_metadata_from_retain_params(retain_params) is None
@@ -401,6 +401,7 @@ class TestRecallWithObservationsAndMentalModels:
class TestReflectUsesMentalModels:
"""Test that reflect searches and uses mental models when available."""
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_reflect_searches_mental_models_when_available(self, memory: MemoryEngine, request_context):
"""Test that reflect uses search_mental_models when the bank has mental models.
+1
View File
@@ -13,6 +13,7 @@ from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_retain_with_chunks(memory, request_context):
"""
@@ -0,0 +1,109 @@
from pathlib import Path
from hindsight_api._vector_index import (
SCANN_MIN_ROWS_FOR_AUTO_INDEX,
bootstrap_extension,
index_type_keyword,
index_using_clause,
pg_extension_name,
should_defer_index_creation,
uses_per_bank_vector_indexes,
validate_extension,
)
from hindsight_api.engine.retain import bank_utils
class RecordingConn:
def __init__(self):
self.statements = []
def execute(self, statement, *args, **kwargs):
self.statements.append(str(statement))
def test_validate_extension_accepts_scann():
assert validate_extension("scann") == "scann"
assert validate_extension("ScaNN") == "scann"
def test_pg_extension_name_maps_scann_to_alloydb_extension():
assert pg_extension_name("scann") == "alloydb_scann"
def test_index_using_clause_scann_uses_cosine_auto_mode():
clause = index_using_clause("scann")
assert "USING scann (embedding cosine)" in clause
assert "mode = 'AUTO'" in clause
def test_index_using_clause_pgvector_matches_existing_clause():
assert index_using_clause("pgvector") == "USING hnsw (embedding vector_cosine_ops)"
def test_index_type_keyword_scann_round_trips_pg_indexes_indexdef():
keyword = index_type_keyword("scann")
indexdef = "CREATE INDEX idx ON memory_units USING scann (embedding cosine) WITH (mode='AUTO')"
assert keyword == "scann"
assert keyword in indexdef.lower()
def test_bootstrap_extension_scann_installs_vector_before_alloydb_scann():
conn = RecordingConn()
bootstrap_extension(conn, "scann")
assert conn.statements == [
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE",
]
def test_scann_index_creation_defers_until_table_is_large_enough():
assert should_defer_index_creation("scann", 0)
assert should_defer_index_creation("scann", SCANN_MIN_ROWS_FOR_AUTO_INDEX - 1)
assert not should_defer_index_creation("scann", SCANN_MIN_ROWS_FOR_AUTO_INDEX)
assert not should_defer_index_creation("pgvector", 0)
def test_scann_does_not_use_per_bank_partial_indexes():
assert not uses_per_bank_vector_indexes("scann")
assert uses_per_bank_vector_indexes("pgvector")
assert uses_per_bank_vector_indexes("pgvectorscale")
assert uses_per_bank_vector_indexes("vchord")
def test_alembic_vector_migrations_freeze_vector_sql_locally():
migration_dir = Path(__file__).resolve().parent.parent / "hindsight_api/alembic/versions"
changed_migrations = [
"5a366d414dce_initial_schema.py",
"a4b5c6d7e8f9_fix_per_bank_vector_index_type.py",
"d5e6f7a8b9c0_add_bank_internal_id_and_per_bank_hnsw.py",
"n9i0j1k2l3m4_learnings_and_pinned_reflections.py",
]
for migration in changed_migrations:
text = (migration_dir / migration).read_text()
assert "hindsight_api._vector_index" not in text
class RecordingOps:
def __init__(self):
self.called = False
async def create_bank_vector_indexes(self, *args, **kwargs):
self.called = True
class ScannConfig:
vector_extension = "scann"
async def test_create_bank_vector_indexes_skips_scann(monkeypatch):
monkeypatch.setattr(bank_utils, "get_config", lambda: ScannConfig())
ops = RecordingOps()
await bank_utils.create_bank_vector_indexes(None, "bank", "00000000-0000-0000-0000-000000000000", ops=ops)
assert not ops.called
+154 -7
View File
@@ -56,13 +56,15 @@ async def pool(backend):
@pytest_asyncio.fixture
async def clean_operations(pool):
"""Clean up async_operations table before and after tests."""
# Clean before test - covers both 'test-worker-' and 'test_worker_recovery' patterns
await pool.execute(
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
)
"""Clean up async_operations table before and after tests.
We must clean ALL pending operations (not just test-worker-* prefixed ones)
because WorkerPoller.claim_batch scans the entire schema for pending tasks.
Stale operations left by other tests (e.g. consolidation) cause spurious
failures when the poller picks them up unexpectedly.
"""
await pool.execute("DELETE FROM async_operations WHERE status = 'pending'")
yield
# Clean after test
await pool.execute(
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
)
@@ -2392,6 +2394,75 @@ async def test_pending_breakdown_explains_unclaimable_rows(pool, backend, clean_
assert buckets["consolidation"]["claimable"] >= 1
class TestSummariseChildErrorMessages:
"""Pure unit tests for the _summarise_child_error_messages helper.
The helper picks a representative error message for a parent whose
children failed. The integration tests above exercise the full path
through _mark_failed; these tests focus on the choice itself.
"""
def _sib(self, status: str, error_message: str | None = None) -> dict:
return {"status": status, "error_message": error_message}
def test_all_failed_with_same_message_inherits_that_message(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", "boom"),
self._sib("failed", "boom"),
self._sib("failed", "boom"),
]
assert _summarise_child_error_messages(siblings) == "boom"
def test_mixed_failed_messages_picks_most_common(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", "common cause"),
self._sib("failed", "common cause"),
self._sib("failed", "rare cause"),
]
assert _summarise_child_error_messages(siblings) == "common cause"
def test_completed_siblings_ignored(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("completed", None),
self._sib("completed", None),
self._sib("failed", "the one real failure"),
]
assert _summarise_child_error_messages(siblings) == "the one real failure"
def test_no_failed_siblings_falls_back_to_generic(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("completed", None),
self._sib("completed", None),
]
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
def test_failed_siblings_with_no_error_message_falls_back_to_generic(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", None),
self._sib("failed", ""),
]
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
def test_whitespace_only_messages_treated_as_empty(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", " "),
self._sib("failed", "actual error"),
]
assert _summarise_child_error_messages(siblings) == "actual error"
class TestMarkFailedParentPropagation:
"""Tests for _mark_failed parent propagation in WorkerPoller.
@@ -2471,10 +2542,21 @@ class TestMarkFailedParentPropagation:
assert "DB constraint violation" in child2_row["error_message"]
# parent must now be failed (all siblings done, at least one failed)
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
parent_row = await pool.fetchrow(
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
parent_id,
)
assert parent_row["status"] == "failed", (
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
)
# Parent error_message must propagate the child's actual error reason,
# not the legacy generic "One or more sub-batches failed". Without this,
# downstream filters that classify failures by error_message lose all
# signal once a batch has children.
assert "DB constraint violation" in (parent_row["error_message"] or ""), (
f"Parent error_message should inherit child's reason, "
f"got: {parent_row['error_message']!r}"
)
@pytest.mark.asyncio
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, backend, clean_operations):
@@ -2794,6 +2876,71 @@ class TestClaimBatchRotation:
finally:
await pool.execute("DELETE FROM async_operations WHERE operation_id = $1", op_id)
@pytest.mark.asyncio
async def test_scan_uses_optional_routine_when_installed(self, pool, backend, clean_operations):
"""When ``public.schemas_with_pending_work()`` exists in pg_proc,
``_scan_active_schemas`` invokes it instead of running per-schema
EXISTS queries from Python. Confirms the OptionalRoutines probe
path is wired correctly end-to-end.
The routine body installed here is a minimal stand-in that
satisfies the contract documented on
``optional_routines.SCHEMAS_WITH_PENDING_WORK`` Hindsight does
not own the canonical implementation.
"""
from hindsight_api.engine.db.postgresql import PostgresConnection
from hindsight_api.worker import WorkerPoller
# Minimal contract-satisfying implementation: returns the empty
# set. Enough to prove the poller follows the server-side path.
await pool.execute(
"CREATE OR REPLACE FUNCTION public.schemas_with_pending_work() "
"RETURNS SETOF text AS $$ BEGIN RETURN; END $$ LANGUAGE plpgsql STABLE"
)
try:
poller = WorkerPoller(
backend=backend,
worker_id="test-routine",
executor=lambda x: None,
)
captured_fetch: list[str] = []
captured_fetchval: list[str] = []
original_fetch = PostgresConnection.fetch
original_fetchval = PostgresConnection.fetchval
async def spy_fetch(self, query, *args, timeout=None):
captured_fetch.append(query)
return await original_fetch(self, query, *args, timeout=timeout)
async def spy_fetchval(self, query, *args, column=0, timeout=None):
captured_fetchval.append(query)
return await original_fetchval(self, query, *args, column=column, timeout=timeout)
PostgresConnection.fetch = spy_fetch # type: ignore[method-assign]
PostgresConnection.fetchval = spy_fetchval # type: ignore[method-assign]
try:
await poller._scan_active_schemas([None])
finally:
PostgresConnection.fetch = original_fetch # type: ignore[method-assign]
PostgresConnection.fetchval = original_fetchval # type: ignore[method-assign]
# Routine was probed (one pg_proc lookup) and then invoked.
assert any("pg_proc" in q for q in captured_fetchval), (
f"Expected a pg_proc existence probe; fetchval queries: {captured_fetchval}"
)
assert any("schemas_with_pending_work" in q for q in captured_fetch), (
f"Expected schemas_with_pending_work() to be invoked; fetch queries: {captured_fetch}"
)
# Fallback per-schema EXISTS path must NOT have run.
assert not any("async_operations" in q and "EXISTS" in q for q in captured_fetchval), (
f"Fallback EXISTS path should be skipped when routine is installed; fetchval queries: {captured_fetchval}"
)
# Probe result is cached so the next scan skips the pg_proc lookup.
assert poller._optional_routines._cache.get("schemas_with_pending_work") is True
finally:
await pool.execute("DROP FUNCTION IF EXISTS public.schemas_with_pending_work()")
@pytest.mark.asyncio
async def test_claim_batch_only_queries_active_schemas(self, pool, backend, clean_operations):
"""claim_batch uses _scan_active_schemas to pre-filter, then
@@ -0,0 +1,35 @@
"""Tests for hindsight_api.worker.main entry-point helpers."""
import asyncio
import signal
from unittest.mock import MagicMock
from hindsight_api.worker.main import _install_shutdown_signal_handlers
def test_install_shutdown_signal_handlers_unix_path():
"""On platforms where asyncio supports signal handlers (Unix), both
SIGINT and SIGTERM are registered and the helper reports success."""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
handler = MagicMock()
installed = _install_shutdown_signal_handlers(loop, handler)
assert installed is True
loop.add_signal_handler.assert_any_call(signal.SIGINT, handler)
loop.add_signal_handler.assert_any_call(signal.SIGTERM, handler)
assert loop.add_signal_handler.call_count == 2
def test_install_shutdown_signal_handlers_windows_path():
"""On Windows, asyncio's ProactorEventLoop raises NotImplementedError
from add_signal_handler. The helper must swallow it and report failure
so the worker keeps running with default Python signal behavior
(regression test for issue #1411)."""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
loop.add_signal_handler.side_effect = NotImplementedError
handler = MagicMock()
installed = _install_shutdown_signal_handlers(loop, handler)
assert installed is False
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.5.6"
version = "0.6.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]>=0.4.17",
"hindsight-api-slim[all]==0.6.2",
]
[tool.uv.sources]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.5.6"
version = "0.6.2"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+6 -2
View File
@@ -269,6 +269,7 @@ impl ApiClient {
bank_id: &str,
files: Vec<(String, Vec<u8>)>,
context: Option<String>,
strategy: Option<String>,
verbose: bool,
) -> Result<FileRetainResult> {
self.runtime.block_on(async {
@@ -284,6 +285,9 @@ impl ApiClient {
if let Some(ctx) = &context {
meta["context"] = serde_json::Value::String(ctx.clone());
}
if let Some(strat) = &strategy {
meta["strategy"] = serde_json::Value::String(strat.clone());
}
// Use filename stem as document_id for deduplication
if let Some(stem) = std::path::Path::new(name)
.file_stem()
@@ -1258,8 +1262,8 @@ impl ApiClient {
// Re-export types from the generated client for use in commands
pub use types::{
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
ReflectResponse, RetainRequest,
BankProfileResponse, MemoryItem, MemoryItemTimestamp, RecallRequest, RecallResponse,
RecallResult, ReflectRequest, ReflectResponse, RetainRequest,
};
#[cfg(test)]
+18 -3
View File
@@ -3,7 +3,9 @@ use std::fs;
use std::path::PathBuf;
use walkdir::WalkDir;
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
use crate::api::{
ApiClient, MemoryItem, MemoryItemTimestamp, RecallRequest, ReflectRequest, RetainRequest,
};
use crate::config;
use crate::output::{self, OutputFormat};
use crate::ui;
@@ -438,6 +440,7 @@ pub fn retain(
content: String,
doc_id: Option<String>,
context: Option<String>,
timestamp: Option<String>,
r#async: bool,
document_tags: Option<Vec<String>>,
verbose: bool,
@@ -451,11 +454,20 @@ pub fn retain(
None
};
// MemoryItem.timestamp is a progenitor anyOf enum; round-trip through JSON to pick the matching variant.
let timestamp = match timestamp {
Some(s) => Some(
serde_json::from_value::<MemoryItemTimestamp>(serde_json::Value::String(s.clone()))
.with_context(|| format!("invalid --timestamp value: {:?}", s))?,
),
None => None,
};
let item = MemoryItem {
content: content.clone(),
context,
metadata: None,
timestamp: None,
timestamp,
document_id: Some(doc_id.clone()),
entities: None,
tags: None,
@@ -498,6 +510,7 @@ pub fn retain(
}
}
#[allow(clippy::too_many_arguments)]
pub fn retain_files(
client: &ApiClient,
agent_id: &str,
@@ -505,6 +518,7 @@ pub fn retain_files(
recursive: bool,
context: Option<String>,
r#async: bool,
strategy: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -567,7 +581,8 @@ pub fn retain_files(
pb.inc(1);
}
let result = client.file_retain(agent_id, file_data, context.clone(), verbose)?;
let result =
client.file_retain(agent_id, file_data, context.clone(), strategy.clone(), verbose)?;
all_operation_ids.extend(result.operation_ids);
}
+14
View File
@@ -591,6 +591,12 @@ enum MemoryCommands {
#[arg(short = 'c', long)]
context: Option<String>,
/// When the content occurred (ISO 8601 datetime, e.g. 2024-01-15T10:30:00Z
/// or 2024-01-15). Pass "unset" to store without a timestamp.
/// Omit to default to now.
#[arg(short = 't', long)]
timestamp: Option<String>,
/// Queue for background processing
#[arg(long)]
r#async: bool,
@@ -619,6 +625,10 @@ enum MemoryCommands {
/// Queue for background processing
#[arg(long)]
r#async: bool,
/// Named retain strategy to use for these files (overrides the bank's default strategy)
#[arg(short = 's', long)]
strategy: Option<String>,
},
/// Delete a memory unit
@@ -1430,6 +1440,7 @@ fn run() -> Result<()> {
content,
doc_id,
context,
timestamp,
r#async,
document_tags,
} => commands::memory::retain(
@@ -1438,6 +1449,7 @@ fn run() -> Result<()> {
content,
doc_id,
context,
timestamp,
r#async,
document_tags,
verbose,
@@ -1449,6 +1461,7 @@ fn run() -> Result<()> {
recursive,
context,
r#async,
strategy,
} => commands::memory::retain_files(
&client,
&bank_id,
@@ -1456,6 +1469,7 @@ fn run() -> Result<()> {
recursive,
context,
r#async,
strategy,
verbose,
output_format,
),
+19
View File
@@ -91,6 +91,25 @@ fn test_ui_command_with_config() {
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_memory_retain_exposes_timestamp_flag() {
// Regression: `hindsight memory retain` historically had no way to set the
// memory's event date even though the SDKs do. The flag must appear in
// --help so users (and docs) can discover it.
let output = Command::new("cargo")
.args(["run", "--", "memory", "retain", "--help"])
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "retain --help failed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--timestamp") && stdout.contains("-t"),
"expected --timestamp/-t flag in retain --help, got: {}",
stdout
);
}
#[test]
fn test_configure_command() {
// Test that configure command creates/updates config
+11 -2
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.5.6
version: 0.6.2
servers:
- url: /
paths:
@@ -3666,7 +3666,7 @@ components:
- updates
title: BankConfigUpdate
BankListItem:
description: Bank list item with profile summary.
description: Bank list item with profile summary and stats.
properties:
bank_id:
title: Bank Id
@@ -3685,6 +3685,13 @@ components:
updated_at:
nullable: true
type: string
fact_count:
default: 0
title: Fact Count
type: integer
last_document_at:
nullable: true
type: string
required:
- bank_id
- disposition
@@ -3699,6 +3706,8 @@ components:
empathy: 3
literalism: 3
skepticism: 3
fact_count: 156
last_document_at: 2024-01-16T14:20:00Z
mission: I am a software engineer helping my team ship quality code
name: Alice
updated_at: 2024-01-16T14:20:00Z
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.6
API version: 0.6.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.6
API version: 0.6.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.6
API version: 0.6.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.6
API version: 0.6.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.6
API version: 0.6.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.6
API version: 0.6.2
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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