Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 50c38f8a53 Add Python client get_version helper
Adds HindsightClient.get_version()/aget_version() convenience wrappers for
the existing /version endpoint, re-exports VersionResponse for typed callers,
and tests both paths against a mocked MonitoringApi.

Python parity for #2252 (TypeScript getVersion). Fixes #2248.
2026-06-17 11:23:02 +02:00
Parafee41 ea8d88057b Add TypeScript client version helper (#2252) 2026-06-17 11:19:35 +02:00
DK09876 cf3bcee89c chore(control-plane): format bank-selector.tsx (lint drift from #2212) (#2250)
Applies eslint/prettier formatting that #2212 missed, unblocking verify-generated-files for all open PRs. No behavior change.
2026-06-16 18:34:11 -07:00
Ben 2d08352429 release(composio): v0.1.0 2026-06-16 16:00:03 -04:00
Ben 1c9ba0e659 feat(composio): add Composio integration (Hindsight memory as custom tools) (#2180)
* feat(composio): add Composio integration (Hindsight memory as custom tools)

Exposes Hindsight retain/recall/reflect as Composio in-process custom tools via
register_hindsight_tools(). The Hindsight bank for each call is the Composio
session's user_id, so one registered tool set isolates memory per user
automatically. Also ships memory_instructions() for pre-recall system-prompt
injection (Composio doesn't auto-inject context).

- hindsight_composio/: tools.py, config.py (dataclass + env fallback), errors.py.
- tests/: 50 tests using a FakeComposio (mirrors the real tool decorator +
  SessionContext) + mocked Hindsight client — exercises the framework wiring.
- CI: test-composio-integration job (uv build/sync/ruff/pytest) + path filter.
- Gallery card + doc page + official Composio icon; release-integration.sh entry.

* fix(composio): register in changelog generator + test memory_instructions

- Add composio to generate_changelog.py INTEGRATIONS dict (release would
  otherwise fail at the changelog step; it was only in release-integration.sh).
- Add TestMemoryInstructions covering formatting, max_results cap, empty/error
  fallback, tag passthrough, and missing-config error.

* address review: Literal config types, typed generics, debug log, real-LLM E2E

- Type budget as Literal[low|mid|high] and tags_match as Literal[any|all|
  any_strict|all_strict] across config + tools (matches autogen/continue)
- Parameterize bare list -> list[Any] on register_hindsight_tools
- _ensure_bank: logger.debug the swallowed create_bank failure so a real
  auth/network error is visible rather than only surfacing later on retain
- Add requires_real_llm E2E bucket exercising retain/recall/reflect through
  the (input, ctx) tool call path against a live Hindsight server; exclude
  from PR CI via -m 'not requires_real_llm'
2026-06-16 15:56:53 -04:00
Ben a25b027759 blog(obsidian): Chat With Your Obsidian Vault, grounded in your notes (#2237)
* blog(obsidian): add Obsidian persistent memory post

Walkthrough of the Hindsight Obsidian plugin: one-way vault sync into a
memory bank, grounded chat panel backed by reflect with note citations
and a reasoning disclosure, implicit vault/folder/date scoping, and the
"vault stays the source of truth" design rule. Includes a beta/BRAT
install callout (plugin v0.1.2) and Cloud vs self-hosted setup.
2026-06-16 15:53:21 -04:00
Evoandr266-tech 0ca0226d36 fix(control-plane): expose "shared" observation scope in the Add Document UI (#2212)
* fix(control-plane): expose "shared" observation scope in the Add Document UI

#2202 added the 'shared' observation-scopes mode and synced it across the server,
HTTP model, retain types, and every client (incl. the control-plane client type in
api.ts), but missed the GUI component itself, so users could not select 'shared'
from the Add-Document form. Wire it through bank-selector.tsx: state union, dropdown
item, request build, and a dedicated preview line ('shared' is tag-independent and
maps to a single global scope [[]] server-side). No locale/generated-file changes.

* fix(control-plane): translate shared observation scope copy

---------

Co-authored-by: r266-tech <[email protected]>
2026-06-16 16:41:25 +02:00
Sanderhoff-alt 60fea4c254 docs: fix stale documentation links (#2221)
Update integration and installation documentation URLs so they point
to the public Hindsight docs site instead of stale Cloud docs paths.
2026-06-16 16:18:29 +02:00
Sanderhoff-alt dff223f552 docs: update configuration guidance (#2228) 2026-06-16 15:55:41 +02:00
Nicolò Boschi 3b3f9de291 revert(worker): trust schemas_with_pending_work() result, drop per-poll re-scan (#2236)
Reverts the stale-routine fallback added in #1666. That change re-ran the
per-schema EXISTS scan whenever the default schema was absent from the
routine result — i.e. on every idle poll, since public is almost always in
scope and usually has no pending work. The scan covered ALL schemas, so it
reintroduced the exact N-query storm the routine exists to avoid, precisely
in the large multi-tenant deployments the optimisation targets.

The routine is now trusted wholesale: any schema it does not return is
treated as having no work this cycle (its documented contract). The #1555
concern (an operator routine scoped to tenant_% starving a single-tenant
public deployment) is addressed by guidance instead: do not install the
routine in single-schema deployments — the per-schema fallback is a single
cheap EXISTS check that covers public correctly and cannot starve.

The only piece kept from #1666 is the harmless public->None normalisation
of the routine's output, so a returned 'public' still counts as work.
2026-06-16 15:53:38 +02:00
Nicolò Boschi e6b2e4cb3e test: unregister engine span recorder on fixture teardown (#2229) (#2231)
LLM-trace recorders live in a process-global registry and providers fan every
call out to ALL registered recorders. The engine test fixtures' teardown gated
`mem.close()` — the only thing that unregisters the recorder — on
`mem._pool and not mem._pool._closing` and swallowed exceptions, so when close()
was skipped or raised before the unregister step, the recorder leaked. A leaked,
still-enabled recorder from an earlier test then recorded a later test's LLM
calls into the shared DB, making test_disabled_writes_no_rows flaky
(`assert 6 == 0`).

Route all four engine fixtures through a `_teardown_memory_engine` helper that
always unregisters the recorder in a `finally` (idempotent — no-op when close()
already did it). Add a fast regression test asserting the registry is left clean
even when close() is skipped.
2026-06-16 15:30:46 +02:00
Nicolò Boschi 3fae76e392 fix(migrations): merge two divergent alembic heads (#2234)
#2209 (d4f6a8c2e1b3, drop archive embedding column) and #<links-index>
(2071c7518f88, add memory_links index) were authored off the same parent and
merged in parallel, leaving the DAG with two heads. `alembic upgrade head`
is ambiguous in that state and CI's test_single_head fails for everyone.

Add a no-op merge revision unifying both heads.
2026-06-16 15:13:43 +02:00
Nicolò Boschi 2f075dedea feat(tags): officially surface tags_match=exact across UI, docs, clients (#2230)
The `exact` set-equality match mode landed in the API + generated clients
in #2149 but was never exposed in the control plane, documented, or added
to the hand-maintained SDK wrappers. This completes the feature.

Control plane: add `exact` to the TagsMatch type/unions and to the
tags_match dropdowns in think-view, search-debug-view, and both
mental-model trigger forms; add translated labels to all 10 locales.

Docs: document `exact` in the recall tags_match table + tag_groups,
the reflect tags value list, and the observations scope-listing guide;
regenerate the docs skill mirror.

Clients: add `exact` to the hand-maintained Python and TypeScript wrapper
Literals/unions and docstrings (generated clients already had it; Rust is
generated from openapi.json at build time).

Supersedes #2159.
2026-06-16 14:58:23 +02:00
Nicolò Boschi 08cfa5d369 test(control-plane): validate t() keys resolve against the catalog (#2232)
Add a vitest guard that walks every src .ts/.tsx file, resolves each
useTranslations("ns") binding, and asserts every static t("key") /
t.rich("key") reference maps to a leaf key in en.json. This closes the
gap between the two existing i18n checks: messages.test.ts only compares
locale catalogs against each other (a key missing from *every* catalog,
en included, passes parity), and find-untranslated.ts does the inverse
(flags strings *not* wrapped in t()). Neither walked from a t() call
site back to the catalog, so a missing key only surfaced as a runtime
next-intl error in the browser.

Runs under the existing `npm test` step in the build-control-plane CI
job, so no workflow change is needed.

The guard immediately surfaced 14 keys referenced by the curation
feature (#1976) but missing from all 10 catalogs (filterActive,
filterInvalidated, invalidatedHint, invalidatedFactsTitle, and the
memoryDetailPanel curation*/editField* set). Add translations for all
locales so the suite is green. Supersedes #2226, which patched only
filterActive.
2026-06-16 14:42:17 +02:00
Nathaniel Clay ArnoldandNicolò Boschi 0135fa39c9 fix(mcp): give update_memory/invalidate_memory non-empty descriptions (#2215)
* fix(mcp): give update_memory/invalidate_memory non-empty descriptions

update_memory and invalidate_memory (added in #1976) used an f-string as
their docstring:

    f"""{_EDIT_DOC}
    Args:
        ...
    """

An f-string is an expression, not a string literal, so Python never assigns
it to the function's __doc__ (it stays None). FastMCP derives a tool's
description from __doc__, so both tools — and their bank_id variants — were
registered with an empty description.

Amazon Bedrock's Converse API rejects any toolSpec whose description is an
empty string, so every Bedrock request that advertised these tools failed
mid-stream (surfacing to clients as a generic 'internal error occurred while
processing the stream'). Providers that tolerate empty descriptions were
unaffected, which is why this only showed up on Bedrock.

Fix: pass the shared doc constant explicitly via @mcp.tool(description=...),
matching how retain/recall already register, and keep a plain-literal
docstring for the Args section. Add a regression test asserting every
registered tool exposes a non-empty description (both registration paths).

* test(mcp): statically reject @mcp.tool definitions without a description

AST-parse mcp_tools.py and fail if any @mcp.tool-decorated function
lacks both a description= kwarg and a real string-literal docstring
(an f-string docstring leaves __doc__ None). Complements the runtime
description test by also covering flag-gated tools and pointing at the
offending line; needs no engine mocking.

* chore(lint): enable ruff B021 (f-string used as docstring)

Catches the f-string-docstring footgun repo-wide at lint time — the
root cause of the empty update_memory/invalidate_memory descriptions.
Clean across hindsight-api-slim; tests/** are excluded from lint so the
static test guards that surface instead.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-16 14:22:20 +02:00
Nicolò Boschi 4d799b5629 test: pin migration-remaining-bankid tests to one xdist worker (#2225)
test_migration_remaining_bank_id_text.py runs two tests that share a
module-scoped pg0 instance on a fixed port (5568). CI runs pytest with
`--dist loadgroup`, which — with no xdist_group on the module — can scatter
those two tests across workers that each instantiate the module fixture and
race to provision the SAME instance. That surfaced as recurring flakes:
"Instance already running", a pg_type UniqueViolation (concurrent CREATE
EXTENSION during migrate-to-head), and "server closed the connection".

Pin the module to a single worker with a shared xdist_group so the fixture
provisions the instance exactly once. Test-only change.
2026-06-16 13:42:14 +02:00
Ben 5e71cebc82 fix(docs): deflake memories.py example by draining async ops between curation steps (#2152)
The memories.py API doc example ran update_memory edit → edit-fields →
invalidate → restore back-to-back on the same unit. Each edit re-embeds and
re-consolidates in the background (a tracked consolidation op), so a later
step could race that work and 404 on a unit mid-rewrite — the restore
intermittently failed with "Memory unit not found". The fixed sleep(3) after
the seed retains was also unreliable under CI load with a live LLM.

Replace the sleep with a wait_for_idle() helper that polls list_operations
until the bank has no pending/processing operations, and drain between each
curation step. All waits sit outside the [docs:...] blocks, so the rendered
documentation snippets are unchanged.
2026-06-16 13:40:54 +02:00
Evo a92e25bcf5 fix(api): gate dry-run extraction behind the operation precheck (#2211)
POST .../memories/dry-run-extract (added in #2205, enabled by default) makes a
real LLM call but was the only enabled-by-default LLM-billable route with no
OperationValidator gating. Wire Depends(precheck_for("dry_run_extract")) like
retain/recall/reflect/mental_model_*/files_retain, and move the feature-flag
check into a dependency declared before the precheck so a disabled route still
returns 404 first. No behavior change when no validator is configured.
2026-06-16 13:40:26 +02:00
8927ab73ae perf(api): index memory_links.bank_id on PostgreSQL (#2223)
* perf(api): index memory_links.bank_id on PostgreSQL

bank_id was added to memory_links in c5d6e7f8a9b0 so bank-scoped reads could
filter the link table directly instead of joining memory_units (an 18s+ JOIN on
large banks), but it landed without an index, so every bank_id = $1 predicate
still sequential-scans the whole table.

Add the missing btree, built CONCURRENTLY inside an autocommit_block with IF NOT
EXISTS for idempotency across retries and re-migrated tenant schemas. The Oracle
baseline (o1a2b3c4d5e6) already creates idx_ml_bank_id on memory_links(bank_id);
this brings the PostgreSQL dialect in line. PG-only by design, so the Oracle
slot is intentionally absent.

* fix(migration): drop invalid leftover index before recreating bank_id index

CREATE INDEX CONCURRENTLY can leave an INVALID index behind if a prior
build is interrupted (lock conflict, disk pressure, signal). IF NOT EXISTS
would then skip recreation, leaving bank_id queries on a seq scan forever.
Drop only an invalid leftover of this name (never a healthy index) before
the concurrent (re)build, mirroring b8c9d0e1f2a3.

* perf(api): composite (bank_id, link_type) index + drop dead entity filter

The stats endpoint's bank-scoped link query is
  SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type
A composite (bank_id, link_type) index serves the filter, grouping and
count as an index-only scan, vs a bank_id-only index that still heap-reads
every row to recover link_type. link_type is low-cardinality so the extra
column barely grows the index.

Also remove the now-dead 'link_type <> entity' predicate from the stats and
graph-expansion queries: entity edges were deleted from memory_links and are
no longer written (migration e9b2c7d1f3a4); they're derived on demand from
unit_entities. Removing the predicate also lets the composite index cover
the stats query.

---------

Co-authored-by: zommiommy <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-16 12:29:42 +02:00
Sanderhoff-alt d0c54560ad feat(api): improve Chinese temporal query parsing (#2220)
Move explicit period extraction out of DateparserQueryAnalyzer. The analyzer
now delegates range parsing to temporal_periods, which keeps the public API and
non-Chinese rules while Chinese-specific rules and boundary handling live in
chinese_temporal_periods.

Handle simplified and traditional Chinese expressions for relative days,
weeks, months, years, weekends, half-year periods, Chinese month names,
quarters, and rolling past/future windows.

Separate precise point expressions from fuzzy range expressions so phrases such
as 两天前, 前两天, 几天前, and 一两周前 map to the intended constraint shape
instead of relying on dateparser fallback behavior.

Keep open future starts such as 明天起, 下周起, and 三天后开始 unconstrained
because the API only represents closed ranges.

Guard Chinese matching so non-CJK queries skip the Chinese regex path, while
Chinese substring checks avoid treating ordinary names and words as temporal
constraints.
2026-06-16 12:20:59 +02:00
Nicolò Boschi 946af18c91 fix(curation): drop the embedding column from invalidated_memory_units (#2209) (#2210)
* fix(curation): drop embeddings from invalidated_memory_units archive (#2209)

Invalidating a memory copied the live row — including its embedding —
into the invalidated_memory_units archive via INSERT … SELECT. After an
embedding-model switch, the live tables are re-dimensioned but the
archive is not, so the move failed with "expected 384 dimensions, not
1536".

The archive is cold storage and never a recall surface, so it has no
business keeping an embedding. Instead of also migrating its dimension,
stop storing the embedding there at all:

- invalidate: project NULL into the embedding slot on the move
- revert: recompute the embedding from text/dates/entities (mirroring
  how an edit re-embeds) so the reverted unit is searchable again

This makes the archive's embedding-column dimension irrelevant, so a
model switch can no longer trip a dimension mismatch. A forward
migration clears any embeddings earlier versions already stored.

* refactor(curation): drop the archive embedding column instead of NULLing it

Make "the invalidated_memory_units archive holds no embedding" a
schema-enforced invariant rather than a convention the move queries must
remember. The embedding column is dropped (migration d4f6a8c2e1b3, PG +
Oracle), so:

- invalidate moves every memory_units column EXCEPT embedding into the
  archive
- revert moves them back (live embedding defaults to NULL) and recomputes
  the embedding from text/dates/entities

This structurally prevents #2209 — there is no archive vector to fall out
of sync with the live model's dimension, so a model switch can't reintroduce
the dimension mismatch via a future code change. DROP COLUMN is metadata-only
on both dialects.

The archive's readers (get_memory_unit/list enumerate columns; export does
SELECT * then strips derived columns) never referenced embedding, so nothing
breaks.

* refactor(curation): never create the archive embedding column

Remove the embedding column at its creation sites rather than creating it
and dropping it afterward:

- PG: c9a1b2d3e4f5 drops the LIKE-inherited embedding right after cloning
  invalidated_memory_units from memory_units
- Oracle: the baseline CREATE TABLE no longer lists the embedding column

The forward drop migration (d4f6a8c2e1b3) stays as a no-op (DROP … IF EXISTS /
Oracle ORA-00904 swallow) on fresh databases and does the real drop on
databases created before the column was removed here.
2026-06-16 11:57:09 +02:00
Evo abc1439675 fix(skill-docs): convert all admonition keywords in the docs→skill generator (#2218)
The MDX→skill converter in scripts/generate-docs-skill.sh only handled
:::tip / :::warning / :::note, and each rule required an inline title.
So :::info and :::caution admonitions — and any title-less opener (e.g.
a bare :::note) — were left as raw `:::` markdown in the CI-enforced
agent-facing skill mirror (skills/hindsight-docs/references/**), where the
generic `:::\s*\n` cleanup then ate the closing fence and the admonition
body bled into the following section.

Most visibly, #2202 added a :::caution "shared vs [[]] vs []" warning to
retain.mdx, which now renders as broken raw markdown in retain.md.

Teach the converter every supported keyword (tip/note/warning/info/caution)
with an optional inline title, mapping each to a blockquote (title-less
openers fall back to the capitalized keyword). Regenerated the skill mirror;
this also repairs pre-existing :::info/:::caution/title-less leaks across the
core API reference docs.

Note: source files that are plain .md (e.g. configuration.md) are copied
verbatim by the generator rather than run through this converter, so their
admonitions are unaffected here — happy to extend the converter to that
copy path in a follow-up if desired.
2026-06-16 11:12:20 +02:00
DK09876 c05ab9103f feat(continue): add Continue.dev integration via HTTP context provider (#2213)
Adds hindsight-continue: Hindsight memory for Continue.dev via its native http context provider (@hindsight recall) plus an optional MCP-server + rules setup. Includes the adapter package, tests against Continue's HTTP contract + a gated E2E, CI job, release registration, docs, and registry entry.
2026-06-15 14:38:39 -07:00
Ben 359b2bc762 feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers) (#2119)
* feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers)

A Zapier Platform CLI app that brings Hindsight memory into Zaps.

Actions:
- Retain Memory (create) -> POST /v1/default/banks/{bank}/memories
- Recall Memories (search) -> POST .../memories/recall
- Reflect (search) -> POST .../reflect

Triggers (instant, via Hindsight's webhook API — subscribe POSTs /webhooks,
unsubscribe DELETEs it):
- Retain Completed, Consolidation Completed, Memory Defense Triggered

Auth: API key as Bearer token, Cloud default with self-hosted override; the
Bank field is a dynamic dropdown from GET /v1/default/banks.

Built on zapier-platform-core 19; 'private': true so the npm release path can
never publish it (Zapier publishing is manual via zapier push/promote, not
release-integration.yml — and zapier is intentionally NOT in VALID_INTEGRATIONS).

Adds test-zapier-integration CI job (npm install -> zapier validate -> npm test)
and a repo README row. 15 mocha/nock unit tests; 'zapier validate' is
structurally clean.

Docs-site gallery card + doc page + icon are a follow-up (need the official
Zapier brand asset; omitted here to keep build-docs green).

* fix(zapier): make apiKey optional for no-auth self-hosted + prettier-clean

- authentication.js: apiKey now optional (required: false). The middleware only
  adds the Bearer header when a key is present, so you can connect to a
  self-hosted instance running without auth by leaving it blank; Cloud still
  requires a working key (blank -> 401 fails the connection test).
- README: document self-hosted / localhost usage, the optional key, and correct
  the CLI binary name to 'zapier-platform' (v19 renamed it from 'zapier'); show
  the .env approach so 'zapier invoke' needs no global install.
- Run prettier across the integration (fixes pre-existing format drift that was
  failing verify-generated-files on this branch).

zapier validate still structurally sound; 15 tests pass.

* fix(zapier): correct reflect answer field + recall output shape (found via live test)

Extensive live testing against Hindsight Cloud surfaced two response-shape bugs
the mocked unit tests missed (they mocked the wrong shapes):

- reflect: the synthesized answer is in the response's `text` field, not
  `answer`. searches/reflect read `data.answer` (undefined), so a Zap got no
  answer. Now reads `data.text` and surfaces it as `answer`. Test mock fixed to
  use the real `text` field so it actually guards this.
- recall: results carry no numeric `score`, and the fact-type field is `type`
  (not `fact_type`). Corrected the sample + outputFields so the Zap editor only
  advertises fields that actually populate; test mock made realistic.

Verified live end-to-end: auth, bank dropdown, retain (full + minimal), recall
(real fact extraction), reflect (now returns the grounded answer), and the
webhook subscribe/list/delete lifecycle. 15 unit tests pass; zapier validate clean.

* docs(zapier): correct .env auth-field prefix to authData_ in README

zapier invoke reads .env auth fields with the authData_ prefix (e.g.
authData_apiKey, authData_apiUrl), not bare apiKey/apiUrl. Confirmed against a
working local .env during live testing.

* docs(zapier): add integrations gallery card + doc page

- gallery entry in integrations.json (id zapier, official, category framework)
- doc page docs-integrations/zapier.md (actions + REST Hook triggers, setup)
- official Zapier logo at static/img/icons/zapier.png

check-integrations passes (forward: entry → doc page); JSON valid; prettier-clean.

* feat(zapier): verify webhook HMAC signatures + optional async retain (review notes 2 & 4)

#2 — Webhook signature verification (was: relying only on Zapier's unguessable URL):
- performSubscribe now generates a random 32-byte secret and registers it with
  the webhook; the secret is stored in subscribeData.
- perform verifies the X-Hindsight-Signature: sha256=<hmac> header (HMAC-SHA256
  of the raw body) and rejects mismatches. (Corrected the header name — the API
  sends X-Hindsight-Signature, not X-Webhook-Signature; body is delivered
  byte-for-byte via content=, so the recomputed HMAC matches.)

#4 — Optional 'Process asynchronously' toggle on Retain (default false). Lets
users with very large content avoid Zapier's action timeout; pairs with the
Retain Completed trigger.

17 unit tests pass (added valid/invalid signature cases); zapier validate clean.
2026-06-15 16:44:04 -04:00
Ben 78b1df8d8a blog(gemini-spark): Gemini Spark persistent memory via MCP (#2208)
* blog(gemini-spark): add Gemini Spark persistent memory post

Walkthrough of the config-only Hindsight + Gemini Spark integration:
agent-initiated recall/retain over MCP (no plugin host, no hooks),
Hindsight Cloud direct path vs self-hosted OAuth proxy, setup for both
the Antigravity desktop mcp_config.json and the antigravity.yaml manifest.
2026-06-15 13:50:12 -04:00
Parafee41 989f30e215 fix typescript shared observation scope (#2207) 2026-06-15 17:37:41 +02:00
Nicolò Boschi d382b340f0 feat(api): dry-run fact extraction endpoint (preview, no persistence) (#2205)
Add POST /v1/default/banks/{bank_id}/memories/dry-run-extract — a
read-only tool that previews what the retain step would extract from
text WITHOUT changing the bank: extraction only, no entity resolution,
links, embeddings, or persistence. The "dry-run-extract" path makes the
non-mutating nature explicit.

Returns a dedicated DryRunExtractionResult: the candidate facts plus the
aggregated LLM token usage. Each fact (ExtractedFact) is a subset of the
memory-unit shape — only what a fresh extraction produces: text,
fact_type, occurred_start/end, entities[] (raw, unresolved names).

Every prompt-affecting setting is overridable per call (retain_mission,
extraction_mode, custom_instructions, chunk_size, entity_labels,
entities_allow_free_form, llm_output_language) plus the narrator
(agent_name), so a candidate config can be A/B'd against the bank's
current one. The reference date field is named `timestamp` to match the
retain item payload. The engine authenticates the tenant before reading
any bank-scoped config.

Gated by a static server-level flag HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT
(default true). Since extraction makes a real LLM call, set it to false
to remove the endpoint (returns 404) on cost/abuse-sensitive deployments.

Control plane: a "Dry-run extraction" dialog opened from the Memory Bank
actions menu (text input + raw JSON output, side-by-side).

Regenerated OpenAPI spec + Python/TypeScript/Go client SDKs.
2026-06-15 16:25:31 +02:00
Sanderhoff-alt 59d5319b84 feat(retain): make structured chunk size configurable (#2139)
Add retain_structured_chunk_size as an explicit retain chunking knob
for structured inputs. When unset, structured inputs follow the
effective retain_chunk_size instead of the hidden 1.5x overflow factor.

Thread the setting through retain extraction, append/prepend chunking,
bank config resolution, templates, MCP docs, maintained clients,
generated OpenAPI artifacts, the control-plane retain strategy UI, and
the Rust CLI set-config command.

Validate retain_chunk_size and retain_structured_chunk_size as positive
integers while allowing either value to be smaller. Keep the existing
retain_max_completion_tokens check scoped to retain_chunk_size.

Preserve upstream validation details for client errors through the
control-plane proxy so UI alerts and toasts can show concrete
configuration errors without exposing server-side failure details.

Update chunking, config, hierarchical config, template, MCP, client
payload, control-plane serialization, SDK-response, API-client, and
retain UI validation tests for the new behavior.
2026-06-15 15:51:51 +02:00
Nicolò Boschi 460fc63d9b feat(migrations): parallelize tenant schema migrations (#2203)
Add HINDSIGHT_API_MIGRATION_CONCURRENCY to migrate tenant schemas concurrently (each in its own spawn process; per-schema work stays sequential). NullPool on migration engines bounds per-worker connections. Validated at 20k schemas: no-op resweep ~60min->~11min (5x) at concurrency=12, peak 29 connections, 0 errors. Default 1 (sequential).
2026-06-15 15:39:42 +02:00
Nicolò Boschi e20a36c959 feat(api): omit null fields from JSON responses where wire-safe (#2204)
* feat(api): omit null fields from JSON responses where wire-safe

API responses included every optional field as `"x": null`. Install a
custom route class (ExcludeNoneRoute) that enables response_model_exclude_none
for routes whose response model has no required-and-nullable field, so those
nulls are dropped.

Routes whose model carries a required-nullable field (e.g. DocumentResponse
.content_hash, OperationResponse.error_message) keep emitting nulls — omitting
a key the OpenAPI `required` set declares would break strict generated clients
(the Rust progenitor client decodes those without serde defaults). Detection is
recursive over nested models/generics, so it stays correct as models evolve.

The OpenAPI schema is unchanged (exclude_none is runtime-only), so the spec and
all generated clients are byte-identical and existing clients remain compatible.
Verified the published 0.8.2 client deserializes recall/retain/reflect/list
responses against a server running this change.

* test(api): tolerate omitted null fields in response assertions

Responses now omit null optional fields (ExcludeNoneRoute). Update the four
tests that read these keys via direct indexing to use `.get(...) is None`,
which holds whether the key is absent or explicitly null:
- operations progress (OperationStatusResponse / OperationsListResponse)
- bank-health latency_ms (when LLM not configured)
- reflect based_on (null when facts not requested)
- bank export bank/mental_models/directives (empty bank)
2026-06-15 13:50:01 +02:00
formatmeandNicolò Boschi 13f3e081b7 fix(api): mental model delta refresh (prompt size, JSON, consolidation) (#2170)
* fix(api): shrink mental model delta LLM prompts for provider limits

Delta refresh (scope mental_model_delta_ops) was sending the full
structured document, reflect synthesis, and every fact ever merged into
based_on. That payload grew on each refresh and triggered Z.ai HTTP 400
code 1261 (Prompt exceeds max length).

- Send only facts from the current reflect to the structured-delta LLM;
  accumulated based_on remains stored for audit.
- Budget and truncate user prompt sections (~24k cl100k tokens default).
- Use compact JSON for the current document block.
- Keep normal APIStatusError retries for 1261.

Tests: prompt budget, retry behavior, delta plumbing assertion update.

* chore(control-plane): knip ignore react-dom (Next.js peer, no direct import)

* fix(api): delegate with_config on ConfiguredLLMProvider

Consolidation calls _consolidation_llm_config.with_config(...) after an
initial with_config bind; without delegation, Python raised TypeError for
bank_id/operation kwargs on the wrapper class.

Add regression test for re-bind trace attribution.

* fix(api): robust mental model delta JSON + lint sync

- parse_delta_operation_list: parse_llm_json + balanced-object extract
- Prompt: JSON escaping rules for glm-style invalid output
- Ruff format on touched files (verify-generated-files)
- Tests: test_delta_operation_parse.py

* fix(api): skip invalid delta ops instead of full-synthesis fallback

When the model omits required fields (e.g. replace_block without index),
validate operations one-by-one and apply the rest. Tighten structured-delta
prompt on index requirement.

* fix(api): drop dead with_config, guard delta facts + all-invalid ops

- Remove redundant ConfiguredLLMProvider.with_config: the __getattr__ proxy
  already forwards to LLMProvider.with_config (which accepts bank_id), and
  every caller (_retain/_reflect/_consolidation_llm_config) is an LLMProvider,
  so the method was never reached. Drop its test (passed against main too).
- Add regression test locking the delta supporting-facts fix: only THIS
  refresh's facts go to the structured-delta prompt, while based_on still
  accumulates all facts for grounding. Fails on the pre-fix code.
- Harden parse_delta_operation_list: when the model emits ops but every one
  fails validation, raise DeltaAllOpsInvalidError so the caller falls back to a
  full rewrite instead of applying zero ops and silently dropping new facts.
  A genuine empty operations array stays a valid no-op.
- knip.json: restore trailing newline (prettier).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-15 13:40:18 +02:00
EvoandNicolò Boschi 20f1a77ea0 fix(db): widen remaining live bank_id columns to TEXT on PostgreSQL (#2106 follow-up) (#2175)
* fix(db): widen remaining live bank_id columns to TEXT on PostgreSQL (#2106 follow-up)

* fix(db): drop mental_model_versions from bank_id widen migration

mental_model_versions is created in j5e6f7g8h9i0 but dropped (DROP TABLE
... CASCADE) in o0j1k2l3m4n5 and never recreated on the upgrade path, so
it does not exist at head. ALTER TABLE mental_model_versions therefore
raised UndefinedTable and -- because migrations run inside the
lifespan-startup transaction -- rolled the whole migration back, bricking
API startup (the exact failure class this repair targets).

Widen only the live tables that exist at head and still carry VARCHAR(64)
bank_id: directives and mental_models. Update the test accordingly (it
previously could not pass: the head migration crashed before any
assertion, and the now-removed FK insert referenced the dropped table).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-15 12:55:45 +02:00
Nicolò Boschi 0a046f97ae feat(consolidation): add "shared" observation_scopes keyword (#2202)
* feat(consolidation): add "shared" observation_scopes keyword

Add a "shared" value for observation_scopes that resolves to a single
global, untagged scope ([[]]). Memories consolidate into one observation
regardless of their tags, while the tags stay on the source facts for
recall filtering.

This is the supported way to deduplicate observations across volatile
per-call provenance tags (e.g. per-session ids): with combined/per_tag,
a unique session tag puts every retain in its own scope, so near-identical
facts never dedup and accumulate one observation per session. "shared"
keeps recall and the dedup probe on the same (empty) scope, fixing
consolidation quality rather than only the duplicate count.

- consolidator: _resolve_obs_tags_list -> [[]], _resolve_write_scopes -> [frozenset()]
- API/engine type literals + OpenAPI + regenerated Python/TS/Go/Rust clients
- CP client type kept in sync
- docs: retain.mdx 'shared' section (+ shared vs [[]] vs [] caveat),
  observations.mdx dedup pointer; regenerated hindsight-docs skill mirror
- tests: unit scope-resolution + e2e parallel-consolidation scope correctness

* chore(opencode): apply prettier formatting to plugin.test.ts

Pre-existing lint drift unrelated to this PR — CI's verify-generated-files
job reformats all integrations (LINT_ALL_INTEGRATIONS) and flagged this file.
Folding the one-line reflow in here to get the gate green.
2026-06-15 12:50:17 +02:00
156a543cf6 docs(reflect): align reflect_async docstring with read-only tool set (#2200)
The docstring listed a 'learn: Create/update mental models with new insights'
step that is not wired into the reflect agent. reflect_async only hands the
agent read tools (search mental models, recall, search observations, expand),
so reflect synthesizes an answer from stored memories and persists nothing.
Update the docstring to match the actual implementation.

Co-authored-by: Kuba Odias <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-15 12:10:56 +02:00
Evo c3970cfd21 fix(metrics): don't count a client-disconnect cancellation as a failed operation (#2185)
run_cancellable_on_disconnect (added in #2127/#2131 for #2122) converts the
engine's OperationCancelledError into HTTPException(499), which propagates out
through record_operation's blanket `except Exception: success = False`, so every
abandoned recall/reflect was counted as a failure on hindsight.operation.total.

Exclude a client cancellation from the counter entirely (neither success nor
failure), detecting it via the exception's __cause__ chain so an unrelated 499
is still recorded as a failure. Adds regression tests.
2026-06-15 12:10:39 +02:00
Miguel de Benito Delgadoandmdbenito 9500a6bf43 fix(opencode): drop utility re-exports from plugin entry to satisfy legacy loader (#2193)
OpenCode's legacy plugin loader (getLegacyPlugins) iterates Object.values(mod)
and calls every function export as a Plugin factory. It deduplicates by
reference, so default and HindsightPlugin (same fn) are fine. But the entry
also re-exported loadConfig and deriveBankId, which the loader invokes as
plugins and registers as hooks — returning a string and a config object
respectively, neither of which is a valid hooks object.

Earlier versions of the dist (e.g. 0.2.1) additionally exported
DEFAULT_HINDSIGHT_API_URL as a string constant, which the loader would call
as a function and crash on with 'Plugin export is not a function'. That was
fixed in 0.2.2 by dropping the string re-export, but the function re-exports
remained and would still produce silently-wrong hook objects on every session.

The plugin itself imports loadConfig and deriveBankId directly from their
submodules, so removing the re-exports is backwards-compatible: no internal
callers change, no public API is removed (these were undocumented
convenience re-exports), and the default export remains a callable function
for direct import.

Add a regression test that asserts the entry has exactly two function
exports, both pointing at the same reference. This is the legacy-loader
invariant: anything else will be incorrectly invoked.

Closes the use case for the local plugins/hindsight.js wrapper required to
load the package as an npm plugin.

Co-authored-by: mdbenito <[email protected]>
2026-06-15 12:10:14 +02:00
Evo 3d6b19af59 fix(search): use effective-time fallback (mentioned_at, occurred_end) for recency scoring (#2197)
* fix(search): use effective-time fallback for recency scoring

* test(search): cover mentioned_at/occurred_end recency fallback

* style: apply ruff format (collapse multi-line ternaries within 120c)

Clears verify-generated-files CI: ruff format collapses the recency
effective-time fallback (reranking.py) and a main-drift one-liner in
consolidator.py that both fit the 120-char line length.
2026-06-15 12:09:36 +02:00
Evo 99fe231b36 docs(litellm): replace removed opinion fact-type with observation (#2198)
* docs(litellm): replace removed opinion fact-type with observation

* style: apply ruff format to consolidator.py (CI generator-sync)

verify-generated-files requires committed files match ruff format output;
collapses a main-drift multi-line ternary that fits the 120-char limit.
2026-06-15 12:09:03 +02:00
Evoandr266-tech 9be7f59c03 docs(models): register the nous provider so the Models page lists it (#2128)
#2102 added the native Nous Portal provider to config.py
PROVIDER_DEFAULT_MODELS and the models.mdx prose, but not to
hindsight-docs/src/data/llmProviders.json -- the single source of truth
that renders the Models page provider grid, default-models, and
capabilities tables -- so Nous is documented in prose but invisible on
the canonical Models grid.

Add the nous entry and regenerate the CI-enforced skill mirror via
scripts/generate-docs-skill.sh. Same pattern as #1911 (fireworks).

Co-authored-by: r266-tech <[email protected]>
2026-06-15 12:05:01 +02:00
Ben 42e72601f4 blog: Cursor persistent memory (editor + CLI in one post) (#2171)
* blog: add Cursor persistent memory post covering both integrations

One post covers both new integrations:
- hindsight-cursor (editor, first-party): plugin hooks + MCP server,
  with the Cursor 3.x additionalContext workaround via workspace
  rules-file fallback
- hindsight-cursor-cli (CLI, community-built by @Korayem): four
  lifecycle hooks (sessionStart, beforeSubmitPrompt, stop, sessionEnd)

The angle of the post is that both surfaces can share a single bankId
and switch between editor and CLI mid-task without losing context.

Every claim sourced from the integrations' README files:
- Editor: install commands, sessionStart/stop hooks, MCP config, the
  Cursor 3.x bug + rules-file workaround, useRulesFileFallback flag,
  bankId default = "cursor"
- CLI: four-hook table, install command, ~/.cursor/hooks.json shape,
  Cursor CLI v0.45+ requirement, bankId default = "cursor-cli",
  dynamicBankGranularity including gitProject

Test stamps from both integrations on current main:
- hindsight-cursor: 82 passed, 4 skipped
- hindsight-cursor-cli: 87 passed, 0 skipped

Cover is a placeholder (Codex art) for now; swap before merging.


* blog(cursor): swap placeholder cover for Hindsight x Cursor card
2026-06-12 15:17:38 -04:00
Nicolò Boschi 4835bf73d2 docs: add Memory Defense + missed items to 0.8.2 blog post (#2173) 2026-06-12 18:05:42 +02:00
Nicolò Boschi a38fc3453c docs: changelog and blog post for v0.8.2 (#2172)
* docs: changelog and blog post for v0.8.2

* docs: clarify per-bank cost attribution is opt-in via env flag
2026-06-12 17:53:50 +02:00
Nicolò Boschi 6f59a09479 Release v0.8.2
- Update version to 0.8.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.8
2026-06-12 17:44:54 +02:00
Nicolò Boschi ea45930949 fix(docs): correct Memory Defense link after dir conversion (#2077) 2026-06-12 17:42:47 +02:00
Ben 9a5aecd178 release(agent-framework): v0.1.0 2026-06-12 08:40:23 -04:00
Nicolò Boschi d81486ff9b fix(control-plane): stop double-fetching graph data on bank view (#2168)
* fix(control-plane): stop double-fetching graph data on bank view

The DataView component had two effects that each loaded graph data — one
keyed on factType/bank/document/chunk, one on tag/scope filters. Both run
on initial mount, so every bank/tab view fired /api/graph twice (issue
2158).

Collapse them into a single auto-loader. When the context changes we drop
the now-meaningless observation scope and feed the cleared value straight
into the same reload, guarding the setSelectedScope(null) echo render with
a ref so the reset never produces a second fetch.

Refs #2158

* fix(control-plane): make graph auto-load idempotent

Add a fetch-signature guard so identical consecutive auto-loads collapse
to a single /api/graph request. This defeats React's mount-effect
double-invoke (dev StrictMode, client-side navigation) and any redundant
re-render that would otherwise re-issue the same query — verified with
Playwright that switching fact-type tabs now fires exactly one request
per view (was two on tab clicks in dev).

Manual reloads (search, load-more, consolidation poll) call loadData
directly and intentionally bypass the guard.

Refs #2158
2026-06-12 12:49:51 +02:00
Evo f6710963e6 fix(consolidation): honor an observation scope limit of 0 (no new observations) (#2163)
The per-scope observation limits added in #2140 document 0 as 'no new
observations', but the two call-site guards used '> 0', so a configured limit of
0 left remaining_observation_slots=None and _build_response_model(None) built an
unconstrained model -- limit:0 behaved like unlimited, the inverse of intent.

Make the guards '>= 0' (matching the truncation guard which already uses '>= 0'),
short-circuit the count query for the 0 case, and add a regression test asserting
a scope cap of 0 creates no new observations.
2026-06-12 11:56:47 +02:00
Ben a63a0c0e70 docs(api): list all 3 supported webhook event types in CreateWebhookRequest (#2155)
The event_types field description said 'Currently supported: consolidation.completed',
but the API actually emits and accepts all three events:
- retain.completed (memory_engine.py)
- consolidation.completed (memory_engine.py)
- memory_defense.triggered (retain/orchestrator.py)

Update the description accordingly and propagate through the regenerated OpenAPI
spec + the embedded copies in the go/python/typescript clients. Description-only
change; no behavior change.
2026-06-12 11:55:51 +02:00
Nicolò Boschi 30fb287d10 fix(api): normalize torch default dtype to float32 after concurrent model init (#2167)
transformers' dtype context manager (entered by SentenceTransformer /
CrossEncoder / from_pretrained) does a non-thread-safe save/restore of the
process-global default dtype. When an fp16 embedding model and an fp32
reranker/query-analyzer load in parallel during MemoryEngine.initialize(), an
unlucky interleave can leave the global default stuck at float16. Every later
encode() then emits NaN vectors that pgvector rejects ("NaN not allowed in
vector") on MPS, or raises "c10::Half != float" on CPU -- non-deterministically
across restarts.

Keep the model loads fully parallel and, once asyncio.gather() has joined every
load thread, normalize the global default dtype back to float32 -- the inference
state a healthy boot already converges to. The reset is race-free (all threads
have finished) and only touches torch if a local provider actually loaded it.

Fixes #2162
2026-06-12 11:51:07 +02:00
Nicolò Boschi f0802b826b chore(ci): enforce unused imports/vars + advisory dead-code scan (#2144)
* chore(ci): enforce unused imports/vars + advisory dead-code scan

Enable ruff F401 (unused imports) and F841 (unused variables) -- previously
ignored as "too noisy" -- across hindsight-api-slim, hindsight-dev, and
hindsight-embed, and clean up the resulting violations. These are now blocking:
lint.sh auto-removes them and the verify-generated-files CI job fails on any
leftover diff.

Add an advisory dead-code scan for what the linter cannot see -- whole unused
Python functions (vulture) and orphaned files/exports/dependencies in the
control plane (knip):
- scripts/hooks/check-unused.sh runs both locally
- new non-blocking check-unused-code CI job surfaces findings on PRs
- hindsight-control-plane/knip.json tunes out toolchain false positives

vulture stays advisory because its function/argument heuristics false-positive
on FastAPI/SQLAlchemy/Pydantic patterns; knip can be flipped to blocking once
the control-plane dead code (PR #2135) lands.

* chore(ci): make knip blocking on unused files/deps; remove dead deps

#2135 deleted tooltip.tsx but left @radix-ui/react-tooltip in package.json, and
react-chrono / three were never imported. Remove all three, and declare
@radix-ui/react-visually-hidden (used in directive-detail-modal but unlisted).

With the control-plane tree now clean, the check-unused-code job runs
`knip --include files,dependencies,unlisted` as a BLOCKING step. vulture and
knip's unused-exports check (the shadcn/ui surface is kept intentionally) stay
advisory.
2026-06-12 11:06:31 +02:00
Chris Latimer 87448b1616 fix(webhook) missing fields in payload 2026-06-12 00:14:47 -06:00
Chris Latimer f304ce7e01 feat(webhooks): SIEM enrichment fields on MemoryDefenseEventData
Adds five optional fields to MemoryDefenseEventData so downstream extensions
(e.g. hindsight-cloud) can surface the per-decision context SIEM operators
need to act on a leaked-secret webhook: severity, the API key that submitted
the retain, fingerprinted hit previews for correlation against credential
inventories, and pointers into the audit trail.

Backward-compatible: all five fields default to None and OSS's built-in
regex defense leaves them unset, so existing OSS receivers see no shape
change. Receivers should treat absence as "not provided" rather than "no
match" — the OSS path still populates matched_types as before.

The hit preview is wrapped in a new MemoryDefenseHit model whose docstring
pins the rule that preview must be a fingerprinted rendering of the value,
never the raw secret. Validates that both detector and preview are present
to guard against extensions accidentally posting the raw value as the only
field.

Closes the gap that motivated keeping a separate memory_defense.violation
event in cloud before the recent consolidation: cloud can now ship the
same SIEM-actionable payload through the canonical memory_defense.triggered
envelope.

feat(webhooks): populate hits[] with fingerprinted previews on OSS

Builds on the schema added in the previous commit by populating the
SIEM-relevant hits field from the OSS regex defense. SIEM receivers
now get a per-match preview (e.g. ghp_AAAA...AAAA) for every redaction
the OSS extension fires, in addition to the existing matched_types list.

Three changes:

1. New _fingerprint_value helper produces a length-aware redaction-
   identifiable rendering of a matched value:
   - length < 6:  returns "[redacted]" (avoid leaking material on
     short matches like an isolated -----BEGIN... marker)
   - length 6-15: first-2 + ellipsis + last-2
   - length > 15: first-4 + ellipsis + last-4
   The raw value never appears in the output.

2. apply_redaction returns a hits list alongside matched_types - one
   entry per matched substring (so two GitHub tokens produce two hits
   rather than collapsing into a single label). Hits threaded through
   RedactionResult -> DefenseDecision -> MemoryDefenseEventData via the
   orchestrator's fire helper.

3. _fire_memory_defense_webhook translates the decision's raw hit dicts
   into MemoryDefenseHit entries. None when the decision carries no
   per-hit data so receivers can distinguish "no preview info" from
   a hypothetical empty list.

Test plan:
- New unit tests for _fingerprint_value across all three length
  buckets (parametrized) plus apply_redaction shape: per-match
  fingerprinted previews, raw value never present, multiple matches
  of the same pattern produce multiple hits.
- Extended test_screen_redacts_secret to assert the regex extension
  passes hits onto DefenseDecision.
- Extended test_retain_fires_webhook_on_redact to assert the wire
  payload carries hits[].
- Helper _memory_defense_webhook_events now orders most-recent-first
  so events[0] always reflects the latest delivery.
- Full test_memory_defense.py + test_webhooks.py: 100 passed.
- ruff + ty: clean.

feat(webhooks): more useful message
2026-06-11 23:27:41 -06:00
DK09876 c6db44b101 fix(test): update hierarchical-config count for observation_scope_limits (#2156)
#2140 (per-scope observation limits) added observation_scope_limits to
_CONFIGURABLE_FIELDS but didn't bump the count tripwire in
test_hierarchical_fields_categorization, so it asserts 38 while the real count
is 39 — failing test-api deterministically on every open PR.

The field is correctly configurable (a per-bank behavioral override). Bump the
count to 39 and add an explicit assertion for the field, matching the test's
documentation pattern.
2026-06-11 15:36:50 -07:00
Chris Latimer 7d57711e75 Merge remote-tracking branch 'origin/main' into feat/parser-accept-cloud-detectors 2026-06-11 14:23:56 -06:00
Chris Latimer 4d369a9a89 fix(broken tests) 2026-06-11 14:13:53 -06:00
Ben e24614db22 blog: Haystack persistent memory (drop-in tools + auto-recall wrapper) (#2147)
* blog: add Haystack persistent memory integration post

Walkthrough of hindsight-haystack — two integration modes:
- create_hindsight_tools() returning a list[Tool] for an Agent
- HindsightMemoryWrapper, a Toolset subclass with auto_recall and
  auto_retain that runs the memory work before/after each turn.
Plus the three memory primitives (retain/recall/reflect) and the
include_* flags to drop any subset.

Every concrete claim verified against the README and
hindsight_haystack/tools.py:
- Package name + version (0.1.0)
- Python >= 3.10, haystack-ai >= 2.12.0, hindsight-client >= 0.4.0
- Exported names from __init__.py
- create_hindsight_tools() and HindsightMemoryWrapper signatures
- "Use toolset.run(agent, ...) not agent.run(...) for auto behavior"
- configure() shape and acceptable kwargs

Underlying integration unit tests: 83/83 passing (3 e2e skipped for
lack of API keys in CI sandbox).

Cover is a placeholder (Codex art) for now; swap before merging.
2026-06-11 15:16:25 -04:00
Ben f5a6c300f1 feat(agent-framework): Hindsight memory for Microsoft Agent Framework (no MCP) (#1989)
* feat(agent-framework): add Hindsight memory integration via context provider

Persistent memory for Microsoft Agent Framework (the successor to Semantic
Kernel) without MCP. HindsightProvider is a ContextProvider whose before_run
recalls relevant memories and injects them into the agent's instructions, and
whose after_run retains the conversation. Reuses the LlamaIndex integration's
client/config pattern and the hindsight-client Python SDK.

Targets the agent-framework-core 1.x before_run/after_run + SessionContext
contract (verified against the installed package since the API has churned).
15 unit tests subclass the real ContextProvider so drift fails loudly, plus a
gated e2e. Includes CI job, release + changelog + docs wiring, and an icon.

* chore(agent-framework): refresh lock to agent-framework-core 1.8.1 (verified no API drift)

* fix(agent-framework): drop unused per-op timeout constants

TIMEOUT_RETAIN/TIMEOUT_RECALL/TIMEOUT_BANK were defined but never used: the
hindsight-client SDK sets one timeout on the constructor and has no per-call
timeout argument, so per-op values can't be wired in. Keep the single
constructor-level TIMEOUT_DEFAULT and document why. Addresses review feedback.
2026-06-11 13:57:20 -04:00
Ben 443cce8146 docs(integrations): use the Gemini logo for Gemini Spark, not the generic MCP icon (#2146)
The Gemini Spark gallery card showed the generic MCP paperclip icon
(/img/icons/mcp.png). Every other named-product integration uses its
own brand mark, so swap in the official Google Gemini 2025 sparkle
(public-domain logo from Wikimedia Commons, {{PD-textlogo}}).
2026-06-11 13:47:44 -04:00
Ben 54e1242193 docs(superagent): add prerequisites to integration quick start (#2137)
* docs(superagent): add prerequisites to integration quick start

The quick-start example calls Superagent guard/redact on the first
retain (both on by default), so it fails immediately without the
required keys. The page documented none of them. Add a Prerequisites
section covering SUPERAGENT_API_KEY and OPENAI_API_KEY, clarify the
hindsight_api_url endpoint (self-hosted vs Cloud), and note that
Superagent's hosted guard-model endpoints are currently unreliable.

* docs(superagent): default the quick start to Hindsight Cloud

Drop the explicit localhost URL so the example uses the package's
default Cloud endpoint (https://api.hindsight.vectorize.io), add
HINDSIGHT_API_KEY to the prerequisites, and show self-hosting as the
opt-in alternative.
2026-06-11 13:43:06 -04:00
Ben 9197498b70 blog: 763,365 downloads in 30 days: Hindsight crosses 1M (#2134)
* blog: hindsight-client passes 1,000,000 downloads
2026-06-11 13:41:25 -04:00
Nicolò Boschi b08f43496a feat(observations): enumerate + filter + visualize observation scopes (#2149)
* feat(observations): enumerate + filter observations by scope

Add an exact (set-equality) tag match mode, a list_observation_scopes
engine method + GET /observations/scopes endpoint, and a scope filter in
the control-plane Observations tab (list + graph views). A scope is the
exact tag set an observation was consolidated under; the empty set is the
global/untagged scope. Regenerated OpenAPI + clients + docs skill.

* fix(i18n): add missing memoryDetailPanel curation* keys

invalidate-memory-dialog.tsx references memoryDetailPanel.curationInvalidateTitle/
Explain/ReasonPlaceholder/Cancel/Invalidate, but these keys were never added to any
locale (the parity test passed because all 10 locales lacked them equally), so the
invalidate dialog logged IntlError: MISSING_MESSAGE and rendered raw key names.
Add all five strings across the 10 locales. Pre-existing gap, unrelated to scopes.

* fix(observations): keep scope-filter trigger single-line for long/multi tags

The scope dropdown trigger relied on SelectValue, which clones the selected
item's wrapping pill layout; a multi-tag or long-tag scope (e.g. [session:2,
user:nicolo]) wrapped to two lines and overflowed the fixed-height control.
Render a compact, single-line, truncating summary in the trigger instead,
keeping the full pills only in the open dropdown list.

* feat(documents): capture observation_scopes in retain_params, show in detail dialog

observation_scopes passed at retain time was only persisted per source fact
(memory_units), never on the document, so the document detail dialog couldn't
show which scoping was requested. Capture it into documents.retain_params in
_build_retain_params (alongside context/event_date/metadata) and surface it as
a top-level field on the get_document response. The control-plane document
detail dialog now shows an 'Observation scopes' row (mode badge or scope chips).

New-documents-only by design: existing docs have no captured value and show
nothing. Note: this also clarifies that all_combinations on 2 tags correctly
creates 3 scopes — the transient '2' is async consolidation still in flight.

* feat(observations): live consolidation refresh + scope clusters on the constellation

Two UX improvements to the observations view:

1. Live refresh while consolidating. The 'In Sync' badge previously read a
   one-shot, up-to-60s-cached stat, so it could show green while observations
   were still materializing (each scope is a separate consolidation pass). The
   view now polls every 4s while pending_consolidation > 0, silently refreshing
   the observations, scope list, and badge in place until consolidation settles.

2. Group-by-scope clustering on the Constellation. A new 'Group by scope' toggle
   lays observations out around per-scope centroids (instead of the id-hash ring),
   colors each scope distinctly, and wraps each scope's nodes in a translucent,
   labeled convex-hull blob — so overlapping tag scopes read as visual clusters.
   Adds clusterKeyFn/clusterColorFn/clusterLabelFn props to Constellation and an
   inline monotone-chain convex hull; suppresses the heat legend while clustering.

* fix(observations): cap scope dropdown height to the viewport

With many scopes the scope filter dropdown grew past the bottom of the screen.
Cap its height at min(60vh, --radix-select-content-available-height) so it fits
the space below the trigger and scrolls for the rest, instead of overflowing.

* feat(observations): make the scope filter a searchable combobox

Replace the plain Select with a Popover + Command (cmdk) combobox so scopes can
be searched by typing — matching the tag filter's search UX — which matters once
a bank has many scopes. Uses a substring filter over each scope's tags (not
cmdk's fuzzy default, which over-matches scattered letters). Keeps the compact,
single-line, height-capped trigger; selection still applies exact-scope filtering.

* chore(cli): mark list_observation_scopes UI-only in coverage manifest

The new scope-enumeration endpoint powers the control-plane scope filter/clusters
and isn't a useful end-user CLI command, so add it to the [skip] list (matches
the other UI-only endpoints) to satisfy check-cli-coverage.

* fix(tests): import TokenUsage from response_models

#2135 removed the TokenUsage re-export from llm_wrapper, but test_load_large_batch
and test_retain still imported it from there, breaking test collection across the
API test jobs. Import it from response_models (where it's defined), matching every
other test.
2026-06-11 19:33:37 +02:00
Chris Latimer f15b93f5cb fix(broken tests) 2026-06-11 11:15:04 -06:00
Nicolò Boschi 5b9027ef16 Merge remote-tracking branch 'upstream/main' into feat/parser-accept-cloud-detectors 2026-06-11 18:45:10 +02:00
Nicolò Boschi b385393b6d feat(consolidation): per-scope observation limits (#2140)
Add an `observation_scope_limits` config field that overrides the bank-wide
`max_observations_per_scope` on a per-scope basis. Each rule maps a scope
pattern (a list of fnmatch tag-globs) to a limit; a consolidation scope
matches under *exact cover* — every tag matched by a glob and every glob
matched by a tag — so `["shared"]` caps the `{shared}` scope without affecting
`{run_1, shared}`, and `["run_*", "shared"]` caps the combined scope only.
The first matching rule wins; scopes matching no rule fall back to
`max_observations_per_scope`.

- config: new `HINDSIGHT_API_OBSERVATION_SCOPE_LIMITS` (JSON), hierarchical
  (per-tenant/bank overridable)
- consolidator: resolve the cap per scope at slot computation; wildcards live
  only in the resolution layer so the SQL count stays exact and indexed
- exposed on `BankTemplateConfig`; regenerated OpenAPI + clients
- unit tests for rule parsing, exact-cover matching, and resolution
2026-06-11 18:27:43 +02:00
Nicolò Boschi 8e6dc5fcd7 fix(control-plane): drop empty meeting-note message that failed locale guard
The enterprise discovery panel used an empty-string en.json value as a
"render nothing for English" sentinel, but the locale catalog guard
(tests/messages/messages.test.ts) bans empty leaf values. Remove the
memoryDefenseEnterpriseMeetingNote key from all locales and the conditional
render — it was low-value copy (a language disclaimer on a demo CTA) and the
source of the build-control-plane / test-hindsight-all failures.
2026-06-11 18:06:24 +02:00
Nicolò Boschi b5f97418cf refactor(memory-defense): accept any detector name instead of a fixed union
The parser no longer gates rules[*].on against a hardcoded detector list.
Unknown detectors are silent no-ops in the OSS extension anyway (only
sensitive_data is screened), so pinning the OSS roster to cloud's just forced
an OSS bump for every new cloud detector to avoid 422-ing a write it never
interprets. on now only has to be a non-empty string; dispatch and
entitlement stay the loaded extension's job.

Also soften the enterprise discovery panel's emerald styling to a more
refined low-saturation tint.
2026-06-11 17:40:29 +02:00
Sanderhoff-alt c96106cc01 chore: remove dead code and stale config (#2135)
Remove unreferenced backend helpers, stale UI/docs components, and
unused imports across the API, control plane, clients, and integrations.

Drop obsolete consolidated-observation helpers and unused scoring code,
clean orphaned React/docs components, and remove stale Radix dependencies.

Align release scripts, Helm docs, lockfiles, generated clients, and
current API examples with the package and endpoint surface still in use.
2026-06-11 17:12:03 +02:00
Nicolò Boschi 4032b27912 feat(embed): local control center web app (#2132)
A persistent, localhost-only control center web app bundled in hindsight-embed:
LLM config wizard, raw .env editor (with effective-only view), daemon +
control-plane Start/Restart/Stop with live API/UI health, editable per-profile
API/UI ports + component versions, daemon + control-plane log tail, profile
delete, deep-linking, token-gated /api/* (CSRF-safe), localhost everywhere.

UI built with Preact + Tailwind (Vite), output committed to static/ and served
by the embed's stdlib http.server (no Node at runtime, offline). Ports moved
from metadata.json into each profile's .env (HINDSIGHT_API_PORT /
HINDSIGHT_EMBED_CP_PORT). CI job verifies the bundle builds + is wired.
2026-06-11 17:09:35 +02:00
Ben e8884bc0a0 docs: remove broken gitcgr code-graph badge (#2133)
The gitcgr.com SSL certificate has expired, so the code-graph badge
image (added in #648) renders as a broken-image icon in the README for
all visitors. Remove it since the third-party service appears defunct
and we don't control the cert.
2026-06-11 17:06:28 +02:00
Chris Latimer 259c82ec01 feat(memory-defense): accept full 7-detector vocabulary in parser
The OSS regex extension still only enforces sensitive_data, but the parser
should accept the full known detector vocabulary so cloud-shape policies
(prompt_injection, size_anomaly, protected_keys, detect_secrets,
base64_decode, llm_screen) pass through the OSS PATCH layer unchanged.
Dispatch + entitlement enforcement happen in the loaded extension; an
on-name the active extension doesn't implement is a silent no-op.

Adds test_parse_policy_accepts_full_detector_union covering all 7 names.
The reject-unknown-detector test still passes for unrelated values like
"nope".
2026-06-11 09:04:04 -06:00
Ben b83f621611 fix(docs): credit cursor-cli integration to its community author (@Korayem) (#2109)
The Cursor CLI integration was contributed by Salem Korayem (@Korayem) in
PR #1975, but the integrations gallery listed it as official/Hindsight Team.
Flip it to a community entry attributed to the author.
2026-06-11 09:16:31 -04:00
Nicolò Boschi 19e607b287 fix(api): make recall/reflect disconnect cancellation actually work (#2131)
The cancellation merged in #2127 never fired in production. Live testing
(curl --max-time against a real server) showed abandoned recall/reflect
requests still ran to completion; 0 cancellations under a 4000-request storm.

Two root causes, both found by black-box testing + ASGI probes:

1. Request.is_disconnected() is broken behind BaseHTTPMiddleware. This app
   installs two @app.middleware("http") handlers (BaseHTTPMiddleware), which run
   the route in a child task behind anyio memory streams, so http.disconnect
   never reaches the route's Request and is_disconnected() returns False forever.
   The #2127 watcher therefore never tripped. (Reproduced in isolation: one
   no-op @app.middleware("http") flips detection from working to broken.)

   Fix: ClientDisconnectCancellationMiddleware, a pure-ASGI middleware installed
   OUTSIDE the BaseHTTPMiddleware layer where it owns the real receive channel.
   It drains receive in a background pump, trips a CancellationToken on
   http.disconnect, and stashes it on the ASGI scope. Only wraps recall/reflect
   (small JSON bodies); everything else passes straight through.

2. Even once the token tripped, recall did not cancel: _search_with_retries
   wraps its whole body in a broad except Exception and re-raises as RuntimeError,
   burying OperationCancelledError. Fix: re-raise OperationCancelledError ahead of
   the broad handlers (both the search body and the connection-retry loop).
   Kept OperationCancelledError as a plain Exception (not BaseException) on
   purpose: BaseException dodges the broad handlers but also slips past the reflect
   agent's isinstance(result, Exception) gather handling and crashes it.

run_cancellable_on_disconnect now just reads the scope token onto RequestContext;
the polling is_disconnected() watcher is gone.

Verified live (real server, real corpus): RECALL CANCELLED and REFLECT CANCELLED
fire; a 30s socket-closing storm produced 397 recall + 80 reflect cancellations
with 40/40 canary recalls served, health all 200, 0 errors, full recovery.
Reflect cancellation is best-effort between agent iterations (a disconnect during
the final-answer LLM call is not interruptible), analogous to the rerank stage.
2026-06-11 12:52:38 +02:00
Nicolò Boschi bc83eacc7b chore(embed): drop unused HINDSIGHT_EMBED_BANK_ID + fix HINDSIGHT_EMBED_LLM_* docs (#2130)
* chore(embed): remove unused HINDSIGHT_EMBED_BANK_ID config var

`HINDSIGHT_EMBED_BANK_ID` was collected (env + interactive/non-interactive
configure), persisted to the profile .env, printed, and round-tripped through
config dicts, but never consumed: the daemon env-builder and run_cli ignore
the `bank_id` key, the memory commands (`memory retain|recall|reflect <bank>`)
take the bank as a required positional arg, and hindsight-api only reads
`HINDSIGHT_API_*` vars. The only reader was a test assertion.

Removes the prompt/read/persist sites in cli.py, the doc rows in the embed
README and sdks/embed.md, and updates the two tests that referenced it.

* chore(embed): fix HINDSIGHT_EMBED_LLM_* docs to the real HINDSIGHT_API_LLM_*

The embed docs/README documented `HINDSIGHT_EMBED_LLM_API_KEY` (marked
Required), `_PROVIDER`, and `_MODEL` as the user-facing LLM config, but no
code reads those names — the CLI honors `HINDSIGHT_API_LLM_*` / `OPENAI_API_KEY`,
`configure` writes the `HINDSIGHT_API_` prefix, and the daemon env-builder
forwards `HINDSIGHT_*` keys verbatim (no EMBED→API rewrite). A user following
the docs literally got "LLM API key is required".

Renames every `HINDSIGHT_EMBED_LLM_*` occurrence to the working
`HINDSIGHT_API_LLM_*` in sdks/embed.md, the embed README, and the two profile
tests (which only assert .env round-trip). Also drops a leftover "memory bank
ID" mention from the README configure description.
2026-06-11 12:37:25 +02:00
Nicolò Boschi 4e7780e593 feat(retain): chunk JSONL at line boundaries (#2126)
* feat(retain): chunk JSONL at line boundaries

Newline-delimited JSON (e.g. session logs) now chunks the same way as
JSON conversation arrays: whole lines are packed into chunks so no line
is split mid-object. This lets JSONL be ingested with mode `append`
without manual coercion to a JSON array.

A single line/turn that overflows the budget is kept whole only up to
1.5x (_CHUNK_OVERFLOW_FACTOR); beyond that it is split as text. The
extractor has no second re-chunk pass, so an unboundedly oversized chunk
would just error at the LLM — this caps the overflow for both the new
JSONL path and the existing conversation-array path.

Closes #2113

* test(retain): assert exact chunk output across text/JSONL/conversation modes
2026-06-11 11:55:39 +02:00
Nicolò Boschi e0221cae6e docs: flag Intel (x86_64) macOS as slim-only in supported-platforms grid (#2115) (#2129)
* docs: flag Intel (x86_64) macOS as slim-only in supported-platforms grid (#2115)

`pip install hindsight-all` on Intel Macs silently backtracks to a
months-old release: every release since 0.4.18 pulls hindsight-api-slim[all],
whose local-ML extra requires torch>=2.6.0 and mlx, neither of which ships
x86_64 macOS wheels. The docs' supported-platforms grid claimed Intel macOS
bare-metal pip was "fully supported", which is false.

- Split the macOS grid row into Apple Silicon (fully supported) and
  Intel/x86_64 (Docker + pg0 , bare-metal pip ⚠️ slim only).
- Mirror the grid into README.md.
- Point Intel-Mac users to hindsight-all-slim / hindsight-api-slim plus a
  hosted embeddings/reranker provider or the in-process ONNX backend
  (which has x86_64 macOS wheels).
- Replace the ad-hoc warnings with one-line pointers to the grid.

Refs #2115

* docs: move Supported Platforms grid to bottom of README

* docs: simplify README platform table to icons, link docs for details
2026-06-11 11:53:16 +02:00
Nicolò Boschi 07c85da988 fix(api): cancel abandoned recall & reflect via cooperative cancellation token (#2127)
* fix(api): cancel abandoned HTTP recall via cooperative cancellation token

Recall ran to completion even after the client disconnected, burning ~2 CPUs
for 60-95s per abandoned request and accumulating toward RECALL_MAX_CONCURRENT
until the instance starved (issue #2122).

Approach: a CancellationToken carried on RequestContext (already threaded into
every engine operation) that the engine checks at recall pipeline stage
boundaries (pre-retrieval, pre-rerank, pre-enrichment), aborting before
dispatching the next expensive stage. The HTTP layer attaches a token that
fires when the client disconnects and maps the resulting OperationCancelledError
to 499.

This is cooperative: it cannot interrupt work already inside a worker thread
(the cross-encoder rerank runs via run_in_executor and cannot be cancelled
once dispatched), but it stops an abandoned recall from progressing into - or
past - that work. The token lives on RequestContext so reflect/consolidation/MCP
and a deadline-based driver can adopt the same checkpoints later.

Scoped to HTTP recall; internal recalls pass no token so checkpoints are no-ops.

* fix(api): extend disconnect cancellation to reflect; share HTTP wiring

Reflect has the same abandoned-work problem as recall (agentic LLM loop +
nested recalls). Thread the same RequestContext cancellation token through it:
the agent loop checks between iterations and the nested recall tool already
checks at its stage boundaries, so an abandoned reflect stops instead of
running every remaining LLM round-trip (issue #2122).

Factor the HTTP wiring into a shared run_cancellable_on_disconnect() helper
used by both the recall and reflect handlers: it attaches the disconnect-driven
token and maps OperationCancelledError to 499, so neither handler duplicates
the try/except.

run_reflect_agent gains an optional cancel_check hook (default None -> inert),
so internal/non-HTTP reflect callers are unaffected.
2026-06-11 11:24:29 +02:00
Mani Saint-Victor 0afa046fc5 fix(migrations): catch CommandError-wrapped ResolutionError in rolling-deployment skip (#2117)
command.upgrade() never raises ResolutionError directly — alembic's
ScriptDirectory._catch_revision_errors wraps it in CommandError, so the
newer-bank rolling-deployment handler never fired and startup died with
a raw traceback. Catch the wrapped form (cause-checked) and route it to
the same warn-and-skip path; unrelated CommandErrors still propagate.

Fixes #2114
2026-06-11 10:33:53 +02:00
DK09876 a1228ec3a7 release(haystack): v0.1.1 2026-06-10 15:45:45 -07:00
DK09876andDK09876 f83fafa45b refactor(haystack): rename HindsightToolset -> HindsightMemoryWrapper (#2118)
The class subclasses Haystack's `Toolset` but is used as an automatic
memory wrapper (auto_recall / auto_retain around an Agent), not a tool
collection. Reusing the `Toolset` name was confusing next to Haystack's
own `Toolset` abstraction — flagged by deepset DevRel in review of the
haystack-integrations gallery entry (deepset-ai/haystack-integrations#505).

Pure rename across the package, tests, README, and docs pages. The class
still subclasses `haystack.tools.Toolset`. No backward-compat alias — the
package is at 0.1.0 with no adoption yet, so the rename is clean.

Co-authored-by: DK09876 <[email protected]>
2026-06-10 15:42:03 -07:00
DK09876 0a74ce3f07 fix(docs): read observation history before curating facts in memories.py (#2120)
memories.py listed memories, then ran edit -> invalidate -> restore on a fact.
Any update_memory call re-consolidates the bank and recreates derived
observations with new ids, so the observation id from the earlier listing was
stale by the time the example called get_observation_history — which the engine
correctly 404s on (NotFoundException), failing test-doc-examples (python) on
every PR.

Move the observation-history read to immediately after list_memories, before
the curate operations, so it uses a live id. No re-consolidation timing
dependency. The existing `if observation is not None` guard still covers the
no-observation case.
2026-06-10 14:24:39 -07:00
Ben 5b5188af82 blog: Flowise persistent memory (three Tool nodes for any chatflow) (#2108)
* blog: add Flowise persistent memory integration post

Walkthrough of the Hindsight Flowise integration — three Tool nodes
(Retain, Recall, Reflect) plus a shared Hindsight API credential.
Every claim verified against source in hindsight-integrations/flowise:
zod schemas, default budget = "mid", default URL, category, the
exposed tool names (hindsight_retain/recall/reflect), and the
DynamicStructuredTool return shape.

Install section is honest about Flowise's distribution model
(upstream monorepo PR, not npm install) rather than promising a
package that doesn't ship that way today.

Underlying integration tests: 17/17 passing (vitest).

Cover is a placeholder (Codex art) for now — swap before merging.

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

* blog(flowise): fix install-section wording — no upstream PR is open

Searched FlowiseAI/Flowise for any open or closed PR matching
"hindsight" / "vectorize" or authored by any Hindsight contributor
(benfrank241, chrislatimer, cdbartholomew, nicoloboschi, DK09876,
fabioscarsi) — zero results. The draft's phrasing implied a PR was
already open and pending merge. Reword to "the eventual distribution
path is an upstream contribution; until those nodes ship in a Flowise
release..." so the post doesn't promise a PR that doesn't exist yet.

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

* blog(flowise): drop the "eventual distribution path" sentence

Tighten the install-section preamble per reviewer feedback. The post
now just tells readers how to install today, without speculating on
where the nodes will eventually live.

Install procedure verified end-to-end:
- Cloned FlowiseAI/Flowise 3.1.2 (commit f4e2794)
- Copied the three Hindsight tool nodes and credential
- pnpm add @vectorize-io/hindsight-client (resolved to 0.8.1)
- pnpm install at the root
- pnpm --filter flowise-components build → SUCCESS
  - tsc completed, gulp finished, no type errors
  - dist/nodes/tools/Hindsight{Retain,Recall,Reflect}/*.{js,d.ts} all emitted
  - dist/credentials/HindsightApi.credential.{js,d.ts} emitted
  - Compiled JS correctly requires @langchain/core/tools,
    @vectorize-io/hindsight-client, and zod

The install path in the post is now build-verified, not just
copy-faithful to the README.

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

* blog(flowise): fix broken link to /developer

The build-docs and verify-generated-files CI jobs failed because the
post linked to /developer, but the developer-docs landing page has
slug: / (it's the docs root, not /developer).

Repoint the "Hindsight API reference" item to /developer/api/quickstart,
which is the actual API entry point and the link other recent posts
use.
2026-06-10 14:21:34 -04:00
Nicolò Boschi 621ab7e66b fix(api): widen history bank_id to TEXT on PostgreSQL (#2106) (#2110)
The split-history migration a7b8c9d0e1f2 declared observation_history.bank_id
(and mental_model_history.bank_id) as VARCHAR(64) on PostgreSQL, but the
backfill source memory_units.bank_id is TEXT (unbounded), as are banks,
documents and entities. Any deployment with a bank_id over 64 chars aborts the
backfill with StringDataRightTruncation; because the migration runs in lifespan
startup inside a transaction, the whole thing rolls back and the API never
comes up — unrecoverable from the running container.

The Oracle path is unaffected (both sides are VARCHAR2(256)), so the fix is
PostgreSQL-only.

- Correct a7b8c9d0e1f2 to create bank_id as TEXT. This recovers deployments
  that *failed*: the migration rolled back, so re-running the fixed DDL
  succeeds. Inert for deployments that already succeeded.
- Add forward-repair migration c3e5a7b9d1f4 (new head) that widens the column
  in place for deployments that already succeeded with the narrow column;
  no-op on already-TEXT columns, so all upgrade paths converge. Mirrors the
  b2d4f6a8c1e3 repair pattern.
- Add a regression test seeding the 78-char bank_id shape from the issue.

Fixes #2106
2026-06-10 18:11:42 +02:00
Nicolò Boschi 72d9881a6a fix(cli): parse get-memory response correctly (#2111)
The `hindsight memory get` command deserialized the API response into a
local `MemoryUnitDetail` struct whose shape had drifted from what
`GET /memories/{memory_id}` (MemoryEngine.get_memory_unit) actually
returns:

- `entities` is a flat list of canonical-name strings, but the struct
  expected a list of `{id, name}` objects, so serde failed whenever a
  memory had entities — surfaced to users as the misleading
  "Invalid API response format".
- the fact type is exposed as `type`, but the struct renamed it to
  `fact_type`, so the Type line always printed UNKNOWN.

The endpoint returns an untyped JSON body in the OpenAPI spec, so the
generated client never validates it and the mismatch only blew up in the
CLI handler. These commands had no test coverage (docs use curl).

Fix the struct to match the response and add regression tests.
2026-06-10 18:07:24 +02:00
Nicolò Boschi de22b606e7 feat(memory): reversible curation — edit/invalidate/revert memory units (#1976)
Edit (text/context/dates/fact_type/entities), invalidate (move to a separate
invalidated_memory_units archive, reversible), and revert raw memory units via
PATCH /memories/{id}. Tracks user edits with edited_at. Control-plane UI, docs
(Memories API page), and multi-language examples included. RFC #1951.
2026-06-10 17:20:59 +02:00
Ben 51d25d84a3 release(obsidian): v0.1.2 2026-06-10 10:59:20 -04:00
Ben c06e85b64f fix(obsidian): clear community-store review for 0.1.2 + asset attestations (#2107)
Obsidian community-store automated review (v0.1.1) flagged three errors and a
warning; this clears them and adds build-provenance attestations.

Errors:
- Manifest description must not include the word 'Obsidian' → reworded to
  '...cites the source notes. Your vault stays the single source of truth.'
- no-static-styles-assignment (chat-view.ts): el.style.height = ... →
  el.setCssStyles({ height }) per the plugin guidelines.
- no-unsupported-api (main.ts): Workspace.revealLeaf requires Obsidian v1.7.2 →
  bump minAppVersion 1.5.0 → 1.7.2 (matches the obsidian@^1.7.2 types we build
  against). versions.json 0.1.2 → 1.7.2.

Warning:
- builtin-modules dep → Node's built-in module.builtinModules in esbuild config;
  dependency removed.

Recommendation (build-provenance attestations for main.js/styles.css):
- Add actions/attest-build-provenance for the Obsidian assets in
  release-integration.yml (+ attestations: write). Assets release in the
  dedicated repo while the build runs here, so verify at owner scope:
  gh attestation verify main.js --owner vectorize-io.

Bumps manifest to 0.1.2. Verified with the bot's own linter
(eslint-plugin-obsidianmd): both code errors clear. build + tsc + 46 tests pass.
2026-06-10 10:58:13 -04:00
Nicolò Boschi 1d1f718ce5 feat(embed): seed .env configs from bundled .env.example template (#2105)
`hindsight-embed configure` and profile creation wrote a bare four-key
file. Seed them from the same `.env.example` shipped in the repo so users
get the full documented option set as commented references.

- Bundle a copy of the repo-root `.env.example` into the package
  (`hindsight_embed/env.example`) so installed/uvx users have the template
  at runtime; a sync test guards against drift.
- Add `env_template.render_config()`: everything is commented out by
  default — only the keys the user explicitly set are active, replaced in
  place (unknown keys appended). This keeps the active config byte-for-byte
  backwards compatible with the old bare file and prevents the template's
  api-server defaults (PORT=8888, OpenAI base URL, gpt-4o-mini, HOST) from
  leaking in and colliding profile ports / forcing the wrong base URL.
- Wire into both `configure` paths and `create_profile`.

Also document that new config flags must update `.env.example` (and re-sync
the bundled embed copy) in the config-addition checklist (CLAUDE.md) and the
code-review checklist.

The hindsight-api side already seeds `.env` from `.env.example`
(`scripts/dev/setup.sh`), so no change there.
2026-06-10 16:46:28 +02:00
Ben a814b97197 release(obsidian): v0.1.1 2026-06-10 09:38:58 -04:00
Ben 61f980f5a3 chore(obsidian): bump manifest + versions to 0.1.1
The release script bumps package.json but not manifest.json/versions.json, and
the community store requires the release tag to equal manifest.json's version.
Bump both ahead of the v0.1.1 release so the dist-repo mirror + BRAT tag match.
2026-06-10 09:38:27 -04:00
Ben fe404efb20 feat(obsidian): grounded-note citations, collapsed-by-default, persisted layout, inline depth (#2104)
Addresses chat-UX feedback:

1. 'Notes retrieved' showed the agent's whole scratchpad (every note any tool
   call touched — ~10 for a one-fact answer). Now shows only the notes the answer
   is grounded on: join based_on.memories (cited facts, no doc id) to the trace's
   recall results (id + document_id) by fact id, deduped by note in citation
   order. Capped at 3 visible with a 'Show all (N more)' toggle. Falls back to the
   full retrieved list only when nothing resolvable was cited (never empty).
2. Notes disclosure now defaults to collapsed (was always-open and noisy).
3. Both disclosures (notes + reasoning) remember the user's last open/closed
   state across sessions via two new persisted settings.
4. Chat depth (reflect budget) is now settable inline in the chat filter bar and
   written back to the persisted default, so the choice sticks.

No API change — match % is intentionally deferred (the score isn't exposed by the
API today). reflect-util.groundedNotes covered by new unit tests; 46 tests pass.
2026-06-10 09:36:05 -04:00
fb16fc4fdd feat(api): per-bank provider cost attribution via OpenAI user field (#1965)
* feat(api): per-bank provider cost attribution via OpenAI user field

Lets operators attribute Hindsight's provider spend per bank.

- Add a `_current_bank_id` engine ContextVar (mirroring the existing
  `_current_schema` pattern) bound in recall_async, retain_async,
  retain_batch_async, and execute_task, with a `get_current_bank_id()`
  accessor. Bindings use a token + finally reset.
- Add `HINDSIGHT_API_LLM_SEND_BANK_AS_USER` (bool, default off). When on,
  outbound OpenAI-compatible LLM and embedding calls are tagged with
  `user=<bank_id>` so downstream cost gateways (OpenRouter usage
  accounting, LiteLLM, Helicone) can key spend per bank. Injection is
  centralized per call_params construction site and never overrides a
  `user` the caller already set.
- Propagate the bank ContextVar into the embedding executor thread:
  generate_embeddings_batch now copies the current context before the
  run_in_executor offload (run_in_executor does not inherit contextvars),
  preserving the existing exception wrapping and 1:1 length validation.
- Make the OpenRouter reranker base URL configurable via
  `HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL` (default unchanged:
  https://openrouter.ai/api/v1/rerank) so rerank can route through a
  metering gateway. The URL is a credential field (not bank-configurable).

Tests cover ContextVar set/reset including on exception, user injection
gated on flag + bank presence + no caller override (chat and tool-calling
paths plus embeddings), real-executor context propagation, and the
configurable rerank base URL.

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

* refactor(engine): bind the bank ContextVar via decorator, not inline try/finally

The inline token/try/finally wraps re-indented the entire bodies of
execute_task, retain_batch_async, and recall_async — ~1,130 lines of
indentation-only churn in the diff for a ~40-line feature.

Replace the four inline bindings with a @_bind_bank_id decorator that
binds _current_bank_id from the method's bank_id argument (or a key in
a dict argument, for execute_task's task_dict) with the same token +
finally-reset semantics. Method bodies return to their original
indentation, shrinking the memory_engine.py diff to +51/-1.

Behavior is unchanged and now directly unit-tested: the decorator gets
its own tests for positional/keyword binding, dict-key extraction,
reset-on-exception, and non-string fallback to None.

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

* refactor(api): dedupe bank-attribution helper into shared module

Collapse the two identical _apply_bank_attribution copies (embeddings + OpenAI-compatible
LLM) into engine/bank_attribution.apply_bank_attribution. Add a docs note that the bank id
is transmitted to the provider as the end-user identifier, and de-pad the new config rows.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-10 15:10:38 +02:00
Nicolò Boschi 82c7df7266 feat(providers): add native Nous Portal provider (Codex-style OAuth, no hermes_cli dep) (#2102)
* feat(providers): add native Nous Portal provider (codex-style OAuth, no hermes_cli dep)

Adds a 'nous' provider that speaks the OpenAI-compatible wire format (thin
subclass of OpenAICompatibleLLM) and authenticates with the rotating,
inference-scoped JWT from a 'hermes portal' login — read natively from
~/.hermes/auth.json, exactly mirroring the Codex provider. No dependency on
the hermes_cli package.

- nous_auth.py: NousAuthManager reads providers.nous OAuth state, decodes the
  JWT exp for proactive refresh, and refreshes via POST {portal}/api/oauth/token
  (x-nous-refresh-token header). Atomic write-back of rotated tokens. Because
  the Hermes auth store is shared with a possibly-running Hermes agent, refresh
  takes the same ~/.hermes/auth.lock flock Hermes uses and re-reads the latest
  refresh_token from disk before exchange (single-use RT reuse-detection safety).
- nous_llm.py: thin subclass; proactive refresh offloaded to a thread so the
  event loop never blocks; one reactive refresh + retry on a 401.
- llm_wrapper.py: register nous in dispatch, validator, no-key set, base-url default.
- tests: auth-store load/refresh/persist/terminal-error + provider wiring (no
  Hermes install or network needed).

* docs(providers): document nous provider + add default model

- config.py: add nous to PROVIDER_DEFAULT_MODELS (deepseek/deepseek-v4-flash)
  so omitting HINDSIGHT_API_LLM_MODEL doesn't fall back to gpt-4o-mini.
- configuration.md: add nous to the provider list + an env example block.
- models.mdx: add a nous example + a 'Nous Portal Setup (Hermes)' section
  covering the 'hermes portal' login, the no-API-key flow, and automatic
  JWT refresh that coordinates with a running Hermes agent.
- skills/hindsight-docs: regenerated bundle from the docs sources.
2026-06-10 14:27:46 +02:00
Nicolò BoschiandChris Latimer a0e6bedcf1 refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface (#2077)
* Implement Memory Guard Lite for OSS

Allow users to prevent token and secret leakage in agent memory.
feat(memory-defense): reject quarantine action in policy parser

refactor(retain): drop quarantine branch from orchestrator

test(retain): remove quarantine-path tests

refactor(memory-defense): remove DefenseAction.QUARANTINE enum value

refactor(api): remove include_quarantined query parameter

refactor(recall): drop include_quarantined parameter from memory engine

test(memory-defense): replace stale parser-reject test with full-union accept test

The previous parametrized test asserted parse_policy() should 422 on any
detector name other than sensitive_data. That contract was deliberately
widened on 2026-06-07 so cloud-style policies pass through api-slim's
parser unchanged. The test was stale; the runtime is correct.

Replaced with test_parse_policy_accepts_full_detector_union, which proves
the actual contract: all 7 detector names are valid in the parser, with
dispatch and entitlement enforcement deferred to the loaded extension.

Memory defense UI

* i18n labels

* Fix tests

* Client changes to fix breaking tests

* Test fixes

* chore: regenerate API clients via generate-clients.sh

The clients were previously hand-generated in a way that diverged from the
project's tooling — including a non-standard hindsight-clients/typescript/client/
directory the generator never produces (the standard output is typescript/generated/),
plus ~150 spurious files.

Revert the entire hindsight-clients/ tree to main and regenerate from the
OpenAPI spec using ./scripts/generate-clients.sh (Rust via progenitor build.rs,
Python/Go via openapi-generator, TypeScript via @hey-api/openapi-ts). The spec
itself is unchanged (a code-regenerated spec is byte-identical to what was
already committed).

Net result is the real API delta only: the new nullable MemoryItem.receipt_uri
field propagated to the Python, TypeScript and Go models.

* refactor(memory-defense): per-bank regex defense, webhooks, drop dead surface

Review cleanup of the memory-defense feature:

- Rename the OSS extension Lite -> Regex (MemoryDefenseRegexExtension,
  memory_defense_regex.py). It is pure regex redaction now.
- Drop the agent_memory_guard (OWASP) dependency entirely — the
  SensitiveDataDetector fallback and to_owasp_policy are gone; nothing
  cloud-tier remains in api-slim.
- Trim the policy to what OSS enforces: { enabled, rules:[{on:sensitive_data,
  action}] }. Removed default_action, protected/immutable namespaces,
  detector_overrides, min_severity, and the unused
  memory_defense_enabled_default server default. Per-bank override stays
  (memory_defense is a configurable field) and the UI writes the trimmed shape.
- Fire a memory_defense.triggered webhook on every non-allow decision (redact
  and block) when one is configured, via the retain orchestrator. Adds
  WebhookEventType.MEMORY_DEFENSE_TRIGGERED + MemoryDefenseEventData. Replaces
  the no-op record_violation hook.
- Block is now actually enforced (drop item / 422 when all blocked) instead of
  being silently downgraded to redact.
- Remove the unused 'status' lifecycle: the add_status migration + its two
  merge migrations, the recall quarantine filter, the status column reads in
  search, and MemoryFact.status. Branch now adds zero migrations (single head).
- Remove receipt_uri from the API (MemoryItem) and clients — it was always
  None and carried no value.

Tests updated/renamed accordingly; OWASP smoke + enabled-default tests removed.

* fix(memory-defense): address code-review findings

- Delete test_migration_status.py (asserted the removed status column/constraint).
- Remove receipt_uri from the Rust CLI (memory.rs + integration_test.rs) — the
  generated client struct no longer has the field, so it wouldn't compile.
- Type the blocked-violations as a BlockedViolation dataclass instead of raw
  dicts (serialized via asdict() in the 422 body); type the webhook helper's
  decision param as DefenseDecision.
- Add an end-to-end test asserting a redact decision queues a
  memory_defense.triggered webhook delivery.
- Drop the unrelated docs/ entry from .gitignore (local scratch, not this PR).

* test(memory-defense): consolidate into a single test_memory_defense.py

Merge the 10 scattered memory-defense test modules (policy parser, regex
engine/screen, redaction benchmark, extension loader, extension-context
wiring, bank-config validation, and the three retain e2e files) into one
test_memory_defense.py, deduping the overlapping unit screen tests and the
duplicated retain redact e2e. 36 tests, same coverage.

* docs(memory-defense): document memory_defense.triggered webhook + block action

- Add memory_defense.triggered to the control-plane webhook event-type selector
  (it was firing but wasn't selectable in the UI).
- Document the memory_defense.triggered event (payload + data fields) on the
  webhooks API page, and link it from the Memory Defense page.
- Document the block action (the page only described redact) and add a
  Notifications section. Regenerate the docs skill copies.

* docs: remove Memory Defense page from version-0.7 (unreleased feature)

The feature was snapshotted into the 0.7 versioned docs by mistake — 0.7 never
shipped Memory Defense. Remove the page and its (sole) Security sidebar category.

* test(memory-defense): assert webhook payload fields + cover block path

- test_retain_fires_webhook_on_redact now parses the queued delivery and
  asserts the MemoryDefenseEventData payload (action/detector/matched_types/
  message + event status), not just that the event type was queued.
- Add test_retain_fires_webhook_on_block: a block decision fires the webhook
  (before the 422 is raised) with action=block. Confirms the delivery persists
  despite the blocked retain returning 422.
- Factor out _memory_defense_webhook_events() helper.

* fix(control-plane): render structured API error details as a string

A blocked retain returns 422 with detail {violations: [{message, ...}]}; the
proxy forwards it as `details` and the client passed that object straight into
the sonner toast, crashing with "Objects are not valid as a React child".
Add describeErrorDetails() to reduce details to a string — joining violation
messages when present (so a Memory Defense block shows e.g. "Sensitive data
pattern matched: aws_access_key"), else JSON-stringifying.

* docs(webhooks): clarify WebhookEvent.status covers the memory_defense action

* feat(memory-defense): record redact/block actions in the audit log

Emit a fire-and-forget 'memory_defense' audit entry for each non-allow decision
(alongside the webhook), with the action/detector/document_id/matched_types in
metadata. Threads the engine's AuditLogger into retain_batch like the webhook
manager; gated by the existing audit_log_enabled switch (off by default).

- Add the memory_defense option to the audit-logs UI action filter + the
  actionMemoryDefense i18n key across all locales.
- Document it on the Memory Defense page and the audit-logging config section.
- Test: a redact retain writes a memory_defense audit row with the expected
  metadata (audit enabled on the test engine).

---------

Co-authored-by: Chris Latimer <[email protected]>
2026-06-10 14:12:46 +02:00
BenandClaude Opus 4.7 fd848a18c1 blog: add truncate markers to oh-my-pi and 10k-stars posts (#2065)
Silences the Docusaurus build warning about untruncated blog posts.
Marker placed after the lead paragraphs so the blog index shows a
clean preview.

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-06-10 14:08:50 +02:00
Evo a0d91408cf docs: add gemini embedding 2 models (#2090) 2026-06-10 13:58:20 +02:00
Nicolò Boschi 0d55a9b78d feat(api): per-bank LLM connectivity probe (#2034)
Adds POST /v1/default/banks/{bank_id}/health/llm so operators can verify the LLMs a
bank uses for retain / consolidation / reflect actually connect — instead of
consolidation silently stalling when the LLM is unconfigured or unreachable.

- Deliberate (non-polled) probe: one minimal real call per unique LLM config —
  operations sharing a configuration are probed once and the result fanned out.
- Status only per operation: connected / not_configured / auth_failed (rejected —
  usually a wrong or expired API key, the most common failure) / unreachable / timeout.
  Never returns the provider, model, endpoint, API key, or raw provider error (the
  detailed error is logged server-side; the auth category is derived from a 401/403 or
  known auth markers in the error, and leaks nothing).
- Off by default (it makes a real provider call); enable with
  HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=true. Exposed as features.bank_llm_health on
  /version so the UI hides the action when disabled.
- Engine returns typed dataclasses; the handler holds no SQL and auth is enforced
  in the engine. The probe reuses the bank's per-operation LLM clients.
- Control plane: a "Health" item in the bank Actions menu opens an "LLM connectivity"
  dialog that probes on open (with a re-test button) and shows per-operation status,
  including a clear "Invalid API key" label for auth failures.
- Regenerated OpenAPI + Python/TS/Go clients; i18n across all 10 locales; tests in
  tests/test_bank_health.py.

A broader per-bank GET /health endpoint (#747) was explored but dropped as redundant
with the existing bank stats; only the connectivity probe is net-new.
2026-06-10 13:57:46 +02:00
Nicolò Boschi 90ee101bec fix(reflect): carry directives + language rule into final synthesis prompt (#2100)
The reflect final answer is a separate LLM call whose system prompt dropped the language rule and the bank's directives (they lived only in the agent/reasoning prompt), so weaker models intermittently drifted to English — the mechanism behind flaky multilingual reflect tests. build_final_system_prompt now re-injects the directives section + reminder and a default language rule; HINDSIGHT_API_LLM_OUTPUT_LANGUAGE stays the hard override. Deterministic prompt tests pin the behaviour; real-LLM language tests pass on gemini-2.5-flash and CI-tier gemini-3.1-flash-lite.
2026-06-10 11:10:05 +02:00
27cb1c6843 Support service_tier selection for Amazon Bedrock (#2098)
* feat: add HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER env var

Adds support for setting Bedrock service tier (flex/priority/reserved)
via environment variable, following the same pattern as the existing
Groq and OpenAI service tier support.

- config.py: env constant, default, dataclass field, os.getenv() load
- llm_wrapper.py: bedrock_service_tier param plumbing
- litellm_llm.py: inject service_tier kwarg for bedrock/ models
- configuration.md: table entry + Bedrock example block
- models.md/mdx: Bedrock tip block update

Closes #2072

* Add validation + tests for HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER

- validate() rejects invalid values (e.g. 'standard') with clear error
- Empty string treated as unset (matching llm_output_language pattern)
- Tests: default, flex, priority, reserved, invalid value, empty string

* fix(api): thread bedrock_service_tier from config into LLM providers

The new HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER flag was plumbed through
LLMProvider/create_llm_provider/LiteLLMLLM but nothing ever constructed
an LLMProvider with the resolved config value, so the env var was inert
(service_tier was never injected into the Bedrock call).

- memory_engine.py: pass bedrock_service_tier=config.llm_bedrock_service_tier
  to all four LLMConfig constructions (default/retain/reflect/consolidation)
- llm_wrapper.py: LLMProvider.from_env() reads the env var too, so ad-hoc
  constructions honor it
- test_bedrock_service_tier.py: plumbing tests asserting the tier reaches
  the LiteLLM call kwargs for bedrock/ models, is omitted otherwise, and
  is guarded off non-Bedrock models

* style(test): ruff format test_config_validation.py (fix verify-generated-files)

---------

Co-authored-by: Hermes Agent (Rob) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-10 10:41:33 +02:00
Nicolò Boschi a942b1c817 feat(api): add Gemini Batch API support for retain fact extraction (#2089)
Adds Gemini Batch API support for retain fact extraction (50% discount, 24h SLA) via the existing HINDSIGHT_API_RETAIN_BATCH_ENABLED flag. GeminiLLM overrides the 4 LLMInterface batch methods, adapting Gemini's upload->create->poll->download flow to the OpenAI-batch shapes the consumer expects (same pattern as FireworksLLM). Gemini-only (Vertex unsupported). Threads usageMetadata into the result body. Live-verified end-to-end. Gemini portion of #1144; consolidation batch is a follow-up.
2026-06-10 10:18:24 +02:00
Nicolò Boschi 0280b3486f feat(ui): export constellation as a shareable SVG poster (#2099)
Add a Share button to the constellation toolbar that downloads the
whole graph as a self-contained SVG poster: dark night-sky background
with a soft glow and plus-grid, the Hindsight logo inlined top-left,
and the nodes/links drawn with the exact canvas formulas (solid heat
dots + hub halos, thin faint colored links) — no labels. Fits the full
graph independent of the live pan/zoom.

Adds exportSvgTitle/exportSvgLabel to all locale message files.
2026-06-10 09:51:03 +02:00
Ben 96910071c9 fix(ci): npm provenance 409 guard + add Obsidian MIT LICENSE (#2094)
* fix(ci): treat npm provenance 409 (tlog) as already-published in release-integration

When a release tag is re-pointed and the workflow re-runs, npm publish --provenance
fails with TLOG_CREATE_ENTRY_ERROR / (409) 'equivalent entry already exists in the
transparency log' because the identical artifact was already logged by the prior run.
The package is already published, so this is benign — widen the already-published
guard to swallow it (alongside the existing 'cannot publish over').

* chore(obsidian): add MIT LICENSE for community-store submission

The Obsidian community-store review bot requires a LICENSE file at the plugin
repo root. The dist repo (vectorize-io/hindsight-obsidian) is mirrored from this
directory via the release workflow, so adding it here propagates on next release.
2026-06-09 16:26:40 -04:00
Evo 68d947b8db docs(integrations): add Gemini Spark page + grid entry (#1779) (#1943) 2026-06-09 16:22:11 -04:00
Ben c815329c14 chore(cursor): ruff-format drift in cursor integration tests (#2095)
The cursor integration test files were committed without ruff formatting,
causing verify-generated-files to fail on every PR branched off main. Run
the formatter to bring them in sync (no logic changes).
2026-06-09 16:15:04 -04:00
Chris Bartholomew 41b6e5746a blog: Hindsight is the fastest-growing open-source AI memory project ever (#2092)
* blog: Hindsight is the fastest-growing open-source AI memory project ever

Equal-age GitHub star analysis (per-star timestamps) plus third-party
validation from OSSCAR (#10 fastest-growing OSS org, ahead of Mem0) and
dope.security (#1 MCP server in enterprise traffic). Adds cdbartholomew
to blog authors.

* blog: add truncate marker, featured image, fix Slack invite link

- Add <!-- truncate --> after the lead (fixes the build warning addressed
  repo-wide in #2065)
- Add featured/social image and hero image
- Replace workspace login URL with the canonical join.slack.com invite

* blog: clean up featured image (remove curve overlapping the headline)

* blog: add captured star-history chart (Hindsight steepest slope); align featured image to brand palette

- Embed a static capture of the overlaid star-history graph in the
  'still accelerating' section; Hindsight shows the steepest slope of
  any project. Replaces the unreliable live-URL embed (rate-limited).
- Recolor the featured/OG card to the Hindsight brand palette
  (#0074d9 -> #009296 gradient, #09090b background) instead of off-palette mint.

* blog: add star-history chart to featured image (text left, chart right)
2026-06-09 15:47:08 -04:00
Ben 85402035ed fix(ci): override git auth with OBSIDIAN_DIST_TOKEN for obsidian mirror push (#2093)
* fix(ci): override git auth header with OBSIDIAN_DIST_TOKEN for mirror push

The previous fix (unsetting the checkout extraheader) wasn't enough: the runner
also authenticates github.com via a git credential helper, so the subtree push
still ran as github-actions[bot] (403 on the dedicated repo). Override the
Authorization header with the dist token via `git -c` — an explicit header
beats both the checkout header and the helper, and propagates to subtree's
internal push via GIT_CONFIG_PARAMETERS.

* fix(ci): unset bot extraheader before overriding with dist token

http.extraheader is multi-valued, so adding our -c header on top of the
checkout's bot header sent two Authorization headers → GitHub 400. Unset the
checkout header first so only the OBSIDIAN_DIST_TOKEN header is sent.

* fix(ci): reset credential helper so only the dist-token header is sent

The runner's git credential helper was injecting the bot Authorization on top
of our extraheader → 'Duplicate header: Authorization' (400). Reset the helper
chain with -c credential.helper= so only the OBSIDIAN_DIST_TOKEN header remains.

* fix(ci): isolate git context for the obsidian mirror push + diagnostics

Push kept sending a duplicate Authorization from a config scope local --unset
didn't reach. Split locally and push from an isolated context (global/system
config nulled, helper disabled, local extraheader unset) with the token in the
push URL → a single Authorization. Also dump auth-config origins for diagnosis.

* fix(ci): reset the inherited extraheader to push as OBSIDIAN_DIST_TOKEN

Diagnostic showed the runner injects the bot token as an http.extraheader via
an *included* config file (no credential helper), which --unset-all can't touch.
Reset the extraheader list with an empty -c value (read last → clears it at
request time) and auth via the push URL → a single dist-token Authorization.
2026-06-09 15:46:49 -04:00
Ben 3f025c1fc3 release(cursor): v0.2.0 2026-06-09 15:21:22 -04:00
Ben 37a20ec524 fix(ci): use OBSIDIAN_DIST_TOKEN for the obsidian mirror push (#2091)
actions/checkout sets an http extraheader for the default GITHUB_TOKEN that
overrode the token embedded in the subtree-push URL, so the push ran as
github-actions[bot] (no access to the dedicated repo → 403). Unset that header
before the push so OBSIDIAN_DIST_TOKEN is used.
2026-06-09 14:26:57 -04:00
Ben a67a8f774d fix(obsidian): mirror plugin to a dedicated repo instead of releasing in the monorepo (#2078)
* fix(obsidian): stop creating a GitHub Release per plugin version

Per-integration GitHub Releases pollute the repo's release list (meant for
the core Hindsight product) and steal the 'Latest' badge — the obsidian
v0.1.0 release displaced v0.8.0. BRAT / the community store also can't target
a tag inside a multi-release monorepo (they read a repo's *latest* release),
so the step never gave working BRAT distribution anyway.

- Remove the 'Attach Obsidian release assets' step from release-integration.yml
  (replaced with a comment explaining why; npm publish is unchanged).
- Point BRAT install instructions at the dedicated repo
  vectorize-io/hindsight-obsidian in the integration README and docs page.
- Add a 'Distribution & maintainers' section documenting the two-repo setup so
  plugin updates are released to both (monorepo = source of truth + npm,
  dedicated repo = BRAT / community-store releases).

* ci(obsidian): mirror plugin to dedicated repo via subtree on release

Instead of manually maintaining two repos, the release workflow now mirrors
hindsight-integrations/obsidian/ to the root of vectorize-io/hindsight-obsidian
(git subtree push --prefix) and cuts the BRAT / community-store GitHub Release
there — the monorepo stays the single source of truth.

- Add the 'Mirror Obsidian plugin to its dedicated repo' step to
  release-integration.yml (unshallow → subtree push → idempotent release).
  Needs secret OBSIDIAN_DIST_TOKEN (contents:write on the dedicated repo).
- Drop the now-unused 'contents: write' permission (no releases are created in
  this repo anymore).
- Rewrite the README 'Distribution & maintainers' section: the mirror is
  automatic, the dedicated repo is generated, don't edit it directly.
2026-06-09 13:47:50 -04:00
91d767cdcb feat(cursor): add Hindsight memory plugin for Cursor (#866)
* feat(cursor): add Hindsight memory plugin for Cursor

Adds a complete Cursor integration using the plugin architecture
(hooks, skills, rules). Automatically recalls relevant memories
before each prompt and retains conversation transcripts on task
completion. Modeled after the claude-code integration with
Cursor-specific adaptations.

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

* docs(cursor): add integration docs, blog post, and sidebar entry

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

* docs(cursor): clarify plugin vs MCP modes, add hook diagnostics

- Add plugin-vs-MCP comparison table near top of integration doc
- Add "Verifying Plugin Hooks" section with state file commands
- Add troubleshooting note: visible tool calls = MCP, not plugin
- Write last_retain.json state file in retain.py for diagnostics
- Add mode: plugin and query_length to recall state file
- Fix test_settings_file_loaded to isolate from user config

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

* fix(cursor): install path, always-write diagnostics, Cloud snippets

- Add mkdir -p before cp -r in all install examples (first-run fix)
- Add "fully quit and reopen Cursor" note to all setup flows
- Recall/retain hooks now write status on every invocation
  (success, empty, skipped, error) not just on success
- Fix docs to show ~/.hindsight/cursor-state/ default path
- Add concrete Hindsight Cloud config snippet to Quick Start
- Add Cloud option to blog post setup section

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

* fix(cursor): add session field to dynamic bank IDs, add changelog

- Support "session" in dynamicBankGranularity for per-conversation banks
- Add changelog page for cursor integration
- Add test for session-based dynamic bank ID

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

* fix(cursor): sync integration README with cookbook/blog setup guidance

- Add mkdir -p for plugin install path
- Add "fully quit and reopen Cursor" instruction
- Show Cloud as Option A, local as Option B, daemon as Option C
- Match the setup flow documented in the cookbook and blog

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

* feat(cursor): add pip/uvx installer, fix review findings

- Add hindsight_cursor package with CLI `init` and `uninstall` commands
- Add pyproject.toml for PyPI publishing via existing release pipeline
- Update README install path: `pip install hindsight-cursor && hindsight-cursor init`
- Fix rule/skill files to describe plugin behavior instead of MCP tools
- Add diagnostics on get_api_url failure paths in both hooks
- Remove missing assets/avatar.png reference from plugin manifest
- Add Cloud token retrieval guidance (Settings > API Keys)
- Add test_cli.py with 8 tests for init/uninstall commands

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

* fix(cursor): daemon timeout, config defaults, full config docs

- Set daemonIdleTimeout default to 300s (was 0/infinite with no cleanup hook)
- Fix retainEveryNTurns fallback from 1 to 10 in retain.py
- Fix DEFAULTS: hindsightApiUrl="" and bankId="cursor" to match settings.json
- Document all config settings in README (was missing ~15 entries)
- Fix pytest version discrepancy in pyproject.toml
- Fix plugin.json author to "Vectorize" for consistency

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

* docs(cursor): streamline setup with init flags, add Docker instructions

- Restructure Quick Start around Cloud vs Local as two clear paths
- Use hindsight-cursor init --api-url/--api-token for one-command setup
- Add Docker run command for users without a local Hindsight server
- Remove separate "configure" step that contradicted init behavior

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

* refactor(cursor): replace beforeSubmitPrompt with sessionStart + MCP

beforeSubmitPrompt does not support additionalContext in Cursor's hook
system — the old recall.py was silently ignored. This rewrites the
architecture to use Cursor's native mechanisms:

- sessionStart hook for ambient project-level recall (supports additionalContext)
- MCP integration for on-demand recall/retain/reflect tools mid-session
- stop hook for auto-retain (unchanged, works correctly)

Also fixes Python floor (3.9 -> 3.10, pytest 9 requires it) and
updates docs/blog to match the new architecture.

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

* fix(cursor): workaround broken sessionStart additionalContext

Cursor's sessionStart hook accepts additionalContext output but silently
drops it before the agent's composer handle is ready — a race condition
acknowledged by Cursor staff in 2026-04, still present in 3.6.31
(verified 2026-06-02 with a marker-emitting test hook). Without a
workaround the plugin's "auto-recall memories at session start" feature
silently does nothing in every install.

Per Cursor staff guidance (Dean Rie, thread 158452), the documented
escape hatch is to write a workspace .cursor/rules/<file>.mdc with
alwaysApply: true — the rules engine injects those reliably. Plugin-
local rules dirs (~/.cursor/plugins/local/...) are NOT reliable per
thread 159101.

Implementation:

- scripts/lib/rules_file.py (new): owns the workaround. Three helpers:
    * rotate_session_rules() — deletes any prior rules file at the top
      of each sessionStart so an empty recall doesn't leave stale
      memories from a previous session.
    * write_session_rules() — writes the .mdc with alwaysApply: true,
      an HTML comment that explains what the file is and links to the
      Cursor bug, and the recalled memories inside a
      <hindsight_memories> block (same wrapper the broken native path
      used, so the static rules guidance is unchanged).
    * ensure_gitignored() — idempotently appends the file path to
      <workspace>/.gitignore when the workspace is a git repo. No-ops
      otherwise. Matches both /-anchored and bare relative forms so we
      don't double-add against an existing entry.

- scripts/session_start.py: rotates at the top, writes the fallback
  file after recall succeeds, gates both behind config flags
  (useRulesFileFallback, appendToGitignore, both default True). Still
  emits additionalContext to stdout below — when Cursor fixes the
  upstream bug, dropping the workspace write is the only code change
  needed; the same plugin works on the native path with no protocol
  rev.

- scripts/lib/config.py: two new config keys + HINDSIGHT_USE_RULES_
  FILE_FALLBACK / HINDSIGHT_APPEND_TO_GITIGNORE env overrides.

- rules/hindsight-memory.mdc: tells the agent where recalled memories
  now appear (the new .cursor/rules/hindsight-session.mdc file) and
  notes that the file is plugin-generated and safe to delete.

- tests/test_rules_file.py: 18 tests pinning the on-disk shape:
  frontmatter, alwaysApply, bug link, rotation, idempotent gitignore
  with both anchor forms, falsy workspace handling, write-error
  degradation.

Why this design (vs. alternatives):

- Just shipping MCP-only and documenting the limitation would repeat
  the OpenAI Agents notebook-10 Pattern-1 failure mode: the agent has
  to choose to call recall, and small models reliably skip it. Auto-
  inject doesn't depend on tool-call choice.
- Reverting to beforeSubmitPrompt would mean a recall per turn instead
  of per session, and Cursor staff have signalled additional_context
  on that hook is unimplemented (forum 150707).
- The workspace file is the price of Cursor's bug being open with no
  ETA. Mitigations: auto-rotate, auto-gitignore, in-file explanatory
  comment, config opt-outs.

Verification:

- Full suite: 74 passed (56 prior + 18 new).
- Smoke end-to-end against a fresh git repo: rules file written with
  correct frontmatter, .gitignore appended cleanly with both an
  explanatory comment and the path entry, no duplicate-add on re-run.

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

* test(cursor): adopt requires_real_llm bucketing + live E2E + lockfile + docs

Aligns cursor with the standing test-bucketing convention from PR #1469
("Split test suite into deterministic mock and real LLM buckets") that the
other eight Python integrations already follow.

Changes:

- pyproject.toml: register the `requires_real_llm` marker so the live
  E2E suite is selectable as a discrete bucket (and excluded from the
  deterministic CI path via `pytest -m "not requires_real_llm"`). Add
  hindsight-client as a dev dep — the E2E driver needs it to seed and
  verify banks; the runtime plugin scripts still use stdlib only.

- tests/test_e2e.py (new): four-test gated suite that drives the actual
  hook scripts the way Cursor does — JSON on stdin, env vars for config
  — against a live Hindsight server. Covers:
    1. session_start writes the rules-file workaround with recalled
       content, appends `.gitignore`, and emits the forward-compat
       `additionalContext` to stdout.
    2. empty-bank case: hook succeeds without writing a rules file.
    3. opt-out: `useRulesFileFallback=false` produces no `.cursor/` or
       `.gitignore` mutations even when recall surfaces content.
    4. retain end-to-end: drives `retain.py` with a JSONL transcript
       (the on-disk shape Cursor actually emits, not an inline messages
       array), then verifies the bank holds the fact via direct recall.

  Two non-obvious fixtures the suite needs:
  - `HOME` / `CURSOR_PLUGIN_DATA` redirected to tmp so the test doesn't
    touch the developer's real `~/.hindsight/cursor.json` or state.
  - `HINDSIGHT_BANK_MISSION` overridden to a focused mission that aligns
    with the seeded fixtures — the production default mission is broad
    boilerplate, fine for real users but too diffuse to reliably
    surface targeted test content within a deadline.
  - `HINDSIGHT_RETAIN_EVERY_N_TURNS=1` because retain.py batches every
    N turns (10 by default) and a single-shot test only has one turn.

- uv.lock: committing per the convention every other Python
  integration follows. 258 KB, 29 packages resolved, `uv lock --check`
  clean.

- README.md: new "How session memory reaches the agent" section
  documenting why the plugin writes `<workspace>/.cursor/rules/
  hindsight-session.mdc` (Cursor's native `additionalContext` channel
  is broken, forum thread 158452, still open in 3.6.31). Captures the
  empirically-verified behaviour: Cursor blocks prompt submission
  until sessionStart returns, so every new agent's first prompt has
  memories, the rules file is regenerated each session, and the file
  is auto-gitignored. Two new config knobs (`useRulesFileFallback`,
  `appendToGitignore`) added to the Session Recall table.

Verification:
- Deterministic bucket: 74 pass / 4 deselected (the new gated E2E).
- Live bucket (HINDSIGHT_API_URL=http://127.0.0.1:8888): 4 pass.

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

* fix(cursor): default to hosted backend + give each retain a distinct document_id

V2 audit (2026-06-02) caught two real bugs in cursor that were missed by
the V1 pass:

1) Goal-5 (Default to Cloud) FAIL — settings.json shipped
   hindsightApiUrl='' and the daemon path treated empty as "fall back to
   local daemon at 127.0.0.1:9077". Users following the docs ("just enable
   the plugin") never reached the hosted backend without explicitly
   passing --api-url. Every other integration's empty-config path lands on
   https://api.hindsight.vectorize.io.

2) The retain path used document_id=session_id in full-session mode,
   which silently upserts the same Hindsight document on every retain.
   The audit's 5-turn distinct-fact driver exposed this as "5-turn cloud
   → 1 topic surfaced" — earlier turns got overwritten because each
   retain rewrote the single per-session document with whatever
   transcript snapshot was current.

Both are addressed below; the live test suite still passes against the
local server and the new deterministic tests pin the cloud-default
resolution + the unique-document-id derivation.

Changes:

- scripts/lib/config.py — add ``DEFAULT_HINDSIGHT_API_URL`` constant
  (``https://api.hindsight.vectorize.io``). Add ``useLocalDaemon`` flag
  (default ``False``) so self-hosters can opt back into the auto-managed
  daemon path. New env override ``HINDSIGHT_USE_LOCAL_DAEMON``.

- scripts/lib/daemon.py — rewrite ``get_api_url`` resolution:
    1. Explicit ``hindsightApiUrl`` wins.
    2. A locally-running server on the configured port is used (preserves
       the "developer already started a daemon" path).
    3. ``useLocalDaemon=True`` AND ``allow_daemon_start=True`` (retain
       path) triggers the auto-managed daemon. Recall path never starts a
       daemon on its own.
    4. Otherwise → ``DEFAULT_HINDSIGHT_API_URL``. A failed daemon-start
       under (3) also falls back here rather than hard-erroring, so the
       plugin keeps working when ``hindsight-embed`` isn't on PATH.

- scripts/retain.py — every retain now derives
  ``document_id = f"{session_id}-{int(time.time() * 1000)}"`` regardless
  of retainMode. The chunked-vs-full-session distinction at the doc-id
  layer was always a misfeature; full-session mode now means "the
  transcript ingested per retain may span the whole session", not "every
  retain writes the same document".

- tests/test_daemon.py (new) — pin the four-tier resolution + env
  override + the source-shape of retain.py's document_id derivation.

Verification:
- Deterministic bucket: 81 pass / 4 deselected (74 prior + 7 new).
- Live bucket: 4 pass / 0 fail against 127.0.0.1:8888.
- Manual smoke for empty-config → returns ``DEFAULT_HINDSIGHT_API_URL``.
- Live server still resolves to ``http://127.0.0.1:8888`` when healthy.

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

* fix(cursor): parse Cursor 3.x role-nested transcript format

retain.py's read_transcript only recognized two transcript shapes:
- Flat:        {role, content}
- Type-nested: {type: "user"|"assistant", message: {role, content}}

Cursor 3.6.31 writes a third shape to its stop-hook transcript:

  {"role":"user","message":{"content":[
    {"type":"text","text":"..."},
    {"type":"tool_use","name":"...","input":{...}}
  ]}}

Top-level has `role` (not `type`), and `content` lives under `message`
as a list of typed blocks (not at the top level as a string). The old
parser's two branches both missed every line: `entry.get("type")` was
None and `"content" in entry` was False. read_transcript silently
returned [] for every Cursor 3 transcript, and retain.py bailed with
status=skipped reason=empty_transcript on every stop hook.

Visible symptom: auto-retain silently stops working under Cursor 3
even though the stop hook fires correctly and transcript_path points
at a real, populated file (verified by reading
~/Library/Application Support/Cursor/logs/.../cursor.hooks.*.log —
the input JSON includes a valid transcript_path that the parser then
ignores). End users see recall continue to work (sessionStart writes
the rules-file workaround) but new turns never get retained.

Fix:
- Add _normalize_blocks_to_text to flatten typed-block lists to a
  single string, inlining a compact [tool_use:<name>] marker so
  downstream Answer:/Thought: handling still sees coherent structure.
- Recognize the role-nested Cursor 3 shape explicitly.
- Keep flat and type-nested handling intact.

Verified end-to-end against a real Cursor 3.6.31 transcript captured
from ~/.cursor/projects/.../agent-transcripts/<conv>/<conv>.jsonl:
read_transcript now returns the 15 messages it should (1 user + 14
assistant turns) instead of 0.

Regression tests (3 added):
- test_read_transcript_parses_flat_format pins the flat shape.
- test_read_transcript_parses_type_nested_format pins the type-nested
  shape.
- test_read_transcript_parses_cursor3_role_nested_with_block_content
  is the regression: fails on the pre-fix parser (returns []), passes
  now. Also asserts the [tool_use:Shell] marker survives.

14/14 tests in test_hooks.py pass.

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

* chore(docs): drop missing image refs in cursor blog post

The 2026-04-03 cursor-persistent-memory blog references
/img/blog/cursor-persistent-memory.png in both frontmatter and
inline markdown, but the image was never added to the repo. build-docs
fails MDX compilation with "Markdown image with URL
/img/blog/cursor-persistent-memory.png couldn't be resolved to an
existing local image file".

Strip the two references so the post renders. The prose stands on its
own without an illustration; an image can be added in a follow-up PR
if/when one is produced.

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

* chore(cursor): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of the OperationProgress schema.
check-openapi-compatibility flagged the missing 'progress' field on
GET /v1/default/banks/{bank_id}/operations/{operation_id} as a
backwards-incompatible removal.

Re-checkout main's openapi.json onto the branch.

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

* chore(cursor): drop cursor-persistent-memory blog post

The blog post was added as marketing for the Cursor integration but
the accompanying illustration was never produced. Earlier commit
0e4b2568 stripped the missing image references so build-docs would
pass; user prefers the blog post itself be dropped from the integration
PR and authored separately.

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

* chore(cursor): ruff format scripts + generate docs-skill changelog

verify-generated-files CI flagged drift in three cursor scripts
(scripts/lib/daemon.py, scripts/retain.py, scripts/session_start.py)
and a missing skills/hindsight-docs/.../integrations/cursor.md.

- scripts: applied ruff format/check (3 files reformatted, all checks
  pass).
- generate-docs-skill.sh produced the integrations/cursor.md changelog
  mirror.

Format-only + a generated file regeneration; no behaviour changes.
All cursor tests pass.

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

* ci: re-trigger CI

A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.

* ci: empty commit to attach pull_request CI check to the PR head

(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)

* ci: trailing newline to force CI retrigger

* fix(cursor): address review — drop dead code, register changelog + gallery

- Remove compose_recall_query / truncate_recall_query from scripts/lib/content.py
  (ported from openclaw but unused — cursor only recalls at sessionStart) and
  their test; slice_last_turns_by_user_boundary stays (used by retain.py).
- Add cursor to the INTEGRATIONS map in generate_changelog.py so the release
  changelog step resolves the slug.
- Add the integrations.json gallery entry + icon and rely on the existing
  docs-integrations/cursor.md so check-integrations.mjs passes.

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Ben <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-09 09:50:22 -07:00
Nicolò Boschi 22ae72a907 feat(api): support gemini-embedding-2 family (per-input embedding) (#2087)
Closes #1139. The gemini-embedding-2 multimodal models aggregate a multi-input request into one embedding, breaking 1:1 input->vector alignment. Force batch size 1 (one input per call) for that family, keep batching for gemini-embedding-001, and raise a clear error on misaligned counts. Adds unit tests + a real Vertex integration test that skips when the preview model isn't enabled.
2026-06-09 18:08:14 +02:00
Ben 4cb78173ba release(cline): v0.2.0 2026-06-09 11:55:01 -04:00
Ben 36e31c675c feat(cline): ship as pip-installable hindsight-cline package (#2088)
The docs told users to run `python /path/to/.../install.py` with no way to
obtain that file (no pip package, no clone step) — effectively unusable. Bring
cline in line with roo-code and cursor-cli by shipping it as a pip package.

    pip install hindsight-cline
    hindsight-cline install --api-url ... --api-token ...
    hindsight-cline uninstall

- Move the install logic into a hindsight_cline package with an argparse CLI
  (install/uninstall subcommands) exposed via a console_scripts entry point.
- Bundle the hook payload (4 hook scripts + lib/ + settings.json) as package
  data under hindsight_cline/hooks/, read via importlib.resources.
- Add pyproject.toml (hatchling), LICENSE, py.typed, uv.lock.
- Switch the CI job to uv build + uv sync --frozen + uv run pytest.
- Update README, docs page, and the launch blog post to the pip flow.

The changelog generator already maps cline -> hindsight-cline. Detecting a
pyproject.toml, the release workflow now publishes hindsight-cline to PyPI
(first release needs a PyPI pending publisher).
2026-06-09 11:53:48 -04:00
Ben 1c133dbd6c release(cursor-cli): v0.2.0 2026-06-09 11:18:22 -04:00
Ben c6dd089445 feat(cursor-cli): ship as pip-installable hindsight-cursor-cli package (#2083)
Convert the Cursor CLI integration from a git-clone + ./scripts/install.sh
flow to a pip-installable package with a `hindsight-cursor-cli install` CLI,
matching roo-code and the other Python integrations.

- Add pyproject.toml (name: hindsight-cursor-cli) and console script
- Add hindsight_cursor_cli/ package: cli.py + install.py, a Python port of
  the old install.sh/uninstall.sh
- Bundle the hook payload (scripts/, settings.json, hooks.json) as package
  data under hindsight_cursor_cli/hooks/; the installer deploys it to
  ~/.cursor/hooks/cursor-cli/ and merges the hook registry
- pyproject is the single source of truth for the version; the installer
  stamps it into the deployed settings.json (used by client.py's User-Agent)
- Remove scripts/install.sh and scripts/uninstall.sh
- Add test_install.py + test_cli.py; retarget existing hook tests
- Switch the CI job to the uv build/sync/test flow
- Update README + docs to `pip install hindsight-cursor-cli`
2026-06-09 11:16:44 -04:00
Ben ba44e0205d docs(icons): use real Cline brand mark in place of placeholder (#2086)
The existing cline.svg was a hand-drawn placeholder — dark rounded
box with two blue eyes and an antenna — that doesn't match Cline's
actual logo. Swap it for the official Cline AI mark (black squircle
with two pill cutouts and a small knob on top).

Source: uxwing.com/cline-ai-icon — licensed for commercial use
without attribution.
2026-06-09 11:14:02 -04:00
Ben d5df9ad083 blog: Cline persistent memory (lifecycle hooks, no MCP) (#2085)
* blog: add Cline persistent memory integration post

Walkthrough of the new Hindsight + Cline integration that wires up
persistent memory via Cline's lifecycle hooks (no MCP). Covers the
four hooks, install + config, per-project and team memory patterns,
and tradeoffs.

Cover is a placeholder (Codex art) for now — swap before merging.


* blog(cline): replace em-dashes with contextual punctuation

Targeted sweep replacing 21 em-dashes with the appropriate punctuation
(commas / semicolons / periods / colons) given each surrounding clause.
The table-cell placeholder on the "Model tool-calling needed" row
becomes "n/a" so the column still reads as "not applicable for the
default."

Code blocks, URLs, file paths, and the ASCII flow diagram (which uses
U+2500 box-drawing characters, not em-dashes) are untouched.

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

* blog(cline): explain why Cloud matters for the Cline workflow

Expand the Cloud-section intro to cover the Cline-specific wins:
multi-machine VS Code sync, no LLM key in the hook environment, and
no local hindsight-api to keep running while doing dev work.


* blog(cline): swap placeholder cover for Hindsight x Cline card
2026-06-09 10:56:16 -04:00
Nicolò Boschi d7ff44b984 chore(integrations): apply CI ruff formatting to haystack + roo-code tests (#2082)
Clears the verify-generated-files drift: CI lints all integrations
(LINT_ALL_INTEGRATIONS) and reformats these recently-added test files,
which were committed without CI-mode formatting and so showed as drift
on every PR.
2026-06-09 16:19:12 +02:00
Nicolò Boschi 3069bb41af docs: changelog and blog post for v0.8.1 (#2080)
* docs: changelog and blog post for v0.8.1

* docs(blog): drop integrations section; add hs-release skill

* docs(hs-release): make changelog worktree a fallback, not a required step

* docs(blog): fix broken 0.8.0 cross-link (date-based blog URL)
2026-06-09 15:33:16 +02:00
Nicolò Boschi 4dc149a1ac Release v0.8.1
- Update version to 0.8.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.8
2026-06-09 14:55:08 +02:00
Nicolò Boschi 1296e9fc12 feat(api): config flag to skip storing raw document text (#2061) (#2062)
* feat(api): add HINDSIGHT_API_STORE_DOCUMENT_TEXT flag to skip raw text storage

When set to false, the retain pipeline runs unchanged (chunking, fact
extraction, embedding, entity linking) but drops the raw source text:
documents.original_text is stored as NULL and chunks.chunk_text as empty.
content_hash is still computed from the real text so delta-retain dedup is
unaffected, and recall is unaffected because it reads from memory_units.

Closes #2061

* feat(api): reject append + drop source-text reads in privacy mode

Follow-up to the HINDSIGHT_API_STORE_DOCUMENT_TEXT flag, covering the
features that read raw document/chunk text back:

- retain update_mode='append' is now rejected when text storage is
  disabled (it rebuilds the document from the stored original_text, which
  is NULL, and would silently drop prior content).
- reflect no longer offers the 'expand' tool (get chunk/document source),
  gated via get_reflect_tools(include_expand=...); the hallucination guards
  no longer hardcode 'expand' as always-allowed.
- reflect's recall step no longer attaches empty source chunks.

Other read sites (get-document/list-chunks/get-chunk endpoints + MCP,
export/import, public recall include_chunks) already degrade gracefully to
empty/None and are documented.

* fix(api): get-document 200 with null text + 400 on append in privacy mode

Caught while testing the flag live against a running server:

- DocumentResponse.original_text was a non-optional str, so the GET
  document endpoint raised ResponseValidationError -> HTTP 500 when the
  text is NULL. Made it str | None.
- The retain handler mapped all exceptions (including the append-rejection
  and duplicate-document_id ValueErrors) to HTTP 500. Map ValueError to 400,
  matching the convention used by the other endpoints.

Adds an HTTP-level regression test (the engine-level test passed because
get_document returns a dict, bypassing response-model validation).

* feat(ui): warn when document text storage is disabled

Surface the store_document_text flag so the control plane can warn users
that raw source text isn't persisted:

- /version feature flags now include store_document_text (regenerated
  OpenAPI spec + SDK clients; also picks up the earlier DocumentResponse
  original_text optional change).
- features-context exposes it (defaults true, so the warning only shows
  when the server explicitly reports privacy mode).
- Document detail dialog: the Content tab shows a small notice instead of
  an empty body when text isn't stored.
- Add Document dialog: a small notice that raw text won't be kept.
- i18n strings added across all locales.

Adds an API test asserting /version reports the flag.

* refactor: drop "privacy mode" wording; reposition document-text warnings

- Remove the "privacy mode" phrasing I had introduced from comments,
  docstrings, test names, docs, and UI labels. The flag is described by
  what it does (skip storing raw document text) instead.
- Add Document dialog: move the warning to just above the action buttons.
- Document dialog: also show the warning on the Chunks tab.

* fix(cli): handle optional document original_text

original_text is now Option<String> in the generated client (it can be
null when document text storage is disabled), so the CLI can't print it
with {} directly. Show "(not stored)" when absent.
2026-06-09 14:46:09 +02:00
Nicolò Boschi 109e1bd955 fix(api): stop forcing vchordrq.probes session GUC on listless vchord indexes (#2076)
Hindsight set a session-level vchordrq.probes override (10/30) for the
vchord backend, but VectorChord requires the probes value to match each
index's build.internal.lists hierarchy. Hindsight's built-in vchordrq
index clause does not set lists, so it is listless and expects 0 probes;
the session GUC supplies 1, and every query on that pooled connection
fails with "need 0 probes, but 1 probes provided".

On vchord deployments this rejects retain completions after extraction
succeeds, so the worker retries forever and the queue fills with stuck
retain ops that block consolidation.

Drop the vchord entries from the ANN tuning dispatcher so no session
probe override is applied; deployments that partition vchordrq indexes
should attach probes via index storage fallback parameters (VectorChord
1.1) instead. pgvector hnsw.ef_search tuning is unchanged.

Refs #1667.
2026-06-09 13:54:36 +02:00
Nicolò Boschi c0f0c3a769 fix(control-plane): drop locale slug from URLs (localePrefix never) (#2075)
Bank selection was lost on refresh for non-default locales because the
locale prefix (e.g. /es/banks/x) defeated path parsing in bank-context.
Switch next-intl to localePrefix "never" so the locale is resolved from
the NEXT_LOCALE cookie and never appears in the URL. Paths stay clean
(/banks/x) for every language, so the existing ^/banks/ parsing works.

Also removes the now-dead stripLocalePrefix() helper in middleware.

Supersedes #2070.
2026-06-09 13:29:20 +02:00
Nicolò Boschi 6ba4aeaf03 chore: format test files with ruff (enable formatter on tests/) (#2074)
Tests were excluded from both ruff lint and format via the top-level
[tool.ruff].exclude in hindsight-api-slim, hindsight-embed and the shared
ruff.toml. As a result test files drifted from the formatter's style and
every PR that touched a test (or ran format-on-save) carried large
formatting-only churn.

Move the tests exclude into [tool.ruff.lint].exclude (and [lint].exclude in
ruff.toml) so the formatter now covers tests while lint rules — too noisy for
test code (unused imports/vars, import ordering) — stay excluded. Then run
ruff format across all test directories.

Note: lint.exclude is a post-traversal path filter, so it needs the glob form
'tests/**' rather than the directory form 'tests/' used by top-level exclude.
2026-06-09 13:23:11 +02:00
Ben 9ea1ef164a release(obsidian): v0.1.0 2026-06-08 16:45:22 -04:00
Ben b0f86f9c0d feat(obsidian): Hindsight plugin for Obsidian (#1941)
* feat(obsidian): add Obsidian plugin integration

Sync an Obsidian vault into a shared Hindsight bank and chat with an agent
grounded on your notes (citations link back to the source note). Obsidian
stays the source of truth: one-way sync, conversation memory off by default.

- TS plugin (esbuild → main.js): requestUrl HTTP client, incremental sync
  engine (hash/mtime gate, upsert/delete/rename, reconcile + orphan prune),
  reflect-backed chat view with citations + reasoning, settings + commands.
- One shared bank ("obsidian") across vaults; implicit scoping via auto tags
  (vault:, folder: ancestors, created:/updated: date buckets) so recall can
  scope by any combo from the UI or an automation. document_id is
  vault-prefixed to avoid cross-vault collisions.
- Tests (vitest, mocked obsidian module): sync upsert/delete/rename/hash-gate,
  auto-scope tags, client request shapes, and the §0.5 guard (no conversation
  retain when the toggle is off).
- Wiring: test-obsidian-integration CI job + aggregate gate, VALID_INTEGRATIONS,
  changelog generator, integrations.json + docs page + changelog page + icon.

Out of scope for v1: rename-proof frontmatter identity; BRAT/community-store
release-asset attachment (release-integration.yml only npm-publishes today).

* feat(obsidian): scoped chat filters, retrieved-notes, debug logging, branding

- Chat scope filters (vault + folder dropdowns above the ask bar) build
  tag_groups (all_strict) passed to reflect; folder tags are hierarchical.
- "Notes retrieved" list + per-step reasoning: reflect's based_on omits
  document_ids, so harvest them from the recall/expand tool outputs (incl.
  nested observation source_facts). New reflect-util with a unit test.
- Debug logging toggle: logs the reflect request (with scope) and the
  retrieved note ids to the console for verifying filters.
- "New chat" view action + command to reset the conversation.
- Branding: real Hindsight logo (favicon) embedded as a data URI for the
  ribbon, chat header, empty state, and tab icon (via an SVG <image>).

* feat(obsidian): chat output extras — copy, snippet previews, wikilink resolution

- "Copy" action under each answer.
- "Notes retrieved" now shows the matched text snippet per note (from the
  recall/expand tool outputs + observation source_facts), so you can see why a
  note was pulled without opening it. New retrievedNotesDetailed() + test.
- Answers render with the active note as sourcePath, so [[wikilinks]] resolve.

* feat(obsidian): auto-grow chat composer + frontmatter/client edge tests

The composer textarea now grows with multi-line input up to a 240px cap,
then scrolls. Adds unit coverage for the two previously untested pure
layers: frontmatter.normalizeNote (no/blocklist/inline-flow frontmatter,
created/date precedence, scalar metadata, unterminated block) and client
edge paths (transport rejection, reflect tag_groups-vs-tags branch, retain
tag omission).

* ci(obsidian): attach BRAT install assets to the GitHub release

Obsidian plugins install from GitHub release assets (main.js, manifest.json,
styles.css), not npm. The release-integration workflow only npm-published
the package, leaving the plugin uninstallable. Add an obsidian-only step
that creates/updates the release for the tag and uploads the three files
(idempotent on re-run), and grant the job contents:write.

* chore(obsidian): fix generated-files drift (prettier + docs-skill mirror)

Run prettier over the integration (README.md table/emphasis formatting and
the new frontmatter.spec.ts array wrapping) and regenerate the agent-skill
changelog mirror that generate-docs-skill.sh produces. Resolves the
verify-generated-files CI check.

* feat(obsidian): persistent sync-status indicator in the status bar

Background, edit-triggered sync previously ran silently — only the manual
'Sync vault now' surfaced a Notice. Add an always-visible status-bar item
that shows synced/syncing/error state plus a live 'last synced x ago' time,
notes the pending-edit count, and triggers a sync on click. All sync paths
(reconcile, debounced flush, single-note ingest, delete, rename) route
through it. Pure label/tooltip logic is unit-tested (9 cases).

* feat(obsidian): mirror sync status in the chat header

Surface the same sync state in the chat panel's header (right-aligned),
reusing renderSyncStatus with no brand prefix since the Hindsight wordmark
is already shown. The plugin pushes updates to any open chat view whenever
sync state changes, and clicking the pill triggers a sync.

* feat(obsidian): show note count + pending in the sync indicator

Replace the bare check mark with the tracked-note count and either the
pending-edit count or the last-sync time (e.g. '✓ 412 notes · 2m ago',
'✓ 412 notes · 3 pending'). Tooltip carries the full breakdown. Count comes
from the local sync index; singular/plural handled.

* feat(obsidian): explicit refresh button for sync (spins while syncing)

The sync status was clickable text with no obvious affordance. Split it into
an informational status label plus a dedicated refresh icon button (in both
the chat header and the status bar) that triggers a sync on click and spins
while a sync is in flight.

* docs(obsidian): document the sync-status indicator in the README
2026-06-08 16:43:06 -04:00
Ben 9ca6617813 release(omo): v0.1.0 2026-06-08 16:22:55 -04:00
Derek Bouius 6dc56498ce feat(integrations): add oh-my-openagent (OMO) integration (#2018)
* feat(integrations): add oh-my-openagent (OMO) integration

Cloud-first Hindsight memory integration for the OMO agent harness.
Provides automatic recall/retain via lifecycle hooks with support
for both Hindsight Cloud (api.hindsight.vectorize.io) and self-hosted.

- 5 lifecycle hooks: SessionStart, UserPromptSubmit, Stop, SubagentStop, SessionEnd
- Always-apply rule for memory guidance
- Config hierarchy: settings.json → ~/.hindsight/omo.json → HINDSIGHT_* env vars
- Bearer token auth for cloud mode (hsk_* keys)
- Interactive demo script for local dev testing
- Full test suite (29 tests)

* chore(ci): add OMO integration test job

- Add test-omo-integration job to test.yml (pip + pytest pattern)
- Add detect-changes output and path filter for omo

* fix: apply lint formatting to OMO integration files

* fix: fix demo importlib.util import and add mkdir to setup instructions

- Import importlib.util explicitly (importlib alone doesn't expose .util)
- Add mkdir -p for ~/.omo/hooks and .omo/rules in README copy instructions
- Default demo API URL to localhost:8888 to match Docker compose port

* docs: rewrite OMO README with cloud-first setup as default

Simplify setup to 4 numbered steps with cloud as the primary path.
Move self-hosted to an optional section. Add testing section.
Clarify that rules are per-project while hooks/scripts are global.

* chore: add omo to VALID_INTEGRATIONS in release script

* fix: address release-blocking issues for OMO integration

- Remove pyproject.toml (causes release workflow to mis-classify omo as
  a Python package and fail uv build). Move pytest config to pytest.ini.
- Add IntegrationMeta entry in generate_changelog.py
- Add integrations.json entry with internal doc link
- Add docs page at docs-integrations/omo.md
- Add omo.svg icon
- Add "version": "0.1.0" to settings.json
2026-06-08 16:21:49 -04:00
Ben 9a9aef6225 release(cline): v0.1.0 2026-06-08 16:11:00 -04:00
Ben 66e58a23af feat(cline): Hindsight memory integration via lifecycle hooks (#1956)
* feat(cline): add Hindsight memory integration via lifecycle hooks (no MCP)

Gives Cline persistent long-term memory without MCP, using its lifecycle
hooks. TaskStart/UserPromptSubmit recall relevant memories and inject them
via contextModification; TaskComplete/TaskCancel retain the task transcript.
Cline hands hooks no transcript, so prompts are accumulated per-task in
local state and retained at task end. Reuses the agent-agnostic core from the
Codex integration (HTTP client, config, bank derivation, state, content
helpers). Includes an install.py, 34 tests, CI job, and release/docs wiring.

* refactor(cline): typed HindsightClineConfig instead of raw dict (review)

Address the code-review should-fix: replace the raw `config` dict (known,
enumerated keys) with a HindsightClineConfig dataclass per SKILL §5. load_config
maps the camelCase settings.json/env keys onto snake_case fields; consumers
read typed attributes. Also tighten type hints flagged in the review:
ensure_bank_mission (client: HindsightClient, debug_fn: Callable[..., None] |
None), _cast_env(typ: type) -> Any, debug_log(... ) -> None, parse_hook_input
(raw: dict[str, Any]), and client _headers/_request dict parameterization.
retain_metadata stays a dict (genuinely user-defined dynamic keys).

* refactor(cline): parameterize retain() metadata dict type
2026-06-08 16:09:46 -04:00
Ben e76021add3 blog: How oh-my-pi Built Persistent Codebase Memory on Hindsight (#2017)
* blog: How oh-my-pi Built Persistent Codebase Memory on Hindsight

Adoption case-study post on oh-my-pi (10k-star terminal coding agent
by @can1357) using Hindsight as its memory backend. All technical
details and code snippets pulled verbatim from the public repo at
github.com/can1357/oh-my-pi.

Covers: their three-mode bank-scoping policy (global / per-project /
per-project-tagged with the default being tag-based with `any` match);
the mental-model seed file (user-preferences, project-conventions,
project-decisions, each with delta-mode refresh_after_consolidation);
the debounced retain queue (16-item batch / 5s interval) and the
full-session auto-retain path; the auto-recall pipeline with the
exact preamble they use; and the reason they replaced
@vectorize-io/hindsight-client with a minimal fetch client.

Closes by tying the pattern back to other Hindsight-backed coding
agents (Hermes, Claude Code, OpenClaw) — same shape, different
implementations.

Cover image is a placeholder reusing the Hermes coding-assistant
card; final Hindsight x oh-my-pi art is a follow-up.

* blog(oh-my-pi): drop irrelevant Python-client aside

* blog(oh-my-pi): swap placeholder for omp + Hindsight branded cover

* blog(oh-my-pi): add Can Bölük (can1357) as co-author

* blog(oh-my-pi): apply final-revised draft

* blog(oh-my-pi): swap cover for retain/recall/reflect cycle diagram


* blog(oh-my-pi): bump date to 2026-06-08
2026-06-08 15:24:09 -04:00
Ben ccf0dc8268 release(haystack): v0.1.0 2026-06-08 14:54:36 -04:00
394d66e607 feat(integrations): add Haystack integration (#1256)
* feat(integrations): add Haystack integration for persistent agent memory

Add hindsight-haystack package providing Haystack Tool instances backed
by Hindsight's retain/recall/reflect APIs. Uses async client methods with
event-loop-safe sync wrapper to work correctly inside Haystack's agent
runtime.

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

* fix(haystack): use persistent event loop for async client calls

aiohttp binds its session to the creating event loop, so asyncio.run()
(which creates/destroys a loop per call) breaks on sequential calls.
Switch to a persistent daemon-thread event loop with
run_coroutine_threadsafe. Also removes unused per-operation timeout
constants and adds _run_sync tests.

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

* feat(haystack): add HindsightToolset with auto-recall/retain, fix review issues

- Add HindsightToolset(Toolset) with auto_recall and auto_retain flags
  that automatically inject recalled memories into the system prompt
  before each turn and retain user/assistant messages after each turn
- Fix _ensure_bank to retry on transient errors instead of permanently
  disabling bank creation
- Fix reflect_on_memory to return structured_output JSON when
  response_schema is set
- Truncate error messages to avoid dumping raw HTTP responses to agents
- Extract _build_backend_kwargs() and _build_tools() as shared helpers
- Add 20 new tests (60 -> 80 total) covering toolset, auto-recall,
  auto-retain, structured output, and bank creation retry

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

* fix(haystack): address review round 2 — max_recall_results, role metadata, _run_sync cleanup

- Add max_recall_results param to HindsightToolset (default 10) to cap
  auto-recall prompt injection size, matching Pydantic AI pattern
- Auto-retain now includes role + source metadata on messages, matching
  LlamaIndex's metadata pattern for distinguishable conversation turns
- _recall_for_prompt now calls the API directly with result cap instead
  of going through the formatted string from recall_memory
- Serialize/deserialize max_recall_results in to_dict/from_dict
- Add tests for max_recall_results and role metadata (82 total)

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

* fix(haystack): default to Cloud without configure(); add gated E2E + bucketing

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised "No Hindsight API URL configured"). Updated the unit test to assert
  the cloud-default + env-key behavior. Satisfies the "default to Cloud" goal.
- Add a gated tests/test_e2e.py (retain/recall/reflect tools against a live
  Hindsight server), marked requires_real_llm; register the marker in
  pyproject; the test-haystack-integration CI job now runs the deterministic
  bucket (-m "not requires_real_llm").

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

* fix(haystack): close owned clients at exit; run E2E client I/O on the bridge loop

The tools run async client calls on a persistent background event loop. aiohttp
sessions bound to that loop were never closed, surfacing as "Unclosed client
session/connector" warnings. Track module-owned Hindsight clients (those created
when the caller didn't pass client=) and close them on the loop via an atexit
hook, then stop the loop. The live E2E now performs all client I/O through that
same loop (acreate_bank/adelete_bank/aclose via _run_sync) and logs cleanup
failures instead of swallowing them — zero unclosed-connector warnings.

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

* chore(haystack): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

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

* fix(haystack): strip api_key from to_dict() so it doesn't leak to YAML

_build_backend_kwargs was emitting the api_key in the serializable dict
that to_dict() returns. Haystack pipelines get dumped to YAML for
inspection, checkpointing, and sharing — a serialized key leaks into
every dump. Reviewer (benfrank241) flagged this on #1256.

Drop the api_key from the serialized backend_kwargs. resolve_client()
already reads HINDSIGHT_API_KEY from the env var as a final fallback,
so a redeployed pipeline picks the key back up from the host's
environment rather than from the YAML.

The test_tools_round_trip_serialization_with_client test previously
asserted the leak — flipped it to assert the key is NOT present
and added a json.dumps probe asserting the literal key value also
doesn't appear under any other field name. Pre-fix, the test fails:
  AssertionError: api_key must not appear in serialized backend_kwargs
   — would leak to YAML pipeline dumps
  assert 'api_key' not in {'api_key': 'client-key', ...}

86/86 tests pass post-fix.

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

* ci: re-trigger CI

A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.

* ci: empty commit to attach pull_request CI check to the PR head

(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)

* ci: trailing newline to force CI retrigger

* fix(haystack): register in changelog/gallery + docs page + tidy tools

Review follow-ups for the Haystack integration:

1. Add haystack to the INTEGRATIONS map in generate_changelog.py so the
   release script's changelog step resolves the slug (was missing, which
   would fail the release).
2. Add the integrations.json gallery entry, a doc page at
   docs-integrations/haystack.md, and an icon — required by
   check-integrations.mjs (forward: entry needs a doc page; reverse: a
   released integration must appear in the gallery).
3. Drop the inaccurate 'Raises: HindsightError' clause from
   create_hindsight_tools — resolution always succeeds (URL defaults to
   Cloud) so it never raises; the error type stays exported as the
   conventional public catch type.
4. Replace the _TOOL_DEFS dict-of-3-tuples with a frozen _ToolDef dataclass
   and drop the redundant method-name field (it equalled the dict key).

* fix(haystack): use official Haystack logo for gallery icon

Replace the placeholder glyph with the real deepset Haystack mark (teal
#0EAF9C rounded square + white symbol), extracted as vector from deepset's
own website source (deepset-ai/haystack-home site-logo partial).

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-08 14:52:38 -04:00
Ben 9891f53177 fix(docs): correct Grok Build icon path in integrations banner (#2063)
The rotating integrations banner referenced /img/icons/grok-build.png,
but the asset is grok-build.svg (the gallery already uses the .svg). The
missing .png rendered as a broken-image placeholder in the marquee. Point
the banner at the existing .svg.
2026-06-08 14:46:49 -04:00
Nicolò Boschi 8170fe880e docs: remove versioned docs for 0.5 and lower (#2059)
Drop Docusaurus versioned snapshots for 0.3, 0.4, and 0.5
(versioned_docs + versioned_sidebars) and remove their entries from
versions.json. Keeps 0.6, 0.7, and 0.8.

docusaurus.config.ts reads versions.json dynamically, so no config
changes are required.
2026-06-08 18:11:50 +02:00
Nicolò Boschi 95d77233bf fix(migrations): install maintenance routines on target_schema=public (#2056) (#2058)
The maintenance-routines migration (e5f6a7b8c9d0) only created the shared
public.banks_needing_consolidation() / public.schemas_with_expired_rows()
routines when the run had no target_schema at all. But the single-tenant
runtime always migrates an explicit schema, defaulting to public, so on
every default PostgreSQL deployment the migration was stamped applied while
the functions were never created. Background maintenance then logs
"function public.schemas_with_expired_rows(...) does not exist" and
"function public.banks_needing_consolidation() does not exist".

Since e5f6a7b8c9d0 is already stamped on affected 0.8.0 databases, editing
it would not re-run there. This adds a forward repair migration that
idempotently (CREATE OR REPLACE) reinstalls the routines on the run that
targets the shared public schema (base run, or explicit target_schema=public),
self-healing already-upgraded deployments and covering fresh upgrades.
Non-public tenant runs still skip it to avoid concurrent CREATE on the same
pg_proc row.

Fixes #2056
2026-06-08 17:52:03 +02:00
Ben 4a0a599473 fix(docs): add cursor-cli to integrations gallery (#2060)
The cursor-cli release (integrations/cursor-cli/v0.1.0, #1975) created a
release tag but never added the integration to the docs single source of
truth. check-integrations.mjs enforces that every released integration tag
has an entry in src/data/integrations.json with a matching doc page, so the
build-docs job has been failing on every PR (e.g. #866) — not from those PRs'
changes, but from the missing cursor-cli entry on main.

Add the gallery entry, the docs-integrations/cursor-cli.md page, and an icon.
Both invariants now pass locally.
2026-06-08 11:51:23 -04:00
Nicolò Boschi bfdc1c5e65 fix(deps): cap tokenizers<=0.23.0 for local-ML extras (#2055) (#2057)
* fix(deps): cap tokenizers<=0.23.0 for local-ML extras (#2055)

transformers (incl. 5.x) hard-requires tokenizers<=0.23.0 via a runtime
check, but tokenizers 0.23.1 is the latest on PyPI. Without a lockfile, an
in-place upgrade to 0.8.0 can resolve tokenizers 0.23.1 and break local
embeddings/reranker startup with an ImportError. Pin the compatible range
in the local-ml and local-onnx extras.

* chore(deps): update uv.lock for tokenizers cap (#2055)
2026-06-08 17:35:16 +02:00
Ben 37e28fac09 release(cursor-cli): v0.1.0 2026-06-08 11:30:11 -04:00
dbfe83a2ae feat(integrations): add Cursor CLI integration (#1975)
* Add .worktrees to .gitignore

* feat(integrations): add Cursor CLI integration

Four Cursor CLI hooks keep memory in sync automatically:

  - sessionStart       — health check + daemon pre-start
  - beforeSubmitPrompt — recall relevant memories and inject as
                         `additional_context`
  - stop               — read the on-disk transcript, retain the
                         conversation (fire-and-forget, async retain)
  - preCompact         — surface which memories will survive the next
                         context-window compaction

The integration follows the same shape as the existing codex
integration (Python hook scripts reading JSON from stdin, writing
JSON to stdout) and the same config schema, so users with a
codex setup can drop in cursor-cli with no new concepts.

Project resolution prefers Cursor's `CURSOR_PROJECT_DIR` env var
(common field in the hook runtime), then `workspace_roots[0]`,
then `cwd` — avoiding the codex `session` default granularity
since Cursor's `stop` hook is fire-and-forget.

CI:
  - new `test-cursor-cli-integration` job in .github/workflows/test.yml
  - `cursor-cli` added to VALID_INTEGRATIONS in scripts/release-integration.sh

Docs:
  - new top-level hindsight-integrations/README.md indexing every
    integration, with cursor-cli highlighted under "Coding agents & CLIs"

72 tests cover the four hook scripts, the bank-id derivation, the
HTTP client, the cursor transcript reader, and the chunked-retain
logic. All pass under `python -m pytest tests/ -v`. Ruff and
shellcheck are clean.

Co-Authored-By: opencode minimax-m3 high <[email protected]>

* fix(cursor-cli): derive bank id in session_start banner

The session banner used a static `config.get("bankId") or "cursor-cli"`
fallback, while recall.py / retain.py / pre_compact.py all called
`derive_bank_id(hook_input, config)`. With `dynamicBankId: true` and
`dynamicBankGranularity: ["project"]`, the banner reported the static
default ("cursor-cli") while the other hooks targeted the derived
bank (e.g. "korayem-cli-agents-hindsight"). Users and agents that
trusted the banner then called `hindsight memory reflect cursor-cli`
against an empty bank, while the hooks themselves were writing to
the correct one.

Mirror recall.py's pattern: import derive_bank_id, call it with the
parsed hook_input, surface the resolved bank in debug logs so users
can confirm parity with the other hooks.

Tests cover all four acceptance criteria:
  - dynamicBankId true → derived bank in banner
  - dynamicBankId false + explicit bankId → static bank in banner
  - HINDSIGHT_BANK_ID env override → resolved through config loader
  - regression: previous tests still pass

Co-Authored-By: opencode minimax-m3 high <[email protected]>

* refactor(cursor-cli): align implementation with codex/claude-code

The cursor-cli implementation shipped several invented surfaces and
patterns that drifted from the codex/claude-code reference. This
commit removes the inventions and brings the script bodies back
to near-parity with the references so future divergence stands
out in a diff.

Removed — invented user-facing surfaces:
  - session_start.py: the "Hindsight memory integration is active
    for this session. Bank: <id>" additional_context banner.
    The references' sessionStart is fire-and-forget with no
    additional_context. Banner output is where the bank-id
    display-mismatch bug lived, and the only consumer that "saw"
    the banner was the agent, which never asked for it.
  - pre_compact.py and its TestPreCompactHook class entirely.
    preCompact is observational in Cursor's spec — it cannot
    influence the compaction itself. The actual mechanism that
    preserves memory through compaction is the beforeSubmitPrompt
    recall that fires after compaction finishes. The "Hindsight
    preserved N memories" user_message was invented value with
    no reference equivalent.

Restored — patterns from codex that were dropped:
  - session_start.py: debug_log for "Hindsight not running" path
    (was changed to a noisier print).
  - recall.py: import time, import write_state, LAST_RECALL_STATE
    const, and the write_state(...) block that drops the most
    recent recall payload to ~/.hindsight/cursor-cli/state/.
    Dead code in codex, but matching the reference for now keeps
    the diff focused on actual cursor-specific differences.
  - recall.py: `prompt = (hook_input.get("prompt") or
    hook_input.get("user_prompt") or "")` — kept the user_prompt
    fallback for defense in depth.
  - retain.py: "Exit codes" section in the docstring and the
    inline comments / blank lines that codex uses for
    readability.
  - lib/__init__.py: removed the cursor-cli-specific docstring
    to match codex's empty file.

Kept — true Cursor-specific differences (justify in PR review):
  - session_start.py / retain.py / recall.py: docstrings mention
    Cursor, not Codex.
  - debug log key: conversation_id (Cursor's term) instead of
    session_id (codex's term). Cursor's `stop` hook carries
    conversation_id; codex's carries session_id.
  - session_id fallback chain: hook_input.get("conversation_id")
    or hook_input.get("session_id") or "unknown" — accepts both
    payload shapes.
  - template_vars includes conversation_id alongside session_id
    so retainTags / retainMetadata templates work either way.
  - retainTags default: ["{conversation_id}"] (codex is empty list)
    — convention is to tag the document with the source-of-truth id.
  - retainContext default: "cursor-cli" (was "codex").
  - agentName default: "cursor-cli" (was "codex").
  - bankMission / retainMission defaults: full text matching the
    Cursor CLI audience (codex leaves them empty).
  - USER_AGENT: "hindsight-cursor-cli/<version>" (was
    "hindsight-codex/<version>").
  - PROFILE_NAME: "cursor-cli" (was "codex") in daemon.py —
    controls the hindsight-embed profile name.
  - bank resolution: CURSOR_PROJECT_DIR env var → workspace_roots[0]
    → cwd (codex only uses cwd). Cursor sets CURSOR_PROJECT_DIR
    on every hook.
  - VALID_FIELDS in bank.py adds "gitProject" as an alias for the
    project resolution.
  - recall output schema: Cursor's beforeSubmitPrompt wants
    {continue, additional_context}, not codex's
    {hookSpecificOutput: {hookEventName, additionalContext}}.

Tests:
  - Removed TestSessionStartHook tests that asserted on the
    deleted banner.
  - Removed TestPreCompactHook class entirely.
  - test_session_start.test_no_output_when_server_reachable is
    the new mirror of codex's expectations: sessionStart emits
    nothing on stdout.

Net: -296 lines, 68 tests passing, ruff + shellcheck clean.

Co-Authored-By: opencode minimax-m3 high <[email protected]>

* fix(cursor-cli): flush memory at session end

Add a Cursor sessionEnd hook that forces a final retain so short sessions are stored even when retainEveryNTurns skips per-turn retention. Also remove stale preCompact/banner docs and align the daemon idle-timeout fallback with the shipped config.

Co-Authored-By: OpenAI GPT-5 Codex High <[email protected]>

* fix(cursor-cli): register integration in changelog generator

cursor-cli was added to VALID_INTEGRATIONS and CI but missing from the
INTEGRATIONS map in generate_changelog.py, which the release script reads
when generating the changelog entry. Without it, the release would fail at
the changelog step.

---------

Co-authored-by: opencode minimax-m3 high <[email protected]>
Co-authored-by: OpenAI GPT-5 Codex High <[email protected]>
2026-06-08 11:27:40 -04:00
Ben 7c2d1848ec release(roo-code): v0.1.0 2026-06-08 11:14:39 -04:00
Ben 644e37ac19 feat(roo-code): package as installable PyPI CLI (hindsight-roo-code) (#2054)
Turn the Roo Code integration into a pip-installable package so users can:

    pip install hindsight-roo-code
    hindsight-roo-code install [--api-url ...] [--project-dir ...] [--global]

- Move install logic into a hindsight_roo_code package with an
  argparse-based CLI exposed via a console_scripts entry point
- Ship the rules file as package data, read via importlib.resources so it
  resolves from the installed wheel
- Add pyproject.toml (hatchling), LICENSE, py.typed
- Add CLI tests; update install/rules tests to import from the package
- Switch the CI job to uv build + uv sync + uv run pytest
- Map roo-code -> hindsight-roo-code in the changelog generator
- Update README and docs to the pip install + CLI flow
2026-06-08 11:11:00 -04:00
1194 changed files with 87364 additions and 30637 deletions
+12
View File
@@ -192,6 +192,18 @@ If a migration adds a new PostgreSQL table (look for `CREATE TABLE` / `op.create
- The guard test `test_backup_tables_covers_entire_schema` in `tests/test_admin_backup_restore.py` enforces this — flag it as a **must fix** if a new table is absent from `BACKUP_TABLES`.
- Oracle-only tables (e.g. `observation_sources`) are intentionally excluded — admin backup/restore is PostgreSQL-only.
### 11b. Check new config flags update the env template
If the diff adds a new configuration field (a new `ENV_*` / `HINDSIGHT_*` env var
in `hindsight-api-slim/hindsight_api/config.py`):
- **`.env.example`** (repo root) — must add the variable (commented if optional)
alongside the docs entry in `hindsight-docs/docs/developer/configuration.md`.
A flag added to `config.py` but absent from `.env.example` is a **should fix**.
- **`hindsight-embed/hindsight_embed/env.example`** — the bundled copy must stay
byte-identical to the repo-root `.env.example` (it seeds embed/profile configs).
The `test_bundled_template_matches_repo_root` sync test fails on drift; if the
root file changed without re-copying, flag it as a **must fix**.
### 12. Review against other coding standards
Check the diff for violations of the standards listed above:
+116
View File
@@ -0,0 +1,116 @@
---
name: hs-release
description: Cut a core Hindsight release (vX.Y.Z) and open the changelog + blog PR. Use when asked to cut/start a release, bump the version, or publish a new Hindsight version.
user_invocable: true
---
# Hindsight Release
Cut a **core** Hindsight release and open the accompanying changelog/blog PR. This is for the core
product version (API, clients, CLI, control plane, Helm). **Integrations are versioned
independently** — use `scripts/release-integration.sh` for those, not this skill.
The release is **irreversible and outward-facing**: it tags a version and pushes it straight to
`main`, which triggers CI that publishes packages to PyPI / npm / Helm. Confirm the version number
and that the intended fixes are already merged to `main` before you start.
## Step 0 — Pre-flight
1. **Decide the base.** A release is cut from the latest `origin/main`, never from a feature
branch. `git fetch origin --tags` first. Confirm the "couple of fixes" the user means are
actually merged to `main` (`git log v<prev>..origin/main --oneline`).
2. **Find where `main` is checked out.** `main` is often already checked out in a sibling worktree
(`git worktree list`). You **cannot** check out `main` in a second worktree — run the release in
the worktree that already holds it. If that worktree is dirty with throwaway cruft
(`.next-*` tsconfig paths, screenshots), `git stash push -u`, fast-forward to `origin/main`,
run the release, then `git stash pop`.
3. **Pitfall:** never pipe the checkout in an `&&` chain like
`git checkout main 2>&1 | tail && git reset --hard ...` — the pipe's exit status is `tail`'s
(always 0), so a failed checkout won't stop the chain and the `reset` fires on the **wrong
branch**. Check out as its own command and verify `git branch --show-current` before resetting.
## Step 1 — Cut the release
Run from the worktree on a clean `main`:
```bash
./scripts/release.sh <version> # e.g. 0.8.1 (no leading v)
```
`release.sh` bumps the version in every component, regenerates the OpenAPI spec + all client SDKs,
updates docs versioning, commits `Release v<version>`, tags `v<version>`, and **pushes the commit
and tag directly to `main`**. The push triggers the `Release` GitHub Actions workflow that builds
and publishes the packages. It is **not** a PR.
Verify after: `gh run list --limit 5` should show the `Release v<version>` workflow running, and
`git ls-remote --tags origin v<version>` should return the tag.
## Step 2 — Changelog + blog PR (separate)
Done **after** the tag exists, as its own PR (precedent: v0.8.0 = #2053, v0.8.1 = #2080). Work on a
branch off the new `main`:
```bash
git checkout -b docs-changelog-<version> origin/main
```
Only spin up a separate worktree (`git worktree add ../hindsight-changelog-<version> -b
docs-changelog-<version> origin/main`) if you can't get a clean checkout otherwise — e.g. `main` is
held in another worktree and the current one has work you don't want to disturb.
**Branch naming:** use the `docs-` (hyphen) convention, e.g. `docs-changelog-0.8.1`. A remote
branch literally named `docs` exists, so any `docs/...` branch is rejected on push with
`directory file conflict`.
### Changelog
```bash
uv run --directory hindsight-dev generate-changelog <version>
```
LLM-summarizes the commits between the previous tag and `v<version>` and prepends an entry to
`hindsight-docs/src/pages/changelog/index.md`. Requires `OPENAI_API_KEY` (already in the repo
`.env`). It excludes `hindsight-integrations/` source, but new integrations whose commits also
touched docs will still appear — that matches precedent, leave them in the **changelog**.
### Blog post
Hand-write `hindsight-docs/blog/YYYY-MM-DD-version-X-Y-Z.md` (mirror an existing one; patch
releases are short — see `2026-06-02-version-0-7-2.md`). Guidance:
- **Explain user impact, not internals/mechanism.** Lead with what the user can now do and what to
set. Config/env-var names are fine (developer-facing), code symbols and internals are not.
- **Do not list integrations in the release blog.** The core blog covers core engine / API /
ops changes; each integration ships its own changelog. (Integrations may still appear in the
generated `changelog/index.md` — that's fine; just keep them out of the blog.)
- Call out an upgrade recommendation when there are operational/data-integrity fixes.
- Validate formatting: `npx prettier --check <blog file>`.
### Sync the docs skill
```bash
./scripts/generate-docs-skill.sh
```
Refreshes `skills/hindsight-docs/references/changelog/index.md`. It will also bump
`skills/hindsight-docs/references/openapi.json` by one version — `release.sh` regenerates the skill
*before* bumping OpenAPI, so the skill copy lags a version in the release commit; this step syncs
it. Expect a one-line `version` diff there; keep it.
### Commit, push, PR
```bash
git add -A
git commit --no-verify -m "docs: changelog and blog post for v<version>"
git push -u origin docs-changelog-<version>
gh pr create --base main --title "docs: changelog and blog post for v<version>" --body "..."
```
Expected files in the PR: the changelog entry, the new blog post, the regenerated skill changelog
mirror, and the skill `openapi.json` version sync.
## Cleanup
If you created a temporary worktree, remove it once the PR is up
(`git worktree remove ../hindsight-changelog-<version>`; the branch stays on origin). Restore any
stash you popped in Step 0.
+8
View File
@@ -47,6 +47,13 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_LOG_LEVEL=info
# Optional retain chunking override for structured logs/transcripts.
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
# Base Path / Reverse Proxy Support (Optional)
# Set these when deploying behind a reverse proxy with path-based routing
@@ -59,6 +66,7 @@ HINDSIGHT_API_LOG_LEVEL=info
# 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)
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
+76 -2
View File
@@ -9,7 +9,11 @@ jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing
id-token: write # for PyPI trusted publishing + build-provenance attestations
attestations: write # for actions/attest-build-provenance (Obsidian assets)
# No `contents: write`: we never create releases in this repo. The Obsidian
# plugin's distribution release is pushed to its dedicated repo using
# OBSIDIAN_DIST_TOKEN (see the "Mirror Obsidian plugin" step below).
steps:
- uses: actions/checkout@v6
@@ -112,6 +116,71 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
# Build-provenance attestations for the Obsidian release assets (community-store
# recommendation). Runs after the build so main.js exists. The assets are
# released in the dedicated repo while the build runs here, so users verify at
# owner scope: `gh attestation verify main.js --owner vectorize-io`.
- name: Attest Obsidian plugin build provenance
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
hindsight-integrations/obsidian/main.js
hindsight-integrations/obsidian/styles.css
# ── Obsidian plugin — mirror to its dedicated repo + cut the BRAT release ──
# We do NOT create a GitHub Release in this monorepo: per-integration
# releases pollute the repo's release list (it's for the core product) and
# steal the "Latest" badge, and BRAT / the community store read a repo's
# *latest* release — not a tag — so they can't target a tag in a monorepo.
#
# Instead this monorepo stays the source of truth, and on each obsidian
# release we mirror hindsight-integrations/obsidian/ → the *root* of
# github.com/vectorize-io/hindsight-obsidian (git subtree, history
# preserved) and cut the BRAT / community-store release *there*.
#
# Requires secret OBSIDIAN_DIST_TOKEN — a token with `contents: write` on
# vectorize-io/hindsight-obsidian (fine-grained PAT or app installation
# token). The dedicated repo is generated; never edit it directly.
- name: Mirror Obsidian plugin to its dedicated repo
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
env:
DIST_TOKEN: ${{ secrets.OBSIDIAN_DIST_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.info.outputs.version }}"
DIST_REPO="vectorize-io/hindsight-obsidian"
OBS_DIR="hindsight-integrations/obsidian"
# `git subtree split` needs full history; the default checkout is shallow.
git fetch --unshallow 2>/dev/null || true
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# The runner injects the default GITHUB_TOKEN as an http.extraheader via
# an *included* config file (/home/runner/work/_temp/git-credentials-*.config),
# so `git config --local --unset-all` can't remove it and it authenticates
# the push as github-actions[bot] (no access to the dedicated repo → 403).
# The documented way to drop an inherited extraheader is to RESET the list
# with an empty value: since command-line `-c` is read last, the empty
# value clears the accumulated headers (including the included one) at
# request-build time. The dist token then comes from the push URL → a
# single Authorization header.
git subtree split --prefix="$OBS_DIR" -b _obs_dist
git -c "http.https://github.com/.extraheader=" \
push "https://x-access-token:${DIST_TOKEN}@github.com/${DIST_REPO}.git" _obs_dist:main
# Cut the BRAT / community-store release. Bare version tag (e.g. 0.1.0)
# to match manifest.json — idempotent so re-runs just refresh the assets.
export GH_TOKEN="$DIST_TOKEN"
ASSETS="$OBS_DIR/main.js $OBS_DIR/manifest.json $OBS_DIR/styles.css"
NOTES="Hindsight for Obsidian v${VERSION}. Install via BRAT (add ${DIST_REPO}) or copy main.js/manifest.json/styles.css into <vault>/.obsidian/plugins/hindsight/."
if gh release view "$VERSION" --repo "$DIST_REPO" >/dev/null 2>&1; then
gh release upload "$VERSION" $ASSETS --repo "$DIST_REPO" --clobber
else
gh release create "$VERSION" $ASSETS --repo "$DIST_REPO" --title "$VERSION" --notes "$NOTES"
fi
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
@@ -121,7 +190,12 @@ jobs:
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
# Treat "already published" as success so re-pointed-tag re-runs stay green.
# "cannot publish over" = the version exists. TLOG_CREATE_ENTRY_ERROR / 409
# "equivalent entry already exists in the transparency log" = the identical
# --provenance artifact was already logged on a prior run (Sigstore tlog is
# idempotent); the package is published, so this is benign.
if echo "$OUTPUT" | grep -qE "cannot publish over|TLOG_CREATE_ENTRY_ERROR|already exists in the transparency log"; then
echo "Package version already published, skipping..."
exit 0
fi
+509 -5
View File
@@ -32,9 +32,14 @@ jobs:
integration-tests: ${{ steps.filter.outputs.integration-tests }}
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
integrations-agent-framework: ${{ steps.filter.outputs.integrations-agent-framework }}
integrations-composio: ${{ steps.filter.outputs.integrations-composio }}
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
integrations-cline: ${{ steps.filter.outputs.integrations-cline }}
integrations-codex: ${{ steps.filter.outputs.integrations-codex }}
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
@@ -44,7 +49,9 @@ jobs:
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-zapier: ${{ steps.filter.outputs.integrations-zapier }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
@@ -58,6 +65,9 @@ jobs:
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
integrations-flowise: ${{ steps.filter.outputs.integrations-flowise }}
integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }}
integrations-obsidian: ${{ steps.filter.outputs.integrations-obsidian }}
integrations-omo: ${{ steps.filter.outputs.integrations-omo }}
integrations-haystack: ${{ steps.filter.outputs.integrations-haystack }}
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }}
dev: ${{ steps.filter.outputs.dev }}
@@ -118,12 +128,22 @@ jobs:
- 'hindsight-integrations/openclaw/**'
integrations-ai-sdk:
- 'hindsight-integrations/ai-sdk/**'
integrations-agent-framework:
- 'hindsight-integrations/agent-framework/**'
integrations-composio:
- 'hindsight-integrations/composio/**'
integrations-chat:
- 'hindsight-integrations/chat/**'
integrations-claude-code:
- 'hindsight-integrations/claude-code/**'
integrations-cline:
- 'hindsight-integrations/cline/**'
integrations-codex:
- 'hindsight-integrations/codex/**'
integrations-continue:
- 'hindsight-integrations/continue/**'
integrations-cursor-cli:
- 'hindsight-integrations/cursor-cli/**'
integrations-crewai:
- 'hindsight-integrations/crewai/**'
integrations-litellm:
@@ -138,12 +158,18 @@ jobs:
- 'hindsight-integrations/langgraph/**'
integrations-llamaindex:
- 'hindsight-integrations/llamaindex/**'
integrations-haystack:
- 'hindsight-integrations/haystack/**'
integrations-paperclip:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
integrations-cursor:
- 'hindsight-integrations/cursor/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
- 'hindsight-integrations/zapier/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-superagent:
@@ -172,6 +198,10 @@ jobs:
- 'hindsight-integrations/flowise/**'
integrations-google-adk:
- 'hindsight-integrations/google-adk/**'
integrations-obsidian:
- 'hindsight-integrations/obsidian/**'
integrations-omo:
- 'hindsight-integrations/omo/**'
tools-agent-sdk:
- 'hindsight-tools/hindsight-agent-sdk/**'
integrations-roo-code:
@@ -432,6 +462,95 @@ jobs:
working-directory: ./hindsight-integrations/claude-code
run: python -m pytest tests/ -v
test-cursor-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cursor == '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 Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/cursor
run: python -m pytest tests/ -v
test-omo-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-omo == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/omo
run: python -m pytest tests/ -v
test-cline-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cline == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
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:
python-version-file: ".python-version"
- name: Build cline integration
working-directory: ./hindsight-integrations/cline
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/cline
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/cline
run: uv run pytest tests -v
test-codex-integration:
needs: [detect-changes]
if: >-
@@ -458,6 +577,43 @@ jobs:
working-directory: ./hindsight-integrations/codex
run: python -m pytest tests/ -v
test-cursor-cli-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cursor-cli == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
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:
python-version-file: ".python-version"
- name: Build cursor-cli integration
working-directory: ./hindsight-integrations/cursor-cli
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/cursor-cli
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/cursor-cli
run: uv run pytest tests -v
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
@@ -583,6 +739,37 @@ jobs:
working-directory: ./hindsight-integrations/n8n
run: npm run build
test-zapier-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zapier == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
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/zapier
run: npm install --no-fund --no-audit
- name: Validate app definition
working-directory: ./hindsight-integrations/zapier
run: npm run validate
- name: Run tests
working-directory: ./hindsight-integrations/zapier
run: npm test
test-hindsight-agent-sdk:
needs: [detect-changes]
if: >-
@@ -831,17 +1018,28 @@ jobs:
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:
python-version: '3.11'
python-version-file: ".python-version"
- name: Install pytest
run: pip install pytest
- name: Build roo-code integration
working-directory: ./hindsight-integrations/roo-code
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/roo-code
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/roo-code
run: python -m pytest tests/ -v
run: uv run pytest tests -v
build-control-plane:
needs: [detect-changes]
@@ -2862,6 +3060,88 @@ jobs:
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-composio-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-composio == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
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:
python-version-file: ".python-version"
- name: Build composio integration
working-directory: ./hindsight-integrations/composio
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/composio
run: uv sync --frozen
- name: Lint
working-directory: ./hindsight-integrations/composio
run: uv run ruff check .
- name: Run tests
working-directory: ./hindsight-integrations/composio
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-continue-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-continue == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
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:
python-version-file: ".python-version"
- name: Build continue integration
working-directory: ./hindsight-integrations/continue
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/continue
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/continue
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-smolagents-integration:
needs: [detect-changes]
if: >-
@@ -2965,6 +3245,84 @@ jobs:
working-directory: ./hindsight-integrations/flowise
run: npm test
test-obsidian-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-obsidian == '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/obsidian
run: npm install --no-audit --no-fund
- name: Type check
working-directory: ./hindsight-integrations/obsidian
run: npx tsc --noEmit
- name: Build
working-directory: ./hindsight-integrations/obsidian
run: npm run build
- name: Run tests
working-directory: ./hindsight-integrations/obsidian
run: npm test
test-agent-framework-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-agent-framework == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
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:
python-version-file: ".python-version"
- name: Build agent-framework integration
working-directory: ./hindsight-integrations/agent-framework
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/agent-framework
run: uv sync --frozen
- name: Lint
working-directory: ./hindsight-integrations/agent-framework
run: uv run ruff check .
- name: Run tests
working-directory: ./hindsight-integrations/agent-framework
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-crewai-integration:
needs: [detect-changes]
if: >-
@@ -3233,6 +3591,45 @@ jobs:
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-haystack-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-haystack == '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:
python-version-file: ".python-version"
- name: Build haystack integration
working-directory: ./hindsight-integrations/haystack
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/haystack
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/haystack
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-openai-agents-integration:
needs: [detect-changes]
if: >-
@@ -3416,6 +3813,49 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/slim-api-server.log 2>/dev/null || true
verify-embed-control-center-bundle:
# The control center UI (Preact + Tailwind) is built with Vite and its static
# output is committed (served as-is by the embed's Python http.server, no Node
# at runtime). We can't byte-diff the committed bundle against a fresh build —
# Vite's content-hashed asset filenames aren't reproducible across the CI
# runner's OS/arch vs the committer's. So instead verify: (1) the committed
# bundle is a real, wired Vite build (index.html references JS/CSS that exist),
# and (2) the source still builds cleanly.
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22'
# Check the committed bundle BEFORE building (the build overwrites static/).
- name: Verify the committed bundle is wired
working-directory: ./hindsight-embed/hindsight_embed/control_center
run: |
test -f static/index.html || { echo "::error::static/index.html missing — run 'npm run build' in control_center/ui and commit static/"; exit 1; }
js=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.js' static/index.html | head -1)
css=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.css' static/index.html | head -1)
{ [ -n "$js" ] && [ -f "static/$js" ]; } || { echo "::error::index.html does not reference a committed JS bundle — rebuild the UI and commit static/"; exit 1; }
{ [ -n "$css" ] && [ -f "static/$css" ]; } || { echo "::error::index.html does not reference a committed CSS bundle — rebuild the UI and commit static/"; exit 1; }
echo "committed bundle is wired ✓"
- name: Verify the source builds cleanly
working-directory: ./hindsight-embed/hindsight_embed/control_center/ui
run: |
npm ci
npm run build
echo "control center UI builds ✓"
test-embed:
needs: [detect-changes]
if: >-
@@ -3631,7 +4071,8 @@ jobs:
target="$RUNNER_TEMP/install-test"
PYTHONPATH="$target" python -c "
from hindsight_embed.daemon_embed_manager import DaemonEmbedManager
cmd = DaemonEmbedManager()._find_api_command()
# api_version is only used for the uvx fallback; the binary branch ignores it.
cmd = DaemonEmbedManager()._find_api_command('0.0.0')
print('Resolved command:', cmd)
assert len(cmd) == 1 and cmd[0].endswith('hindsight-api.exe'), (
f'Expected sibling hindsight-api.exe, got {cmd!r}. '
@@ -3992,6 +4433,60 @@ jobs:
fi
done
# Dead-code detection beyond what ruff's F401/F841 catch (those are already
# BLOCKING via the ruff config + the verify-generated-files job).
#
# - knip (control plane): BLOCKING on unused files / dependencies / unlisted
# dependencies. These are unambiguous — an orphaned file or a dead
# package.json entry — so they fail the build.
# - vulture (Python) + knip unused *exports*: ADVISORY only. vulture's
# function/argument heuristics false-positive on FastAPI/SQLAlchemy/Pydantic
# patterns, and the control plane intentionally keeps an unused shadcn/ui
# component surface, so these are surfaced in the step summary, not gated.
check-unused-code:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 15
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
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install Control Plane dependencies
run: npm install --workspace=hindsight-control-plane
- name: knip — unused files / dependencies (blocking)
working-directory: hindsight-control-plane
run: npx --yes knip@5 --no-progress --include files,dependencies,unlisted
- name: Advisory scan — vulture + knip exports
continue-on-error: true
run: |
{
echo '## Dead-code scan (advisory)'
echo ''
echo '```'
./scripts/hooks/check-unused.sh 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
verify-generated-files:
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -4173,10 +4668,14 @@ jobs:
- build-openclaw-integration
- smoke-openclaw-install
- test-claude-code-integration
- test-cursor-integration
- test-cline-integration
- test-codex-integration
- test-cursor-cli-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- test-omo-integration
- test-cloudflare-oauth-proxy-integration
- build-chat-integration
- test-paperclip-integration
@@ -4205,9 +4704,12 @@ jobs:
- test-integration
- test-ag2-integration
- test-autogen-integration
- test-continue-integration
- test-smolagents-integration
- test-dify-integration
- test-flowise-integration
- test-obsidian-integration
- test-agent-framework-integration
- test-crewai-integration
- test-langgraph-integration
- test-superagent-integration
@@ -4216,9 +4718,11 @@ jobs:
- test-llamaindex-integration
- test-openai-agents-integration
- test-agentcore-integration
- test-haystack-integration
- test-pip-slim
- test-embed
- test-embed-windows
- verify-embed-control-center-bundle
- test-hindsight-all
- test-hindsight-agent-sdk
- test-claude-agent-sdk-integration
+4 -1
View File
@@ -15,6 +15,8 @@ node_modules/
# Environment variables and local config
.env
.env.bak*
.env.*.bak
docker-compose.yml
docker-compose.override.yml
@@ -59,4 +61,5 @@ hindsight-integrations/_drafts/
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
blog-post*
blog-post*
.worktrees/
+30 -5
View File
@@ -216,6 +216,18 @@ migration file dispatches through `run_for_dialect`, which calls either
./scripts/hooks/lint.sh
```
Dead-code detection runs in CI (the `check-unused-code` job) at two levels:
- **Blocking:** unused imports (ruff `F401`) and variables (`F841`) — `lint.sh` auto-removes
them and `verify-generated-files` fails on any leftover diff; and **knip** for orphaned
control-plane files / unused (or unlisted) `package.json` dependencies.
- **Advisory:** whole unused Python functions (vulture) and unused control-plane *exports*
(the shadcn/ui surface is kept on purpose) — surfaced, not gated.
Run both locally with:
```bash
./scripts/hooks/check-unused.sh
```
**After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done.
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
@@ -315,7 +327,10 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
- No change is needed for ordinary environment-backed config fields. The CLI starts from `_get_raw_config()`,
so new `HindsightConfig` fields are carried through automatically.
- If the new field should be overridable by a CLI flag, add the argparse option in `_parse_cli_args()` and include
that field in the `dataclasses.replace(config, ...)` call near the "CLI override" comment.
3. **Use hierarchical config in MemoryEngine**:
```python
@@ -335,6 +350,16 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
6. **Env template** (`.env.example`):
- Add the variable to the appropriate section, commented if optional, with a
short inline comment describing it (mirror the documentation entry).
- This file is the single source of truth for the env template:
`scripts/dev/setup.sh` copies it to `.env`, and `hindsight-embed` ships a
bundled copy (`hindsight-embed/hindsight_embed/env.example`) that seeds
embed/profile configs. After editing `.env.example`, re-copy it to the
embed package (`cp .env.example hindsight-embed/hindsight_embed/env.example`)
or the `test_bundled_template_matches_repo_root` sync test will fail.
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
@@ -351,7 +376,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```bash
cp .env.example .env
# Edit .env with LLM API key
# Edit .env with the LLM provider/model and credentials for your setup
# Python deps
uv sync --directory hindsight-api-slim/
@@ -360,10 +385,10 @@ uv sync --directory hindsight-api-slim/
npm install
```
Required env vars:
Common LLM settings:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
- `HINDSIGHT_API_LLM_API_KEY`: API key for providers that require one
- `HINDSIGHT_API_LLM_MODEL`: Model name (defaults are provider-specific)
Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
+15 -1
View File
@@ -7,7 +7,6 @@
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![gitcgr](https://gitcgr.com/badge/vectorize-io/hindsight.svg)](https://gitcgr.com/vectorize-io/hindsight)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
<br/>
@@ -143,6 +142,8 @@ main();
pip install hindsight-all -U
```
On Intel (x86_64) Macs, install `hindsight-all-slim` instead — see [Supported Platforms](#supported-platforms).
```python
import os
from hindsight import HindsightServer, HindsightClient
@@ -300,6 +301,19 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
---
## Supported Platforms
| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|----------|--------|------------------|--------------------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
---
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).
Generated
-1
View File
@@ -77,7 +77,6 @@
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
@@ -1,6 +1,6 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
# docker compose -f docker/docker-compose/vchord/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/vchord/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.0
appVersion: "0.8.0"
version: 0.8.2
appVersion: "0.8.2"
keywords:
- ai
- memory
+3 -3
View File
@@ -66,13 +66,13 @@ helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f value
| Parameter | Description | Default |
|-----------|-------------|---------|
| `version` | Default image tag for all components | `0.1.0` |
| `version` | Default image tag for all components | Chart `appVersion` |
| `api.enabled` | Enable the API component | `true` |
| `api.image.repository` | API image repository | `hindsight/api` |
| `api.image.repository` | API image repository | `ghcr.io/vectorize-io/hindsight-api` |
| `api.image.tag` | API image tag (defaults to `version`) | - |
| `api.service.port` | API service port | `8888` |
| `controlPlane.enabled` | Enable the control plane | `true` |
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
| `controlPlane.image.repository` | Control plane image repository | `ghcr.io/vectorize-io/hindsight-control-plane` |
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
| `controlPlane.service.port` | Control plane service port | `3000` |
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
-3
View File
@@ -13,9 +13,6 @@
# - Any other env vars you want to inject
# existingSecret: "my-hindsight-secret"
# Global settings
replicaCount: 1
# Image settings for api
api:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.0",
"version": "0.8.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.8.0"
version = "0.8.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.8.0",
"hindsight-api-slim==0.8.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.8.0"
version = "0.8.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.8.0",
"hindsight-api-slim[all]==0.8.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.8.0",
"hindsight-api-slim[local-llm]==0.8.2",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.0"
__version__ = "0.8.2"
@@ -54,23 +54,20 @@ _INDEX_TYPE_KEYWORDS = {
# pre-dispatcher code (internal benchmarks tuned around our embedding count
# and recall floor; see the link_utils / pool init call sites for the
# latency-vs-recall framing).
# - vchord exposes vchordrq.probes (no default; see VectorChord issue #392)
# and vchordrq.epsilon (default 1.9). probes = 10 / 30 are starting
# defaults pending a workload-specific sweep — vchordrq's recall curve
# shape differs from HNSW's, so the pgvector numbers don't translate
# directly. Revisit with a per-cluster benchmark once we have production
# recall data; until then these are deliberately conservative on the
# high-recall path. We leave epsilon at its default; tightening it is a
# separate trade-off.
# - vchord exposes vchordrq.probes, but its shape must match the index's
# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback
# parameters for this reason: a session GUC overrides every vchordrq index,
# and a single value can be invalid for listless or mixed-layout indexes.
# Hindsight's built-in vchord clause does not set lists, so the safe default
# is no session-level probe override; deployments that partition vchordrq
# indexes should attach probes to the index storage parameters instead.
# - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement
# knob in the engine today, so the dispatcher returns no statements for them.
_ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "60"),),
"vchord": (("vchordrq.probes", "10"),),
}
_ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "200"),),
"vchord": (("vchordrq.probes", "30"),),
}
_EXTENSION_INSTALL_SQL = {
+17 -32
View File
@@ -49,6 +49,7 @@ BACKUP_TABLES = [
"entities",
"chunks",
"memory_units",
"invalidated_memory_units",
"unit_entities",
"entity_cooccurrences",
"memory_links",
@@ -257,12 +258,7 @@ async def _run_migration(
embedding_dimension: int | None = None,
) -> list[str]:
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
from ..migrations import run_migrations_for_schemas
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
@@ -283,32 +279,21 @@ async def _run_migration(
# Preserve order while removing duplicates.
schemas = list(dict.fromkeys(schemas))
for schema in schemas:
run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url)
if embedding_dimension is not None:
for schema in schemas:
ensure_embedding_dimension(
resolved_url,
embedding_dimension,
schema=schema,
vector_extension=config.vector_extension,
)
for schema in schemas:
ensure_vector_extension(
resolved_url,
vector_extension=config.vector_extension,
schema=schema,
)
for schema in schemas:
ensure_text_search_extension(
resolved_url,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
schema=schema,
)
# Migrate up to `migration_concurrency` schemas at once (each in its own
# process); within a schema the work stays sequential. Run off the event
# loop so the process pool's blocking joins don't stall it.
await asyncio.to_thread(
run_migrations_for_schemas,
resolved_url,
schemas,
concurrency=config.migration_concurrency,
migration_database_url=config.migration_database_url,
embedding_dimension=embedding_dimension,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
ensure_extensions=True,
)
return schemas
@@ -0,0 +1,105 @@
"""Add a composite index on memory_links(bank_id, link_type) (PostgreSQL).
``bank_id`` was added to ``memory_links`` in ``c5d6e7f8a9b0`` precisely so that
bank-scoped reads (e.g. the stats endpoint) could filter on the link table
directly instead of joining ``memory_units`` — that JOIN took 18+ seconds on
banks with millions of links. The column landed without an index, so every
``bank_id = $1`` predicate still falls back to a sequential scan over the whole
table.
This adds the missing btree. It is composite on ``(bank_id, link_type)`` rather
than ``bank_id`` alone because the hot query is the stats endpoint's
``SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type``: a
``(bank_id, link_type)`` index serves that filter, grouping and count as an
index-only scan, never touching the heap, whereas a ``bank_id``-only index would
still have to read every matching row to recover ``link_type``. ``link_type`` is
low-cardinality (only ``temporal``/``semantic``/``caused_by`` are written —
entity edges were dropped in ``e9b2c7d1f3a4``), so the trailing column adds
little to the index size while removing the heap fetch.
The Oracle baseline (``o1a2b3c4d5e6``) already creates ``idx_ml_bank_id`` on
``memory_links(bank_id)``; that single-column index already covers Oracle's
bank-scoped filter, so the Oracle slot here is intentionally absent and only the
PostgreSQL dialect gets the composite index.
``memory_links`` can hold tens of millions of rows, so the index is built
CONCURRENTLY to avoid taking a write lock on the table. CONCURRENTLY cannot run
inside a transaction block, so the statement runs in an ``autocommit_block()``;
``IF NOT EXISTS`` keeps it idempotent across retries and re-migrated tenant
schemas. A CONCURRENTLY build interrupted partway (lock conflict, disk
pressure, signal) leaves the index behind as *invalid*; ``IF NOT EXISTS`` would
then skip over it forever, so the upgrade first drops any invalid leftover of
this name before (re)creating it.
Revision ID: 2071c7518f88
Revises: a1d3f5b7c9e2
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "2071c7518f88"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = "idx_memory_links_bank_id_link_type"
def _get_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:
bind = op.get_bind()
# `or None` collapses an unset option and an explicit empty string into NULL
# so the COALESCE below falls back to current_schema() in both cases.
target_schema = context.config.get_main_option("target_schema") or None
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
# autocommit_block runs each statement outside Alembic's migration
# transaction.
with op.get_context().autocommit_block():
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
# index of this name behind. `CREATE INDEX ... IF NOT EXISTS` would see
# that relation and skip, so bank_id queries would keep seq-scanning.
# Drop only the invalid leftover — never a healthy index — so the retry
# actually rebuilds a usable one.
leftover_invalid = bind.execute(
text(
"SELECT NOT i.indisvalid "
"FROM pg_class c "
"JOIN pg_index i ON c.oid = i.indexrelid "
"JOIN pg_namespace n ON c.relnamespace = n.oid "
"WHERE c.relname = :index_name "
" AND n.nspname = COALESCE(:target_schema, current_schema())"
),
{"index_name": _INDEX_NAME, "target_schema": target_schema},
).scalar()
if leftover_invalid:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
# IF NOT EXISTS keeps the create idempotent across retries and schemas.
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX_NAME} ON {schema}memory_links(bank_id, link_type)")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,85 @@
"""Repair: widen the remaining live ``bank_id`` columns from VARCHAR(64) to TEXT on PostgreSQL.
Follow-up to ``c3e5a7b9d1f4`` (issue #2106), which widened the two *history*
tables (``observation_history``, ``mental_model_history``) to ``TEXT`` after the
narrow ``VARCHAR(64)`` declaration bricked startup. The same VARCHAR(64) / TEXT
inconsistency still affects the live tables that store a user-supplied
``bank_id``:
* ``directives`` -- created VARCHAR(64) in ``p1k2l3m4n5o6``
* ``mental_models`` -- VARCHAR(64) (origin ``pinned_reflections`` in
``n9i0j1k2l3m4``; recreated in ``h3c4d5e6f7g8``)
``mental_model_versions`` is intentionally *not* widened here: it is created in
``j5e6f7g8h9i0`` but dropped (``DROP TABLE ... CASCADE``) in ``o0j1k2l3m4n5`` and
never recreated on the upgrade path, so it does not exist at head. Issuing
``ALTER TABLE mental_model_versions ...`` would raise ``UndefinedTable`` and --
because migrations run inside the lifespan-startup transaction -- roll the whole
migration back, bricking the API. (It is unrelated to the live
``mental_model_history`` table widened by ``c3e5a7b9d1f4``.)
``banks.bank_id`` is ``TEXT`` (unbounded), so a deployment can create a bank
whose id exceeds 64 chars -- the 78-char hierarchical org-unit shape reported in
issue #2106 -- and the bank insert succeeds. The next write that propagates that
id (``create_directive``, ``create_mental_model`` / consolidation, or
mental-model versioning) then aborts with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
i.e. a 500 on core write endpoints, instead of the startup brick that
``c3e5a7b9d1f4`` already repaired.
``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is already ``TEXT``,
so every upgrade path converges on ``TEXT``. These tables are per-tenant (they
live in each tenant schema, not ``public``), so this runs for every migrated
schema via the search-path-aware prefix -- the same mechanism as
``c3e5a7b9d1f4``.
PostgreSQL only: these tables are created by PostgreSQL-only migrations
(``run_for_dialect(pg=...)``); on Oracle they are absent or already
``VARCHAR2(256)`` (consistent, never truncates), so the Oracle slot is
intentionally absent -- mirroring ``c3e5a7b9d1f4``.
Revision ID: a1d3f5b7c9e2
Revises: c3e5a7b9d1f4
Create Date: 2026-06-13
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1d3f5b7c9e2"
down_revision: str | Sequence[str] | None = "c3e5a7b9d1f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_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:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}directives ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column types are owned by
# the migrations that created the tables.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -62,7 +62,7 @@ def _pg_upgrade() -> None:
CREATE TABLE IF NOT EXISTS {schema}mental_model_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mental_model_id VARCHAR(64) NOT NULL,
bank_id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (mental_model_id, bank_id)
@@ -83,7 +83,7 @@ def _pg_upgrade() -> None:
CREATE TABLE IF NOT EXISTS {schema}observation_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
observation_id UUID NOT NULL,
bank_id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (observation_id)
@@ -0,0 +1,156 @@
"""Repair: install maintenance routines on the ``public`` / base-schema run.
The original maintenance-routines migration (``e5f6a7b8c9d0``) only created the
shared ``public.banks_needing_consolidation()`` and
``public.schemas_with_expired_rows(...)`` routines when the run had *no*
``target_schema`` at all. But the single-tenant runtime always migrates an
explicit schema — which defaults to ``public`` — so on every default
PostgreSQL deployment the migration was stamped as applied while the functions
were never created. Background maintenance then logs::
Retention sweep failed for llm_requests: function public.schemas_with_expired_rows(...) does not exist
Consolidation reconcile discovery failed: function public.banks_needing_consolidation() does not exist
See https://github.com/vectorize-io/hindsight/issues/2056.
Because ``e5f6a7b8c9d0`` is already stamped on affected ``0.8.0`` databases,
editing it would not re-run it there. This forward migration re-installs the
functions idempotently (``CREATE OR REPLACE``) on the run that targets the
shared ``public`` schema (base run with no ``target_schema``, or an explicit
``target_schema=public``), self-healing already-upgraded deployments and
covering fresh upgrades from earlier versions.
Per-tenant runs against a non-``public`` schema still skip it: re-issuing
``CREATE OR REPLACE FUNCTION public....`` from each concurrent tenant migration
aborts with ``tuple concurrently updated`` on the ``pg_proc`` catalog row, and
the base/public run has already created the functions for every tenant to use.
Runs that target ``public`` are serialized by the per-schema migration advisory
lock, so only one wins the create.
PostgreSQL only — the worker poller and these tables are not wired for Oracle,
so the Oracle slot is intentionally absent (mirrors ``e5f6a7b8c9d0``).
Revision ID: b2d4f6a8c1e3
Revises: e5f6a7b8c9d0
Create Date: 2026-06-08
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b2d4f6a8c1e3"
down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _should_install_public_routines(target_schema: str | None) -> bool:
"""True for the run that must (re)create the shared ``public.*`` routines.
The routines physically live in ``public`` (hard-coded ``public.`` qualifier
in the SQL below), so they must be installed exactly once — on the base run
(no ``target_schema``) or on the run that explicitly targets ``public``. A
run against any other tenant schema skips it to avoid concurrent
``CREATE OR REPLACE`` on the same ``pg_proc`` row.
"""
return not target_schema or target_schema == "public"
def _pg_upgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
# Banks with eligible-but-unscheduled facts and no in-flight consolidation.
# Auto-consolidation is filtered here only at the bank level (cheap prune);
# the full hierarchical resolution (global -> tenant -> bank, plus
# enable_observations) is done by the caller for the small returned set.
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
END LOOP;
END;
$fn$;
"""
)
# Schemas holding at least one row of p_table older than p_days. p_ts_col is
# the timestamp column to compare. Returns nothing when p_days <= 0
# (retention disabled).
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op: ``e5f6a7b8c9d0`` owns the lifecycle of these functions and drops
# them on its own downgrade. This migration only ever (re)creates them, so
# there is nothing to undo without racing that migration's DROP.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,75 @@
"""Repair: widen ``*_history.bank_id`` from VARCHAR(64) to TEXT on PostgreSQL.
The original split-history migration (``a7b8c9d0e1f2``) declared
``observation_history.bank_id`` and ``mental_model_history.bank_id`` as
``VARCHAR(64)`` on PostgreSQL. But ``memory_units.bank_id`` — the backfill
source for observations — is ``TEXT`` (unbounded), as are ``banks``,
``documents`` and ``entities``. Any deployment whose ``bank_id`` exceeds 64
characters aborts the backfill ``INSERT`` with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
Because the migration runs in ``lifespan`` startup inside a transaction, the
whole migration rolls back and the API never comes up — unrecoverable from the
running container. See https://github.com/vectorize-io/hindsight/issues/2106.
``a7b8c9d0e1f2`` itself has been corrected to create the column as ``TEXT``,
which unblocks deployments that *failed* (the migration rolled back, so it
re-runs the fixed DDL). This forward migration covers deployments that already
*succeeded* with the narrow ``VARCHAR(64)`` column — where editing
``a7b8c9d0e1f2`` has no effect because it will not re-run — by widening the
column in place. ``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is
already ``TEXT`` (fresh installs and re-run failures), so every upgrade path
converges on ``TEXT``.
The history tables are per-tenant (they live in each tenant schema, not
``public``), so this runs for every migrated schema via the search-path-aware
prefix — unlike the shared-``public`` routines repaired in ``b2d4f6a8c1e3``.
PostgreSQL only. On Oracle both ``memory_units.bank_id`` and the history
``bank_id`` columns are already ``VARCHAR2(256)`` (consistent, never
truncates), so the Oracle slot is intentionally absent.
Revision ID: c3e5a7b9d1f4
Revises: c9a1b2d3e4f5
Create Date: 2026-06-10
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3e5a7b9d1f4"
down_revision: str | Sequence[str] | None = "c9a1b2d3e4f5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_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:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}observation_history ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_model_history ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column type is owned by
# ``a7b8c9d0e1f2``'s lifecycle.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,108 @@
"""Add invalidated_memory_units table for curation (edit/invalidate).
Curation keeps the recall hot-path (``memory_units``) clean by *moving*
invalidated facts into a sibling archive table rather than flagging them in
place. If a row is in ``memory_units`` it is live; if it is in
``invalidated_memory_units`` it has been retired. Recall/consolidation/graph
queries never need a state predicate — the rows simply aren't there.
The archive mirrors ``memory_units`` column-for-column — except ``embedding``,
which it never keeps: the archive is cold storage, never a recall surface, and
revert recomputes the embedding from the unit's text/dates/entities. Keeping no
archive vector also means a later embedding-model switch (which re-dimensions
``memory_units``) can't trip a dimension mismatch on the move (#2209). Plus:
- ``invalidation_reason`` optional free text recorded on invalidate
- ``invalidated_at`` when it was retired
- ``entity_ids`` snapshot of the unit's entity associations, so revert
can restore them (``unit_entities`` is cascade-deleted
when the live row is removed)
This migration also adds ``edited_at`` to ``memory_units``: set whenever a user
edits a memory's fields (text, context, dates, fact_type, entities) via curation.
NULL means never manually modified; a non-NULL value answers "has the user ever
changed this?" with the time of the last edit (distinct from ``updated_at``,
which background operations also bump). It is added to ``memory_units`` *before*
the archive is cloned below, so the archive inherits the column and the marker
travels with a fact when it is invalidated.
Revision ID: c9a1b2d3e4f5
Revises: b2d4f6a8c1e3
Create Date: 2026-06-03
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c9a1b2d3e4f5"
down_revision: str | Sequence[str] | None = "b2d4f6a8c1e3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# Add edited_at to the live table FIRST so the archive's LIKE clone below
# inherits it (keeps the two tables column-for-column identical for round-trip).
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ")
# LIKE ... INCLUDING DEFAULTS clones every memory_units column (incl.
# edited_at) so an invalidated row can move back verbatim. We deliberately
# omit indexes/constraints — the archive is cold storage, not a recall
# surface; only the lookups below need indexing.
op.execute(
f"CREATE TABLE IF NOT EXISTS {schema}invalidated_memory_units (LIKE {schema}memory_units INCLUDING DEFAULTS)"
)
# ...then drop the inherited embedding: the archive never stores one (revert
# recomputes it), so it isn't created here only to be dropped again later by
# d4f6a8c2e1b3. That migration still runs as a no-op (DROP ... IF EXISTS) on
# fresh DBs and does the real drop on DBs created before this column was removed.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
op.execute(
f"ALTER TABLE {schema}invalidated_memory_units "
f"ADD COLUMN IF NOT EXISTS invalidation_reason TEXT, "
f"ADD COLUMN IF NOT EXISTS invalidated_at TIMESTAMPTZ DEFAULT now(), "
f"ADD COLUMN IF NOT EXISTS entity_ids UUID[]"
)
op.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_invalidated_mu_id ON {schema}invalidated_memory_units (id)")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_invalidated_mu_bank "
f"ON {schema}invalidated_memory_units (bank_id, invalidated_at)"
)
# Deleting a document (or bank) should clear its archived facts too, mirroring
# the memory_units → documents cascade.
op.execute(
f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invalidated_mu_document_fkey') THEN
ALTER TABLE {schema}invalidated_memory_units
ADD CONSTRAINT invalidated_mu_document_fkey
FOREIGN KEY (document_id, bank_id)
REFERENCES {schema}documents(id, bank_id) ON DELETE CASCADE;
END IF; END $$;
"""
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Drops the archive (and its inherited edited_at) wholesale, then removes
# edited_at from the live table.
op.execute(f"DROP TABLE IF EXISTS {schema}invalidated_memory_units")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS edited_at")
def upgrade() -> None:
# PG-only: Oracle gets the table from the baseline snapshot, matching the
# convention used by sibling column/index migrations in this tree.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,93 @@
"""Drop the embedding column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, so it has no business
keeping an embedding. Earlier curation code copied the live row's embedding into
``invalidated_memory_units`` on invalidate; the engine now leaves it out on
invalidate and recomputes it on revert, so the column is dead weight.
Dropping it makes "the archive holds no embedding" a schema-enforced invariant
rather than a convention the move queries have to honour, and removes a latent
failure mode (#2209): after an embedding-model switch the live tables are
re-dimensioned but the archive was not, so a stale old-dimension embedding in
the archive tripped a vector-dimension mismatch on the INSERT … SELECT
round-trip. With no column at all, there is nothing to mismatch.
The creation sites no longer add the column (the PG ``LIKE`` clone in
c9a1b2d3e4f5 drops it; the Oracle baseline omits it), so on a fresh database
this migration is a no-op (DROP ... IF EXISTS / Oracle ORA-00904 swallow). It
does the real work on databases created before the column was removed there.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an unconstrained vector column (any dimension) — empty, since the
embeddings are intentionally discarded.
Revision ID: d4f6a8c2e1b3
Revises: a1d3f5b7c9e2
Create Date: 2026-06-15
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "d4f6a8c2e1b3"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
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:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Unconstrained `vector` (no dimension) so the re-added column accepts any
# model's embeddings; it comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS embedding vector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a fresh schema whose
# baseline already omits the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN embedding';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (embedding VECTOR)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,39 @@
"""Merge two divergent migration heads.
``d4f6a8c2e1b3`` (drop the curation-archive embedding column) and
``2071c7518f88`` (add the memory_links(bank_id, link_type) index) were authored
in parallel off the same parent (``a1d3f5b7c9e2``) and merged independently,
leaving the DAG with two heads. This is a no-op merge that re-unifies them so
``alembic upgrade head`` is unambiguous again (enforced by
``tests/test_alembic_dag.py::test_single_head``).
Revision ID: e1f2a3b4c5d6
Revises: d4f6a8c2e1b3, 2071c7518f88
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e1f2a3b4c5d6"
down_revision: str | Sequence[str] | None = ("d4f6a8c2e1b3", "2071c7518f88")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
# Pure DAG merge — both parents already applied their schema changes.
pass
def _pg_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -122,6 +122,7 @@ _TABLES: tuple[str, ...] = (
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_memory_units PRIMARY KEY (id),
@@ -138,6 +139,50 @@ _TABLES: tuple[str, ...] = (
PARTITION BY LIST (bank_id) AUTOMATIC
(PARTITION p_default VALUES ('__default__'))
""",
# Cold archive for curation: invalidated facts are MOVED here out of
# memory_units so the recall hot-path never sees them. Mirrors memory_units
# plus invalidation bookkeeping and an entity-id snapshot for lossless revert.
# No `embedding` column: the archive is cold storage and revert recomputes the
# embedding, so there is no archive vector to fall out of sync with the live
# model's dimension on a model switch (#2209).
"""
CREATE TABLE IF NOT EXISTS invalidated_memory_units (
id RAW(16) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
document_id VARCHAR2(512),
chunk_id VARCHAR2(512),
text CLOB NOT NULL,
context CLOB,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
occurred_start TIMESTAMP WITH TIME ZONE,
occurred_end TIMESTAMP WITH TIME ZONE,
mentioned_at TIMESTAMP WITH TIME ZONE,
fact_type VARCHAR2(64) DEFAULT 'world' NOT NULL,
confidence_score BINARY_DOUBLE,
access_count NUMBER(10) DEFAULT 0 NOT NULL,
consolidated_at TIMESTAMP WITH TIME ZONE,
observation_scopes CLOB CONSTRAINT imu_obs_scopes_json CHECK (observation_scopes IS JSON OR observation_scopes IS NULL),
tags CLOB DEFAULT '[]' NOT NULL,
metadata CLOB DEFAULT '{}' NOT NULL
CONSTRAINT imu_metadata_json CHECK (metadata IS JSON),
proof_count NUMBER(10) DEFAULT 1,
source_memory_ids CLOB,
history CLOB DEFAULT '[]'
CONSTRAINT imu_history_json CHECK (history IS JSON OR history IS NULL),
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
invalidation_reason CLOB,
invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP,
entity_ids CLOB CONSTRAINT imu_entity_ids_json CHECK (entity_ids IS JSON OR entity_ids IS NULL),
CONSTRAINT pk_invalidated_memory_units PRIMARY KEY (id),
CONSTRAINT fk_imu_document FOREIGN KEY (document_id, bank_id)
REFERENCES documents(id, bank_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS entities (
id RAW(16) DEFAULT SYS_GUID() NOT NULL,
@@ -16,9 +16,7 @@ retention parameters, retrieval settings, etc.) in Python field name format.
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects.postgresql import JSONB
from hindsight_api.alembic._dialect import run_for_dialect
@@ -0,0 +1,97 @@
"""Client-disconnect detection that works behind ``BaseHTTPMiddleware``.
``Request.is_disconnected()`` is the obvious way to notice an abandoned HTTP
request, but it is silently broken once any ``@app.middleware("http")``
(Starlette ``BaseHTTPMiddleware``) is installed: that middleware runs the route
in a child task behind anyio memory streams, so the ``http.disconnect`` ASGI
event never reaches the route's ``Request``. This app has such middlewares, so
the recall/reflect cancellation in #2122/#2127 never actually fired in
production — the disconnect was never observed.
This pure-ASGI middleware sits *outside* the ``BaseHTTPMiddleware`` layer, where
it still owns the real ``receive`` channel. For the recall and reflect routes it
drains ``receive`` in a background task and trips a :class:`CancellationToken`
the moment ``http.disconnect`` arrives, stashing the token on the ASGI ``scope``.
The route copies that token onto its ``RequestContext`` and the engine checks it
at stage boundaries — so abandoned work stops instead of running to completion.
It only wraps recall/reflect (small JSON bodies); every other request — uploads,
MCP streams, etc. — passes straight through untouched, so there is no buffering
or latency cost elsewhere.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from ..cancellation import CancellationToken
# Key under which the per-request CancellationToken is stored on the ASGI scope.
# A dedicated top-level scope key (not scope["state"]) avoids any interaction
# with Starlette's per-request state copying.
SCOPE_CANCELLATION_TOKEN = "hindsight.cancellation_token"
_CLIENT_DISCONNECTED_REASON = "client disconnected"
Scope = MutableMapping[str, Any]
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
def _should_monitor(path: str) -> bool:
"""Only the two long-running, abandon-prone read endpoints need monitoring."""
return path.endswith("/memories/recall") or path.endswith("/reflect")
class ClientDisconnectCancellationMiddleware:
"""Trip a scope-level CancellationToken when the client disconnects.
Must be installed *outside* any ``BaseHTTPMiddleware`` so it owns the real
ASGI ``receive`` channel.
"""
def __init__(self, app: Callable) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or not _should_monitor(scope.get("path", "")):
await self.app(scope, receive, send)
return
token = CancellationToken()
scope[SCOPE_CANCELLATION_TOKEN] = token
# The downstream app still needs to read the request body, so we cannot
# simply consume `receive` ourselves. Instead a single pump task drains
# the real channel, forwards every message to a queue the app reads from,
# and trips the token the instant `http.disconnect` shows up — which the
# app would otherwise never pull once it has finished reading the body.
queue: asyncio.Queue = asyncio.Queue()
async def pump() -> None:
while True:
message = await receive()
if message["type"] == "http.disconnect":
token.cancel(_CLIENT_DISCONNECTED_REASON)
await queue.put(message)
return
await queue.put(message)
async def proxied_receive() -> MutableMapping[str, Any]:
return await queue.get()
pump_task = asyncio.create_task(pump())
try:
await self.app(scope, proxied_receive, send)
finally:
pump_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pump_task
def get_scope_cancellation_token(scope: Scope) -> CancellationToken | None:
"""Return the CancellationToken the middleware attached, if any."""
return scope.get(SCOPE_CANCELLATION_TOKEN)
+619 -53
View File
@@ -10,13 +10,16 @@ import json
import logging
import re
import uuid
from collections.abc import Awaitable
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, Literal
from datetime import datetime
from typing import Any, Literal, TypeVar
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.gzip import GZipMiddleware
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
from hindsight_api.cancellation import OperationCancelledError
from hindsight_api.engine.audit import (
AuditEntry,
AuditLogger,
@@ -41,13 +44,78 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]:
return {}
from typing import Callable
from collections.abc import Iterable
from types import UnionType
from typing import Callable, Union, get_args, get_origin
from fastapi.routing import APIRoute
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from hindsight_api import MemoryEngine
def _annotation_is_nullable(annotation: Any) -> bool:
"""True if the annotation is a Union that includes None (i.e. ``X | None``)."""
if get_origin(annotation) in (Union, UnionType):
return any(arg is type(None) for arg in get_args(annotation))
return False
def _iter_models(annotation: Any) -> Iterable[type[BaseModel]]:
"""Yield every Pydantic model referenced by an annotation, recursing through generics."""
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
yield annotation
return
for arg in get_args(annotation):
yield from _iter_models(arg)
def _model_has_required_nullable(model: type[BaseModel], seen: set[type[BaseModel]]) -> bool:
"""True if the model (or any nested model) declares a required *and* nullable field.
Such a field is in the OpenAPI ``required`` set but may serialize to null, so dropping
it (via ``exclude_none``) would omit a key that strict generated clients expect to be
present. Routes whose response model contains one of these must keep emitting nulls to
stay wire-compatible with already-generated clients.
"""
if model in seen:
return False
seen.add(model)
for field in model.model_fields.values():
annotation = field.annotation
if field.is_required() and _annotation_is_nullable(annotation):
return True
for nested in _iter_models(annotation):
if _model_has_required_nullable(nested, seen):
return True
return False
def _response_model_has_required_nullable(response_model: Any) -> bool:
seen: set[type[BaseModel]] = set()
return any(_model_has_required_nullable(model, seen) for model in _iter_models(response_model))
class ExcludeNoneRoute(APIRoute):
"""Route class that drops null fields from responses, preserving wire compatibility.
``response_model_exclude_none`` is enabled automatically for every route whose response
model has no required-and-nullable field. Routes that *do* have such a field (where an
omitted key would break strict clients) are left untouched and keep emitting nulls.
An explicit ``response_model_exclude_none`` passed to the route decorator is respected.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
response_model = kwargs.get("response_model")
if (
not kwargs.get("response_model_exclude_none")
and response_model is not None
and not _response_model_has_required_nullable(response_model)
):
kwargs["response_model_exclude_none"] = True
super().__init__(*args, **kwargs)
def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
"""
Field wrapper that ensures default_factory values appear in OpenAPI schema.
@@ -80,9 +148,14 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
from hindsight_api.config import get_config
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.response_models import (
VALID_RECALL_FACT_TYPES,
DryRunExtractionResult,
MemoryFact,
TokenUsage,
)
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
@@ -90,6 +163,44 @@ from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
# 499 is the de facto reverse-proxy status for "client closed request".
_CLIENT_CLOSED_REQUEST_STATUS_CODE = 499
_T = TypeVar("_T")
async def run_cancellable_on_disconnect(
http_request: Request,
request_context: RequestContext,
coro: Awaitable[_T],
*,
operation: str,
bank_id: str,
) -> _T:
"""Run an engine coroutine, aborting it with 499 if the client disconnects.
Shared by the recall and reflect handlers. The actual disconnect detection
lives in ``ClientDisconnectCancellationMiddleware`` (a pure-ASGI middleware
installed outside the ``BaseHTTPMiddleware`` layer), which attaches a
:class:`CancellationToken` to the ASGI scope and trips it on
``http.disconnect``. Here we simply hand that token to the engine via
``RequestContext`` the engine checks it at stage/iteration boundaries and
translate the resulting ``OperationCancelledError`` into 499 so abandoned
work stops instead of running to completion (issue #2122).
Note: ``Request.is_disconnected()`` is deliberately NOT used it silently
never fires behind ``BaseHTTPMiddleware``, which is why the original #2127
implementation did not actually cancel anything in this app.
"""
token = get_scope_cancellation_token(http_request.scope)
if token is not None:
request_context.cancellation = token
try:
return await coro
except OperationCancelledError as e:
logger.info(f"[{operation.upper()} CANCELLED] bank={bank_id} reason={e.reason}")
raise HTTPException(status_code=_CLIENT_CLOSED_REQUEST_STATUS_CODE, detail=e.reason) from e
class EntityIncludeOptions(BaseModel):
"""Options for including entity observations in recall results."""
@@ -224,7 +335,7 @@ class RecallResult(BaseModel):
id: str
text: str
type: str | None = None # fact type: world, experience, opinion, observation
type: str | None = None # fact type: world, experience, observation
entities: list[str] | None = None # Entity names mentioned in this fact
context: str | None = None
occurred_start: str | None = None # ISO format date when the event started
@@ -504,13 +615,16 @@ class MemoryItem(BaseModel):
return [v]
return v
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field(
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = Field(
default=None,
title="ObservationScopes",
description=(
"How to scope observations during consolidation. "
"'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. "
"'combined' (default) runs a single pass with all tags together. "
"'shared' runs a single pass over one global, untagged scope, so memories consolidate together "
"regardless of their tags — useful for deduplicating across volatile per-call provenance tags "
"(e.g. per-session ids) while keeping those tags on the source facts. "
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
),
)
@@ -811,7 +925,7 @@ class ReflectFact(BaseModel):
text: str = Field(
description="Fact text. When type='observation', this contains markdown-formatted consolidated knowledge"
)
type: str | None = None # fact type: world, experience, opinion, observation
type: str | None = None # fact type: world, experience, observation
context: str | None = None
occurred_start: str | None = None
occurred_end: str | None = None
@@ -1108,7 +1222,14 @@ class CreateBankRequest(BaseModel):
)
retain_chunk_size: int | None = Field(
default=None,
description="Maximum token size for each content chunk during retain.",
description="Target maximum characters for each content chunk during retain.",
)
retain_structured_chunk_size: int | None = Field(
default=None,
description=(
"Maximum characters for a single JSONL line or conversation turn to keep whole during retain. "
"Defaults to retain_chunk_size when unset."
),
)
enable_observations: bool | None = Field(
default=None,
@@ -1148,6 +1269,7 @@ class CreateBankRequest(BaseModel):
"retain_extraction_mode",
"retain_custom_instructions",
"retain_chunk_size",
"retain_structured_chunk_size",
"enable_observations",
"observations_mission",
):
@@ -1239,6 +1361,33 @@ class GraphDataResponse(BaseModel):
limit: int
class ObservationScope(BaseModel):
"""A distinct observation scope: an exact tag set plus its observation count."""
tags: list[str] = Field(
description="The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope."
)
count: int = Field(description="Number of observations that live under this scope")
class ObservationScopesResponse(BaseModel):
"""Response model for the observation scopes enumeration endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"scopes": [
{"tags": ["user:alice"], "count": 12},
{"tags": ["user:alice", "project:apollo"], "count": 4},
{"tags": [], "count": 2},
]
}
}
)
scopes: list[ObservationScope] = Field(description="Distinct observation scopes, most populous first")
class ListMemoryUnitsResponse(BaseModel):
"""Response model for list memory units endpoint."""
@@ -1268,6 +1417,32 @@ class ListMemoryUnitsResponse(BaseModel):
offset: int
class DryRunExtractRequest(BaseModel):
"""Request to run fact extraction ONLY (no resolution/links/embeddings/persistence).
Every field below the content/context/date is a prompt-affecting override applied just for this
call used to preview what a candidate retain mission (or any extraction setting) would extract,
without changing the bank. Unset (null) fields fall back to the bank's resolved config.
"""
content: str = Field(description="Text to extract facts from (e.g. a document or a single chunk).")
context: str = Field(default="", description="Optional context about the content.")
# Named `timestamp` to match the retain item payload (retain maps timestamp -> event_date internally).
timestamp: datetime | None = Field(
default=None, description="Reference timestamp for resolving relative times (ISO 8601)."
)
agent_name: str | None = Field(default=None, description="Narrator override (memory owner) primed in the prompt.")
# --- prompt-affecting config overrides (null = use the bank's value) ---
retain_mission: str | None = None
retain_extraction_mode: str | None = None
retain_custom_instructions: str | None = None
retain_extract_causal_links: bool | None = None
retain_chunk_size: int | None = None
entity_labels: list | None = None
entities_allow_free_form: bool | None = None
llm_output_language: str | None = None
class ListDocumentsResponse(BaseModel):
"""Response model for list documents endpoint."""
@@ -1352,7 +1527,8 @@ class DocumentResponse(BaseModel):
id: str
bank_id: str
original_text: str
# None when document text storage is disabled (HINDSIGHT_API_STORE_DOCUMENT_TEXT=false).
original_text: str | None
content_hash: str | None
created_at: str
updated_at: str
@@ -1363,6 +1539,12 @@ class DocumentResponse(BaseModel):
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
document_metadata: dict[str, Any] | None = Field(default=None, description="Document metadata")
retain_params: dict[str, Any] | None = Field(default=None, description="Parameters used during retain")
observation_scopes: str | list[list[str]] | None = Field(
default=None,
description="The observation_scopes spec configured at retain time (e.g. 'all_combinations', "
"'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set "
"(default 'combined' scoping) or for documents retained before this was captured.",
)
class UpdateDocumentRequest(BaseModel):
@@ -1389,6 +1571,83 @@ class UpdateDocumentResponse(BaseModel):
success: bool = True
class UpdateMemoryRequest(BaseModel):
"""Request model for curating a single memory unit (edit / invalidate / revert).
Provide ``text`` to correct the fact, and/or ``state`` to invalidate
('invalidated') or revert ('valid') it. ``reason`` is optional free text
recorded on the memory. At least one of ``text`` or ``state`` must be set.
Only world/experience facts can be curated; observations are derived.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"state": "invalidated",
"reason": "superseded: server decommissioned 2026-06-01",
}
}
)
text: str | None = Field(
default=None,
description="New fact text. Re-embeds the memory, drops its derived "
"observations and links, and triggers re-consolidation.",
)
context: str | None = Field(
default=None,
description="New context for the fact. '' clears it; omit to leave unchanged.",
)
occurred_start: str | None = Field(
default=None,
description="New occurred-range start (ISO 8601). '' clears it; omit to leave unchanged.",
)
occurred_end: str | None = Field(
default=None,
description="New occurred-range end (ISO 8601). '' clears it; omit to leave unchanged.",
)
fact_type: str | None = Field(
default=None,
description="Reclassify the fact: 'world' or 'experience'. Omit to leave unchanged.",
)
entities: list[str] | None = Field(
default=None,
description="Replace the fact's entities. Names are resolved/find-or-created "
"the same way retain does; '[]' detaches all entities. Omit to leave unchanged.",
)
state: str | None = Field(
default=None,
description="Curation state: 'invalidated' to soft-retire the memory "
"(excluded from recall/consolidation, links and derived observations "
"pruned, moved to the archive) or 'valid' to revert. Reversible.",
)
reason: str | None = Field(
default=None,
description="Optional free-text reason recorded when invalidating.",
)
@model_validator(mode="after")
def _require_an_edit(self) -> "UpdateMemoryRequest":
if all(
v is None
for v in (
self.text,
self.context,
self.occurred_start,
self.occurred_end,
self.fact_type,
self.entities,
self.state,
)
):
raise ValueError("Provide at least one field to update.")
if self.state is not None and self.state not in ("valid", "invalidated"):
raise ValueError("state must be 'valid' or 'invalidated'.")
if self.fact_type is not None and self.fact_type not in ("world", "experience"):
raise ValueError("fact_type must be 'world' or 'experience'.")
return self
class DeleteDocumentResponse(BaseModel):
"""Response model for delete document endpoint."""
@@ -1538,6 +1797,51 @@ class BankStatsResponse(BaseModel):
total_observations: int = Field(default=0, description="Total number of observations")
class LlmOperationHealth(BaseModel):
"""LLM connectivity status for a single operation. Status only — no provider/model/
endpoint/error, so the probe never discloses the LLM configuration."""
operation: Literal["retain", "consolidation", "reflect"] = Field(
# Distinct title so the generated clients don't collide this inline enum with the
# async-operation "operation" enum (progenitor names Rust types from the title).
title="LlmHealthOperation",
description="Operation whose LLM was probed",
)
ok: bool = Field(description="True only when the probe connected successfully")
status: Literal["connected", "not_configured", "auth_failed", "unreachable", "timeout"] = Field(
# Distinct title — otherwise this inline enum's default title "Status" collides
# with the async-operation status enum and breaks the generated Rust client/CLI.
title="LlmHealthStatus",
description="'connected'; 'not_configured' (provider is 'none'); 'auth_failed' (rejected — "
"usually a wrong/expired API key); 'unreachable' (call failed); 'timeout'",
)
latency_ms: float | None = Field(default=None, description="Round-trip latency of the probe call")
class BankLlmHealthResponse(BaseModel):
"""Per-bank LLM connectivity probe across retain/consolidation/reflect. Operations
that share a configuration are probed once. Discloses status only never the
provider, model, endpoint, API key, or raw error."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"bank_id": "user123",
"operations": [
{"operation": "retain", "ok": True, "status": "connected", "latency_ms": 412.0},
{"operation": "consolidation", "ok": True, "status": "connected", "latency_ms": 412.0},
{"operation": "reflect", "ok": False, "status": "not_configured", "latency_ms": None},
],
}
}
)
bank_id: str = Field(description="Bank identifier")
operations: list[LlmOperationHealth] = Field(
description="Connectivity status per operation (retain, consolidation, reflect)"
)
class MemoryTimeseriesBucket(BaseModel):
"""One bucket in the memory ingestion time-series."""
@@ -1830,7 +2134,14 @@ class BankTemplateConfig(BaseModel):
retain_custom_instructions: str | None = Field(
default=None, description="Custom extraction prompt (when mode='custom')"
)
retain_chunk_size: int | None = Field(default=None, description="Max token size for each content chunk")
retain_chunk_size: int | None = Field(default=None, description="Target max characters for each content chunk")
retain_structured_chunk_size: int | None = Field(
default=None,
description=(
"Max characters for a single JSONL line or conversation turn to keep whole; "
"defaults to retain_chunk_size when unset"
),
)
enable_observations: bool | None = Field(default=None, description="Toggle observation consolidation")
observations_mission: str | None = Field(default=None, description="Controls what gets synthesised")
disposition_skepticism: int | None = Field(default=None, ge=1, le=5, description="Skepticism trait (1-5)")
@@ -1866,6 +2177,16 @@ class BankTemplateConfig(BaseModel):
max_observations_per_scope: int | None = Field(
default=None, description="Max observations to retain per consolidation scope"
)
observation_scope_limits: list[dict[str, Any]] | None = Field(
default=None,
description=(
"Per-scope overrides of max_observations_per_scope: "
'[{"scope": ["run_*", "shared"], "limit": 1}]. Each scope is a list of '
"fnmatch tag-globs; a consolidation scope matches under exact cover "
"(every tag matched by a glob and every glob matched by a tag). The first "
"matching rule wins; unmatched scopes fall back to max_observations_per_scope."
),
)
reflect_source_facts_max_tokens: int | None = Field(
default=None, description="Max tokens of source facts per reflect call"
)
@@ -2413,11 +2734,15 @@ class FeaturesInfo(BaseModel):
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
worker: bool = Field(description="Whether the background worker is enabled")
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
bank_llm_health: bool = Field(description="Whether the per-bank LLM connectivity probe is enabled")
file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled")
document_export_api: bool = Field(description="Whether the document export endpoint is enabled")
document_import_api: bool = Field(description="Whether the document import endpoint is enabled")
audit_log: bool = Field(description="Whether audit logging is enabled")
llm_trace: bool = Field(description="Whether per-bank LLM request tracing is enabled")
store_document_text: bool = Field(
description="Whether raw source text is persisted. When false, document/chunk source text is not stored."
)
class VersionResponse(BaseModel):
@@ -2459,7 +2784,7 @@ class CreateWebhookRequest(BaseModel):
secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret (optional)")
event_types: list[str] = Field(
default=["consolidation.completed"],
description="List of event types to deliver. Currently supported: 'consolidation.completed'",
description="List of event types to deliver. Supported: 'retain.completed', 'consolidation.completed', 'memory_defense.triggered'.",
)
enabled: bool = Field(default=True, description="Whether this webhook is active")
http_config: WebhookHttpConfig = Field(
@@ -2569,7 +2894,6 @@ def _make_audited_http(audit_logger_getter: Callable[[], AuditLogger | None]):
from datetime import datetime as _dt
from datetime import timezone as _tz
from functools import wraps
from typing import Callable as _Callable
def audited(action: str, *, request_param: str | None = "request"):
"""Decorator that wraps an HTTP handler with audit logging.
@@ -2800,6 +3124,10 @@ def create_app(
root_path=config.base_path,
)
# Drop null fields from responses (omit `"x": null`) for routes where it's wire-safe.
# Must be set before any route is registered so @app.<method> decorators pick it up.
app.router.route_class = ExcludeNoneRoute
# IMPORTANT: Set memory on app.state immediately, don't wait for lifespan
# This is required for mounted sub-applications where lifespan may not fire
app.state.memory = memory
@@ -2913,8 +3241,6 @@ def create_app(
# Replace UUIDs and numeric IDs with placeholders
import re
from starlette.requests import Request
path = request.url.path
# Replace UUIDs
path = re.sub(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "/{id}", path)
@@ -2944,6 +3270,13 @@ def create_app(
app.include_router(root_router)
logging.info("HTTP extension root router mounted")
# Client-disconnect cancellation for recall/reflect. Added LAST so it sits
# OUTSIDE the @app.middleware("http") (BaseHTTPMiddleware) layers above —
# that placement is mandatory: BaseHTTPMiddleware breaks
# Request.is_disconnected(), so the only way to observe an abandoned request
# is to own the raw ASGI receive channel from outside it (issue #2122).
app.add_middleware(ClientDisconnectCancellationMiddleware)
return app
@@ -3080,11 +3413,13 @@ def _register_routes(app: FastAPI):
mcp=config.mcp_enabled,
worker=config.worker_enabled,
bank_config_api=config.enable_bank_config_api,
bank_llm_health=config.enable_bank_llm_health,
file_upload_api=config.enable_file_upload_api,
document_export_api=config.enable_document_export_api,
document_import_api=config.enable_document_import_api,
audit_log=config.audit_log_enabled,
llm_trace=config.llm_trace_enabled,
store_document_text=config.store_document_text,
),
)
@@ -3106,7 +3441,7 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/graph",
response_model=GraphDataResponse,
summary="Get memory graph data",
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).",
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/observation).",
operation_id="get_graph",
tags=["Memory"],
)
@@ -3159,6 +3494,8 @@ def _register_routes(app: FastAPI):
type: str | None = None,
q: str | None = None,
consolidation_state: str | None = None,
state: str | None = None,
document_id: str | None = None,
limit: int = 100,
offset: int = 0,
request_context: RequestContext = Depends(get_request_context),
@@ -3171,7 +3508,7 @@ def _register_routes(app: FastAPI):
Args:
bank_id: Memory Bank ID (from path)
type: Filter by fact type (world, experience, opinion)
type: Filter by fact type (world, experience, observation)
q: Search query for full-text search (searches text and context)
consolidation_state: Filter by consolidation state for source memories
(world/experience). One of 'failed', 'pending', or 'done'.
@@ -3184,6 +3521,8 @@ def _register_routes(app: FastAPI):
fact_type=type,
search_query=q,
consolidation_state=consolidation_state,
state=state,
document_id=document_id,
limit=limit,
offset=offset,
request_context=request_context,
@@ -3202,6 +3541,75 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/list: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
async def _require_dry_run_enabled() -> None:
"""Feature-flag gate for dry-run extraction.
Declared as a dependency BEFORE ``precheck_for("dry_run_extract")`` so a
disabled route returns 404 regardless of tenant/billing state FastAPI
resolves path-operation dependencies in signature order, so this runs
first and preserves the original "disabled → 404" contract.
"""
if not get_config().enable_dry_run_extract:
raise HTTPException(
status_code=404,
detail="Dry-run extraction is disabled. Set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true to re-enable.",
)
@app.post(
"/v1/default/banks/{bank_id}/memories/dry-run-extract",
response_model=DryRunExtractionResult,
summary="Dry-run fact extraction (preview, no persistence)",
description=(
"Preview what the retain step would extract from text WITHOUT changing the bank — no "
"entity resolution, links, embeddings, or persistence. Returns the candidate facts and "
"the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, "
"chunk size, …) is overridable in the body to A/B a candidate config against the bank's "
"current one. This is a read-only tool: nothing is stored."
),
operation_id="dry_run_extract_memories",
tags=["Memory"],
)
async def api_dry_run_extract(
bank_id: str,
body: DryRunExtractRequest,
request_context: RequestContext = Depends(get_request_context),
_enabled: None = Depends(_require_dry_run_enabled),
_precheck: None = Depends(precheck_for("dry_run_extract")),
):
try:
override_fields = (
"retain_mission",
"retain_extraction_mode",
"retain_custom_instructions",
"retain_extract_causal_links",
"retain_chunk_size",
"entity_labels",
"entities_allow_free_form",
"llm_output_language",
)
overrides = {f: getattr(body, f) for f in override_fields if getattr(body, f) is not None}
return await app.state.memory.extract_dry_run(
bank_id,
body.content,
context=body.context or "",
event_date=body.timestamp,
overrides=overrides,
agent_name=body.agent_name,
request_context=request_context,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/dry-run-extract: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/memories/{memory_id}",
summary="Get memory unit",
@@ -3237,6 +3645,53 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/memories/{memory_id}",
summary="Curate memory unit",
description="Edit a memory's text and/or change its curation state "
"(invalidate / revert). Invalidated memories are excluded from recall, "
"consolidation, and graph maintenance but kept for audit (reversible). "
"Only world/experience facts can be curated; observations are derived.",
operation_id="update_memory",
tags=["Memory"],
)
async def api_update_memory(
bank_id: str,
memory_id: str,
request: UpdateMemoryRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Curate a single memory unit (edit text / invalidate / revert)."""
try:
data = await app.state.memory.update_memory_unit(
bank_id=bank_id,
memory_id=memory_id,
text=request.text,
context=request.context,
occurred_start=request.occurred_start,
occurred_end=request.occurred_end,
new_fact_type=request.fact_type,
entities=request.entities,
state=request.state,
reason=request.reason,
request_context=request_context,
)
if data is None:
raise HTTPException(status_code=404, detail=f"Memory unit '{memory_id}' not found")
return data
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/memories/{memory_id}/history",
summary="Get observation history",
@@ -3285,6 +3740,7 @@ def _register_routes(app: FastAPI):
async def api_recall(
bank_id: str,
request: RecallRequest,
http_request: Request,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("recall")),
):
@@ -3340,25 +3796,34 @@ def _register_routes(app: FastAPI):
"recall", bank_id=bank_id, source="api", budget=request.budget.value, max_tokens=request.max_tokens
):
recall_start = time.time()
core_result = await app.state.memory.recall_async(
# Cancel the recall if the client disconnects: the engine checks
# request_context at each stage boundary and aborts abandoned
# work rather than running it to completion (issue #2122).
core_result = await run_cancellable_on_disconnect(
http_request,
request_context,
app.state.memory.recall_async(
bank_id=bank_id,
query=request.query,
budget=request.budget,
max_tokens=request.max_tokens,
enable_trace=request.trace,
fact_type=fact_types,
question_date=question_date,
include_entities=include_entities,
max_entity_tokens=max_entity_tokens,
include_chunks=include_chunks,
max_chunk_tokens=max_chunk_tokens,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
tag_groups=request.tag_groups,
),
operation="recall",
bank_id=bank_id,
query=request.query,
budget=request.budget,
max_tokens=request.max_tokens,
enable_trace=request.trace,
fact_type=fact_types,
question_date=question_date,
include_entities=include_entities,
max_entity_tokens=max_entity_tokens,
include_chunks=include_chunks,
max_chunk_tokens=max_chunk_tokens,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
tag_groups=request.tag_groups,
)
# Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
@@ -3462,11 +3927,11 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/reflect",
response_model=ReflectResponse,
summary="Reflect and generate answer",
description="Reflect and formulate an answer using bank identity, world facts, and opinions.\n\n"
description="Reflect and formulate an answer using bank identity, world facts, observations, and mental models.\n\n"
"This endpoint:\n"
"1. Retrieves experience (conversations and events)\n"
"2. Retrieves world facts relevant to the query\n"
"3. Retrieves existing opinions (bank's perspectives)\n"
"3. Retrieves observations and mental models (bank's synthesized perspectives)\n"
"4. Uses LLM to formulate a contextual answer\n"
"5. Returns plain text answer and the facts used",
operation_id="reflect",
@@ -3476,6 +3941,7 @@ def _register_routes(app: FastAPI):
async def api_reflect(
bank_id: str,
request: ReflectRequest,
http_request: Request,
request_context: RequestContext = Depends(get_request_context),
_precheck: None = Depends(precheck_for("reflect")),
):
@@ -3489,20 +3955,30 @@ def _register_routes(app: FastAPI):
# Use the memory system's reflect_async method (record metrics)
with metrics.record_operation("reflect", bank_id=bank_id, source="api", budget=request.budget.value):
core_result = await app.state.memory.reflect_async(
# Cancel the reflect if the client disconnects: the agent loop
# checks request_context between iterations and the nested recall
# checks at its stage boundaries, so abandoned work stops instead
# of running to completion (issue #2122).
core_result = await run_cancellable_on_disconnect(
http_request,
request_context,
app.state.memory.reflect_async(
bank_id=bank_id,
query=query,
budget=request.budget,
context=None, # Deprecated, now concatenated with query
max_tokens=request.max_tokens,
response_schema=request.response_schema,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
tag_groups=request.tag_groups,
fact_types=request.fact_types,
exclude_mental_models=request.exclude_mental_models,
exclude_mental_model_ids=request.exclude_mental_model_ids,
),
operation="reflect",
bank_id=bank_id,
query=query,
budget=request.budget,
context=None, # Deprecated, now concatenated with query
max_tokens=request.max_tokens,
response_schema=request.response_schema,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
tag_groups=request.tag_groups,
fact_types=request.fact_types,
exclude_mental_models=request.exclude_mental_models,
exclude_mental_model_ids=request.exclude_mental_model_ids,
)
# Build based_on (memories + mental_models + directives) if facts are requested
@@ -3668,6 +4144,45 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/stats: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/health/llm",
response_model=BankLlmHealthResponse,
summary="Test the bank's LLM connectivity",
description="Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call "
"each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' "
"instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status "
"only — never the provider, model, endpoint, API key, or raw error. Disable with "
"HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false.",
operation_id="test_bank_llm",
tags=["Banks"],
)
async def api_bank_llm_health(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""Probe per-bank LLM connectivity."""
if not get_config().enable_bank_llm_health:
raise HTTPException(
status_code=404,
detail="Bank LLM health check is disabled. Set HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=true to enable.",
)
try:
result = await app.state.memory.check_bank_llm(bank_id, request_context=request_context)
return BankLlmHealthResponse(
bank_id=result.bank_id,
operations=[
LlmOperationHealth(operation=op.operation, ok=op.ok, status=op.status, latency_ms=op.latency_ms)
for op in result.operations
],
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/health/llm: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/stats/memories-timeseries",
response_model=MemoriesTimeseriesResponse,
@@ -5486,6 +6001,35 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/observations/scopes",
response_model=ObservationScopesResponse,
summary="List observation scopes",
description=(
"Enumerate the distinct scopes across a bank's observations. Each observation lives "
"under a scope: the exact set of tags it was consolidated with. Returns every distinct "
"scope (tag order normalized) with the number of observations in it; the empty tag list "
"is the global/untagged scope. Use a returned scope with the graph endpoint "
"(tags=<scope> & tags_match=exact) to filter observations to exactly that scope."
),
operation_id="list_observation_scopes",
tags=["Memory"],
)
async def api_list_observation_scopes(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
"""List the distinct observation scopes (exact tag sets) for a bank."""
try:
return await app.state.memory.list_observation_scopes(bank_id, request_context=request_context)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/observations/scopes: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/consolidation/recover",
response_model=RecoverConsolidationResponse,
@@ -5627,6 +6171,15 @@ def _register_routes(app: FastAPI):
app.state.memory._operation_validator.validate_bank_write(ctx)
)
# Validate Memory Defense policy shape before persisting.
if "memory_defense" in request.updates and request.updates["memory_defense"] is not None:
from hindsight_api.extensions.memory_defense import parse_policy
try:
parse_policy(request.updates["memory_defense"])
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"invalid memory_defense policy: {exc}")
# Update config via config resolver (validates configurable fields and permissions)
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
@@ -6155,7 +6708,21 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except ValueError as e:
# Invalid request parameters (e.g. duplicate document_ids, or
# update_mode='append' when document text storage is disabled) are
# client errors, not server faults.
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
from dataclasses import asdict
from hindsight_api.engine.retain.orchestrator import MemoryDefenseAllBlockedError
if isinstance(e, MemoryDefenseAllBlockedError):
raise HTTPException(
status_code=422,
detail={"violations": [asdict(v) for v in e.violations]},
)
import traceback
# Create a summary of the input for debugging
@@ -6274,7 +6841,6 @@ def _register_routes(app: FastAPI):
_validate_parsers(_resolve_parser(request_data.parser), "request-level parser")
# Prepare file items and calculate total batch size
import io
file_items = []
total_batch_size = 0
@@ -6353,14 +6919,14 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/memories",
response_model=DeleteResponse,
summary="Clear memory bank memories",
description="Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.",
description="Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.",
operation_id="clear_bank_memories",
tags=["Memory"],
)
@audited("clear_memories", request_param=None)
async def api_clear_bank_memories(
bank_id: str,
type: str | None = Query(None, description="Optional fact type filter (world, experience, opinion)"),
type: str | None = Query(None, description="Optional fact type filter (world, experience, observation)"),
request_context: RequestContext = Depends(get_request_context),
):
"""Clear memories for a memory bank, optionally filtered by type."""
@@ -113,6 +113,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"delete_directive",
"list_memories",
"get_memory",
"update_memory",
"invalidate_memory",
"list_documents",
"get_document",
"delete_document",
@@ -0,0 +1,85 @@
"""Cooperative cancellation for long-running engine operations.
Recall runs as a staged pipeline whose heavy stages — graph expansion and
cross-encoder reranking — execute in worker threads (``run_in_executor``) that
asyncio task cancellation cannot interrupt once they have started. Cancelling
the awaiting task only unblocks the ``await``; the thread keeps burning CPU to
completion. So rather than rely on task cancellation, callers thread a
``CancellationToken`` through ``RequestContext`` and the engine checks it at
stage boundaries (``raise_if_cancelled``), bailing out *before* dispatching the
next expensive stage.
This is cooperative by design: it cannot stop a computation already inside a
worker thread, but it does stop an abandoned recall from progressing into — or
past — that work, which is what starves the instance in issue #2122. The token
lives on ``RequestContext``, so any operation that receives one (recall today;
reflect/consolidation/MCP later) can adopt the same checkpoints, and any driver
(client disconnect today; a deadline tomorrow) can fire it.
"""
from __future__ import annotations
import asyncio
class OperationCancelledError(Exception):
"""Raised at a checkpoint when the operation has been cancelled.
Carries the ``reason`` set by whoever cancelled (e.g. "client disconnected")
so the HTTP layer can translate it into the appropriate status code instead
of a generic 500.
NOTE: this is a plain ``Exception`` on purpose, NOT ``BaseException``. The
recall/reflect pipelines have broad ``except Exception`` handlers that would
otherwise swallow it — those handlers re-raise ``OperationCancelledError``
explicitly (see ``_search_with_retries``) so cancellation propagates to the
HTTP layer. A ``BaseException`` would dodge those handlers but also slip past
legitimate ``isinstance(result, Exception)`` checks (e.g. the reflect agent's
``asyncio.gather(..., return_exceptions=True)`` tool-result handling), which
expect every non-tuple result to be an ``Exception``.
"""
def __init__(self, reason: str = "operation cancelled") -> None:
super().__init__(reason)
self.reason = reason
class CancellationToken:
"""A one-shot, cooperative cancellation signal.
Cheap to poll (``raise_if_cancelled``) at stage boundaries and awaitable
(``wait``) so a driver task can block until cancellation. Safe to share
across an engine call tree; polling is a no-op until something cancels, and
cancellation is idempotent (the first reason wins).
"""
__slots__ = ("_event", "_reason")
def __init__(self) -> None:
self._event = asyncio.Event()
self._reason = "operation cancelled"
def cancel(self, reason: str = "operation cancelled") -> None:
"""Signal cancellation. Idempotent; the first reason recorded wins."""
if not self._event.is_set():
self._reason = reason
self._event.set()
@property
def cancelled(self) -> bool:
"""Whether cancellation has been signalled."""
return self._event.is_set()
@property
def reason(self) -> str:
"""The reason recorded by the first ``cancel`` call."""
return self._reason
def raise_if_cancelled(self) -> None:
"""Raise ``OperationCancelledError`` if cancellation has been signalled."""
if self._event.is_set():
raise OperationCancelledError(self._reason)
async def wait(self) -> None:
"""Block until cancellation is signalled."""
await self._event.wait()
+156 -14
View File
@@ -141,9 +141,11 @@ ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_REASONING_EFFORT = "HINDSIGHT_API_LLM_REASONING_EFFORT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_BEDROCK_SERVICE_TIER = "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
@@ -156,6 +158,7 @@ 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_BEDROCK_SERVICE_TIER = None # None (default), "flex", "priority", or "reserved"
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
@@ -252,6 +255,7 @@ ENV_EMBEDDINGS_OPENROUTER_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY
ENV_EMBEDDINGS_OPENROUTER_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL"
ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY"
ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL"
ENV_RERANKER_OPENROUTER_BASE_URL = "HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL"
# ZeroEntropy configuration (embeddings)
ENV_EMBEDDINGS_ZEROENTROPY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY"
@@ -351,6 +355,8 @@ ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
ENV_ENABLE_BANK_LLM_HEALTH = "HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH"
ENV_ENABLE_DRY_RUN_EXTRACT = "HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT"
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
@@ -391,6 +397,7 @@ ENV_LLM_PROMPT_CACHE_ENABLED = "HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_STRUCTURED_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
@@ -424,6 +431,7 @@ ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH
ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE"
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN"
ENV_STORE_DOCUMENT_TEXT = "HINDSIGHT_API_STORE_DOCUMENT_TEXT"
# Document transfer (export/import documents between banks without re-running the LLM)
ENV_ENABLE_DOCUMENT_EXPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API"
@@ -447,6 +455,7 @@ ENV_CONSOLIDATION_RECALL_BUDGET = "HINDSIGHT_API_CONSOLIDATION_RECALL_BUDGET"
ENV_CONSOLIDATION_MAX_ATTEMPTS = "HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS"
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE"
ENV_OBSERVATION_SCOPE_LIMITS = "HINDSIGHT_API_OBSERVATION_SCOPE_LIMITS"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_OBSERVATION_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
@@ -472,6 +481,7 @@ ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
# Database migrations
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
ENV_MIGRATION_CONCURRENCY = "HINDSIGHT_API_MIGRATION_CONCURRENCY"
# Database connection pool
ENV_DB_POOL_MIN_SIZE = "HINDSIGHT_API_DB_POOL_MIN_SIZE"
@@ -592,6 +602,7 @@ PROVIDER_DEFAULT_MODELS = {
"volcano": "doubao-pro-32k",
"openrouter": "qwen/qwen3.5-9b",
"fireworks": "accounts/fireworks/models/llama-v3p1-8b-instruct",
"nous": "deepseek/deepseek-v4-flash",
}
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
# Built-in llama.cpp defaults
@@ -615,6 +626,7 @@ DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry expone
DEFAULT_LLM_MAX_BACKOFF = 60.0 # Max backoff cap in seconds for retry exponential backoff
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
DEFAULT_LLM_REASONING_EFFORT = "low"
DEFAULT_LLM_SEND_BANK_AS_USER = False # Opt-in: tag provider calls with user=<bank_id>
# Vertex AI defaults
DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
@@ -735,6 +747,7 @@ DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
# OpenRouter defaults
DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b"
DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
DEFAULT_RERANKER_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/rerank"
# ZeroEntropy defaults
DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1"
@@ -792,6 +805,13 @@ DEFAULT_MCP_ENABLED = True
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
DEFAULT_ENABLE_BANK_CONFIG_API = True
# Dry-run extraction is a preview tool that makes a real LLM call but stores nothing. Enabled by
# default; set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=false to remove the endpoint (e.g. to cap
# provider cost/abuse on untrusted deployments).
DEFAULT_ENABLE_DRY_RUN_EXTRACT = True
# The per-bank LLM connectivity probe makes a real provider call, so it's OFF by
# default (cost/abuse concerns) and must be explicitly enabled to expose the endpoint.
DEFAULT_ENABLE_BANK_LLM_HEALTH = False
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
@@ -831,6 +851,7 @@ DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (a
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage)
DEFAULT_STORE_DOCUMENT_TEXT = True # Persist raw source text in documents.original_text / chunks.chunk_text
# Document transfer defaults (export/import enabled by default; gated independently)
DEFAULT_ENABLE_DOCUMENT_EXPORT_API = True
@@ -880,9 +901,16 @@ DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
)
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
DEFAULT_MAX_OBSERVATIONS_PER_SCOPE = -1 # Max observations per tag scope (-1 = unlimited)
# Per-scope overrides of the cap above: list of {"scope": [tag-globs], "limit": int}.
# First rule whose pattern exact-covers a scope's tags wins; else the default above.
DEFAULT_OBSERVATION_SCOPE_LIMITS: list | None = None
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
# Number of tenant schemas to migrate concurrently. Each schema runs in its own
# process (Alembic's command.upgrade() is not thread-safe); within a schema the
# work is always sequential. 1 = fully sequential (the safe default).
DEFAULT_MIGRATION_CONCURRENCY = 1
# Database connection pool
DEFAULT_DB_POOL_MIN_SIZE = 5
@@ -1060,6 +1088,63 @@ def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
return _parse_positive_int(name, raw, 1)
def _validate_retain_chunking_int(name: str, value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{name} must be an integer, got {value!r}")
if value < 1:
raise ValueError(f"{name} must be >= 1, got {value}")
return value
def validate_retain_chunking_config(
retain_chunk_size: Any,
retain_structured_chunk_size: Any,
*,
retain_chunk_size_name: str = "retain_chunk_size",
retain_structured_chunk_size_name: str = "retain_structured_chunk_size",
) -> None:
"""Validate retain chunking size fields.
Defaults emit field-style names ("retain_chunk_size") so API/PATCH callers
don't have to override them. The startup validator (HindsightConfig.validate)
overrides to env-style names ("HINDSIGHT_API_RETAIN_CHUNK_SIZE") for env
misconfig errors.
"""
_validate_retain_chunking_int(retain_chunk_size_name, retain_chunk_size)
if retain_structured_chunk_size is None:
return
_validate_retain_chunking_int(
retain_structured_chunk_size_name,
retain_structured_chunk_size,
)
def validate_retain_completion_token_budget(
*,
llm_provider: str,
retain_max_completion_tokens: int,
retain_chunk_size: int,
retain_llm_model: str | None = None,
llm_model: str | None = None,
retain_llm_provider: str | None = None,
retain_max_completion_tokens_name: str = "retain_max_completion_tokens",
retain_chunk_size_name: str = "retain_chunk_size",
) -> None:
"""Validate that retain LLM output capacity exceeds the configured chunk size."""
if llm_provider == "none" or retain_max_completion_tokens > retain_chunk_size:
return
raise ValueError(
f"Invalid configuration: {retain_max_completion_tokens_name} "
f"({retain_max_completion_tokens}) must be greater than "
f"{retain_chunk_size_name} ({retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase {retain_max_completion_tokens_name} to a value > {retain_chunk_size}"
f"\n 2. Use a model that supports at least {retain_max_completion_tokens} output tokens"
f"\n (current model: {retain_llm_model or llm_model}, "
f"provider: {retain_llm_provider or llm_provider})"
)
def _parse_optional_choice(name: str, raw: str | None, allowed: frozenset[str]) -> str | None:
"""Parse an optional string env var constrained to a small allowlist."""
if raw is None or raw == "":
@@ -1212,6 +1297,7 @@ class HindsightConfig:
llm_reasoning_effort: str
llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto"
llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper)
llm_bedrock_service_tier: str | None # Bedrock: None (default), "flex", "priority", or "reserved"
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
@@ -1219,6 +1305,11 @@ class HindsightConfig:
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
llm_strict_schema: bool # Grammar-enforce structured output via the provider's strongest schema mode (see DEFAULT_LLM_STRICT_SCHEMA)
# Tags outbound OpenAI-compatible LLM + embedding calls with `user=<bank_id>` for
# per-bank cost attribution. Downstream cost gateways (OpenRouter usage accounting,
# LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never
# overrides a `user` the caller already set.
llm_send_bank_as_user: bool
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
@@ -1350,6 +1441,7 @@ class HindsightConfig:
reranker_cohere_timeout: float
reranker_openrouter_api_key: str | None
reranker_openrouter_model: str
reranker_openrouter_base_url: str
reranker_openrouter_timeout: float
reranker_litellm_api_base: str
reranker_litellm_api_key: str | None
@@ -1387,6 +1479,8 @@ class HindsightConfig:
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
enable_bank_config_api: bool
enable_bank_llm_health: bool
enable_dry_run_extract: bool
# Default bank template (static, server-level only). When set, the manifest is applied
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
default_bank_template: dict | None
@@ -1405,6 +1499,7 @@ class HindsightConfig:
# Retain settings
retain_max_completion_tokens: int
retain_chunk_size: int
retain_structured_chunk_size: int | None
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_mission: str | None
@@ -1439,6 +1534,7 @@ class HindsightConfig:
file_conversion_max_batch_size: int # Max files per request
enable_file_upload_api: bool
file_delete_after_retain: bool
store_document_text: bool # When False, store NULL original_text / empty chunk_text
enable_document_export_api: bool
enable_document_import_api: bool
@@ -1462,6 +1558,10 @@ class HindsightConfig:
consolidation_max_attempts: int
observations_mission: str | None
max_observations_per_scope: int
# Per-scope observation caps overriding max_observations_per_scope.
# Raw JSON shape: [{"scope": ["run_*", "shared"], "limit": 1}, ...]
# (validated/applied in engine.consolidation.consolidator._effective_scope_limit)
observation_scope_limits: list | None
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
# List of label group dicts: [{key, description, type, optional, values: [{value, description}]}]
@@ -1470,6 +1570,10 @@ class HindsightConfig:
# When False: only label entities are extracted (or no entities at all if no labels configured)
entities_allow_free_form: bool
# Memory Defense policy (dict matching DefensePolicy schema — validated on write)
# None = Memory Defense disabled / not configured for this bank
memory_defense: dict | None
# Reflect agent settings
reflect_mission: str | None
reflect_source_facts_max_tokens: int
@@ -1504,6 +1608,7 @@ class HindsightConfig:
# Database migrations
run_migrations_on_startup: bool
migration_concurrency: int
# Database connection pool
db_pool_min_size: int
@@ -1594,6 +1699,7 @@ class HindsightConfig:
"embeddings_tei_base_url",
"reranker_tei_base_url",
"reranker_cohere_base_url",
"reranker_openrouter_base_url",
"embeddings_zeroentropy_base_url",
"reranker_zeroentropy_base_url",
"reranker_siliconflow_base_url",
@@ -1622,6 +1728,7 @@ class HindsightConfig:
"mcp_enabled_tools",
# Retention settings (behavioral)
"retain_chunk_size",
"retain_structured_chunk_size",
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
@@ -1641,6 +1748,7 @@ class HindsightConfig:
"consolidation_source_facts_max_tokens_per_observation",
"observations_mission",
"max_observations_per_scope",
"observation_scope_limits",
# Reflect settings
"reflect_mission",
"reflect_source_facts_max_tokens",
@@ -1664,6 +1772,8 @@ class HindsightConfig:
"disposition_empathy",
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
"llm_gemini_safety_settings",
# Memory Defense policy (validated against DefensePolicy schema on write)
"memory_defense",
}
@property
@@ -1759,6 +1869,16 @@ class HindsightConfig:
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
)
# Validate bedrock_service_tier
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
raise ValueError(
f"Invalid HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER: "
f"{self.llm_bedrock_service_tier!r}. Must be one of: "
f"{', '.join(t for t in valid_bedrock_tiers if t is not None)}. "
f"Note: 'standard' is not a valid Bedrock service tier -- use unset for default tier."
)
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
@@ -1768,20 +1888,23 @@ class HindsightConfig:
"disabling observations/consolidation. Reflect will return HTTP 400."
)
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks
# (not applicable when provider is "none" since no LLM calls are made)
if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than "
f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}"
f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens"
f"\n (current model: {self.retain_llm_model or self.llm_model}, "
f"provider: {self.retain_llm_provider or self.llm_provider})"
)
validate_retain_chunking_config(
self.retain_chunk_size,
self.retain_structured_chunk_size,
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
retain_structured_chunk_size_name="HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE",
)
validate_retain_completion_token_budget(
llm_provider=self.llm_provider,
retain_max_completion_tokens=self.retain_max_completion_tokens,
retain_chunk_size=self.retain_chunk_size,
retain_llm_model=self.retain_llm_model,
llm_model=self.llm_model,
retain_llm_provider=self.retain_llm_provider,
retain_max_completion_tokens_name="HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
)
# Warn if local ML dependencies are missing when configured.
# Don't hard-fail here — the actual ImportError fires at model init time
@@ -1872,9 +1995,12 @@ class HindsightConfig:
llm_reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
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_bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
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_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
in ("true", "1"),
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,
@@ -2154,6 +2280,9 @@ class HindsightConfig:
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
reranker_openrouter_model=os.getenv(ENV_RERANKER_OPENROUTER_MODEL, DEFAULT_RERANKER_OPENROUTER_MODEL),
reranker_openrouter_base_url=os.getenv(
ENV_RERANKER_OPENROUTER_BASE_URL, DEFAULT_RERANKER_OPENROUTER_BASE_URL
),
reranker_openrouter_timeout=float(
os.getenv(ENV_RERANKER_OPENROUTER_TIMEOUT, str(DEFAULT_RERANKER_OPENROUTER_TIMEOUT))
),
@@ -2216,8 +2345,12 @@ class HindsightConfig:
if os.getenv(ENV_MCP_ENABLED_TOOLS)
else DEFAULT_MCP_ENABLED_TOOLS,
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
enable_bank_llm_health=os.getenv(ENV_ENABLE_BANK_LLM_HEALTH, str(DEFAULT_ENABLE_BANK_LLM_HEALTH)).lower()
== "true",
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
enable_dry_run_extract=os.getenv(ENV_ENABLE_DRY_RUN_EXTRACT, str(DEFAULT_ENABLE_DRY_RUN_EXTRACT)).lower()
== "true",
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
@@ -2247,6 +2380,10 @@ class HindsightConfig:
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
),
retain_chunk_size=int(os.getenv(ENV_RETAIN_CHUNK_SIZE, str(DEFAULT_RETAIN_CHUNK_SIZE))),
retain_structured_chunk_size=_parse_optional_positive_int(
ENV_RETAIN_STRUCTURED_CHUNK_SIZE,
os.getenv(ENV_RETAIN_STRUCTURED_CHUNK_SIZE),
),
retain_extract_causal_links=os.getenv(
ENV_RETAIN_EXTRACT_CAUSAL_LINKS, str(DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS)
).lower()
@@ -2302,6 +2439,7 @@ class HindsightConfig:
ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN)
).lower()
== "true",
store_document_text=os.getenv(ENV_STORE_DOCUMENT_TEXT, str(DEFAULT_STORE_DOCUMENT_TEXT)).lower() == "true",
enable_document_export_api=os.getenv(
ENV_ENABLE_DOCUMENT_EXPORT_API, str(DEFAULT_ENABLE_DOCUMENT_EXPORT_API)
).lower()
@@ -2385,10 +2523,14 @@ class HindsightConfig:
max_observations_per_scope=int(
os.getenv(ENV_MAX_OBSERVATIONS_PER_SCOPE, str(DEFAULT_MAX_OBSERVATIONS_PER_SCOPE))
),
observation_scope_limits=json.loads(os.getenv(ENV_OBSERVATION_SCOPE_LIMITS, "null"))
or DEFAULT_OBSERVATION_SCOPE_LIMITS,
entity_labels=None,
entities_allow_free_form=True,
memory_defense=None,
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
migration_concurrency=int(os.getenv(ENV_MIGRATION_CONCURRENCY, str(DEFAULT_MIGRATION_CONCURRENCY))),
# Database connection pool
db_pool_min_size=int(os.getenv(ENV_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))),
db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
@@ -18,6 +18,8 @@ from hindsight_api.config import (
HindsightConfig,
_get_raw_config,
normalize_config_dict,
validate_retain_chunking_config,
validate_retain_completion_token_budget,
)
from hindsight_api.engine.memory_engine import fq_table
from hindsight_api.extensions.tenant import TenantExtension
@@ -29,6 +31,35 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies: Any) -> None:
"""Validate retain strategy chunking with the same semantics as apply_strategy()."""
if not isinstance(strategies, dict):
return
configurable = HindsightConfig.get_configurable_fields()
for strategy_name, overrides in strategies.items():
if not isinstance(overrides, dict):
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
continue
try:
resolved = replace(base_config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
except ValueError as e:
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
class ConfigResolver:
"""Resolves hierarchical configuration with tenant/bank overrides."""
@@ -46,6 +77,26 @@ class ConfigResolver:
self._configurable_fields = HindsightConfig.get_configurable_fields()
self._credential_fields = HindsightConfig.get_credential_fields()
async def _resolve_parent_config_dict(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
"""Resolve global + tenant config before bank-level overrides."""
config_dict = asdict(self._global_config)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
return config_dict
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
"""
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
@@ -65,23 +116,7 @@ class ConfigResolver:
Returns:
Complete HindsightConfig with hierarchical overrides applied
"""
# Start with global config (all fields)
config_dict = asdict(self._global_config)
# Load tenant config overrides (if tenant extension available)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
config_dict = await self._resolve_parent_config_dict(bank_id, context)
# Load bank config overrides
bank_overrides = await self._load_bank_config(bank_id)
@@ -92,6 +127,10 @@ class ConfigResolver:
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
# Create a new config instance by copying the global config and updating fields
resolved_config = HindsightConfig(**config_dict)
validate_retain_chunking_config(
resolved_config.retain_chunk_size,
resolved_config.retain_structured_chunk_size,
)
return resolved_config
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
@@ -266,6 +305,29 @@ class ConfigResolver:
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
chunking_fields_updated = (
"retain_chunk_size" in normalized_updates
or "retain_structured_chunk_size" in normalized_updates
or "retain_strategies" in normalized_updates
)
if chunking_fields_updated:
config_dict = await self._resolve_parent_config_dict(bank_id, context)
active_bank_overrides = await self._load_bank_config(bank_id)
for key, value in normalized_updates.items():
if key not in self._configurable_fields:
continue
if value is None:
active_bank_overrides.pop(key, None)
else:
active_bank_overrides[key] = value
config_dict.update(active_bank_overrides)
base_config = HindsightConfig(**config_dict)
validate_retain_chunking_config(
base_config.retain_chunk_size,
base_config.retain_structured_chunk_size,
)
_validate_retain_strategy_chunking(base_config, base_config.retain_strategies)
# Persist the override. Banks are created lazily (on first retain), so a
# PATCH that precedes any ingestion would otherwise UPDATE zero rows and
# silently no-op while returning 200. Ensure the bank row exists first
@@ -364,7 +426,8 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
entity_labels, entities_allow_free_form, etc.
retain_structured_chunk_size, entity_labels,
entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
@@ -386,4 +449,17 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
return replace(config, **filtered)
resolved = replace(config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
return resolved
@@ -0,0 +1,34 @@
"""Per-bank provider cost attribution via the OpenAI ``user`` field.
Shared by the OpenAI-compatible LLM path and the OpenAI embeddings path so both
tag outbound requests identically. Opt-in via ``HINDSIGHT_API_LLM_SEND_BANK_AS_USER``;
downstream cost gateways (OpenRouter usage accounting, LiteLLM, Helicone) key spend
on the OpenAI ``user`` field.
Note: when enabled, the bank id is transmitted to the upstream provider as the
end-user identifier. Banks that are themselves end-user identifiers are therefore
forwarded to the provider — which is exactly what the OpenAI ``user`` field is for,
but operators should opt in with that in mind.
"""
from typing import Any
def apply_bank_attribution(request: dict[str, Any]) -> None:
"""Tag ``request`` with ``user=<bank_id>`` for per-bank cost attribution.
Mutates ``request`` in place. No-op when the flag is off, no bank is in context,
or the caller already set ``user`` — we never override an explicit value.
"""
if "user" in request:
return
# Lazy imports: memory_engine imports the embeddings/provider modules that call
# this, so a top-level import of memory_engine here would be circular.
from ..config import get_config
from .memory_engine import get_current_bank_id
if not get_config().llm_send_bank_as_user:
return
bank_id = get_current_bank_id()
if bank_id:
request["user"] = bank_id
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ from collections import defaultdict
from contextlib import AsyncExitStack
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from fnmatch import fnmatchcase
from itertools import combinations
from typing import TYPE_CHECKING, Any, Literal
@@ -334,7 +335,15 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
Returns ``None`` for the default ``combined``-mode single pass (caller uses
the memory's own tags). Returns a list[list[str]] when the memory requested
multi-pass scoping (``per_tag``, ``all_combinations``, or an explicit list).
multi-pass scoping (``per_tag``, ``all_combinations``, ``shared``, or an
explicit list).
``shared`` resolves to ``[[]]`` — a single pass over the empty (untagged)
scope. The created observation carries no tags and recall/dedup match it with
``tags_match="any"``, so every memory consolidates into one shared observation
regardless of its own tags. Use it to deduplicate across volatile per-call
provenance tags (e.g. per-session ids) without dropping those tags from the
source facts.
"""
parsed = _parse_observation_scopes(memory)
tags = list(memory.get("tags") or [])
@@ -345,6 +354,8 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
if not tags:
return None
return [list(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
if parsed == "shared":
return [[]]
if parsed == "combined" or parsed is None:
return None
return parsed # explicit list[list[str]]
@@ -361,6 +372,7 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
- ``combined`` / ``None`` -> ``[frozenset(memory.tags)]``
- ``per_tag`` -> ``[frozenset({t}) for t in memory.tags]``
- ``all_combinations`` -> one frozenset per nonempty subset of tags
- ``shared`` -> ``[frozenset()]`` (the single untagged scope)
- explicit ``list[list[str]]`` -> one frozenset per declared scope
Empty-tag memories collapse to a single ``frozenset()`` in all modes so they
@@ -375,6 +387,8 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
if not tags:
return [frozenset()]
return [frozenset(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
if parsed == "shared":
return [frozenset()]
if parsed == "combined" or parsed is None:
return [frozenset(tags)]
return [frozenset(s) for s in parsed] # explicit list[list[str]]
@@ -523,6 +537,86 @@ async def _count_observations_for_scope(
)
@dataclass(frozen=True)
class _ScopeLimitRule:
"""One ``observation_scope_limits`` rule: a scope pattern -> an observation cap.
``globs`` is a tuple of fnmatch tag-globs describing one consolidation scope.
A concrete scope (the set of ``fact_tags`` for a consolidation pass) matches
under *exact cover*: every tag is matched by some glob AND every glob matches
some tag. So ``["shared"]`` matches the scope ``{shared}`` but not
``{run_1, shared}``, and ``["run_*", "shared"]`` matches ``{run_1, shared}``
but not ``{shared}``.
``limit`` is the cap applied to matching scopes (-1 = unlimited, 0 = no new
observations, >0 = hard cap), mirroring ``max_observations_per_scope``.
"""
globs: tuple[str, ...]
limit: int
def _parse_scope_limit_rules(raw: Any) -> list[_ScopeLimitRule]:
"""Parse the raw ``observation_scope_limits`` config into ordered rules.
The config round-trips as JSON through env and the bank-config API, so this
is defensive: malformed entries are skipped rather than raising, and list
order is preserved (first match wins in :func:`_effective_scope_limit`).
"""
if not isinstance(raw, list):
return []
rules: list[_ScopeLimitRule] = []
for entry in raw:
if not isinstance(entry, dict):
continue
scope = entry.get("scope")
limit = entry.get("limit")
if not isinstance(scope, list) or not scope:
continue
if not all(isinstance(g, str) and g for g in scope):
continue
# bool is an int subclass — reject True/False masquerading as a limit.
if not isinstance(limit, int) or isinstance(limit, bool):
continue
rules.append(_ScopeLimitRule(globs=tuple(scope), limit=limit))
return rules
def _scope_matches_globs(globs: tuple[str, ...], tags: list[str]) -> bool:
"""Exact-cover match between a scope pattern and a concrete tag set.
True iff every tag is covered by at least one glob AND every glob covers at
least one tag (no uncovered tags, no vacuous globs). Untagged scopes never
match, so a scope limit never applies to untagged observations (consistent
with the ``and fact_tags`` guard at the call site). Matching is
case-sensitive (``fnmatchcase``) for deterministic cross-platform behaviour.
"""
tagset = set(tags)
if not tagset:
return False
if not all(any(fnmatchcase(t, g) for g in globs) for t in tagset):
return False
if not all(any(fnmatchcase(t, g) for t in tagset) for g in globs):
return False
return True
def _effective_scope_limit(config: Any, fact_tags: list[str]) -> int:
"""Resolve the observation cap for one concrete consolidation scope.
The first rule in ``observation_scope_limits`` whose pattern exact-covers
``fact_tags`` wins; otherwise falls back to the bank-wide
``max_observations_per_scope``. Wildcards live only here, matched against the
already-resolved concrete tags — the SQL count stays exact and indexed.
"""
if config is None:
return -1
for rule in _parse_scope_limit_rules(getattr(config, "observation_scope_limits", None)):
if _scope_matches_globs(rule.globs, fact_tags):
return rule.limit
return config.max_observations_per_scope
def _build_response_model(max_creates: int | None = None) -> type[_ConsolidationBatchResponse]:
"""Build a response model, optionally constraining max creates via JSON schema."""
if max_creates is None or max_creates < 0:
@@ -1403,11 +1497,15 @@ async def _process_memory_batch(
# All memories in the batch share the same tag set (enforced by batching)
fact_tags = memories[0].get("tags") or [] if memories else []
# 2b. Compute remaining observation slots for this scope (if limit configured)
max_obs = config.max_observations_per_scope if config is not None else -1
# 2b. Compute remaining observation slots for this scope (if limit configured).
# The cap is resolved per-scope: an observation_scope_limits rule may override
# the bank-wide max_observations_per_scope for scopes matching its tag pattern.
max_obs = _effective_scope_limit(config, fact_tags)
remaining_observation_slots: int | None = None
if max_obs > 0 and fact_tags:
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags)
if max_obs >= 0 and fact_tags:
# max_obs == 0 means "no new observations": there are no slots regardless
# of the current count, so skip the count query for that case.
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags) if max_obs > 0 else 0
remaining_observation_slots = max(max_obs - current_count, 0)
if remaining_observation_slots == 0:
logger.info(
@@ -2045,7 +2143,7 @@ async def _consolidate_batch_with_llm(
# Build capacity note for the prompt when observation limit is configured
observation_capacity_note: str | None = None
if remaining_observation_slots is not None and max_observations_per_scope > 0:
if remaining_observation_slots is not None and max_observations_per_scope >= 0:
if remaining_observation_slots == 0:
observation_capacity_note = (
f"OBSERVATION LIMIT REACHED ({max_observations_per_scope}/{max_observations_per_scope}). "
@@ -27,34 +27,21 @@ from ..config import (
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
DEFAULT_RERANKER_SILICONFLOW_MODEL,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_HTTP_TIMEOUT,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_RERANKER_ALIBABA_API_KEY,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_SILICONFLOW_API_KEY,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_HTTP_TIMEOUT,
ENV_RERANKER_TEI_MAX_CONCURRENT,
ENV_RERANKER_TEI_URL,
ENV_RERANKER_ZEROENTROPY_API_KEY,
)
@@ -303,7 +290,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
"""
import numpy as np
try:
if self.bucket_batching and len(pairs) > 1:
@@ -1679,7 +1665,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_openrouter_model,
base_url="https://openrouter.ai/api/v1/rerank",
base_url=config.reranker_openrouter_base_url,
timeout=config.reranker_openrouter_timeout,
)
elif provider == "flashrank":
@@ -19,7 +19,6 @@ and mirrors Django's ``DatabaseOperations`` architecture.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .result import ResultRow
@@ -8,8 +8,6 @@ columns can't appear in GROUP BY).
import json
import uuid as uuid_mod
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
@@ -4,11 +4,6 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
import json
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .result import ResultRow
@@ -620,7 +615,6 @@ class PostgreSQLOps(DataAccessOps):
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
# v0.5.6 array ops: unnest, &&, COUNT(DISTINCT) on source_memory_ids.
from ..schema import fq_table
entity_rows = await conn.fetch(
f"""
@@ -106,6 +106,15 @@ SCHEMAS_WITH_PENDING_WORK = OptionalRoutine(
deployment.
* Should be cheap and idempotent — called every poll cycle (~30s).
The poller trusts the result wholesale: any schema the routine does
not return is treated as having no work this cycle. It does NOT
second-guess omissions with a per-schema scan — that would re-run the
exact queries this routine exists to avoid. Consequently the routine
is *only* appropriate for multi-tenant deployments. Single-schema
(default/public only) installs should NOT create it: the per-schema
fallback below is a single cheap EXISTS check that covers ``public``
correctly and cannot starve.
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
@@ -26,11 +26,8 @@ from ..config import (
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE,
DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS,
DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
@@ -40,13 +37,6 @@ from ..config import (
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
ENV_EMBEDDINGS_ONNX_MODEL_ID,
ENV_EMBEDDINGS_ONNX_MODEL_PATH,
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
@@ -57,6 +47,7 @@ from ..config import (
ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
ENV_LLM_API_KEY,
)
from .bank_attribution import apply_bank_attribution
logger = logging.getLogger(__name__)
@@ -705,6 +696,7 @@ class OpenAIEmbeddings(Embeddings):
}
if self.dimensions is not None:
request["dimensions"] = self.dimensions
apply_bank_attribution(request)
response = self._client.embeddings.create(**request)
@@ -1347,6 +1339,21 @@ class LiteLLMSDKEmbeddings(Embeddings):
return all_embeddings
# Gemini Embedding 2+ multimodal models return a SINGLE aggregated embedding
# for a multi-input request instead of one vector per input (see
# https://ai.google.dev/gemini-api/docs/embeddings#embedding-aggregation). For
# these models we must embed one input per call to preserve the 1:1 input→vector
# alignment the rest of the pipeline relies on. The marker matches preview and GA
# names (e.g. "gemini-embedding-2-preview", "gemini-embedding-2"), with or
# without a "google/" or "models/" prefix.
_GEMINI_AGGREGATING_MODEL_MARKER = "gemini-embedding-2"
def _gemini_model_aggregates_inputs(model: str) -> bool:
"""Whether the model aggregates a multi-input request into one embedding."""
return _GEMINI_AGGREGATING_MODEL_MARKER in model.lower()
class GeminiEmbeddings(Embeddings):
"""
Google embeddings via the google.genai SDK.
@@ -1356,6 +1363,10 @@ class GeminiEmbeddings(Embeddings):
2. Vertex AI with service account or Application Default Credentials (ADC)
Uses the embed_content API: client.models.embed_content(model, contents)
Gemini Embedding 2+ multimodal models aggregate a multi-input request into a
single embedding, so for those the batch size is forced to 1 (one input per
call) to keep one vector per input.
"""
def __init__(
@@ -1510,9 +1521,13 @@ class GeminiEmbeddings(Embeddings):
all_embeddings = []
# Gemini Embedding 2+ multimodal models return one aggregated vector for a
# multi-input request, so embed one input per call to keep 1:1 alignment.
batch_size = 1 if _gemini_model_aggregates_inputs(self.model) else self.batch_size
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
embed_kwargs = {"model": self.model, "contents": batch}
if self._embed_config is not None:
@@ -1520,7 +1535,13 @@ class GeminiEmbeddings(Embeddings):
result = self._client.models.embed_content(**embed_kwargs)
all_embeddings.extend([emb.values for emb in result.embeddings])
embeddings = result.embeddings or []
if len(embeddings) != len(batch):
raise RuntimeError(
f"Gemini embeddings backend returned {len(embeddings)} vectors for "
f"{len(batch)} input texts (model {self.model}); expected exact 1:1 alignment"
)
all_embeddings.extend([emb.values for emb in embeddings])
# L2-normalize when output_dimensionality is set — Gemini only returns
# normalized vectors at full 3072 dims; truncated dims need re-normalization
@@ -834,14 +834,12 @@ class EntityResolver:
best_candidate = None
best_score = 0.0
best_name_similarity = 0.0
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
for row in candidates:
candidate_id = row["id"]
canonical_name = row["canonical_name"]
metadata = row["metadata"]
last_seen = row["last_seen"]
score = 0.0
@@ -888,7 +886,6 @@ class EntityResolver:
if score > best_score:
best_score = score
best_candidate = candidate_id
best_name_similarity = name_similarity
# Threshold for considering it the same entity
threshold = 0.6
@@ -10,7 +10,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.memory_engine import BankLlmHealthInfo, Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.models import RequestContext
@@ -483,6 +483,20 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def check_bank_llm(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> "BankLlmHealthInfo":
"""
Probe the LLM consolidation would use for this bank. Deliberate connectivity
test (one real minimal call); never returns the API key. See
MemoryEngine.check_bank_llm.
"""
...
@abstractmethod
async def get_entity(
self,
@@ -8,7 +8,7 @@ enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, et
from abc import ABC, abstractmethod
from typing import Any
from .response_models import LLMToolCallResult, TokenUsage
from .response_models import LLMToolCallResult
class LLMInterface(ABC):
@@ -11,14 +11,10 @@ import time
import uuid
from contextlib import AsyncExitStack
from pathlib import Path
from typing import Any
import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from typing import TYPE_CHECKING, Any
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
@@ -27,16 +23,14 @@ except ImportError:
from ..config import (
DEFAULT_LLM_MAX_CONCURRENT,
DEFAULT_LLM_TIMEOUT,
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT,
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_MAX_CONCURRENT,
ENV_LLM_TIMEOUT,
ENV_REFLECT_LLM_MAX_CONCURRENT,
ENV_RETAIN_LLM_MAX_CONCURRENT,
)
from ..metrics import get_metrics_collector
from .response_models import TokenUsage
if TYPE_CHECKING:
from .response_models import LLMToolCallResult
# Seed applied to every Groq request for deterministic behavior.
DEFAULT_LLM_SEED = 4242
@@ -232,6 +226,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"litellm",
"litellmrouter",
"bedrock",
"nous",
}
)
@@ -249,6 +244,7 @@ def create_llm_provider(
reasoning_effort: str,
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_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,
@@ -269,6 +265,7 @@ def create_llm_provider(
reasoning_effort: Reasoning effort level for supported providers.
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).
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
extra_body: Extra request-body params merged into the provider's native
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
VertexAI and LiteLLM providers (each merges them in its own parameter
@@ -284,7 +281,6 @@ def create_llm_provider(
Returns:
LLMInterface implementation for the specified provider.
"""
from .llm_interface import LLMInterface
from .providers import (
AnthropicLLM,
ClaudeCodeLLM,
@@ -401,6 +397,7 @@ def create_llm_provider(
model=bedrock_model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
bedrock_service_tier=bedrock_service_tier,
)
elif provider_lower == "llamacpp":
@@ -434,6 +431,21 @@ def create_llm_provider(
extra_body=extra_body,
)
elif provider_lower == "nous":
# Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating
# inference:invoke JWT auth read natively from ~/.hermes/auth.json
# (no static api_key, no hermes_cli dependency — same shape as Codex).
from hindsight_api.engine.providers.nous_llm import NousLLM
return NousLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower in (
"openai",
"groq",
@@ -478,6 +490,7 @@ class LLMProvider:
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
extra_body: dict[str, Any] | None = None,
@@ -495,6 +508,7 @@ class LLMProvider:
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra request-body params merged into the provider's native call
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
@@ -517,6 +531,7 @@ class LLMProvider:
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
# Gemini prompt caching: when True, retain extraction (and any future
@@ -563,6 +578,7 @@ class LLMProvider:
"zai",
"opencode-go",
"fireworks",
"nous",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -587,6 +603,8 @@ class LLMProvider:
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"
elif self.provider == "nous":
self.base_url = "https://inference-api.nousresearch.com/v1"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
@@ -679,6 +697,7 @@ class LLMProvider:
reasoning_effort=self.reasoning_effort,
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
bedrock_service_tier=self.bedrock_service_tier,
extra_body=self.extra_body,
default_headers=self.default_headers,
vertexai_project_id=vertexai_project_id,
@@ -1120,6 +1139,7 @@ class LLMProvider:
DEFAULT_LLM_REASONING_EFFORT,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_BEDROCK_SERVICE_TIER,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_MODEL,
@@ -1151,6 +1171,7 @@ class LLMProvider:
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
extra_body=extra_body,
default_headers=default_headers,
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
)
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -15,7 +15,7 @@ from typing import Any
from pydantic import ValidationError
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -24,7 +24,7 @@ from typing import Any
import httpx
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -397,7 +397,6 @@ class CodexLLM(LLMInterface):
}
url = f"{self.base_url}/codex/responses"
last_exception = None
# Manual attempt tracking instead of ``for attempt in range(...)`` so
# that the reactive-refresh path can retry once without consuming a
@@ -428,7 +427,6 @@ class CodexLLM(LLMInterface):
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
attempt += 1
continue
raise
@@ -490,7 +488,6 @@ class CodexLLM(LLMInterface):
return result
except httpx.HTTPStatusError as e:
last_exception = e
status_code = e.response.status_code
# Auth error: try one OAuth refresh + retry before giving up.
@@ -549,7 +546,6 @@ class CodexLLM(LLMInterface):
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
@@ -564,10 +560,6 @@ class CodexLLM(LLMInterface):
logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Codex call failed after all retries")
async def _parse_sse_stream(self, response: httpx.Response) -> str:
"""
Parse Server-Sent Events (SSE) stream from Codex API.
@@ -8,9 +8,9 @@ This provider supports both:
import asyncio
import base64
import io
import json
import logging
import os
import time
from contextvars import ContextVar
from typing import Any
@@ -19,7 +19,7 @@ from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -35,7 +35,6 @@ _safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settin
# Vertex AI imports (optional)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
@@ -43,6 +42,14 @@ except ImportError:
VERTEXAI_AVAILABLE = False
def _to_int(value: Any) -> int:
"""Coerce Gemini's optional/string completion counts to int, defaulting to 0."""
try:
return int(value)
except (ValueError, TypeError):
return 0
class GeminiLLM(LLMInterface):
"""
LLM provider for Google Gemini and Vertex AI.
@@ -826,6 +833,282 @@ class GeminiLLM(LLMInterface):
tools=tools,
)
# ── Batch API (Gemini API only — not Vertex AI) ─────────────────────────
#
# Google's Gemini Batch API gives a flat 50% discount on input + output
# tokens with a 24h completion SLA (https://ai.google.dev/gemini-api/docs/batch-api).
# The retain orchestrator and ``fact_extraction`` consumer speak the
# OpenAI-batch interface contract, so these overrides translate that shape
# to/from Gemini's file-upload → ``batches.create`` → ``batches.get`` →
# download flow — nothing downstream changes (same pattern as FireworksLLM).
#
# Interface contract preserved (see fact_extraction.py result handling)::
# result["response"]["body"]["choices"][0]["message"]["content"]
async def supports_batch_api(self) -> bool:
"""True for the Gemini API; False for Vertex AI.
Only ``provider="gemini"`` is supported: it exposes the file-upload
Batch API used below. Vertex AI's batch path is GCS/BigQuery-backed (no
file-upload analogue), so it stays unsupported here the startup
validation then surfaces a clear error instead of silently falling back
to synchronous, full-price calls.
"""
return self.provider == "gemini"
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""Submit a batch of (OpenAI-shaped) requests to the Gemini Batch API."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
# endpoint/completion_window are part of the shared LLMInterface batch
# contract (used by the OpenAI path) but have no analogue on Gemini: the
# request shape is fixed (generateContent) and the SLA is server-side.
# Kept for signature compatibility with the shared retain driver.
logger.info(f"Submitting Gemini batch with {len(requests)} requests")
jsonl = self._translate_requests(requests)
# Upload the JSONL as a Gemini file (mime_type must be "jsonl"; a
# BytesIO has no path for the SDK to infer it from).
file_obj = io.BytesIO(jsonl.encode("utf-8"))
uploaded = await self._client.aio.files.upload(
file=file_obj,
config=genai_types.UploadFileConfig(mime_type="jsonl", display_name="hindsight-batch-input"),
)
batch = await self._client.aio.batches.create(
model=self.model,
src=uploaded.name,
config=genai_types.CreateBatchJobConfig(display_name="hindsight-batch"),
)
logger.info(f"Gemini batch submitted: {batch.name}, state={self._state_name(batch.state)}")
return {
"batch_id": batch.name,
"status": self._normalize_state(batch.state),
"input_file_id": uploaded.name,
"request_count": len(requests),
}
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""Get the status of a Gemini batch job, in the shared status shape."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.aio.batches.get(name=batch_id)
stats = batch.completion_stats
successful = _to_int(getattr(stats, "successful_count", None)) if stats else 0
failed = _to_int(getattr(stats, "failed_count", None)) if stats else 0
incomplete = _to_int(getattr(stats, "incomplete_count", None)) if stats else 0
result: dict[str, Any] = {
"batch_id": batch.name,
"status": self._normalize_state(batch.state),
"request_counts": {
"total": successful + failed + incomplete,
"completed": successful,
"failed": failed,
},
}
if batch.dest and getattr(batch.dest, "file_name", None):
result["output_file_id"] = batch.dest.file_name
if batch.error:
result["errors"] = self._error_to_dict(batch.error)
return result
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""Download and normalize completed Gemini batch results."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.aio.batches.get(name=batch_id)
status = self._normalize_state(batch.state)
if status != "completed":
raise ValueError(f"Gemini batch {batch_id} is not completed yet (state: {self._state_name(batch.state)})")
dest = batch.dest
if not dest or not getattr(dest, "file_name", None):
raise ValueError(
f"Gemini batch {batch_id} completed but reported no output file "
f"(submit_batch always uses file mode, so this is unexpected)"
)
content = await self._client.aio.files.download(file=dest.file_name)
text = content.decode("utf-8") if isinstance(content, (bytes, bytearray)) else str(content)
# The output is a JSONL error file plus results merged into one stream;
# error lines carry an `error` so partial failures surface per key
# instead of vanishing (JOB_STATE_PARTIALLY_SUCCEEDED maps to completed).
results: list[dict[str, Any]] = []
for line in text.strip().split("\n"):
if line.strip():
results.append(self._normalize_output_line(json.loads(line)))
logger.info(f"Retrieved {len(results)} results for Gemini batch {batch_id}")
return results
# ----- pure translation/normalization helpers (unit-tested) ----------
@staticmethod
def _translate_requests(requests: list[dict[str, Any]]) -> str:
"""OpenAI batch requests -> Gemini batch input JSONL.
Each output line is ``{"key": <custom_id>, "request": <GenerateContentRequest>}``;
the model is supplied to ``batches.create`` so it is omitted per-line.
"""
lines = []
for req in requests:
gemini_request = GeminiLLM._openai_body_to_gemini_request(req.get("body") or {})
lines.append(json.dumps({"key": req.get("custom_id"), "request": gemini_request}, ensure_ascii=False))
return "\n".join(lines)
@staticmethod
def _openai_body_to_gemini_request(body: dict[str, Any]) -> dict[str, Any]:
"""OpenAI chat-completions body -> Gemini ``GenerateContentRequest`` JSON.
Mirrors the synchronous ``call`` path: system messages become
``systemInstruction``; a ``response_format`` json_schema forces JSON
output (``responseMimeType``), appends the schema as a textual hint, and
grammar-enforces via ``responseJsonSchema`` when ``strict`` is set.
"""
system_texts: list[str] = []
contents: list[dict[str, Any]] = []
for msg in body.get("messages") or []:
role = msg.get("role", "user")
text = msg.get("content", "") or ""
if role == "system":
system_texts.append(text)
elif role == "assistant":
contents.append({"role": "model", "parts": [{"text": text}]})
else:
contents.append({"role": "user", "parts": [{"text": text}]})
generation_config: dict[str, Any] = {}
if body.get("temperature") is not None:
generation_config["temperature"] = body["temperature"]
if body.get("max_completion_tokens") is not None:
generation_config["maxOutputTokens"] = body["max_completion_tokens"]
response_format = body.get("response_format")
if isinstance(response_format, dict) and response_format.get("type") == "json_schema":
json_schema = response_format.get("json_schema") or {}
schema = json_schema.get("schema")
generation_config["responseMimeType"] = "application/json"
if schema:
system_texts.append(
"You must respond with valid JSON matching this schema:\n" + json.dumps(schema, ensure_ascii=False)
)
if json_schema.get("strict"):
generation_config["responseJsonSchema"] = schema
request: dict[str, Any] = {"contents": contents}
if system_texts:
request["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_texts)}]}
if generation_config:
request["generationConfig"] = generation_config
return request
@staticmethod
def _normalize_output_line(line: dict[str, Any]) -> dict[str, Any]:
"""Gemini batch output line -> OpenAI-batch-output shape.
Target: ``{"custom_id", "response": {"body": {"choices": [...], "usage": {...}}}, "error"}``
so the consumer's ``result["response"]["body"]["choices"][0]...`` works and
it can read ``body["usage"]`` for token accounting (the consumer reports
zero usage otherwise).
"""
custom_id = line.get("key") if line.get("key") is not None else line.get("custom_id")
error = line.get("error")
if error:
return {"custom_id": custom_id, "response": None, "error": error}
response = line.get("response") or {}
body: dict[str, Any] = {"choices": [{"message": {"content": GeminiLLM._extract_text_from_response(response)}}]}
usage = GeminiLLM._usage_from_response(response)
if usage is not None:
body["usage"] = usage
return {"custom_id": custom_id, "response": {"body": body}, "error": None}
@staticmethod
def _extract_text_from_response(response: dict[str, Any]) -> str:
"""Concatenate the text parts of a (JSON) GenerateContentResponse."""
candidates = response.get("candidates") or []
if not candidates:
return ""
content = candidates[0].get("content") or {}
parts = content.get("parts") or []
return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("text"))
@staticmethod
def _usage_from_response(response: dict[str, Any]) -> dict[str, Any] | None:
"""Gemini ``usageMetadata`` -> OpenAI-shaped ``usage`` block, or None.
The batch consumer accumulates token usage from ``body["usage"]`` using
OpenAI key names, so translate here to keep the output contract uniform
across providers. Handles both the REST camelCase (downloaded JSONL) and
snake_case spellings defensively.
"""
meta = response.get("usageMetadata") or response.get("usage_metadata")
if not isinstance(meta, dict):
return None
prompt = meta.get("promptTokenCount") or meta.get("prompt_token_count") or 0
completion = meta.get("candidatesTokenCount") or meta.get("candidates_token_count") or 0
total = meta.get("totalTokenCount") or meta.get("total_token_count") or 0
return {"prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": total}
@staticmethod
def _normalize_state(state: Any) -> str:
"""Gemini ``JobState`` -> the retain driver's status strings.
Unknown / in-flight states map to ``in_progress`` so the driver keeps
polling; ``PARTIALLY_SUCCEEDED`` maps to ``completed`` (per-line errors
surface the partial failures during retrieval).
"""
name = GeminiLLM._state_name(state).upper()
if name in ("JOB_STATE_SUCCEEDED", "JOB_STATE_PARTIALLY_SUCCEEDED"):
return "completed"
if name == "JOB_STATE_FAILED":
return "failed"
if name in ("JOB_STATE_CANCELLED", "JOB_STATE_CANCELLING"):
return "cancelled"
if name == "JOB_STATE_EXPIRED":
return "expired"
return "in_progress"
@staticmethod
def _state_name(state: Any) -> str:
"""Extract the bare ``JOB_STATE_*`` name from a JobState enum or string."""
if state is None:
return ""
name = getattr(state, "name", None)
if name:
return str(name)
text = str(state)
if "." in text:
text = text.rsplit(".", 1)[-1]
return text
@staticmethod
def _error_to_dict(error: Any) -> dict[str, Any]:
"""Coerce a Gemini JobError into a JSON-serializable dict for logging."""
if hasattr(error, "model_dump"):
try:
return error.model_dump(exclude_none=True)
except Exception:
pass
return {"message": str(error)}
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
@@ -49,6 +49,7 @@ class LiteLLMLLM(LLMInterface):
reasoning_effort: str = "low",
timeout: float = 300.0,
extra_body: dict[str, Any] | None = None,
bedrock_service_tier: str | None = None,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -60,6 +61,7 @@ class LiteLLMLLM(LLMInterface):
# drops any the target model rejects (litellm.drop_params=True below).
# Sourced from llm_extra_body (env: HINDSIGHT_API_LLM_EXTRA_BODY).
self._extra_body: dict[str, Any] = extra_body or {}
self.bedrock_service_tier = bedrock_service_tier
try:
import litellm
@@ -119,6 +121,10 @@ class LiteLLMLLM(LLMInterface):
for key, value in self._extra_body.items():
kwargs.setdefault(key, value)
# Bedrock service tier: flex (50% cheaper), priority, or reserved
if self.model.startswith("bedrock/") and self.bedrock_service_tier is not None:
kwargs["service_tier"] = self.bedrock_service_tier
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
@@ -0,0 +1,463 @@
"""
Native Nous Portal OAuth authentication manager.
The Nous Portal inference endpoint (https://inference-api.nousresearch.com/v1)
speaks the OpenAI-compatible wire format but authenticates with a short-lived,
inference-scoped JWT rather than a static API key. Hermes obtains that JWT once
via an interactive browser login (``hermes portal``) and persists the resulting
OAuth state ``access_token`` + ``refresh_token`` under ``providers.nous`` in
``~/.hermes/auth.json``.
This manager reads that file *directly* and refreshes the access token itself,
exactly mirroring ``codex_auth.py`` (read ``~/.codex/auth.json`` + native
refresh). It deliberately does **not** import the Hermes ``hermes_cli`` package:
that package is the interactive CLI, not a library Hindsight can depend on. The
refresh request shape is mirrored from Hermes' own resolver
(``POST {portal}/api/oauth/token`` with an ``x-nous-refresh-token`` header and a
``grant_type=refresh_token`` form body), so server-side changes affect both
clients identically. The inference bearer is the access token itself in
Hermes' state the ``agent_key`` field is literally ``= access_token``.
Single-use refresh tokens
-------------------------
Nous refresh tokens are single-use with server-side reuse-detection: if two
processes refresh with the same ``refresh_token``, or a rotated token is not
persisted back, the Portal revokes the whole session as a theft signal. Because
Hindsight shares ``~/.hermes/auth.json`` with a possibly-running Hermes agent,
every refresh here is performed while holding the **same cross-process advisory
lock Hermes uses** (``~/.hermes/auth.lock`` via ``fcntl.flock``) and re-reads the
latest ``refresh_token`` from disk under that lock before exchanging it. That is
the protocol Hermes follows too, so the two coordinate safely through the file.
Usage
-----
mgr = NousAuthManager.from_file()
token = mgr.ensure_fresh_token() # proactive; refreshes if near expiry
... # use token as Bearer
mgr.refresh_tokens(force=True) # reactive, on a 401
"""
from __future__ import annotations
import base64
import binascii
import contextlib
import json
import logging
import os
import tempfile
import threading
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants — mirrored from Hermes' canonical Nous resolver
# (hermes_cli/auth.py: DEFAULT_NOUS_* and _refresh_access_token). Endpoints and
# client id are overridable via the same env vars Hermes honours, so a staging
# Portal or a future change can be pointed at without a code change.
# ---------------------------------------------------------------------------
_NOUS_PORTAL_BASE_URL = (
os.environ.get("HERMES_PORTAL_BASE_URL")
or os.environ.get("NOUS_PORTAL_BASE_URL")
or "https://portal.nousresearch.com"
)
_NOUS_INFERENCE_BASE_URL = os.environ.get("NOUS_INFERENCE_BASE_URL") or "https://inference-api.nousresearch.com/v1"
_NOUS_CLIENT_ID = "hermes-cli"
# Proactively refresh this many seconds before the JWT ``exp`` claim — matches
# the 120s skew Hermes' own runtime resolver uses for Nous.
_NOUS_TOKEN_REFRESH_SKEW_SECONDS = 120
# OAuth error codes the Portal returns when the refresh_token itself is no
# longer usable. These are terminal — retrying will not succeed; the user must
# re-run ``hermes portal``.
_NOUS_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"invalid_grant", "invalid_token", "refresh_token_reused", "refresh_token_expired"}
)
_AUTH_LOCK_TIMEOUT_SECONDS = 20.0
def _default_auth_file() -> Path:
return Path.home() / ".hermes" / "auth.json"
class NousNotLoggedInError(RuntimeError):
"""Raised when ``~/.hermes/auth.json`` has no usable Nous OAuth state.
Remediation: run ``hermes portal`` to log in to Nous Portal.
"""
class NousRefreshExpiredError(RuntimeError):
"""Raised when the Nous refresh_token itself is permanently invalid.
The user must re-run ``hermes portal`` to obtain new credentials. Callers
should surface a clear remediation message and stop retrying.
"""
@contextlib.contextmanager
def _hermes_auth_lock(auth_file: Path, timeout_seconds: float = _AUTH_LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
"""Cross-process advisory lock on the Hermes auth store.
Uses ``<auth_file>.lock`` (i.e. ``~/.hermes/auth.lock``) with
``fcntl.flock(LOCK_EX)`` the exact same lock file and primitive Hermes'
``_auth_store_lock`` takes so a refresh here is mutually exclusive with a
concurrently-running Hermes agent. Degrades to a no-op (with a debug log)
where ``fcntl`` is unavailable (Windows); the single-process in-memory lock
still serialises this process's own refreshes.
"""
if fcntl is None: # pragma: no cover - Windows
logger.debug("fcntl unavailable; Nous refresh proceeds without a cross-process lock.")
yield
return
lock_path = auth_file.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, "a+") as lock_file:
deadline = time.monotonic() + max(1.0, timeout_seconds)
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (BlockingIOError, OSError):
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the Hermes auth store lock") from None
time.sleep(0.05)
try:
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
class NousAuthManager:
"""Sync Nous Portal OAuth credential manager.
Holds the access_token + refresh_token in memory and handles
proactive/reactive refresh. A ``threading.Lock`` gives single-flight
semantics within the process; the cross-process ``fcntl`` lock guards
against a concurrent Hermes agent (see module docstring).
"""
def __init__(
self,
access_token: str,
refresh_token: str | None,
auth_file: Path,
*,
portal_base_url: str = _NOUS_PORTAL_BASE_URL,
inference_base_url: str = _NOUS_INFERENCE_BASE_URL,
client_id: str = _NOUS_CLIENT_ID,
) -> None:
self.access_token = access_token
self.refresh_token = refresh_token
self._auth_file = auth_file
self._portal_base_url = portal_base_url.rstrip("/")
self._inference_base_url = inference_base_url.rstrip("/")
self._client_id = client_id
self._lock = threading.Lock()
self._http_client = httpx.Client(timeout=30.0)
# ------------------------------------------------------------------
# Construction
# ------------------------------------------------------------------
@classmethod
def from_file(cls, auth_file: Path | None = None) -> "NousAuthManager":
"""Build a manager from ``providers.nous`` in the Hermes auth store.
Raises
------
NousNotLoggedInError:
If the file is missing, unreadable, or has no Nous OAuth state with
an ``access_token``.
"""
if auth_file is None:
auth_file = _default_auth_file()
if not auth_file.exists():
raise NousNotLoggedInError(
f"Hermes auth file not found: {auth_file}. Run 'hermes portal' to log in to Nous Portal."
)
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
raise NousNotLoggedInError(f"Could not read Hermes auth file {auth_file}: {type(e).__name__}") from e
state = cls._nous_state(data)
if not state:
raise NousNotLoggedInError(
"Hermes is not logged into Nous Portal (no providers.nous OAuth state). Run 'hermes portal'."
)
access_token = state.get("access_token")
if not isinstance(access_token, str) or not access_token:
raise NousNotLoggedInError("Nous OAuth state has no access_token. Re-authenticate with 'hermes portal'.")
return cls(
access_token=access_token,
refresh_token=state.get("refresh_token"),
auth_file=auth_file,
portal_base_url=cls._optional_url(state.get("portal_base_url")) or _NOUS_PORTAL_BASE_URL,
inference_base_url=cls._optional_url(state.get("inference_base_url")) or _NOUS_INFERENCE_BASE_URL,
client_id=str(state.get("client_id") or _NOUS_CLIENT_ID),
)
@staticmethod
def _nous_state(data: dict[str, Any]) -> dict[str, Any]:
"""Pull the ``providers.nous`` state dict out of a loaded auth store."""
providers = data.get("providers")
if not isinstance(providers, dict):
return {}
state = providers.get("nous")
return state if isinstance(state, dict) else {}
@staticmethod
def _optional_url(value: Any) -> str | None:
return value.rstrip("/") if isinstance(value, str) and value.strip() else None
@property
def base_url(self) -> str:
return self._inference_base_url
# ------------------------------------------------------------------
# Token state
# ------------------------------------------------------------------
@staticmethod
def load_refresh_token_from_file(auth_file: Path) -> str | None:
"""Read ``providers.nous.refresh_token`` from ``auth_file``.
Returns ``None`` when the file is unreadable or omits the field. Does
not raise the caller degrades to using the in-memory token.
"""
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
return NousAuthManager._nous_state(data).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 failure.
The signature is not verified the server is the source of truth on
acceptance. This only schedules proactive refresh.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
padding = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding).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 = _NOUS_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`` is unparseable.
"""
exp = self._decode_jwt_exp_unixtime(self.access_token)
if exp is None:
return False
return exp <= int(time.time()) + skew_seconds
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def _persist_state_atomic(self, updated: dict[str, Any]) -> None:
"""Patch ``providers.nous`` in ``_auth_file`` and write atomically.
Re-reads the on-disk store first so fields written by Hermes (other
providers, the credential pool, rotated tokens) are never clobbered,
then patches only the Nous OAuth fields and ``os.replace``s into place
(atomic on POSIX within the same filesystem). Must be called while
holding :func:`_hermes_auth_lock`.
"""
try:
with open(self._auth_file) as f:
loaded = json.load(f)
current: dict[str, Any] = loaded if isinstance(loaded, dict) else {}
except (OSError, json.JSONDecodeError):
current = {}
providers = current.get("providers")
if not isinstance(providers, dict):
providers = {}
current["providers"] = providers
state = providers.get("nous")
if not isinstance(state, dict):
state = {}
providers["nous"] = state
state.update(updated)
# The inference bearer is the access token itself; keep agent_key in
# sync so Hermes' own resolver/status sees the rotation too.
state["agent_key"] = updated.get("access_token", state.get("access_token"))
current["updated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
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())
with contextlib.suppress(OSError):
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, self._auth_file)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
# ------------------------------------------------------------------
# Refresh
# ------------------------------------------------------------------
@staticmethod
def _extract_oauth_error_code(response: httpx.Response) -> str | None:
"""Pull the OAuth error code out of a 4xx refresh response, if present."""
try:
body = response.json()
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(body, dict):
return None
err = body.get("error")
if isinstance(err, str):
return err
if isinstance(err, dict) and isinstance(err.get("code"), str):
return err["code"]
code = body.get("error_code")
return code if isinstance(code, str) else None
def refresh_tokens(self, reason: str = "", *, force: bool = False) -> None:
"""Single-flight Nous OAuth token refresh.
Serialised through ``self._lock`` (in-process single-flight) and
:func:`_hermes_auth_lock` (cross-process, vs a running Hermes agent).
The latest ``refresh_token`` is re-read from disk under the lock before
the exchange single-use tokens make using a stale in-memory RT a
session-revoking mistake.
Raises
------
NousRefreshExpiredError:
On a terminal refresh error (expired/reused/invalid grant).
RuntimeError:
For other refresh failures (network, 5xx, missing refresh_token).
"""
token_before_lock = self.access_token
with self._lock:
if force:
if self.access_token != token_before_lock:
return # another caller already refreshed while we waited
elif not self._token_is_stale():
return
with _hermes_auth_lock(self._auth_file):
# Re-read the freshest refresh_token persisted by whoever rotated
# last (this process or Hermes). Using a stale RT is exactly what
# trips the Portal's single-use reuse-detection.
disk_rt = self.load_refresh_token_from_file(self._auth_file)
if disk_rt:
self.refresh_token = disk_rt
if not self.refresh_token:
raise RuntimeError(
"Nous access_token is expired but no refresh_token is available. "
"Run 'hermes portal' to re-authenticate."
)
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Nous Portal access_token{log_reason}")
try:
response = self._http_client.post(
f"{self._portal_base_url}/api/oauth/token",
headers={"x-nous-refresh-token": self.refresh_token},
data={"grant_type": "refresh_token", "client_id": self._client_id},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Nous OAuth refresh network error: {type(e).__name__}") from e
if response.status_code != 200:
code = self._extract_oauth_error_code(response)
if code in _NOUS_TERMINAL_REFRESH_ERROR_CODES or response.status_code in (400, 401):
raise NousRefreshExpiredError(
f"Nous refresh_token is no longer valid (status={response.status_code}, "
f"error={code or 'none'}). Run 'hermes portal' to re-authenticate."
)
raise RuntimeError(f"Nous OAuth refresh failed with HTTP {response.status_code}")
try:
body = response.json()
except (json.JSONDecodeError, ValueError) as e:
raise RuntimeError(f"Nous OAuth refresh returned non-JSON body: {e}") from e
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Nous OAuth refresh returned no access_token")
new_refresh = body.get("refresh_token") or self.refresh_token
# Update in-memory state first so waiters see fresh credentials
# even if the disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted: dict[str, Any] = {"access_token": new_access, "refresh_token": new_refresh}
expires_in = body.get("expires_in")
if isinstance(expires_in, (int, float)):
persisted["expires_at"] = datetime.fromtimestamp(
time.time() + float(expires_in), tz=timezone.utc
).isoformat()
try:
self._persist_state_atomic(persisted)
except OSError as e:
logger.warning(
f"Nous refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are current; the on-disk rotated token was not saved."
)
logger.info("Nous Portal access_token refreshed successfully")
def ensure_fresh_token(self) -> str:
"""Refresh proactively if near/at expiry, then return the bearer token.
Cheap when fresh (a JWT exp decode + comparison).
"""
if self._token_is_stale():
self.refresh_tokens(reason="proactive (token near expiry)")
return self.access_token
def close(self) -> None:
"""Close the underlying HTTP client."""
self._http_client.close()
@@ -0,0 +1,167 @@
"""
Nous Portal LLM provider for Hindsight.
Thin wrapper over :class:`OpenAICompatibleLLM`. The Nous Portal speaks the
OpenAI chat-completions wire format, so all request/response handling is
inherited unchanged. The only thing Nous needs on top is a rotating,
inference-scoped JWT (there is no static API key in the Hermes login flow),
which :class:`NousAuthManager` reads from ``~/.hermes/auth.json`` and refreshes
natively the same pattern as the Codex provider, with no dependency on the
``hermes_cli`` package. See ``nous_auth.py`` for the auth mechanics.
Configure with::
llm_provider = "nous"
llm_base_url = "https://inference-api.nousresearch.com/v1" # or omit
llm_model = "deepseek/deepseek-v4-flash" # any Nous slug
No API key is set in config; the token comes from the shared Hermes auth store
after a one-time ``hermes portal`` login.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from openai import APIStatusError, AsyncOpenAI
from hindsight_api.engine.providers.nous_auth import (
NousAuthManager,
NousNotLoggedInError,
NousRefreshExpiredError,
)
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
logger = logging.getLogger(__name__)
__all__ = ["NousLLM", "NousAuthManager", "NousNotLoggedInError", "NousRefreshExpiredError"]
class NousLLM(OpenAICompatibleLLM):
"""OpenAI-compatible provider for the Nous Portal with rotating-JWT auth."""
def __init__(
self,
provider: str,
api_key: str, # Ignored — the token is read from ~/.hermes/auth.json
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
try:
self._auth = NousAuthManager.from_file()
except NousNotLoggedInError as e:
raise RuntimeError(
f"Failed to load Nous Portal credentials: {e}\n\n"
"To set up Nous authentication:\n"
"1. Install Hermes: https://hermes-agent.nousresearch.com\n"
"2. Log in to Nous Portal: hermes portal\n"
"3. Verify: hermes portal status\n\n"
"Or use a different provider (openai, anthropic, gemini) with an API key."
) from e
# Single-flight async refresh lock — concurrent coroutines racing toward
# an expired token produce one network refresh.
self._auth_lock = asyncio.Lock()
token = self._auth.access_token
resolved_base = base_url or self._auth.base_url
# Parent validates provider against a fixed list; present as "openai"
# (identical wire format) while retaining the true identity for logs.
super().__init__(
provider="openai",
api_key=token,
base_url=resolved_base,
model=model,
reasoning_effort=reasoning_effort,
**kwargs,
)
self._nous_provider_name = provider
logger.info(
"Nous LLM initialized: model=%s base_url=%s (rotating inference:invoke JWT)",
self.model,
self.base_url,
)
# ------------------------------------------------------------------
# Token lifecycle
# ------------------------------------------------------------------
def _rebuild_client(self) -> None:
"""Rebuild the OpenAI SDK client against the current token."""
self.api_key = self._auth.access_token
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
max_retries=0,
timeout=self.timeout,
)
async def _ensure_fresh_token(self) -> None:
"""Proactively refresh if the JWT is near expiry; rebuild on change.
Cheap when fresh (a JWT exp decode). The blocking refresh (network +
cross-process file lock) is offloaded to a thread so the event loop is
never stalled.
"""
if not self._auth._token_is_stale():
return
await self._refresh(reason="proactive (token near expiry)", force=False)
async def _refresh(self, *, reason: str, force: bool) -> None:
token_before = self.api_key
async with self._auth_lock:
if force:
if self.api_key != token_before:
return # another coroutine already refreshed
elif not self._auth._token_is_stale():
return
await asyncio.to_thread(lambda: self._auth.refresh_tokens(reason, force=force))
if self._auth.access_token != self.api_key:
self._rebuild_client()
async def _with_auth_retry(self, fn: Any, label: str, *args: Any, **kwargs: Any) -> Any:
"""Run an OpenAI-compatible call, refreshing once on a 401.
The proactive refresh covers most expiries; a token can still be
rejected mid-flight if Hermes rotated it out from under us or the exp
claim was unparseable. One reactive refresh + retry is the safety net.
"""
await self._ensure_fresh_token()
try:
return await fn(*args, **kwargs)
except APIStatusError as e:
if getattr(e, "status_code", None) != 401:
raise
logger.warning("Nous 401 (%s) — forcing token refresh and retrying once.", label)
try:
await self._refresh(reason=f"reactive (HTTP 401 on {label})", force=True)
except NousRefreshExpiredError as refresh_err:
raise RuntimeError(
"Nous authentication failed and the refresh_token is no longer valid.\n"
"Run 'hermes portal' to re-authenticate."
) from refresh_err
return await fn(*args, **kwargs)
# ------------------------------------------------------------------
# Overrides
# ------------------------------------------------------------------
async def verify_connection(self) -> None:
await self._ensure_fresh_token()
return await super().verify_connection()
async def call(self, *args: Any, **kwargs: Any) -> Any:
return await self._with_auth_retry(super().call, "call", *args, **kwargs)
async def call_with_tools(self, *args: Any, **kwargs: Any) -> Any:
return await self._with_auth_retry(super().call_with_tools, "call_with_tools", *args, **kwargs)
async def cleanup(self) -> None:
self._auth.close()
parent_cleanup = getattr(super(), "cleanup", None)
if parent_cleanup is not None:
await parent_cleanup()
@@ -33,6 +33,7 @@ import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.bank_attribution import apply_bank_attribution
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
@@ -595,6 +596,8 @@ class OpenAICompatibleLLM(LLMInterface):
call_params["messages"] = _ensure_json_word_in_user_message(call_params["messages"])
call_params["response_format"] = {"type": "json_object"}
apply_bank_attribution(call_params)
last_exception = None
for attempt in range(max_retries + 1):
@@ -945,6 +948,8 @@ class OpenAICompatibleLLM(LLMInterface):
if extra_body:
call_params["extra_body"] = extra_body
apply_bank_attribution(call_params)
last_exception = None
for attempt in range(max_retries + 1):
@@ -6,12 +6,17 @@ structured information like temporal constraints.
"""
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
from hindsight_api.engine.temporal_periods import (
NO_TEMPORAL_CONSTRAINT,
extract_period,
is_embedded_cjk_dateparser_match,
)
logger = logging.getLogger(__name__)
@@ -123,9 +128,12 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Check for period expressions first (these need special handling)
query_lower = query.lower()
period_result = self._extract_period(query_lower, reference_date)
if period_result is not None:
return QueryAnalysis(temporal_constraint=period_result)
period_result = extract_period(query_lower, reference_date)
if period_result is NO_TEMPORAL_CONSTRAINT:
return QueryAnalysis(temporal_constraint=None)
if isinstance(period_result, tuple):
start_date, end_date = period_result
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
# Lazy load dateparser (only imports on first call, then cached)
self.load()
@@ -158,7 +166,12 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Filter out false positives (common words parsed as dates)
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3]
valid_results = [
(text, date)
for text, date in results
if (text.lower() not in false_positives or len(text) > 3)
and not is_embedded_cjk_dateparser_match(query, text)
]
if not valid_results:
return QueryAnalysis(temporal_constraint=None)
@@ -172,127 +185,6 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
"""
Extract period-based temporal expressions (week, month, year, weekend).
These need special handling as they represent date ranges, not single dates.
Supports multiple languages.
"""
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
return TemporalConstraint(
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
# Yesterday patterns (English, Spanish, Italian, French, German)
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return constraint(d, d)
# Today patterns
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
return constraint(reference_date, reference_date)
# "a couple of days ago" / "a few days ago" patterns
# These are imprecise so we create a range
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
# "a couple of days" = approximately 2 days, give range of 1-3 days
return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
# "a few days" = approximately 3-4 days, give range of 2-5 days
return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
# "a couple of weeks ago" / "a few weeks ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
# "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks
return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
# "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks
return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
# "a couple of months ago" / "a few months ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
# "a couple of months" = approximately 2 months, give range of 1-3 months
return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
# "a few months" = approximately 3-4 months, give range of 2-5 months
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
# Last week patterns (English, Spanish, Italian, French, German)
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
query,
re.IGNORECASE,
):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
# Last month patterns
if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
query,
re.IGNORECASE,
):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return constraint(start, end)
# Last year patterns
if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
query,
re.IGNORECASE,
):
year = reference_date.year - 1
return constraint(datetime(year, 1, 1), datetime(year, 12, 31))
# Last weekend patterns
if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
query,
re.IGNORECASE,
):
days_since_sat = (reference_date.weekday() + 2) % 7
if days_since_sat == 0:
days_since_sat = 7
sat = reference_date - timedelta(days=days_since_sat)
return constraint(sat, sat + timedelta(days=1))
# Month + Year patterns (e.g., "June 2024", "junio 2024", "giugno 2024")
month_patterns = {
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
}
for pattern, month_num in month_patterns.items():
match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE)
if match:
year = int(match.group(2))
start = datetime(year, month_num, 1)
if month_num == 12:
end = datetime(year, 12, 31)
else:
end = datetime(year, month_num + 1, 1) - timedelta(days=1)
return constraint(start, end)
return None
class TransformerQueryAnalyzer(QueryAnalyzer):
"""
@@ -14,6 +14,7 @@ import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from ...config import get_config
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
@@ -340,6 +341,7 @@ async def run_reflect_agent(
budget: str | None = None,
max_context_tokens: int = 100_000,
llm_output_language: str | None = None,
cancel_check: Callable[[], None] | None = None,
) -> ReflectAgentResult:
"""
Execute the reflect agent loop using native tool calling.
@@ -376,12 +378,16 @@ async def run_reflect_agent(
# Extract directive rules for tool schema (if any)
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist)
# Get tools for this agent (with directive compliance field if directives exist).
# The expand tool only reads back raw source text (chunks/documents), so it is
# useless and excluded when document text storage is disabled.
include_expand = get_config().store_document_text
tools = get_reflect_tools(
directive_rules=directive_rules,
include_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
include_expand=include_expand,
)
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
@@ -488,6 +494,13 @@ async def run_reflect_agent(
# under ``auto`` tool choice. None means the full forced path still applies.
stop_forcing_from_iteration: int | None = None
for iteration in range(max_iterations):
# Cooperative cancellation checkpoint: abort the agent loop between
# iterations if the caller (e.g. an HTTP client) has gone away, rather
# than spending another LLM round-trip on a result nobody will read
# (issue #2122). Raises OperationCancelledError when fired.
if cancel_check is not None:
cancel_check()
is_last = iteration == max_iterations - 1
if is_last:
@@ -500,7 +513,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -560,7 +575,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -679,7 +696,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -803,7 +822,9 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
},
{"role": "user", "content": prompt},
],
@@ -908,7 +929,9 @@ async def run_reflect_agent(
hallucinated_tools = []
for tc in other_tools:
norm = _normalize_tool_name(tc.name)
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
# "done" is always available. "expand" is governed by enabled_tools
# (it is excluded when text storage is disabled), so it is not hardcoded here.
if enabled_tools is not None and norm not in enabled_tools and norm != "done":
hallucinated_tools.append(tc)
else:
allowed_tools.append(tc)
@@ -1236,8 +1259,10 @@ async def _execute_tool(
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
# Guard against LLMs hallucinating calls to tools that were not provided
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
# Guard against LLMs hallucinating calls to tools that were not provided.
# "done" is always available; "expand" is governed by enabled_tools (excluded
# when text storage is disabled), so it is not hardcoded as always-allowed here.
if enabled_tools is not None and tool_name not in enabled_tools and tool_name != "done":
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
if tool_name == "search_mental_models":
@@ -26,10 +26,13 @@ or stay the same per refresh, never get worse.
from __future__ import annotations
import json
import logging
from typing import Annotated, Any, Literal, Union
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from hindsight_api.engine.llm_wrapper import parse_llm_json
from .structured_doc import (
Block,
@@ -144,6 +147,27 @@ Operation = Annotated[
Field(discriminator="op"),
]
_OPERATION_ADAPTER: TypeAdapter[Operation] = TypeAdapter(Operation)
def _validate_operations_list(raw_ops: Any) -> tuple[list[Operation], list[dict[str, Any]]]:
"""Validate each operation independently; drop invalid ops instead of failing the batch."""
if not isinstance(raw_ops, list):
raise TypeError(f"operations must be a list, got {type(raw_ops)!r}")
valid: list[Operation] = []
skipped: list[dict[str, Any]] = []
for i, item in enumerate(raw_ops):
try:
valid.append(_OPERATION_ADAPTER.validate_python(item))
except ValidationError as exc:
skipped.append({"index": i, "op": item, "error": exc.errors(include_url=False)})
logger.warning(
"[STRUCTURED_DELTA] skipping invalid operation at index %s: %s",
i,
exc.errors(include_url=False),
)
return valid, skipped
class DeltaOperationList(BaseModel):
"""Container for the operations produced by an LLM delta call."""
@@ -152,6 +176,104 @@ class DeltaOperationList(BaseModel):
operations: list[Operation] = Field(default_factory=list)
class DeltaAllOpsInvalidError(ValueError):
"""Raised when the model emitted operations but none survived validation.
Distinct from an empty ``operations`` array (a legitimate no-op): here every
op was malformed, so returning zero valid ops would make the caller apply
nothing and silently drop this refresh's new facts. Raising instead lets the
caller fall back to a full rewrite, which still integrates the new facts.
"""
def _finalize_operations(valid: list[Operation], skipped: list[dict[str, Any]]) -> DeltaOperationList:
"""Build the result, but refuse a wholesale validation failure as a silent no-op."""
if skipped and not valid:
raise DeltaAllOpsInvalidError(f"all {len(skipped)} delta operation(s) failed validation")
return DeltaOperationList(operations=valid)
def _extract_balanced_json_object(text: str) -> str | None:
"""Return the first top-level ``{...}`` slice, ignoring trailing junk."""
start = text.find("{")
if start < 0:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None
def parse_delta_operation_list(raw: Any) -> DeltaOperationList:
"""Parse structured-delta LLM output into a validated operation list."""
if isinstance(raw, DeltaOperationList):
return raw
if isinstance(raw, dict):
ops_raw = raw.get("operations", [])
valid, skipped = _validate_operations_list(ops_raw)
if skipped:
logger.info(
"[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s) from dict payload",
len(valid),
len(skipped),
)
return _finalize_operations(valid, skipped)
text = (raw or "").strip()
if not text:
return DeltaOperationList()
candidates: list[str] = [text]
extracted = _extract_balanced_json_object(text)
if extracted and extracted != text:
candidates.append(extracted)
last_error: Exception | None = None
for candidate in candidates:
try:
payload = parse_llm_json(candidate)
except json.JSONDecodeError as exc:
last_error = exc
continue
if not isinstance(payload, dict) or "operations" not in payload:
last_error = ValueError("delta payload must be an object with an operations array")
continue
try:
valid, skipped = _validate_operations_list(payload["operations"])
except TypeError as exc:
last_error = exc
continue
if skipped:
logger.info(
"[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s)",
len(valid),
len(skipped),
)
return _finalize_operations(valid, skipped)
if last_error is not None:
raise last_error
return DeltaOperationList()
# Application ---------------------------------------------------------------
@@ -604,16 +604,44 @@ Just provide the direct answer with proper markdown formatting.
CRITICAL: This is a NON-CONVERSATIONAL system. NEVER ask follow-up questions, offer to search again, suggest alternatives, or end with anything like "Would you like me to..." or "Let me know if...". The user cannot reply. Your answer must be complete and self-contained."""
def build_final_system_prompt(mission: str | None = None, llm_output_language: str | None = None) -> str:
# The final synthesis is a SEPARATE LLM call with its own system prompt — the
# agent/reasoning system prompt (which carries directives and the language rule)
# is NOT in scope here. So this default language rule, and the directives, must
# be repeated for the answer-writing model. Without it, weaker models drift to
# English even when the question/facts are in another language or a directive
# demands a specific one (the cause of flaky multilingual reflect tests).
_FINAL_LANGUAGE_RULE = (
"## LANGUAGE\n"
"- Respond in the SAME language as the user's question "
"(e.g. a question in Chinese gets a Chinese answer; Japanese → Japanese).\n"
"- If a directive above specifies a response language, follow the directive — "
"it takes precedence over this default."
)
def build_final_system_prompt(
mission: str | None = None,
llm_output_language: str | None = None,
directives: list[dict[str, Any]] | None = None,
) -> str:
"""Build the final synthesis system prompt, using mission as role when set.
When ``llm_output_language`` is set, the response is forced into that
language regardless of the query/source language.
``directives`` are re-injected here (they live in the agent/reasoning prompt,
but the final answer is a separate call) so output-constraining rules most
visibly response language are honoured by the model that actually writes
the answer. When ``llm_output_language`` is set it forces that language
regardless of the query/source/directive language (config override wins).
"""
from hindsight_api.engine.prompt_utils import escape_for_prompt, output_language_directive
role_section = escape_for_prompt(mission.strip()) if mission else _DEFAULT_FINAL_ROLE
return _FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section) + output_language_directive(llm_output_language)
parts = [build_directives_section(directives) if directives else ""]
parts.append(_FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section))
parts.append(_FINAL_LANGUAGE_RULE)
parts.append(build_directives_reminder(directives) if directives else "")
return "\n\n".join(p.strip() for p in parts if p.strip()) + output_language_directive(llm_output_language)
# Backward-compatible constant for non-identity missions
@@ -706,7 +734,65 @@ Examples
``{"operations": [{"op": "replace_block", "section_id": "overview",
"index": 0, "block": {"type": "paragraph", "text": "Updated summary."}}]}``
- Remove an obsolete block
``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}``"""
``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}``
JSON STRING RULES (critical)
- Every ``text`` and ``items`` string must be valid JSON: escape ``"`` as ``\\"``,
backslashes as ``\\\\``, and newlines as ``\\n``. Do not use raw backticks inside
strings unless needed; prefer plain quotes for file paths.
- ``replace_block``, ``insert_block``, and ``remove_block`` MUST include ``index`` (0-based block position in that section). Use ``replace_section_blocks`` only when replacing every block in a section.
- Do not append extra ``]`` or ``}`` after the closing ``}`` of the root object."""
_STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS = 24_000
def _truncate_cl100k(text: str, max_tokens: int) -> str:
"""Truncate text to at most max_tokens using cl100k_base."""
if max_tokens <= 0:
return ""
from .tokenization import count_cl100k_tokens
if count_cl100k_tokens(text) <= max_tokens:
return text
enc = __import__("tiktoken").get_encoding("cl100k_base")
return enc.decode(enc.encode(text)[:max_tokens])
def _fit_structured_delta_prompt_parts(
*,
source_query: str,
current_document_json: str,
candidate_markdown: str,
facts_block: str,
budget_hint: str,
task_footer: str,
max_input_tokens: int,
) -> tuple[str, str, str, bool]:
"""Shrink large prompt sections to fit within max_input_tokens (cl100k estimate)."""
from .tokenization import count_cl100k_tokens
fixed = (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n"
f"{budget_hint}\n\n"
f"{task_footer}"
)
facts_header = "## SUPPORTING FACTS (new since last refresh — integrate these)\n"
facts_prefix_tokens = count_cl100k_tokens(facts_header)
reserved_facts = min(4096, max(512, max_input_tokens // 8))
doc_budget = max(1024, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 55 // 100)
cand_budget = max(512, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 30 // 100)
facts_budget = max(256, reserved_facts - facts_prefix_tokens)
doc_json = _truncate_cl100k(current_document_json, doc_budget)
candidate = _truncate_cl100k(candidate_markdown, cand_budget)
facts_body = _truncate_cl100k(facts_block, facts_budget)
truncated = doc_json != current_document_json or candidate != candidate_markdown or facts_body != facts_block
return doc_json, candidate, facts_body, truncated
def build_structured_delta_prompt(
@@ -716,6 +802,7 @@ def build_structured_delta_prompt(
supporting_facts: list[dict[str, Any]],
source_query: str,
max_output_tokens: int | None = None,
max_input_tokens: int | None = None,
) -> str:
"""Build the user prompt for a structured-delta mental model refresh.
@@ -746,19 +833,39 @@ def build_structured_delta_prompt(
"block-level ops) so the response always parses as valid JSON."
)
return (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n{current_document_json}\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n{candidate_markdown}\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_block}"
f"{budget_hint}\n\n"
task_footer = (
"## Task\n"
"Output a JSON object matching the operations schema. Integrate the new "
"supporting facts into CURRENT DOCUMENT. Add, update, or remove content "
"as needed. Preserve unchanged sections and blocks by not mentioning them."
)
input_cap = max_input_tokens if max_input_tokens is not None else _STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS
doc_json, candidate, facts_body, input_truncated = _fit_structured_delta_prompt_parts(
source_query=source_query,
current_document_json=current_document_json,
candidate_markdown=candidate_markdown,
facts_block=facts_block,
budget_hint=budget_hint,
task_footer=task_footer,
max_input_tokens=input_cap,
)
truncation_note = ""
if input_truncated:
truncation_note = (
"\n\n*Note: Document, synthesis, or facts were truncated to fit the model "
"context window. Prefer minimal, high-leverage operations.*"
)
return (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n{doc_json}\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n{candidate}\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_body}"
f"{budget_hint}{truncation_note}\n\n"
f"{task_footer}"
)
DELTA_SYSTEM_PROMPT = """You are performing a surgical delta update to an existing mental model document.
@@ -232,6 +232,7 @@ def get_reflect_tools(
include_mental_models: bool = True,
include_observations: bool = True,
include_recall: bool = True,
include_expand: bool = True,
) -> list[dict]:
"""
Get the list of tools for the reflect agent.
@@ -247,6 +248,9 @@ def get_reflect_tools(
include_mental_models: Whether to include the search_mental_models tool.
include_observations: Whether to include the search_observations tool.
include_recall: Whether to include the recall tool.
include_expand: Whether to include the expand tool. Disabled when raw
document/chunk text is not stored, since expand only reads back
source text and would return empty results.
Returns:
List of tool definitions in OpenAI format
@@ -260,7 +264,8 @@ def get_reflect_tools(
if include_recall:
tools.append(TOOL_RECALL)
tools.append(TOOL_EXPAND)
if include_expand:
tools.append(TOOL_EXPAND)
# Use directive-aware done tool if directives are present
if directive_rules:
@@ -105,6 +105,34 @@ class TokenUsage(BaseModel):
)
class ExtractedFact(BaseModel):
"""A single candidate fact produced by dry-run extraction (no resolution/links/persistence).
A deliberate subset of the persisted memory-unit shape only the fields a fresh extraction
yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, )
are omitted because nothing is stored. Entities are raw, unresolved names.
"""
text: str = Field(description="The extracted fact text.")
fact_type: str = Field(description="Perspective classification: 'world' or 'experience'.")
occurred_start: str | None = Field(default=None, description="ISO timestamp the fact's event started, if dated.")
occurred_end: str | None = Field(default=None, description="ISO timestamp the fact's event ended, if dated.")
entities: list[str] = Field(
default_factory=list, description="Raw (unresolved) entity names mentioned in the fact."
)
class DryRunExtractionResult(BaseModel):
"""Result of dry-run fact extraction: candidate facts plus aggregated LLM token usage."""
facts: list[ExtractedFact] = Field(
default_factory=list, description="Candidate facts the retain step would extract."
)
usage: TokenUsage = Field(
default_factory=TokenUsage, description="Aggregated token usage across the extraction LLM calls."
)
class DispositionTraits(BaseModel):
"""
Disposition traits for a memory bank.
@@ -4,7 +4,6 @@ bank profile utilities for disposition and mission management.
import json
import logging
import re
import uuid
from dataclasses import dataclass
from typing import TypedDict
@@ -8,6 +8,7 @@ import hashlib
import logging
from dataclasses import dataclass
from ...config import get_config
from ..memory_engine import fq_table
from .types import ChunkMetadata
@@ -88,6 +89,11 @@ async def store_chunks_batch(
if not chunks:
return {}
# When document text storage is disabled, persist empty chunk_text (the
# column is NOT NULL) while still computing content_hash from the real text
# so delta-retain dedup is unaffected.
store_text = get_config().store_document_text
# Prepare chunk data for batch insert
chunk_ids = []
chunk_texts = []
@@ -98,7 +104,7 @@ async def store_chunks_batch(
for chunk in chunks:
chunk_id = f"{bank_id}_{document_id}_{chunk.chunk_index}"
chunk_ids.append(chunk_id)
chunk_texts.append(chunk.chunk_text)
chunk_texts.append(chunk.chunk_text if store_text else "")
chunk_indices.append(chunk.chunk_index)
content_hashes.append(compute_chunk_hash(chunk.chunk_text))
chunk_id_map[chunk.chunk_index] = chunk_id
@@ -3,6 +3,7 @@ Embedding generation utilities for memory units.
"""
import asyncio
import contextvars
import logging
from typing import Literal, Protocol
@@ -89,7 +90,14 @@ async def generate_embeddings_batch(
"""
try:
loop = asyncio.get_event_loop()
embeddings = await loop.run_in_executor(None, _encode_with_input_type, embeddings_backend, texts, input_type)
# run_in_executor runs the encode in a worker thread, which does NOT inherit
# the caller's contextvars. Capture the current context and run the encode
# inside it so context-dependent behavior (e.g. per-bank `user` attribution
# read via get_current_bank_id()) survives the thread hop.
ctx = contextvars.copy_context()
embeddings = await loop.run_in_executor(
None, lambda: ctx.run(_encode_with_input_type, embeddings_backend, texts, input_type)
)
except Exception as e:
raise Exception(f"Failed to generate batch embeddings: {str(e)}")
@@ -14,7 +14,6 @@ from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ...config import get_config
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
@@ -406,64 +405,93 @@ class VerbatimFactExtractionResponse(BaseModel):
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
def chunk_text(text: str, max_chars: int) -> list[str]:
# Separators for sentence-aware recursive text splitting, ordered most- to
# least-preferred. The final "" lets the splitter break mid-word as a last
# resort so a chunk can never exceed the size budget.
_RECURSIVE_TEXT_SEPARATORS = [
"\n\n", # Paragraph breaks
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
]
def _split_oversized_unit(text: str, max_chars: int) -> list[str]:
"""Sentence-aware split of a single unit that overflowed the budget.
Used when one JSONL line / conversation turn is so large it can't be kept
whole within the configured structured-chunk limit. The resulting fragments
are no longer valid JSON, but the fact extractor treats every chunk as plain
text.
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chars,
chunk_overlap=0,
length_function=len,
is_separator_regex=False,
separators=_RECURSIVE_TEXT_SEPARATORS,
)
return splitter.split_text(text)
def chunk_text(text: str, max_chars: int, structured_chunk_size: int | None = None) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
For JSON conversation arrays (user/assistant turns), splits at turn boundaries
while preserving speaker context. For plain text, uses sentence-aware splitting.
For JSON conversation arrays (user/assistant turns) and JSONL (newline-delimited
JSON objects), splits at turn/line boundaries so no object is split across chunks.
A single turn/line that overflows ``max_chars`` is kept whole only up to
``structured_chunk_size``. When unset, that limit defaults to ``max_chars``.
For plain text, uses sentence-aware splitting.
Args:
text: Input text to chunk (plain text or JSON conversation)
max_chars: Maximum characters per chunk (default 120k 30k tokens)
text: Input text to chunk (plain text, JSON conversation, or JSONL)
max_chars: Target maximum characters per chunk
structured_chunk_size: Maximum characters for a single JSONL line or
conversation turn to keep whole. Defaults to ``max_chars``.
Returns:
List of text chunks, roughly under max_chars
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
# If text is small enough, return as-is
if len(text) <= max_chars:
return [text]
structured_limit = structured_chunk_size if structured_chunk_size is not None else max_chars
# Try to parse as JSON conversation array
try:
parsed = json.loads(text)
if isinstance(parsed, list) and all(isinstance(turn, dict) for turn in parsed):
# This looks like a conversation - chunk at turn boundaries
return _chunk_conversation(parsed, max_chars)
return _chunk_conversation(parsed, max_chars, structured_limit)
except (json.JSONDecodeError, ValueError):
pass
# Try to parse as JSONL (newline-delimited JSON objects, e.g. session logs)
jsonl_chunks = _chunk_jsonl(text, max_chars, structured_limit)
if jsonl_chunks is not None:
return jsonl_chunks
# Fall back to sentence-aware text splitting
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chars,
chunk_overlap=0,
length_function=len,
is_separator_regex=False,
separators=[
"\n\n", # Paragraph breaks
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
],
)
return splitter.split_text(text)
return _split_oversized_unit(text, max_chars)
def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
def _chunk_conversation(turns: list[dict], max_chars: int, structured_limit: int) -> list[str]:
"""
Chunk a conversation array at turn boundaries, preserving complete turns.
Args:
turns: List of conversation turn dicts (with 'role' and 'content' keys)
max_chars: Maximum characters per chunk
structured_limit: Maximum characters for a single turn to keep whole
Returns:
List of JSON-serialized chunks, each containing complete turns
@@ -473,28 +501,105 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
current_chunk = []
current_size = 2 # Account for "[]"
for turn in turns:
# Estimate size of this turn when serialized (with comma separator)
turn_json = json.dumps(turn, ensure_ascii=False)
turn_size = len(turn_json) + 1 # +1 for comma
# If adding this turn would exceed limit and we have turns, save current chunk
if current_size + turn_size > max_chars and current_chunk:
def _flush() -> None:
nonlocal current_chunk, current_size
if current_chunk:
chunks.append(json.dumps(current_chunk, ensure_ascii=False))
current_chunk = []
current_size = 2 # Reset to "[]"
for turn in turns:
# Estimate size of this turn when serialized (with comma separator)
turn_json = json.dumps(turn, ensure_ascii=False)
turn_unit_size = len(turn_json)
turn_size = turn_unit_size + 1 # +1 for comma
# A turn too large to keep whole even alone: flush, then split it as
# text so no chunk runs far over budget (the extractor won't re-chunk).
if turn_unit_size > structured_limit:
_flush()
chunks.extend(_split_oversized_unit(turn_json, structured_limit))
continue
# If adding this turn would exceed limit and we have turns, save current chunk
if current_size + turn_size > max_chars and current_chunk:
_flush()
# Add turn to current chunk
current_chunk.append(turn)
current_size += turn_size
# Add final chunk if non-empty
if current_chunk:
chunks.append(json.dumps(current_chunk, ensure_ascii=False))
_flush()
return chunks if chunks else [json.dumps(turns, ensure_ascii=False)]
def _chunk_jsonl(text: str, max_chars: int, structured_limit: int) -> list[str] | None:
"""Chunk newline-delimited JSON (JSONL) at line boundaries.
Detects JSONL two or more non-empty lines, each a complete JSON object
and packs whole lines into chunks so no line is split across chunks (multiple
short lines may share a chunk). A line that overflows ``max_chars`` is kept
whole only up to ``structured_limit``. Returns ``None`` if the input is not
JSONL, so the caller falls back to plain-text splitting.
Args:
text: Input text to inspect/chunk.
max_chars: Maximum characters per chunk.
structured_limit: Maximum characters for a single JSONL line to
keep whole.
Returns:
List of JSONL chunks (lines joined by newline), or ``None`` if not JSONL.
"""
lines = [line for line in text.splitlines() if line.strip()]
if len(lines) < 2:
return None
for line in lines:
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(obj, dict):
return None
chunks: list[str] = []
current_chunk: list[str] = []
current_size = 0
def _flush() -> None:
nonlocal current_chunk, current_size
if current_chunk:
chunks.append("\n".join(current_chunk))
current_chunk = []
current_size = 0
for line in lines:
line_unit_size = len(line)
line_size = len(line) + 1 # +1 for the joining newline
# A line too large to keep whole even alone: flush, then split it as
# text so no chunk runs far over budget (the extractor won't re-chunk).
if line_unit_size > structured_limit:
_flush()
chunks.extend(_split_oversized_unit(line, structured_limit))
continue
# If adding this line would exceed the limit and we have lines, flush.
# A line up to structured_limit is kept whole (a bounded overflow).
if current_size + line_size > max_chars and current_chunk:
_flush()
current_chunk.append(line)
current_size += line_size
_flush()
return chunks
# =============================================================================
# FACT EXTRACTION PROMPTS
# =============================================================================
@@ -1634,7 +1739,11 @@ async def extract_facts_from_text(
- chunks: List of tuples (chunk_text, fact_count) for each chunk
- usage: Aggregated token usage across all LLM calls
"""
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
chunks = chunk_text(
text,
max_chars=config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
# Log chunk count before starting LLM requests
total_chars = sum(len(c) for c in chunks)
@@ -1776,8 +1885,7 @@ async def extract_facts_from_contents_batch_api(
logger.info(f"Using Batch API for fact extraction ({len(contents)} contents)")
# Check config for extraction mode and causal link extraction (used throughout)
extraction_mode = config.retain_extraction_mode
# Check config for causal link extraction (used throughout)
extract_causal_links = config.retain_extract_causal_links
# Check if provider supports batch API
@@ -1818,7 +1926,11 @@ async def extract_facts_from_contents_batch_api(
prompt, response_schema = _build_extraction_prompt_and_schema(config)
for content_index, item in enumerate(contents):
chunks = chunk_text(item.content, max_chars=config.retain_chunk_size)
chunks = chunk_text(
item.content,
max_chars=config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
for chunk_index_in_content, chunk in enumerate(chunks):
all_chunks_info.append((chunk, content_index, chunk_index_in_content, item.event_date, item.context))
@@ -2239,7 +2351,11 @@ def _extract_facts_chunks(
global_chunk_idx = 0
for content_index, content in enumerate(contents):
chunks = chunk_text(content.content, config.retain_chunk_size)
chunks = chunk_text(
content.content,
config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
for chunk in chunks:
chunks_metadata.append(
ChunkMetadata(
@@ -399,7 +399,12 @@ async def _upsert_document_row(
INSERT so that re-ingesting a document (which deletes + inserts the row)
keeps the original creation timestamp. ``updated_at`` is always set to
``NOW()`` on both INSERT and the ON CONFLICT UPDATE branch.
When ``store_document_text`` is disabled, the raw source text
is dropped and ``original_text`` is stored as NULL. The ``content_hash`` is
still computed from the real content so delta-retain dedup is unaffected.
"""
original_text = combined_content if get_config().store_document_text else None
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at)
@@ -413,7 +418,7 @@ async def _upsert_document_row(
""",
document_id,
bank_id,
combined_content,
original_text,
content_hash,
json.dumps(retain_params) if retain_params else None,
document_tags or [],
@@ -574,12 +574,10 @@ async def compute_semantic_links_ann(
# the transaction end handles both.
rows: list = []
async with conn.transaction():
# Transaction-local ANN tuning. Each supported backend exposes its own
# GUC (hnsw.ef_search on pgvector, vchordrq.probes on vchord); the
# dispatcher returns the right knob for the configured backend with a
# value tuned for top-50 semantic link creation (lower recall but much
# lower latency than the recall-side default). SET LOCAL auto-reverts
# at commit, so we don't pollute the pool for subsequent queries.
# Transaction-local ANN tuning. The dispatcher only returns GUCs that
# are safe to apply at session/transaction scope for the configured
# backend. VectorChord probe values are index-shaped, so vchordrq uses
# index storage fallback parameters instead of a blanket SET LOCAL.
for guc, value in ann_search_tuning_settings(configured_vector_extension(), kind="low_latency"):
await conn.execute(f"SET LOCAL {guc} = {value}")
@@ -636,7 +634,7 @@ async def compute_semantic_links_ann(
logger.debug(f"[ANN] fact_type={fact_type}: {len(ft_rows)} rows in {time_mod.time() - t_query:.3f}s")
rows.extend(ft_rows)
# Transaction commits here. _ann_seeds is dropped (ON COMMIT DROP).
# hnsw.ef_search reverts (SET LOCAL).
# Transaction-local ANN tuning reverts (SET LOCAL).
for row in rows:
sim = float(min(1.0, max(0.0, row["similarity"])))
@@ -802,8 +800,6 @@ async def create_causal_links_batch(
try:
import time as time_mod
create_start = time_mod.time()
# Build links list
links = []
for fact_idx, causal_relations in enumerate(causal_relations_per_fact):
@@ -15,18 +15,166 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from ...extensions.memory_defense import (
DefenseAction,
DefenseDecision,
MemoryDefenseExtension,
apply_redaction,
parse_policy,
)
from ...worker.stage import set_stage
from ..db.base import DatabaseBackend
from ..db_utils import acquire_with_retry
from ..memory_engine import count_tokens, fq_table
from . import bank_utils
@dataclass
class BlockedViolation:
"""One item blocked by the Memory Defense policy (surfaced in the 422 body)."""
index: int
detector: str | None
message: str
class MemoryDefenseAllBlockedError(Exception):
"""Raised when every item in a retain batch is blocked by the Memory Defense policy."""
def __init__(self, violations: list[BlockedViolation]) -> None:
self.violations = violations
super().__init__(f"all {len(violations)} items blocked by Memory Defense policy")
def utcnow():
"""Get current UTC time."""
return datetime.now(UTC)
def _redact_document_body(body: str, config: Any) -> str:
"""Apply Memory Defense redaction to a document body.
Per-item screening only scrubs the chunked content that goes through
`screen()`. When a sub-batch carries `document_body_override` (the full
original text of an oversized item see `_split_contents_into_sub_batches`),
that override bypasses screening and would persist verbatim into
`documents.original_text`. Apply the same redactor here so the document
body is scrubbed regardless of which path produced it.
"""
try:
policy = parse_policy(getattr(config, "memory_defense", None))
except Exception:
return body
if not policy.enabled:
return body
if not any(r.on == "sensitive_data" for r in policy.rules):
return body
return apply_redaction(body).content
async def _fire_memory_defense_webhook(
webhook_manager: Any,
*,
conn: Any,
schema: str | None,
bank_id: str,
operation_id: str | None,
document_id: str | None,
decision: DefenseDecision,
) -> None:
"""Fire a memory_defense.triggered webhook for a non-allow decision.
No-op when no webhook manager is wired or none is subscribed. Delivery
failures are swallowed so screening never blocks a retain.
"""
if webhook_manager is None:
return
try:
from ...webhooks import (
MemoryDefenseEventData,
MemoryDefenseHit,
WebhookEvent,
WebhookEventType,
)
# Translate per-match raw dicts on the decision into MemoryDefenseHit
# entries on the wire. The decision's hits list is already fingerprinted
# by apply_redaction (the raw value never lands in hits, by contract),
# so this is purely a shape conversion. None when no per-hit data is
# available so receivers can distinguish "no preview info" from
# "scanned, nothing matched" (the latter wouldn't be a webhook delivery
# in the first place).
decision_hits = getattr(decision, "hits", None) or []
hits: list[MemoryDefenseHit] | None = [
MemoryDefenseHit(
detector=str(h.get("detector") or ""),
preview=str(h.get("preview") or ""),
)
for h in decision_hits
if h.get("detector") and h.get("preview")
] or None
event = WebhookEvent(
event=WebhookEventType.MEMORY_DEFENSE_TRIGGERED,
bank_id=bank_id,
operation_id=operation_id or "",
status=decision.action.value,
timestamp=utcnow(),
data=MemoryDefenseEventData(
action=decision.action.value,
detector=decision.detector,
document_id=document_id,
matched_types=decision.matched_types or None,
message=decision.message or None,
hits=hits,
# Optional SIEM-enrichment fields populated by downstream
# extensions (e.g. hindsight-cloud's _CloudDefenseDecision
# subclass). Read via getattr so OSS doesn't need to know
# about extension subclasses. Combined with the manager's
# exclude_none serialization, missing values stay absent
# from the wire entirely rather than appearing as null.
severity=getattr(decision, "severity", None),
api_key_name=getattr(decision, "api_key_name", None),
memory_unit_id=getattr(decision, "memory_unit_id", None),
receipt_uri=getattr(decision, "receipt_uri", None),
),
)
await webhook_manager.fire_event_with_conn(event, conn, schema=schema)
except Exception:
logger.warning("memory_defense webhook delivery failed", exc_info=True)
def _audit_memory_defense(
audit_logger: Any,
*,
bank_id: str,
document_id: str | None,
decision: DefenseDecision,
) -> None:
"""Write a fire-and-forget ``memory_defense`` audit entry for a non-allow decision.
No-op when audit logging is disabled (the logger gates on its own config).
The action taken (redact/block) and what matched live in the entry metadata.
"""
if audit_logger is None:
return
from ..audit import AuditEntry
entry = AuditEntry(
action="memory_defense",
transport="system",
bank_id=bank_id,
metadata={
"action": decision.action.value,
"detector": decision.detector,
"document_id": document_id,
"matched_types": decision.matched_types,
"message": decision.message,
},
)
entry.ended_at = entry.started_at # point-in-time policy decision (duration 0)
audit_logger.log_fire_and_forget(entry)
def _merge_processed_content_tokens(a: int | None, b: int | None) -> int | None:
"""Combine the processed-content-tokens signal across sub-results.
@@ -158,6 +306,8 @@ def _build_retain_params(contents_dicts, document_tags=None, doc_contents=None):
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
if first_item.get("observation_scopes") is not None:
retain_params["observation_scopes"] = first_item["observation_scopes"]
return retain_params, merged_tags
@@ -425,6 +575,9 @@ async def retain_batch(
document_body_override: str | None = None,
chunk_index_offset: int = 0,
progress_callback: "Callable[..., Awaitable[None]] | None" = None,
webhook_manager: Any = None,
memory_defense_extension: "MemoryDefenseExtension | None" = None,
audit_logger: Any = None,
) -> tuple[list[list[str]], TokenUsage, int | None]:
"""
Process a batch of content through the retain pipeline.
@@ -515,6 +668,10 @@ async def retain_batch(
db_semaphore=db_semaphore,
document_body_override=document_body_override,
chunk_index_offset=chunk_index_offset,
progress_callback=progress_callback,
webhook_manager=webhook_manager,
memory_defense_extension=memory_defense_extension,
audit_logger=audit_logger,
)
for group_idx, orig_idx in enumerate(original_indices[doc_key]):
if group_idx < len(group_ids):
@@ -523,6 +680,80 @@ async def retain_batch(
total_processed_tokens = _merge_processed_content_tokens(total_processed_tokens, group_processed)
return result_unit_ids, total_usage, total_processed_tokens
# --- Memory Defense pre-extraction screening ---
# Delegate to the loaded extension. `config` is a resolved HindsightConfig
# object at this point (see _retain_batch_async_internal). On a non-allow
# decision we redact in place or drop the item, and fire a
# memory_defense.triggered webhook when one is configured.
_policy = parse_policy(getattr(config, "memory_defense", None))
_blocked_violations: list[BlockedViolation] = []
if memory_defense_extension is not None and _policy.enabled:
async with acquire_with_retry(pool) as _defense_conn:
for _idx, _content in enumerate(contents):
# Prefer the per-item document_id over the batch-level value so
# the decision and webhook carry the document the caller
# submitted, not whichever doc_id the batch happens to share.
_item_doc_id = contents_dicts[_idx].get("document_id") or document_id
_decision = await memory_defense_extension.screen(
policy=_policy,
bank_id=bank_id,
document_id=_item_doc_id,
content=_content.content,
tags=_content.tags,
)
if _decision.action is DefenseAction.ALLOW:
continue
if _decision.action is DefenseAction.REDACT:
_redacted = _decision.redacted_content or _content.content
_content.content = _redacted
# Mirror the redaction into the raw dict so the document
# body persisted further down the pipeline also stores the
# redacted text, not the verbatim secret.
contents_dicts[_idx]["content"] = _redacted
elif _decision.action is DefenseAction.BLOCK:
_blocked_violations.append(
BlockedViolation(
index=_idx,
detector=_decision.detector,
message=_decision.message,
)
)
await _fire_memory_defense_webhook(
webhook_manager,
conn=_defense_conn,
schema=schema,
bank_id=bank_id,
operation_id=operation_id,
document_id=_item_doc_id,
decision=_decision,
)
_audit_memory_defense(
audit_logger,
bank_id=bank_id,
document_id=_item_doc_id,
decision=_decision,
)
if _blocked_violations:
# All items blocked → raise so the HTTP layer can return 422.
if len(_blocked_violations) == len(contents):
raise MemoryDefenseAllBlockedError(_blocked_violations)
# Remove blocked items from the pipeline.
_skip_indices = {v.index for v in _blocked_violations}
if _skip_indices:
_surviving = [i for i in range(len(contents)) if i not in _skip_indices]
contents = [contents[i] for i in _surviving]
contents_dicts = [contents_dicts[i] for i in _surviving]
# If nothing survives, return empty results immediately.
if not contents:
return [[] for _ in contents_dicts], TokenUsage(), 0
# Resolve effective document_id early so both delta and streaming paths
# can find existing chunks from a prior attempt. On retry, a generated
# document_id is recovered from operation result_metadata.document_ids[0].
@@ -667,10 +898,15 @@ async def retain_batch(
# retain code paths.
chunk_batch_size = getattr(config, "retain_chunk_batch_size", 100)
chunk_size = getattr(config, "retain_chunk_size", 3000)
structured_chunk_size = getattr(config, "retain_structured_chunk_size", None)
all_pre_chunks: list[str] = []
chunk_to_content: list[int] = [] # maps chunk index -> index into contents
for content_idx, content in enumerate(contents):
content_chunks = fact_extraction.chunk_text(content.content, chunk_size)
content_chunks = fact_extraction.chunk_text(
content.content,
chunk_size,
structured_chunk_size=structured_chunk_size,
)
all_pre_chunks.extend(content_chunks)
chunk_to_content.extend([content_idx] * len(content_chunks))
@@ -895,7 +1131,9 @@ async def _streaming_retain_batch(
# so documents.original_text stores the complete payload, not just this
# slice (issue #1838).
if document_body_override is not None:
combined_content = document_body_override
# The override is the unmodified original body — apply redaction so
# secrets in oversized inputs don't bypass screening.
combined_content = _redact_document_body(document_body_override, config)
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Memory: contents_dicts content strings are now captured in combined_content.
@@ -1686,6 +1924,7 @@ async def _try_delta_retain(
start_time,
outbox_callback,
document_body_override=document_body_override,
config=config,
)
# Build content items for only the changed/new chunks
@@ -1703,6 +1942,7 @@ async def _try_delta_retain(
start_time,
outbox_callback,
document_body_override=document_body_override,
config=config,
)
# Freshness recheck BEFORE the (expensive) LLM extraction.
@@ -1754,6 +1994,7 @@ async def _try_delta_retain(
start_time,
outbox_callback,
document_body_override=document_body_override,
config=config,
)
log_buffer.append(
f"[delta] Recheck: {len(recheck.changed) + len(recheck.new) + len(recheck.removed)} chunks still differ — "
@@ -1829,9 +2070,10 @@ async def _try_delta_retain(
step_start = time.time()
# When this sub-batch is one slice of an oversized item
# split across multiple sub-batches, store the full body
# (issue #1838) instead of just the slice.
# (issue #1838) instead of just the slice. Redact the
# override since it bypassed per-chunk screening.
if document_body_override is not None:
combined_content = document_body_override
combined_content = _redact_document_body(document_body_override, config)
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
@@ -1960,6 +2202,7 @@ async def _delta_metadata_only(
outbox_callback,
*,
document_body_override: str | None = None,
config: Any = None,
):
"""Handle the case where no chunks changed — just update document metadata and tags."""
async with acquire_with_retry(pool) as conn:
@@ -1972,8 +2215,9 @@ async def _delta_metadata_only(
)
# When this sub-batch is a slice of an oversized item, write the
# full original body (issue #1838) instead of just the slice.
# Redact the override since it bypassed per-chunk screening.
if document_body_override is not None:
combined_content = document_body_override
combined_content = _redact_document_body(document_body_override, config)
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
@@ -2042,9 +2286,14 @@ def _chunk_contents_for_delta(contents: list[RetainContent], config) -> dict[int
"""
result = {}
global_chunk_idx = 0
chunk_size = getattr(config, "retain_chunk_size", 3000)
structured_chunk_size = getattr(config, "retain_structured_chunk_size", None)
for content in contents:
chunk_size = getattr(config, "retain_chunk_size", 3000)
chunks = fact_extraction.chunk_text(content.content, chunk_size)
chunks = fact_extraction.chunk_text(
content.content,
chunk_size,
structured_chunk_size=structured_chunk_size,
)
for chunk_text in chunks:
result[global_chunk_idx] = chunk_text
global_chunk_idx += 1
@@ -24,7 +24,9 @@ class RetainContentDict(TypedDict, total=False):
tags: Visibility scope tags for this content item (optional)
observation_scopes: How to scope observations for consolidation (optional).
"per_tag" runs one pass per individual tag; "combined" (default) runs a
single pass with all tags; a list[list[str]] specifies exact passes.
single pass with all tags; "shared" runs a single pass over one global,
untagged scope so memories consolidate together regardless of tags;
a list[list[str]] specifies exact passes.
update_mode: How to handle existing documents with the same document_id (optional).
"replace" (default) deletes old data and reprocesses. "append" concatenates
new content to the existing document and reprocesses.
@@ -38,7 +40,7 @@ class RetainContentDict(TypedDict, total=False):
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
tags: list[str] # Visibility scope tags
observation_scopes: (
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
) # Observation scopes for consolidation
update_mode: Literal["replace", "append"]
@@ -57,7 +59,7 @@ class RetainContent:
metadata: dict[str, str] = field(default_factory=dict)
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
tags: list[str] = field(default_factory=list) # Visibility scope tags
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
None # Observation scopes
)
@@ -124,7 +126,7 @@ class ExtractedFact:
mentioned_at: datetime | None = None
metadata: dict[str, str] = field(default_factory=dict)
tags: list[str] = field(default_factory=list) # Visibility scope tags
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
None # Observation scopes
)
@@ -176,7 +178,7 @@ class ProcessedFact:
tags: list[str] = field(default_factory=list)
# Observation scopes for consolidation
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = None
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = None
@property
def is_duplicate(self) -> bool:
@@ -2,8 +2,6 @@
Helper functions for hybrid search (semantic + BM25 + graph).
"""
from typing import Any
from .types import MergedCandidate, RetrievalResult
@@ -156,39 +154,3 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
)
for pos, doc_id in enumerate(ordered_ids)
]
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
"""
Normalize scores based on deltas (min-max normalization within result set).
This ensures all scores are in [0, 1] range based on the spread in THIS result set.
Args:
results: List of result dicts
score_keys: Keys to normalize (e.g., ["recency", "frequency"])
Returns:
Results with normalized scores added as "{key}_normalized"
"""
for key in score_keys:
values = [r.get(key, 0.0) for r in results if key in r]
if not values:
continue
min_val = min(values)
max_val = max(values)
delta = max_val - min_val
if delta > 0:
for r in results:
if key in r:
r[f"{key}_normalized"] = (r[key] - min_val) / delta
else:
# All values are the same, set to 0.5
for r in results:
if key in r:
r[f"{key}_normalized"] = 0.5
return results
@@ -99,9 +99,15 @@ def apply_combined_scoring(
for sr in scored_results:
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
# Use the unit's effective time (occurred_start, then mentioned_at, then
# occurred_end) — the same COALESCE order as retrieval._coalesce_date — so a
# memory that carries only a mentioned_at / occurred_end (e.g. conversation
# facts or ongoing states that intentionally lack occurred_start) still gets
# correct recency ordering instead of a flat neutral 0.5.
sr.recency = 0.5
if sr.retrieval.occurred_start:
occurred = sr.retrieval.occurred_start
effective = sr.retrieval.occurred_start or sr.retrieval.mentioned_at or sr.retrieval.occurred_end
if effective:
occurred = effective
if occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
days_ago = (now - occurred).total_seconds() / 86400
@@ -13,7 +13,7 @@ import logging
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, Optional
from typing import TYPE_CHECKING, Any, Optional
from ...config import get_config
from ..db_utils import acquire_with_retry
@@ -24,6 +24,9 @@ from .link_expansion_retrieval import LinkExpansionRetriever
from .tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause_simple
from .types import GraphRetrievalTimings, RetrievalResult
if TYPE_CHECKING:
from ..query_analyzer import QueryAnalyzer
logger = logging.getLogger(__name__)
@@ -2,14 +2,18 @@
Tags filtering utilities for retrieval.
Provides SQL building functions for filtering memories by tags.
Supports four matching modes via TagsMatch enum:
Supports five matching modes via TagsMatch enum:
- "any": OR matching, includes untagged memories (default, backward compatible)
- "all": AND matching, includes untagged memories
- "any_strict": OR matching, excludes untagged memories
- "all_strict": AND matching, excludes untagged memories
- "exact": set-equality matching, excludes untagged memories
OR matching (any/any_strict): Memory matches if ANY of its tags overlap with request tags
AND matching (all/all_strict): Memory matches if ALL request tags are present in its tags
EXACT matching: Memory matches only if its tag set EQUALS the request tag set (order-
independent). Used for observation "scope" filtering, where each observation lives
under exactly one scope (its full tag set) and "scope [a]" must not match "[a, b]".
"""
from __future__ import annotations
@@ -18,7 +22,7 @@ from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
TagsMatch = Literal["any", "all", "any_strict", "all_strict"]
TagsMatch = Literal["any", "all", "any_strict", "all_strict", "exact"]
def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]:
@@ -38,6 +42,10 @@ def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]:
return "&&", False
elif match == "all_strict":
return "@>", False
elif match == "exact":
# Set equality is handled by the callers via `@> AND <@`; the operator
# here is unused. Untagged rows never equal a non-empty scope.
return "@>", False
else:
# Default to "any" behavior
return "&&", True
@@ -78,6 +86,13 @@ def build_tags_where_clause(
return "", [], param_offset
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact":
# Set equality (order-independent): superset AND subset. Untagged rows
# (empty array) never satisfy `@>` of a non-empty scope, so they're excluded.
clause = f"AND ({column} @> ${param_offset} AND {column} <@ ${param_offset})"
return clause, [tags], param_offset + 1
operator, include_untagged = _parse_tags_match(match)
if include_untagged:
@@ -115,6 +130,12 @@ def build_tags_where_clause_simple(
return ""
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact":
# Set equality (order-independent): superset AND subset. Untagged rows
# (empty array) never satisfy `@>` of a non-empty scope, so they're excluded.
return f"AND ({column} @> ${param_num} AND {column} <@ ${param_num})"
operator, include_untagged = _parse_tags_match(match)
if include_untagged:
@@ -164,7 +185,11 @@ def filter_results_by_tags(
# else: skip untagged
else:
result_tags_set = set(result_tags)
if is_any_match:
if match == "exact":
# Set equality: tag set must match the scope exactly
if result_tags_set == tags_set:
filtered.append(result)
elif is_any_match:
# Any overlap
if result_tags_set & tags_set:
filtered.append(result)
@@ -241,6 +266,9 @@ def _build_group_clause(
"""
if isinstance(group, TagGroupLeaf):
column = f"{table_alias}tags" if table_alias else "tags"
if group.match == "exact":
clause = f"({column} @> ${param_offset} AND {column} <@ ${param_offset})"
return clause, [group.tags], param_offset + 1
operator, include_untagged = _parse_tags_match(group.match)
if include_untagged:
clause = f"({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_offset})"
@@ -349,6 +377,8 @@ def _match_group(result: object, group: TagGroup) -> bool:
return include_untagged
else:
result_tags_set = set(result_tags)
if group.match == "exact":
return result_tags_set == tags_set
if is_any_match:
return bool(result_tags_set & tags_set)
else:
@@ -2,7 +2,7 @@
import logging
import os
from datetime import datetime, timedelta, timezone
from datetime import timedelta, timezone
import obstore as obs
from obstore.store import GCSStore
@@ -0,0 +1,155 @@
"""Explicit period extraction helpers for DateparserQueryAnalyzer.
This module keeps the public period-extraction API and the non-Chinese period
rules. Chinese rules live in chinese_temporal_periods.py because that rule set is
substantially larger and has different boundary behavior from whitespace-based
languages.
"""
import calendar
import re
import unicodedata
from datetime import datetime, timedelta
DateRange = tuple[datetime, datetime]
class NoTemporalConstraintSentinel:
pass
NO_TEMPORAL_CONSTRAINT = NoTemporalConstraintSentinel()
__all__ = [
"NO_TEMPORAL_CONSTRAINT",
"extract_period",
"is_embedded_cjk_dateparser_match",
]
def _is_cjk_character(char: str) -> bool:
return "\u4e00" <= char <= "\u9fff"
def is_embedded_cjk_dateparser_match(query: str, matched_text: str) -> bool:
from hindsight_api.engine.chinese_temporal_periods import (
is_embedded_cjk_dateparser_match as chinese_is_embedded_cjk_dateparser_match,
)
return chinese_is_embedded_cjk_dateparser_match(query, matched_text)
def _constraint(start: datetime, end: datetime) -> DateRange:
return (
start.replace(hour=0, minute=0, second=0, microsecond=0),
end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
def _month_end(year: int, month: int) -> datetime:
return datetime(year, month, calendar.monthrange(year, month)[1])
def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRange | None:
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return _constraint(d, d)
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
return _constraint(reference_date, reference_date)
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
query,
re.IGNORECASE,
):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return _constraint(start, start + timedelta(days=6))
if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
query,
re.IGNORECASE,
):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return _constraint(start, end)
if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
query,
re.IGNORECASE,
):
year = reference_date.year - 1
return _constraint(datetime(year, 1, 1), datetime(year, 12, 31))
if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
query,
re.IGNORECASE,
):
days_since_sat = (reference_date.weekday() + 2) % 7
if days_since_sat == 0:
days_since_sat = 7
sat = reference_date - timedelta(days=days_since_sat)
return _constraint(sat, sat + timedelta(days=1))
month_patterns = {
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
}
for pattern, month_num in month_patterns.items():
match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE)
if match:
year = int(match.group(2))
start = datetime(year, month_num, 1)
return _constraint(start, _month_end(year, month_num))
return None
def extract_period(query: str, reference_date: datetime) -> DateRange | NoTemporalConstraintSentinel | None:
"""Extract explicit period-based temporal expressions.
Non-Chinese rules are kept here. Chinese rules are delegated to
chinese_temporal_periods.py and are skipped entirely for non-CJK queries.
"""
query = unicodedata.normalize("NFKC", query)
if any(_is_cjk_character(char) for char in query):
from hindsight_api.engine.chinese_temporal_periods import extract_chinese_period
chinese_result = extract_chinese_period(query, reference_date)
if chinese_result is not None:
return chinese_result
return _extract_non_chinese_period(query, reference_date)
@@ -80,6 +80,12 @@ _SKIP_TABLES = frozenset(
"async_operations", # in-flight ops; drain on the source before migrating
"graph_maintenance_queue", # transient work queue; regenerated on import
"file_storage", # raw uploads; documents.original_text is already carried
# Curation archive of retired facts — local operational state, not part of
# the live knowledge the export replays. Its rows mirror memory_units (stale
# embedding) and snapshot source-bank entity ids that the import re-resolves
# to fresh ids, so carrying them would only produce dangling associations.
# Revert anything worth keeping on the source before migrating.
"invalidated_memory_units",
}
)
# Derived columns dropped from carried rows so the target regenerates them with
@@ -22,7 +22,7 @@ from pydantic import BaseModel, Field
# Bump when the archive layout changes in a backward-incompatible way.
SCHEMA_VERSION = 1
ObservationScopes = Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
class TransferCausalRelation(BaseModel):
@@ -16,11 +16,24 @@ with the system (e.g., running migrations for tenant schemas).
"""
from hindsight_api.extensions.base import Extension
from hindsight_api.extensions.builtin import ApiKeyTenantExtension, SupabaseTenantExtension
from hindsight_api.extensions.builtin import (
ApiKeyTenantExtension,
MemoryDefenseRegexExtension,
SupabaseTenantExtension,
)
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
from hindsight_api.extensions.http import HttpExtension
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.mcp import MCPExtension
from hindsight_api.extensions.memory_defense import (
DefenseAction,
DefenseDecision,
DefensePolicy,
MemoryDefenseExtension,
PolicyRule,
apply_redaction,
parse_policy,
)
from hindsight_api.extensions.operation_validator import (
# Bank Management operations
BankListContext,
@@ -104,4 +117,13 @@ __all__ = [
"Tenant",
"TenantContext",
"TenantExtension",
# Memory Defense
"DefenseAction",
"DefenseDecision",
"DefensePolicy",
"MemoryDefenseExtension",
"MemoryDefenseRegexExtension",
"PolicyRule",
"apply_redaction",
"parse_policy",
]
@@ -13,10 +13,12 @@ Example usage:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
"""
from hindsight_api.extensions.builtin.memory_defense_regex import MemoryDefenseRegexExtension
from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
__all__ = [
"ApiKeyTenantExtension",
"MemoryDefenseRegexExtension",
"SupabaseTenantExtension",
]
@@ -0,0 +1,56 @@
"""Memory Defense (regex) — the default extension shipping with hindsight-api-slim.
Scrubs known secret/PII patterns from retained content via the
``sensitive_data`` detector. Matching is pure regex (see ``apply_redaction``):
no LLM call, no external dependency. A ``sensitive_data`` rule may either
``redact`` matches in place or ``block`` the item entirely.
"""
from __future__ import annotations
import logging
from hindsight_api.extensions.memory_defense import (
DefenseAction,
DefenseDecision,
DefensePolicy,
MemoryDefenseExtension,
apply_redaction,
)
logger = logging.getLogger(__name__)
class MemoryDefenseRegexExtension(MemoryDefenseExtension):
"""Default Memory Defense — regex-based secret/PII redaction."""
async def screen(
self,
*,
policy: DefensePolicy,
bank_id: str,
document_id: str | None,
content: str,
tags: list[str],
) -> DefenseDecision:
if not policy.enabled:
return DefenseDecision(action=DefenseAction.ALLOW)
# The regex extension only runs the sensitive_data detector. If the
# policy doesn't include a rule for it, there's nothing to do.
rule = next((r for r in policy.rules if r.on == "sensitive_data"), None)
if rule is None or rule.action is DefenseAction.ALLOW:
return DefenseDecision(action=DefenseAction.ALLOW)
result = apply_redaction(content)
if not result.matched_types:
return DefenseDecision(action=DefenseAction.ALLOW)
return DefenseDecision(
action=rule.action,
detector="sensitive_data",
message=f"Sensitive data pattern matched: {', '.join(result.matched_types)}",
redacted_content=result.content if rule.action is DefenseAction.REDACT else None,
matched_types=result.matched_types,
hits=result.hits,
)
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hindsight_api.engine.interface import MemoryEngineInterface
from hindsight_api.webhooks.manager import WebhookManager
class ExtensionContext(ABC):
@@ -83,6 +84,8 @@ class DefaultExtensionContext(ExtensionContext):
self,
database_url: str,
memory_engine: "MemoryEngineInterface | None" = None,
webhook_manager: "WebhookManager | None" = None,
current_schema: str | None = None,
):
"""
Initialize the context.
@@ -90,9 +93,13 @@ class DefaultExtensionContext(ExtensionContext):
Args:
database_url: SQLAlchemy database URL for migrations.
memory_engine: Optional MemoryEngine instance for memory operations.
webhook_manager: Optional WebhookManager for firing webhooks.
current_schema: Optional current schema name for tenant context.
"""
self._database_url = database_url
self._memory_engine = memory_engine
self.webhook_manager = webhook_manager
self.current_schema = current_schema
async def run_migration(self, schema: str) -> None:
"""Run migrations for a specific schema."""
@@ -0,0 +1,271 @@
"""Memory Defense extension contract and shared policy types.
Lives in extensions/ (not engine/) because it defines the public contract
between the retain orchestrator and any installed Memory Defense extension
the same shape as TenantExtension and OperationValidatorExtension.
api-slim ships the :class:`MemoryDefenseExtension` protocol and a regex default
that scrubs known secret/PII patterns from retained content.
"""
from __future__ import annotations
import logging
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from hindsight_api.extensions.base import Extension
logger = logging.getLogger(__name__)
class DefenseAction(str, Enum):
ALLOW = "allow"
REDACT = "redact"
BLOCK = "block"
_VALID_ACTIONS = {a.value for a in DefenseAction}
# ``policy.rules[*].on`` names a detector. The OSS extension only screens for
# ``sensitive_data``; any other name is a silent no-op here and is dispatched
# by whichever extension is loaded (e.g. hindsight-cloud screens cloud-only
# detectors). The parser therefore does NOT validate ``on`` against a fixed
# list — pinning the OSS roster to cloud's would force an OSS bump for every
# new cloud detector just to avoid 422-ing a write it never interprets. We
# only require ``on`` to be a non-empty string; entitlement and dispatch are
# the loaded extension's ``screen()`` job.
@dataclass(frozen=True)
class PolicyRule:
on: str
action: DefenseAction
@dataclass(frozen=True)
class DefensePolicy:
enabled: bool = False
rules: tuple[PolicyRule, ...] = ()
@dataclass
class DefenseDecision:
action: DefenseAction
detector: str | None = None
message: str = ""
redacted_content: str | None = None
matched_types: list[str] = field(default_factory=list)
# Per-match fingerprinted previews. Each entry is
# ``{"detector": <pattern label>, "preview": <fingerprinted value>}``.
# The preview is *never* the raw value — see :func:`_fingerprint_value`.
# OSS populates this from ``apply_redaction``; downstream extensions
# populate it from their own detectors. Optional: empty when the
# match path didn't capture per-hit values.
hits: list[dict] = field(default_factory=list)
@dataclass
class RedactionResult:
content: str
matched_types: list[str]
# Same shape as ``DefenseDecision.hits`` — one entry per matched value
# (so a single content with two GitHub tokens produces two entries).
hits: list[dict] = field(default_factory=list)
def _fingerprint_value(value: str) -> str:
"""Return a redaction-identifiable preview of a matched value.
The preview keeps the prefix and a short suffix so a SIEM operator can
correlate against their credential inventory (the prefix names the
provider; the suffix disambiguates specific instances) without the raw
secret crossing the wire. Length-aware so short values don't accidentally
leak material:
- Length < 6: redact entirely (return a fixed-length mask). Catches
noise like a single ``-----BEGIN...`` marker line.
- Length 6-15: keep the first 2 + last 2 around an ellipsis.
- Length > 15: keep the first 4 + last 4 around an ellipsis.
Examples::
_fingerprint_value("ghp_AAAA...AAAA" + "A" * 36) -> "ghp_...AAAA"
_fingerprint_value("AKIA" + "B" * 16) -> "AKIA...BBBB"
_fingerprint_value("123-45-6789") -> "12...89"
_fingerprint_value("abc") -> "[redacted]"
"""
n = len(value)
if n < 6:
return "[redacted]"
if n <= 15:
return f"{value[:2]}...{value[-2:]}"
return f"{value[:4]}...{value[-4:]}"
def parse_policy(raw: dict | None) -> DefensePolicy:
"""Parse a raw bank-config dict into a frozen DefensePolicy.
Raises ValueError for a missing/empty ``on`` or an unknown action; the
HTTP layer converts those into a 422 response.
"""
if raw is None:
return DefensePolicy()
rules: list[PolicyRule] = []
for item in raw.get("rules", []) or []:
on_raw = item.get("on")
if not isinstance(on_raw, str) or not on_raw:
raise ValueError(f"invalid on {on_raw!r}; must be a non-empty string")
action_raw = item.get("action")
if action_raw not in _VALID_ACTIONS:
raise ValueError(f"invalid action {action_raw!r}; must be one of {sorted(_VALID_ACTIONS)}")
rules.append(PolicyRule(on=on_raw, action=DefenseAction(action_raw)))
return DefensePolicy(
enabled=bool(raw.get("enabled", False)),
rules=tuple(rules),
)
# Secret/PII redaction patterns.
#
# Scope: high-confidence patterns with unambiguous prefixes (low false-positive
# rate). Context-dependent matches (e.g. Cohere/Mistral keys that only stand
# out near surrounding "cohere"/"mistral" tokens) are NOT covered by pure
# regex — operators who need that should layer a context-aware secret
# scanner (detect-secrets, trufflehog) on top.
#
# Order matters: more-specific patterns first so broader ones don't consume
# substrings partially. Example: `sk-ant-...` and `sk-proj-...` must run
# before the generic `sk-...` pattern.
_REDACTION_PATTERNS: list[tuple[str, str]] = [
# --- AI / LLM providers ---
("anthropic_key", r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"),
("openai_project_key", r"\bsk-proj-[A-Za-z0-9_-]{48,}\b"),
("openai_admin_key", r"\bsk-admin-[A-Za-z0-9_-]{40,}\b"),
("openai_key", r"\bsk-[A-Za-z0-9_-]{20,}\b"),
("google_api_key", r"\bAIza[0-9A-Za-z_-]{35}\b"),
("google_oauth_token", r"\bya29\.[0-9A-Za-z_-]{20,}\b"),
("xai_key", r"\bxai-[A-Za-z0-9]{40,}\b"),
("groq_key", r"\bgsk_[A-Za-z0-9]{20,}\b"),
("huggingface_token", r"\bhf_[A-Za-z0-9]{30,}\b"),
("replicate_token", r"\br8_[A-Za-z0-9]{30,}\b"),
("perplexity_key", r"\bpplx-[A-Za-z0-9]{40,}\b"),
("databricks_token", r"\bdapi[A-Za-z0-9]{32}\b"),
# --- Cloud providers ---
("aws_access_key", r"\bAKIA[0-9A-Z]{16}\b"),
("aws_session_token", r"\bASIA[0-9A-Z]{16}\b"),
(
"aws_secret_key",
r"(?i)aws(.{0,20})?(secret|private)?[\s_-]?access[\s_-]?key[\s_-]?[:=][\s\"']*([A-Za-z0-9/+=]{40})",
),
("digitalocean_token", r"\bdop_v1_[a-f0-9]{64}\b"),
# --- Source control & CI ---
("github_fg_pat", r"\bgithub_pat_[A-Za-z0-9_]{60,}\b"),
("github_token", r"\bghp_[A-Za-z0-9]{36}\b"),
("github_app_token", r"\bghs_[A-Za-z0-9]{36}\b"),
("github_user_token", r"\bghu_[A-Za-z0-9]{36}\b"),
("github_refresh", r"\bghr_[A-Za-z0-9]{36}\b"),
("github_oauth", r"\bgho_[A-Za-z0-9]{36}\b"),
("gitlab_pat", r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
("npm_token", r"\bnpm_[A-Za-z0-9]{30,}\b"),
("pypi_token", r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{20,}\b"),
# --- Payment processors ---
("stripe_secret", r"\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b"),
("stripe_restricted", r"\brk_(?:live|test)_[A-Za-z0-9]{20,}\b"),
("square_token", r"\bsq0[a-z]{3}-[A-Za-z0-9_-]{22,}\b"),
("braintree_token", r"\baccess_token\$production\$[a-z0-9]{16}\$[a-f0-9]{32}\b"),
# --- Communication / email ---
("slack_token", r"\bxox[abpr]-[0-9A-Za-z-]{10,}\b"),
("slack_webhook", r"https://hooks\.slack\.com/services/T[A-Za-z0-9_]{8,}/B[A-Za-z0-9_]{8,}/[A-Za-z0-9_]{20,}"),
("twilio_api_key", r"\bSK[0-9a-fA-F]{32}\b"),
("twilio_account_sid", r"\bAC[0-9a-fA-F]{32}\b"),
("sendgrid_key", r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"),
("mailgun_key", r"\bkey-[A-Za-z0-9]{32}\b"),
("discord_bot", r"\b[MNO][A-Za-z0-9]{23}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27}\b"),
("telegram_bot", r"\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b"),
# --- Commerce ---
("shopify_token", r"\bshpat_[a-fA-F0-9]{32}\b"),
# --- Database connection strings (creds embedded in URL) ---
("db_url_postgres", r"postgres(?:ql)?://[^\s:/@]+:[^\s/@]+@[^\s]+"),
("db_url_mysql", r"mysql://[^\s:/@]+:[^\s/@]+@[^\s]+"),
("db_url_mongodb", r"mongodb(?:\+srv)?://[^\s:/@]+:[^\s/@]+@[^\s]+"),
# --- Private keys & generic credentials ---
("private_key_pem", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY( BLOCK)?-----"),
("jwt", r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
# --- PII (US-centric defaults; can be tuned per deployment) ---
# NOTE: credit_card regex is intentionally narrowed to 13-19 digits with
# exact separators to reduce false positives on long product IDs.
("credit_card", r"\b(?:\d{4}[ -]?){3}\d{1,4}\b"),
("ssn_us", r"\b\d{3}-\d{2}-\d{4}\b"),
]
_COMPILED_REDACTIONS: list[tuple[str, re.Pattern]] = [
(label, re.compile(pattern)) for label, pattern in _REDACTION_PATTERNS
]
def apply_redaction(content: str) -> RedactionResult:
"""Scrub known secret/PII patterns from content with [REDACTED:type] markers.
Returns the (possibly unchanged) content alongside:
- ``matched_types``: pattern labels that matched (deduplicated, in
first-occurrence order). Empty when nothing matched.
- ``hits``: per-match fingerprinted previews one entry per matched
substring (so two GitHub tokens in the same content produce two
entries). Each entry is ``{"detector": label, "preview": fingerprint}``
where ``preview`` is a length-aware redaction of the original value.
The raw secret never appears in ``hits``.
The two-pass shape (find matches first, then substitute) lets us capture
raw values for fingerprinting before they're replaced by ``[REDACTED:type]``
markers. A single-pass approach would lose the originals.
"""
matched: list[str] = []
hits: list[dict] = []
for label, pattern in _COMPILED_REDACTIONS:
raw_hits = pattern.findall(content)
if not raw_hits:
continue
if label not in matched:
matched.append(label)
for raw in raw_hits:
# findall returns either a string or a tuple of capture groups
# depending on the pattern. The redaction-pattern catalog uses a
# mix; coerce to the matched substring as best we can.
if isinstance(raw, tuple):
# Pick the longest non-empty group as the canonical match.
non_empty = [g for g in raw if g]
raw_str = max(non_empty, key=len) if non_empty else ""
else:
raw_str = raw
if not raw_str:
continue
hits.append({"detector": label, "preview": _fingerprint_value(raw_str)})
content = pattern.sub(f"[REDACTED:{label}]", content)
return RedactionResult(content=content, matched_types=matched, hits=hits)
class MemoryDefenseExtension(Extension, ABC):
"""Abstract base for Memory Defense extensions.
Implementations decide whether to allow, redact, or block a given retain
item by inspecting its content against a per-bank policy. The orchestrator
applies the returned decision (redacts content / drops blocked items) and
fires a webhook for non-allow decisions when one is configured.
"""
@abstractmethod
async def screen(
self,
*,
policy: DefensePolicy,
bank_id: str,
document_id: str | None,
content: str,
tags: list[str],
) -> DefenseDecision:
"""Inspect content under the given policy and return a decision."""
...
@@ -3,7 +3,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from hindsight_api.extensions.base import Extension
+214 -2
View File
@@ -50,6 +50,8 @@ _ALL_TOOLS: frozenset[str] = frozenset(
"delete_directive",
"list_memories",
"get_memory",
"update_memory",
"invalidate_memory",
"list_documents",
"get_document",
"delete_document",
@@ -228,6 +230,8 @@ def register_mcp_tools(
"delete_directive",
"list_memories",
"get_memory",
"update_memory",
"invalidate_memory",
"list_documents",
"get_document",
"delete_document",
@@ -299,6 +303,12 @@ def register_mcp_tools(
if "get_memory" in tools_to_register:
_register_get_memory(mcp, memory, config)
if "update_memory" in tools_to_register:
_register_update_memory(mcp, memory, config)
if "invalidate_memory" in tools_to_register:
_register_invalidate_memory(mcp, memory, config)
# Document tools
if "list_documents" in tools_to_register:
_register_list_documents(mcp, memory, config)
@@ -2293,6 +2303,206 @@ def _register_get_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
return {"error": str(e)}
def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the update_memory (edit) tool."""
_EDIT_DOC = """
Edit a memory unit to correct what was extracted.
Pass any of text / context / occurred_start / occurred_end / fact_type /
entities. For context and the dates, "" clears the field and omitting it
leaves it unchanged; entities replaces the fact's entity set ([] detaches
all). The memory is re-embedded and its derived observations, links, and
graph are recomputed automatically.
Only raw world/experience facts can be edited; observations are derived.
To retire or restore a fact, use invalidate_memory instead.
"""
if config.include_bank_id_param:
@mcp.tool(description=_EDIT_DOC)
async def update_memory(
memory_id: str,
text: str | None = None,
context: str | None = None,
occurred_start: str | None = None,
occurred_end: str | None = None,
fact_type: str | None = None,
entities: list[str] | None = None,
bank_id: str | None = None,
) -> str:
"""
Args:
memory_id: The ID of the memory unit to edit.
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
result = await memory.update_memory_unit(
target_bank,
memory_id,
text=text,
context=context,
occurred_start=occurred_start,
occurred_end=occurred_end,
new_fact_type=fact_type,
entities=entities,
request_context=_get_request_context(config),
)
if result is None:
return json.dumps({"error": f"Memory '{memory_id}' not found"})
return json.dumps(result, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error updating memory: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(description=_EDIT_DOC)
async def update_memory(
memory_id: str,
text: str | None = None,
context: str | None = None,
occurred_start: str | None = None,
occurred_end: str | None = None,
fact_type: str | None = None,
entities: list[str] | None = None,
) -> dict:
"""
Args:
memory_id: The ID of the memory unit to edit.
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
result = await memory.update_memory_unit(
target_bank,
memory_id,
text=text,
context=context,
occurred_start=occurred_start,
occurred_end=occurred_end,
new_fact_type=fact_type,
entities=entities,
request_context=_get_request_context(config),
)
if result is None:
return {"error": f"Memory '{memory_id}' not found"}
return result
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error updating memory: {e}", exc_info=True)
return {"error": str(e)}
def _register_invalidate_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the invalidate_memory (retire / restore) tool."""
_INVALIDATE_DOC = """
Soft-retire a memory unit (or restore a previously retired one).
Invalidating moves the fact out of the active set: it's excluded from
recall, consolidation, and the knowledge graph, its links are pruned, and
its derived observations are recomputed without it but it's kept for
audit and is fully reversible. Pass restore=True to bring it back.
Only raw world/experience facts can be invalidated; observations are derived.
"""
if config.include_bank_id_param:
@mcp.tool(description=_INVALIDATE_DOC)
async def invalidate_memory(
memory_id: str,
reason: str | None = None,
restore: bool = False,
bank_id: str | None = None,
) -> str:
"""
Args:
memory_id: The ID of the memory unit to retire (or restore).
reason: Optional free-text reason recorded when invalidating.
restore: Set True to restore a previously invalidated fact.
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
result = await memory.update_memory_unit(
target_bank,
memory_id,
state="valid" if restore else "invalidated",
reason=reason,
request_context=_get_request_context(config),
)
if result is None:
return json.dumps({"error": f"Memory '{memory_id}' not found"})
return json.dumps(result, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error invalidating memory: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(description=_INVALIDATE_DOC)
async def invalidate_memory(
memory_id: str,
reason: str | None = None,
restore: bool = False,
) -> dict:
"""
Args:
memory_id: The ID of the memory unit to retire (or restore).
reason: Optional free-text reason recorded when invalidating.
restore: Set True to restore a previously invalidated fact.
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
result = await memory.update_memory_unit(
target_bank,
memory_id,
state="valid" if restore else "invalidated",
reason=reason,
request_context=_get_request_context(config),
)
if result is None:
return {"error": f"Memory '{memory_id}' not found"}
return result
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error invalidating memory: {e}", exc_info=True)
return {"error": str(e)}
# =========================================================================
# DOCUMENT TOOLS
# =========================================================================
@@ -2981,7 +3191,8 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
- retain_mission: Steers what gets extracted during retain().
- retain_extraction_mode: 'concise' (default), 'verbose', or 'custom'.
- retain_custom_instructions: Custom extraction prompt (active when mode is 'custom').
- retain_chunk_size: Maximum token size for each content chunk.
- retain_chunk_size: Target maximum characters for each content chunk.
- retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation turn to keep whole.
- retain_chunk_batch_size: Number of chunks to process in parallel.
- enable_observations: Toggle observation consolidation after retain().
- observations_mission: Controls observation synthesis rules.
@@ -3040,7 +3251,8 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
- retain_mission: Steers what gets extracted during retain().
- retain_extraction_mode: 'concise' (default), 'verbose', or 'custom'.
- retain_custom_instructions: Custom extraction prompt (active when mode is 'custom').
- retain_chunk_size: Maximum token size for each content chunk.
- retain_chunk_size: Target maximum characters for each content chunk.
- retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation turn to keep whole.
- retain_chunk_batch_size: Number of chunks to process in parallel.
- enable_observations: Toggle observation consolidation after retain().
- observations_mission: Controls observation synthesis rules.
+45 -8
View File
@@ -39,6 +39,32 @@ def _get_tenant() -> str:
return get_current_schema()
def _is_client_cancellation(exc: BaseException) -> bool:
"""Whether *exc* is a client-disconnect cancellation rather than a failure.
An abandoned recall/reflect raises OperationCancelledError (issue #2122);
the HTTP layer re-raises it as ``HTTPException(499) from exc`` (see
api/http.py run_cancellable_on_disconnect). The exception itself, or any
link in its ``__cause__`` chain, being an OperationCancelledError marks it
as a cancellation. Matching on the cause chain rather than a bare status
code avoids misclassifying an unrelated 499 as a cancellation. Per the
engine contract a cancellation is "not a failure to retry or report"
(cancellation.OperationCancelledError), so it must not be counted against
``hindsight.operation.total``.
"""
# Imported lazily to avoid import-time coupling (cf. _get_tenant above).
from hindsight_api.cancellation import OperationCancelledError
cause: BaseException | None = exc
seen: set[int] = set() # guard against a cyclic __cause__ chain
while cause is not None and id(cause) not in seen:
if isinstance(cause, OperationCancelledError):
return True
seen.add(id(cause))
cause = cause.__cause__
return False
# Custom bucket boundaries for operation duration (in seconds)
# Fine granularity in 0-30s range where most operations complete
DURATION_BUCKETS = (0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0)
@@ -373,20 +399,31 @@ class MetricsCollector(MetricsCollectorBase):
attributes["max_tokens"] = str(max_tokens)
success = True
cancelled = False
try:
yield
except Exception:
success = False
except Exception as exc:
# A client disconnect cancels the operation cooperatively (#2122),
# raised as OperationCancelledError and re-raised by the HTTP layer
# as HTTPException(499) from it. An abandoned request is neither a
# success nor a failure, so it is excluded from the metric entirely
# rather than inflating either the failure or the success rate on
# hindsight.operation.total.
if _is_client_cancellation(exc):
cancelled = True
else:
success = False
raise
finally:
duration = time.time() - start_time
attributes["success"] = str(success).lower()
if not cancelled:
duration = time.time() - start_time
attributes["success"] = str(success).lower()
# Record duration
self.operation_duration.record(duration, attributes)
# Record duration
self.operation_duration.record(duration, attributes)
# Record operation count
self.operation_total.add(1, attributes)
# Record operation count
self.operation_total.add(1, attributes)
def record_llm_call(
self,
+150 -6
View File
@@ -25,7 +25,9 @@ from pathlib import Path
from alembic import command
from alembic.config import Config
from alembic.script.revision import ResolutionError
from alembic.util.exc import CommandError
from sqlalchemy import Connection, create_engine, text
from sqlalchemy.pool import NullPool
from ._pg_search import normalize_pg_search_tokenizer, pg_search_bm25_columns
from ._vector_index import (
@@ -131,7 +133,12 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
try:
with _alembic_lock:
command.upgrade(alembic_cfg, "heads")
except ResolutionError as e:
except (ResolutionError, CommandError) as e:
# command.upgrade() wraps ResolutionError in CommandError via
# ScriptDirectory._catch_revision_errors, so the wrapped form is what
# actually reaches us; re-raise CommandErrors with any other cause.
if isinstance(e, CommandError) and not isinstance(e.__cause__, ResolutionError):
raise
# This happens during rolling deployments when a newer version of the code
# has already run migrations, and this older replica doesn't have the new
# migration files. The database is already at a newer revision than we know.
@@ -241,7 +248,14 @@ def run_migrations(
# 2. After acquiring the lock, COMMIT the transaction on the advisory-lock
# connection itself before running migrations. pg_advisory_lock is
# session-level, so the lock survives the COMMIT.
engine = create_engine(migration_url)
# NullPool: do not retain the connection in a pool after the migration.
# Each schema migration opens a few short-lived engines (here plus the
# ensure_* steps); with the default QueuePool those connections linger
# until GC, and running many schemas in parallel (migration_concurrency)
# multiplies that footprint and exhausts max_connections — observed as
# "FATAL: sorry, too many clients already" sweeping 20k schemas at
# concurrency 12. NullPool closes the connection on return.
engine = create_engine(migration_url, poolclass=NullPool)
with engine.connect() as conn:
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
while True:
@@ -394,7 +408,7 @@ def check_migration_status(
return None, None
# Get current revision from database
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as connection:
context = MigrationContext.configure(connection)
current_rev = context.get_current_revision()
@@ -567,7 +581,7 @@ def ensure_embedding_dimension(
"""
schema_name = schema or "public"
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as conn:
# Check if memory_units table exists (proxy for schema being initialized)
table_exists = conn.execute(
@@ -590,6 +604,10 @@ def ensure_embedding_dimension(
_migrate_table_embedding_dimension(conn, schema_name, "memory_units", required_dimension, vector_ext)
_migrate_table_embedding_dimension(conn, schema_name, "mental_models", required_dimension, vector_ext)
# NOTE: invalidated_memory_units is deliberately omitted. The curation archive has no
# embedding column at all (dropped in migration d4f6a8c2e1b3) — invalidate stores no
# embedding and revert recomputes one — so there is no archive vector to re-dimension
# and a model switch can't trip a dimension mismatch there (#2209).
def ensure_vector_extension(
@@ -616,7 +634,7 @@ def ensure_vector_extension(
"""
schema_name = schema or "public"
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as conn:
# Detect which vector extension should be used
target_ext = _detect_vector_extension(conn, vector_extension)
@@ -830,7 +848,7 @@ def ensure_text_search_extension(
schema_name = schema or "public"
pg_search_tokenizer = normalize_pg_search_tokenizer(pg_search_tokenizer)
engine = create_engine(to_libpq_url(database_url))
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as conn:
# Tables with search_vector columns to check
tables_to_check = [
@@ -1123,3 +1141,129 @@ def ensure_text_search_extension(
conn.commit()
logger.info(f"Successfully migrated text search to {text_search_extension}")
def _migrate_one_schema_pg(
database_url: str,
schema: str,
*,
migration_database_url: str | None,
embedding_dimension: int | None,
vector_extension: str,
text_search_extension: str,
pg_search_tokenizer: str | None,
ensure_extensions: bool,
) -> str:
"""Run migrations + post-migration extension setup for a SINGLE PG schema.
Module-level (not a closure) so it is picklable and can run inside a
``ProcessPoolExecutor`` worker. The steps run strictly in order this is
the per-tenant sequential unit; parallelism happens only *across* schemas.
Returns the schema name on success; raises on the first failing step so the
caller can attribute the failure back to this schema.
"""
run_migrations(database_url, schema=schema, migration_database_url=migration_database_url)
if embedding_dimension is not None:
ensure_embedding_dimension(
database_url,
embedding_dimension,
schema=schema,
vector_extension=vector_extension,
)
if ensure_extensions:
ensure_vector_extension(database_url, vector_extension=vector_extension, schema=schema)
ensure_text_search_extension(
database_url,
text_search_extension=text_search_extension,
schema=schema,
pg_search_tokenizer=pg_search_tokenizer,
)
return schema
def _make_migration_executor(max_workers: int):
"""Build the executor that runs per-schema migrations in parallel.
Each schema must run in its OWN process Alembic's ``command.upgrade()``
uses non-thread-safe module globals (serialized in-process by
``_alembic_lock``), so a thread pool would not actually run two upgrades at
once. ``spawn`` gives every worker a clean interpreter on all platforms,
avoiding the fork-of-a-multithreaded-process deadlock hazard (the API server
holds threads/pools when migrations run on startup).
Factored out so tests can substitute an in-process executor.
"""
import multiprocessing
from concurrent.futures import ProcessPoolExecutor
return ProcessPoolExecutor(max_workers=max_workers, mp_context=multiprocessing.get_context("spawn"))
def run_migrations_for_schemas(
database_url: str,
schemas: list[str],
*,
concurrency: int = 1,
migration_database_url: str | None = None,
embedding_dimension: int | None = None,
vector_extension: str = "pgvector",
text_search_extension: str = "native",
pg_search_tokenizer: str | None = None,
ensure_extensions: bool = True,
) -> None:
"""Run PostgreSQL migrations for many schemas, up to ``concurrency`` at once.
Within a schema the work is always sequential (migrate embedding dim
vector ext text-search ext). Across schemas, when ``concurrency > 1`` each
schema is migrated in its OWN process: Alembic's ``command.upgrade()`` relies
on non-thread-safe module-level globals (serialized in-process by
``_alembic_lock``), so threads would gain nothing separate interpreters
each get a clean Alembic context. Per-schema advisory locks
(``_get_schema_lock_id``) keep concurrent processes from colliding on the
same schema across replicas.
``database_url`` must already be resolved (e.g. an embedded ``pg0`` instance
started in the parent) workers receive it verbatim and only connect.
Failures are collected per schema and re-raised together so one bad tenant
does not hide the status of the others.
"""
if not schemas:
return
worker_kwargs = dict(
migration_database_url=migration_database_url,
embedding_dimension=embedding_dimension,
vector_extension=vector_extension,
text_search_extension=text_search_extension,
pg_search_tokenizer=pg_search_tokenizer,
ensure_extensions=ensure_extensions,
)
effective = max(1, min(concurrency, len(schemas)))
if effective == 1:
# Inline, in-process — no subprocess overhead for the common single
# tenant / sequential case (and keeps embedded pg0 dev simple).
for schema in schemas:
_migrate_one_schema_pg(database_url, schema, **worker_kwargs)
return
logger.info("Migrating %d schema(s) with concurrency=%d", len(schemas), effective)
errors: dict[str, BaseException] = {}
with _make_migration_executor(effective) as executor:
futures = {
executor.submit(_migrate_one_schema_pg, database_url, schema, **worker_kwargs): schema for schema in schemas
}
for future in futures:
schema = futures[future]
try:
future.result()
except Exception as exc: # noqa: BLE001 — aggregate per-schema, re-raise below
errors[schema] = exc
logger.error("Migration failed for schema '%s': %s", schema, exc)
if errors:
failed = ", ".join(sorted(errors))
raise RuntimeError(
f"Database migrations failed for {len(errors)} of {len(schemas)} schema(s): {failed}"
) from next(iter(errors.values()))
@@ -4,8 +4,12 @@ SQLAlchemy models for the memory system.
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID as PyUUID
if TYPE_CHECKING:
from .cancellation import CancellationToken
@dataclass
class RequestContext:
@@ -30,6 +34,21 @@ class RequestContext:
# validators that want exponential backoff on repeated failures (e.g.
# "defer for 2^retry_count minutes") without querying the DB themselves.
retry_count: int = 0
# Cooperative cancellation signal for long-running operations. The HTTP
# layer sets this to a token that fires when the client disconnects; the
# engine checks it at stage boundaries and aborts abandoned work so it stops
# consuming CPU/DB resources (issue #2122). None means "never cancelled" —
# every checkpoint is a no-op.
cancellation: "CancellationToken | None" = None
def raise_if_cancelled(self) -> None:
"""Abort the current operation if its cancellation token has fired.
A no-op when no token is attached, so engine code can call it at every
stage boundary without caring whether the caller opted into cancellation.
"""
if self.cancellation is not None:
self.cancellation.raise_if_cancelled()
from pgvector.sqlalchemy import Vector
@@ -1,7 +1,15 @@
"""Webhook system for Hindsight API event notifications."""
from .manager import WebhookManager
from .models import ConsolidationEventData, RetainEventData, WebhookConfig, WebhookEvent, WebhookEventType
from .models import (
ConsolidationEventData,
MemoryDefenseEventData,
MemoryDefenseHit,
RetainEventData,
WebhookConfig,
WebhookEvent,
WebhookEventType,
)
__all__ = [
"WebhookManager",
@@ -9,5 +17,7 @@ __all__ = [
"WebhookEvent",
"WebhookEventType",
"ConsolidationEventData",
"MemoryDefenseEventData",
"MemoryDefenseHit",
"RetainEventData",
]
@@ -70,7 +70,10 @@ class WebhookManager:
webhook_table = _fq_table("webhooks", schema)
ops_table = _fq_table("async_operations", schema)
now = datetime.now(timezone.utc)
payload_str = event.model_dump_json()
# Drop null fields so receivers don't see promised-but-unfilled keys.
# OSS leaves SIEM-enrichment fields (severity, api_key_name, etc.) None
# because it doesn't have the data; cloud populates them when it does.
payload_str = event.model_dump_json(exclude_none=True)
try:
async with self._backend.acquire() as conn:
@@ -150,7 +153,10 @@ class WebhookManager:
webhook_table = _fq_table("webhooks", schema)
ops_table = _fq_table("async_operations", schema)
now = datetime.now(timezone.utc)
payload_str = event.model_dump_json()
# Drop null fields so receivers don't see promised-but-unfilled keys.
# OSS leaves SIEM-enrichment fields (severity, api_key_name, etc.) None
# because it doesn't have the data; cloud populates them when it does.
payload_str = event.model_dump_json(exclude_none=True)
try:
rows = await self._backend.ops.get_webhooks_for_dispatch(
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
class WebhookEventType(StrEnum):
CONSOLIDATION_COMPLETED = "consolidation.completed"
RETAIN_COMPLETED = "retain.completed"
MEMORY_DEFENSE_TRIGGERED = "memory_defense.triggered"
class ConsolidationEventData(BaseModel):
@@ -23,13 +24,52 @@ class RetainEventData(BaseModel):
tags: list[str] | None = None
class MemoryDefenseHit(BaseModel):
"""A single secret match inside a non-allow decision.
``preview`` is a fingerprinted, redaction-identifiable rendering of the
matched value (e.g. ``ghp_AAAA...BBBB``) so SIEM operators can correlate
against their credential inventory WITHOUT the raw secret crossing the
network. Implementations must never put the raw value here.
"""
detector: str # the inner detector that matched (e.g. "GitHub Token")
preview: str # fingerprinted value, never the raw secret
class MemoryDefenseEventData(BaseModel):
"""Payload for a memory_defense.triggered event (one item, one non-allow decision).
The four base fields (``action``/``detector``/``document_id``/``message``)
plus ``matched_types`` are populated by every implementation including OSS's
built-in regex defense. The remaining fields are optional SIEM-enrichment
surfaces that downstream extensions (e.g. hindsight-cloud) populate when
they have richer per-decision context severity classification, the API
key that submitted the retain, fingerprinted hit previews for SIEM
correlation, and pointers into the audit trail. OSS leaves them ``None``;
receivers should treat absence as "not provided" rather than "no match".
"""
action: str # "redact" or "block"
detector: str | None = None # e.g. "sensitive_data"
document_id: str | None = None
matched_types: list[str] | None = None # redaction pattern labels that fired
message: str | None = None
# --- Optional SIEM enrichment (populated by extensions, not OSS) ---
severity: str | None = None # "low" / "medium" / "high" / "critical"
api_key_name: str | None = None # human-readable name of the submitting API key
hits: list[MemoryDefenseHit] | None = None # per-match fingerprints for correlation
memory_unit_id: str | None = None # drill-down pointer (when the decision was REDACT)
receipt_uri: str | None = None # storage pointer for the audit trail entry
class WebhookEvent(BaseModel):
event: WebhookEventType
bank_id: str
operation_id: str
status: str # "completed" or "failed"
status: str # "completed"/"failed" for retain/consolidation; the action ("redact"/"block") for memory_defense
timestamp: datetime
data: ConsolidationEventData | RetainEventData
data: ConsolidationEventData | RetainEventData | MemoryDefenseEventData
class WebhookHttpConfig(BaseModel):
@@ -16,7 +16,7 @@ import time
import traceback
from collections import Counter
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from ..engine.schema import fq_table_explicit as fq_table
@@ -226,38 +226,23 @@ class WorkerPoller:
"""
async with self._backend.acquire() as conn:
if await self._optional_routines.is_installed(conn, "schemas_with_pending_work"):
# The routine IS the authority on where work exists: every schema
# it returns is claimable, and every schema it does NOT return is
# treated as having nothing to do this cycle. That is the entire
# point of installing it — one round-trip replaces N per-schema
# EXISTS probes. We deliberately do NOT re-verify the omitted
# schemas with a per-schema scan: that re-runs the exact queries
# the routine exists to avoid, on every idle poll, silently
# negating the optimisation.
#
# Because the result is trusted wholesale, the routine is only
# appropriate for multi-tenant deployments. A single-schema
# (default/public only) install should NOT create it and instead
# falls through to the per-schema path below — a single cheap
# EXISTS check that cannot starve. See
# ``hindsight_api.engine.db.optional_routines``.
rows = await conn.fetch("SELECT * FROM public.schemas_with_pending_work()")
routine_active = {self._normalize_poll_schema(r[0]) for r in rows}
known_schemas = set(schemas)
active = routine_active & known_schemas
unknown = routine_active - known_schemas
if unknown:
logger.warning(
"Optional PG routine public.schemas_with_pending_work() returned schema(s) "
"not present in tenant discovery: %s",
sorted(str(s) for s in unknown),
)
# The optional routine returns PostgreSQL schema names, but the poller uses
# None for the default schema. Older operator-supplied implementations also
# commonly scan tenant_% only; when the default schema is in scope but absent
# from the routine result, verify via the fully-correct per-schema fallback so
# public single-tenant deployments cannot silently starve.
should_verify_with_fallback = (None in known_schemas and None not in active) or (
bool(routine_active) and not active
)
if not should_verify_with_fallback:
return active
fallback_active = await self._scan_active_schemas_by_exists(conn, schemas)
missed = fallback_active - active
if missed:
logger.warning(
"Optional PG routine public.schemas_with_pending_work() missed claimable schema(s) %s; "
"using per-schema fallback for this poll",
sorted(str(s) for s in missed),
)
return fallback_active
return {self._normalize_poll_schema(r[0]) for r in rows}
return await self._scan_active_schemas_by_exists(conn, schemas)
@@ -824,7 +809,6 @@ class WorkerPoller:
recovered = 0
for row in rows:
operation_id = str(row["operation_id"])
task_payload = row["task_payload"]
result_metadata = row["result_metadata"]
# Parse metadata
@@ -838,12 +822,6 @@ class WorkerPoller:
f"Recovering batch operation: operation_id={operation_id}, batch_id={batch_id}, provider={batch_provider}"
)
# Parse task_payload
if isinstance(task_payload, str):
task_dict = json.loads(task_payload)
else:
task_dict = task_payload
# Mark operation as ready for re-processing
# Reset to pending with task_payload intact so worker picks it up again
async with self._backend.acquire() as conn:
+13 -7
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.8.0"
version = "0.8.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -81,6 +81,10 @@ local-ml = [
# Local ML models for embeddings/reranking
"sentence-transformers>=3.3.0",
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
# transformers (incl. latest 5.x) hard-requires tokenizers<=0.23.0 via a
# runtime check; without this cap an in-place upgrade can pull tokenizers
# 0.23.1 and break local embeddings/reranker startup. See issue #2055.
"tokenizers>=0.22.0,<=0.23.0",
"torch>=2.6.0", # CVE fix for remote code execution
"einops>=0.8.2",
"flashrank>=0.2.0",
@@ -100,6 +104,7 @@ local-onnx = [
# In-process ONNX Runtime embeddings without an Ollama/TEI sidecar
"onnxruntime>=1.17.0",
"transformers>=4.53.0",
"tokenizers>=0.22.0,<=0.23.0", # See issue #2055 (transformers caps tokenizers<=0.23.0)
"huggingface-hub>=0.20.0",
"numpy>=1.26.0",
]
@@ -182,23 +187,24 @@ dev = [
[tool.ruff]
line-length = 120
target-version = "py311"
exclude = [
"tests/",
"**/tests/",
]
[tool.ruff.lint]
# Tests are formatted (via `ruff format`) but excluded from lint rules, which
# are too noisy for test code (unused imports/vars, import ordering).
exclude = [
"tests/**",
"**/tests/**",
]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B021", # flake8-bugbear: f-string used as docstring (leaves __doc__ None)
]
ignore = [
"E501", # line too long (handled by formatter)
"E402", # module import not at top of file
"F401", # unused import (too noisy during development)
"F841", # unused variable (too noisy during development)
"F811", # redefined while unused
"F821", # undefined name (forward references in type hints)
]
+63 -29
View File
@@ -1,6 +1,7 @@
"""
Pytest configuration and shared fixtures.
"""
import asyncio
import os
from pathlib import Path
@@ -15,6 +16,27 @@ from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.engine.task_backend import SyncTaskBackend
from hindsight_api.pg0 import EmbeddedPostgres
from hindsight_api.tracing import unregister_span_recorder
async def _teardown_memory_engine(mem: MemoryEngine) -> None:
"""Tear down a test MemoryEngine, guaranteeing its span recorder is unregistered.
LLM-trace recorders live in a process-global registry; ``MemoryEngine.close()`` is
the only thing that removes the engine's recorder from it. If close() is skipped
(pool already closing) or raises before that step, the recorder leaks and a later
test's LLM calls get recorded into the shared DB — the flaky
test_llm_trace::test_disabled_writes_no_rows (#2229). Unregister unconditionally;
it's a no-op when close() already did it.
"""
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
finally:
unregister_span_recorder(mem._llm_recorder)
# Default pg0 instance configuration for tests
DEFAULT_PG0_INSTANCE_NAME = "hindsight-test"
@@ -76,6 +98,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
if db_url and not _parse_pg0_url(db_url)[0]:
# Plain postgresql:// URL - use it directly but still run migrations
from hindsight_api.migrations import run_migrations
run_migrations(db_url)
return db_url
@@ -127,6 +150,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
# Run migrations - uses PostgreSQL advisory lock internally,
# so safe to call from multiple workers (only one will actually run migrations)
from hindsight_api.migrations import run_migrations
run_migrations(url)
# Clean up stale test data from previous sessions. Per-bank vector indexes
@@ -157,8 +181,7 @@ def _cleanup_stale_test_data(db_url: str) -> None:
conn = await asyncpg.connect(db_url)
try:
idx_rows = await conn.fetch(
"SELECT indexname FROM pg_indexes "
"WHERE schemaname = 'public' AND indexname LIKE 'idx_mu_emb_%'"
"SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexname LIKE 'idx_mu_emb_%'"
)
if idx_rows:
for row in idx_rows:
@@ -166,10 +189,20 @@ def _cleanup_stale_test_data(db_url: str) -> None:
# Truncate test data in dependency order
for table in [
"entity_cooccurrences", "unit_entities", "memory_links",
"entities", "memory_units", "chunks", "documents",
"mental_models", "directives", "async_operations",
"audit_log", "webhooks", "file_storage", "banks",
"entity_cooccurrences",
"unit_entities",
"memory_links",
"entities",
"memory_units",
"chunks",
"documents",
"mental_models",
"directives",
"async_operations",
"audit_log",
"webhooks",
"file_storage",
"banks",
]:
try:
await conn.execute(f"TRUNCATE {table} CASCADE")
@@ -252,8 +285,7 @@ def oracle_db_url(_oracle_admin_dsn):
# Create test user (idempotent — skip if already exists)
try:
cursor.execute(
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" '
f"DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS"
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS'
)
except oracledb.DatabaseError as e:
if hasattr(e.args[0], "code") and e.args[0].code == 1920:
@@ -331,10 +363,7 @@ async def oracle_memory(oracle_db_url, embeddings, cross_encoder, query_analyzer
)
await mem.initialize()
yield mem
try:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
finally:
# Restore original env var and clear config cache
if old_backend is None:
@@ -420,13 +449,12 @@ def cross_encoder(tmp_path_factory, worker_id):
return ce
@pytest.fixture(scope="session")
def query_analyzer():
return DateparserQueryAnalyzer()
@pytest_asyncio.fixture(scope="function")
async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""
@@ -453,11 +481,7 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
@pytest_asyncio.fixture(scope="function")
@@ -486,11 +510,7 @@ async def memory_real_llm(pg0_db_url, embeddings, cross_encoder, query_analyzer)
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
@pytest_asyncio.fixture(scope="function")
@@ -517,8 +537,22 @@ async def memory_no_llm_verify(pg0_db_url, embeddings, cross_encoder, query_anal
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
await _teardown_memory_engine(mem)
@pytest_asyncio.fixture
async def api_client(memory):
"""General-purpose HTTP test client over the `memory` fixture's app.
Use for any integration test that exercises the FastAPI surface without
needing audit-logging side effects. See `audit_api_client` for the
audit-enabled variant.
"""
import httpx
from hindsight_api.api import create_app
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
+4 -2
View File
@@ -23,6 +23,7 @@ from urllib.parse import urlparse
# Helpers
# ---------------------------------------------------------------------------
def _log(step: int, total: int, msg: str) -> None:
print(f" [{step}/{total}] {msg}")
@@ -64,8 +65,7 @@ def _bootstrap_test_user(admin_dsn: dict[str, str]) -> str:
# Create user (skip if already exists - ORA-01920)
try:
cursor.execute(
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" '
f"DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS"
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS'
)
except oracledb.DatabaseError as e:
if hasattr(e.args[0], "code") and e.args[0].code == 1920:
@@ -100,6 +100,7 @@ def _bootstrap_test_user(admin_dsn: dict[str, str]) -> str:
# Main
# ---------------------------------------------------------------------------
async def _run() -> None:
total_steps = 8
@@ -290,6 +291,7 @@ def main() -> int:
except Exception as exc:
print(f"\nFAILED: {exc}", file=sys.stderr)
import traceback
traceback.print_exc()
return 1
+2 -4
View File
@@ -87,7 +87,7 @@ async def _judge_once(
"content": (
"You are a test evaluation judge. Given a response and evaluation criteria, "
"determine whether the response meets the criteria. "
"Respond with JSON: {\"meets_criteria\": true/false, \"reasoning\": \"brief explanation\"}"
'Respond with JSON: {"meets_criteria": true/false, "reasoning": "brief explanation"}'
),
},
{
@@ -163,9 +163,7 @@ async def evaluate(
if met > not_met:
agreeing = next(v for v in verdicts if v.meets_criteria)
logger.info(
f"Judge: primary 'not met' overruled by majority ({met}/{len(verdicts)} met). Criteria: {criteria}"
)
logger.info(f"Judge: primary 'not met' overruled by majority ({met}/{len(verdicts)} met). Criteria: {criteria}")
return JudgeVerdict(
meets_criteria=True,
reasoning=f"Majority of {len(verdicts)} judges met criteria (primary verdict overruled as noise). {agreeing.reasoning}",
+9 -22
View File
@@ -1,6 +1,7 @@
"""
Tests for agent management API (profile, disposition).
"""
import pytest
import uuid
from hindsight_api import MemoryEngine, RequestContext
@@ -17,9 +18,7 @@ class TestAgentProfile:
"""Tests for agent profile management."""
@pytest.mark.asyncio
async def test_get_bank_profile_no_auto_create_returns_none(
self, memory: MemoryEngine, request_context
):
async def test_get_bank_profile_no_auto_create_returns_none(self, memory: MemoryEngine, request_context):
"""When create_if_missing=False is passed, a missing bank returns None
rather than being silently auto-created. This is what read-only
endpoints (HTTP GET, polling, etc.) must use to avoid creating banks
@@ -27,28 +26,20 @@ class TestAgentProfile:
bank_id = unique_agent_id("test_no_auto_create")
# First call with create_if_missing=False on a non-existent bank
result = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
result = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False)
assert result is None, "Expected None for missing bank with create_if_missing=False"
# Verify the bank was NOT created as a side effect
result_again = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
result_again = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False)
assert result_again is None, "Bank must not exist after read-only call"
# And explicit auto-create still works
created = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=True
)
created = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=True)
assert created is not None
assert created["disposition"]["skepticism"] == 3
# Now read-only call sees it
seen = await memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
seen = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False)
assert seen is not None
assert seen["disposition"]["skepticism"] == 3
@@ -122,11 +113,7 @@ class TestAgentEndpoint:
bank_id = unique_agent_id("test_put_create")
request = CreateBankRequest(
disposition=DispositionTraits(
skepticism=4,
literalism=5,
empathy=2
),
disposition=DispositionTraits(skepticism=4, literalism=5, empathy=2),
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -155,7 +142,7 @@ class TestAgentDispositionIntegration:
disposition = {
"skepticism": 5, # Very skeptical
"literalism": 4, # High literalism
"empathy": 2, # Low empathy
"empathy": 2, # Low empathy
}
await memory.update_bank_disposition(bank_id, disposition, request_context=request_context)
@@ -163,7 +150,7 @@ class TestAgentDispositionIntegration:
bank_id=bank_id,
contents=[
{"content": "Traditional painting techniques have been used for centuries"},
{"content": "Modern digital art is changing the art world"}
{"content": "Modern digital art is changing the art world"},
],
request_context=request_context,
)
@@ -0,0 +1,331 @@
"""
Tests for per-bank provider cost attribution.
Covers the opt-in `HINDSIGHT_API_LLM_SEND_BANK_AS_USER` plumbing that tags
outbound OpenAI-compatible LLM and embedding calls with `user=<bank_id>`, the
`_current_bank_id` engine ContextVar that carries the bank across the async call
chain, and its propagation into the embedding executor thread.
All deterministic no network, stdlib/pytest only.
"""
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.engine.bank_attribution import apply_bank_attribution
from hindsight_api.engine.embeddings import OpenAIEmbeddings
from hindsight_api.engine.memory_engine import (
_bind_bank_id,
_current_bank_id,
get_current_bank_id,
)
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
from hindsight_api.engine.retain.embedding_utils import generate_embeddings_batch
@pytest.fixture(autouse=True)
def restore_send_bank_env():
"""Save/restore the attribution env var and clear the cached config."""
from hindsight_api.config import clear_config_cache
original = os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER")
clear_config_cache()
yield
if original is None:
os.environ.pop("HINDSIGHT_API_LLM_SEND_BANK_AS_USER", None)
else:
os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = original
clear_config_cache()
def _set_flag(enabled: bool) -> None:
from hindsight_api.config import clear_config_cache
os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = "true" if enabled else "false"
clear_config_cache()
# ── ContextVar lifecycle ──────────────────────────────────────────────────────
class TestBankContextVar:
def test_default_is_none(self):
assert get_current_bank_id() is None
def test_set_and_reset(self):
token = _current_bank_id.set("user-42")
try:
assert get_current_bank_id() == "user-42"
finally:
_current_bank_id.reset(token)
assert get_current_bank_id() is None
def test_reset_runs_even_on_exception(self):
"""A finally-based reset must unwind the binding even when the body raises."""
token = _current_bank_id.set("user-boom")
try:
with pytest.raises(ValueError):
try:
assert get_current_bank_id() == "user-boom"
raise ValueError("boom")
finally:
_current_bank_id.reset(token)
finally:
pass
assert get_current_bank_id() is None
class TestBindBankIdDecorator:
"""The engine binds the bank via @_bind_bank_id on recall/retain/batch/task methods."""
async def test_binds_named_arg_positional_and_keyword(self):
@_bind_bank_id()
async def op(bank_id: str, query: str) -> str | None:
return get_current_bank_id()
assert await op("user-pos", "q") == "user-pos"
assert await op(bank_id="user-kw", query="q") == "user-kw"
assert get_current_bank_id() is None
async def test_extracts_dict_key(self):
@_bind_bank_id("task_dict", key="bank_id")
async def op(task_dict: dict) -> str | None:
return get_current_bank_id()
assert await op({"bank_id": "user-task", "type": "consolidation"}) == "user-task"
assert await op({"type": "consolidation"}) is None
assert get_current_bank_id() is None
async def test_resets_on_exception(self):
@_bind_bank_id()
async def op(bank_id: str) -> None:
assert get_current_bank_id() == "user-boom"
raise ValueError("boom")
with pytest.raises(ValueError):
await op("user-boom")
assert get_current_bank_id() is None
async def test_non_string_value_binds_none(self):
@_bind_bank_id()
async def op(bank_id: object) -> str | None:
return get_current_bank_id()
assert await op(12345) is None
# ── LLM provider: user injection ──────────────────────────────────────────────
class _SimpleJson(BaseModel):
ok: bool
def _llm() -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider="openai",
api_key="test-key",
base_url="https://example.test/v1",
model="gpt-4o-mini",
)
def _chat_response(content: str = '{"ok": true}'):
choice = SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(content=content, tool_calls=None, refusal=None),
)
return SimpleNamespace(choices=[choice], usage=None, error=None)
async def _call(llm: OpenAICompatibleLLM, create: AsyncMock):
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
return await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
)
async def test_user_injected_when_flag_on_and_bank_set():
_set_flag(True)
llm = _llm()
create = AsyncMock(return_value=_chat_response())
token = _current_bank_id.set("user-7")
try:
await _call(llm, create)
finally:
_current_bank_id.reset(token)
assert create.call_args.kwargs["user"] == "user-7"
async def test_user_not_injected_when_flag_off():
_set_flag(False)
llm = _llm()
create = AsyncMock(return_value=_chat_response())
token = _current_bank_id.set("user-7")
try:
await _call(llm, create)
finally:
_current_bank_id.reset(token)
assert "user" not in create.call_args.kwargs
async def test_user_not_injected_when_bank_unset():
_set_flag(True)
llm = _llm()
create = AsyncMock(return_value=_chat_response())
# No bank bound in context.
assert get_current_bank_id() is None
await _call(llm, create)
assert "user" not in create.call_args.kwargs
async def test_caller_set_user_is_not_overridden():
"""The helper never clobbers a `user` the caller already placed in call_params."""
_set_flag(True)
# Simulate a caller-provided user via the centralized helper directly.
params = {"user": "explicit-user"}
token = _current_bank_id.set("user-7")
try:
apply_bank_attribution(params)
finally:
_current_bank_id.reset(token)
assert params["user"] == "explicit-user"
async def test_user_injected_in_tool_calling_path():
"""call_with_tools() builds its own call_params; attribution must reach it too."""
_set_flag(True)
llm = _llm()
tool_response = SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(content="done", tool_calls=None, refusal=None, reasoning_content=None),
)
],
usage=None,
error=None,
)
create = AsyncMock(return_value=tool_response)
llm._client.chat.completions.create = create
token = _current_bank_id.set("user-tools")
try:
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call_with_tools(
messages=[{"role": "user", "content": "ping"}],
tools=[{"type": "function", "function": {"name": "noop", "parameters": {}}}],
max_retries=0,
)
finally:
_current_bank_id.reset(token)
assert create.call_args.kwargs["user"] == "user-tools"
# ── Embeddings: user injection ─────────────────────────────────────────────────
def _openai_embeddings() -> OpenAIEmbeddings:
emb = OpenAIEmbeddings(api_key="sk-test", model="text-embedding-3-small", batch_size=100)
emb._dimension = 1536
return emb
def _fake_embed_client(captured: list[dict]):
def fake_create(**kwargs):
captured.append(kwargs)
n = len(kwargs["input"])
return SimpleNamespace(data=[SimpleNamespace(index=i, embedding=[0.0] * 1536) for i in range(n)])
return SimpleNamespace(embeddings=SimpleNamespace(create=fake_create))
def test_embeddings_user_injected_when_flag_on_and_bank_set():
_set_flag(True)
emb = _openai_embeddings()
captured: list[dict] = []
emb._client = _fake_embed_client(captured)
token = _current_bank_id.set("user-emb")
try:
emb.encode(["hello"])
finally:
_current_bank_id.reset(token)
assert captured[0]["user"] == "user-emb"
def test_embeddings_user_not_injected_when_flag_off():
_set_flag(False)
emb = _openai_embeddings()
captured: list[dict] = []
emb._client = _fake_embed_client(captured)
token = _current_bank_id.set("user-emb")
try:
emb.encode(["hello"])
finally:
_current_bank_id.reset(token)
assert "user" not in captured[0]
def test_embeddings_user_not_injected_when_bank_unset():
_set_flag(True)
emb = _openai_embeddings()
captured: list[dict] = []
emb._client = _fake_embed_client(captured)
assert get_current_bank_id() is None
emb.encode(["hello"])
assert "user" not in captured[0]
# ── Executor context propagation ──────────────────────────────────────────────
class _BankCapturingBackend:
"""Embeddings backend whose encode records the bank id visible at call time.
The real `generate_embeddings_batch` offloads encode to a thread via
run_in_executor; this verifies the bank ContextVar survives that thread hop.
"""
dimension = 1
def __init__(self) -> None:
self.seen_bank_id: str | None = "UNSET"
def encode_documents(self, texts: list[str]) -> list[list[float]]:
self.seen_bank_id = get_current_bank_id()
return [[0.0] for _ in texts]
def encode_query(self, texts: list[str]) -> list[list[float]]:
return self.encode_documents(texts)
async def test_executor_propagates_bank_contextvar_into_worker_thread():
backend = _BankCapturingBackend()
token = _current_bank_id.set("user-thread")
try:
vectors = await generate_embeddings_batch(backend, ["a", "b"], input_type="document")
finally:
_current_bank_id.reset(token)
assert backend.seen_bank_id == "user-thread"
assert len(vectors) == 2
async def test_executor_length_validation_preserved():
"""The 1:1 alignment guard must still fire after the context-aware offload."""
class _ShortBackend:
dimension = 1
def encode_documents(self, texts: list[str]) -> list[list[float]]:
return [[0.0]] # one vector for two inputs
def encode_query(self, texts: list[str]) -> list[list[float]]:
return self.encode_documents(texts)
with pytest.raises(Exception, match="expected exact 1:1 alignment"):
await generate_embeddings_batch(_ShortBackend(), ["a", "b"], input_type="document")
@@ -0,0 +1,132 @@
"""
Config wiring for per-bank attribution and the configurable OpenRouter rerank URL.
- HINDSIGHT_API_LLM_SEND_BANK_AS_USER (default off, opt-in bool)
- HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL (default = previously hardcoded URL)
Deterministic, no network.
"""
import os
from dataclasses import fields
from unittest.mock import patch
from hindsight_api.config import DEFAULT_RERANKER_OPENROUTER_BASE_URL, HindsightConfig
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
def _restore_env(saved: dict[str, str | None]) -> None:
from hindsight_api.config import clear_config_cache
for key, value in saved.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
clear_config_cache()
def _make_full_config(**overrides):
"""Build a complete HindsightConfig from type-based defaults plus overrides.
Mirrors the helper in test_reranker_timeouts.py so we can exercise the
factory without touching real env/config.
"""
defaults: dict = {}
for f in fields(HindsightConfig):
if f.type == "str":
defaults[f.name] = ""
elif f.type == "str | None":
defaults[f.name] = None
elif f.type == "int":
defaults[f.name] = 0
elif f.type == "int | None":
defaults[f.name] = None
elif f.type == "float":
defaults[f.name] = 0.0
elif f.type == "float | None":
defaults[f.name] = None
elif f.type == "bool":
defaults[f.name] = False
else:
defaults[f.name] = None
defaults.update(overrides)
return HindsightConfig(**defaults)
class TestSendBankAsUserConfig:
def test_default_is_false(self):
from hindsight_api.config import clear_config_cache
saved = {"HINDSIGHT_API_LLM_SEND_BANK_AS_USER": os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER")}
os.environ.pop("HINDSIGHT_API_LLM_SEND_BANK_AS_USER", None)
clear_config_cache()
try:
assert HindsightConfig.from_env().llm_send_bank_as_user is False
finally:
_restore_env(saved)
def test_true_enables(self):
from hindsight_api.config import clear_config_cache
saved = {"HINDSIGHT_API_LLM_SEND_BANK_AS_USER": os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER")}
os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = "true"
clear_config_cache()
try:
assert HindsightConfig.from_env().llm_send_bank_as_user is True
finally:
_restore_env(saved)
def test_one_enables(self):
from hindsight_api.config import clear_config_cache
saved = {"HINDSIGHT_API_LLM_SEND_BANK_AS_USER": os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER")}
os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = "1"
clear_config_cache()
try:
assert HindsightConfig.from_env().llm_send_bank_as_user is True
finally:
_restore_env(saved)
class TestRerankerOpenRouterBaseUrlConfig:
def test_default_matches_previously_hardcoded_url(self):
from hindsight_api.config import clear_config_cache
saved = {
"HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL": os.environ.get("HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL")
}
os.environ.pop("HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL", None)
clear_config_cache()
try:
config = HindsightConfig.from_env()
assert config.reranker_openrouter_base_url == DEFAULT_RERANKER_OPENROUTER_BASE_URL
assert config.reranker_openrouter_base_url == "https://openrouter.ai/api/v1/rerank"
finally:
_restore_env(saved)
def test_env_override_is_read(self):
from hindsight_api.config import clear_config_cache
saved = {
"HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL": os.environ.get("HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL")
}
os.environ["HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL"] = "https://gateway.internal/v1/rerank"
clear_config_cache()
try:
assert HindsightConfig.from_env().reranker_openrouter_base_url == "https://gateway.internal/v1/rerank"
finally:
_restore_env(saved)
def test_factory_threads_configured_base_url_into_cross_encoder(self):
"""create_cross_encoder_from_env() honors the configured OpenRouter rerank URL."""
config = _make_full_config(
reranker_provider="openrouter",
reranker_openrouter_api_key="k",
reranker_openrouter_model="cohere/rerank-v3.5",
reranker_openrouter_base_url="https://gateway.internal/v1/rerank",
reranker_openrouter_timeout=60.0,
)
with patch("hindsight_api.config.get_config", return_value=config):
encoder = create_cross_encoder_from_env()
assert encoder.base_url == "https://gateway.internal/v1/rerank"

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