mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
docs-skill-openapi-0.10.0
207 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5d46f9c8c8 |
Release v0.10.0
- Update version to 0.10.0 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Create documentation version-0.10 |
||
|
|
88456981f6 |
fix(api): declare binary download bodies so generated clients return bytes (#4315)
* fix(api): declare binary download bodies so generated clients return bytes (#4292) GET attachments/{id} and files/download/{key} returned a raw Response with no response_class, so FastAPI added application/json next to the declared binary media type. The generated clients picked JSON and decoded the body as text, corrupting the bytes (UnicodeDecodeError on a PNG's 0x89 magic byte). Set response_class=Response and a string/binary schema on both routes, and regenerate the spec and clients (Python now returns bytearray, TS Blob). Tests: an api-slim spec guard that no 200 response mixes application/json with a binary media type, and a live-server Python client test that retains an inline PNG and fetches it back byte-identical. * chore(docs-skill): regenerate the skill's OpenAPI copy for the binary download schema |
||
|
|
e1310d34f9 |
feat(api,control-plane): recall results carry the attachments behind each fact (#4277)
* feat(api,control-plane): recall results carry the attachments behind each fact A recall result reported no attachments, so an agent that recalled a fact derived from a screenshot had no way to show it. The only handle available was `include.chunks`, and going through the chunk is wrong: a chunk lists every attachment its text references, so a fact drawn from the prose beside a screenshot would be shown that screenshot as its evidence. Recall now returns `attachments[]` on each result, resolved from the per-fact edge the extractor recorded at retain time — the same edge the memory read endpoints already return. It is unconditional rather than another `include` flag: the ids live on `memory_units.attachment_ids`, so a bank that has retained no attachments pays one indexed read that resolves nothing. The Recall Analyzer renders them beneath each result, and drops the score breakdown row in favour of entity and tag chips (the shared facet chips) plus the occurred/mentioned timestamps — the per-signal scores are a retrieval debugging concern and are already in the Trace tab. Scores render at four significant digits with the exact value on hover; fixed decimals would collapse 0.001125 and 0.001004 to the same "0.001", which is why they were unrounded before. Entities are included by default because that flag gates the entity names on each result, not just the observations block. * chore: regenerate the docs-skill OpenAPI reference |
||
|
|
565303d913 |
docs(documents): say the tags PATCH replaces the array, and test clearing it (#4272)
* test(documents): cover clearing a document's tags with an empty array
The tags PATCH replaces the array rather than merging it, so `tags: []` is how
a caller drops every tag. Every guard on that path is written `is not None`
rather than a truthiness check so the empty list survives it, but nothing
exercised it: a regression to `if tags:` would have turned a clear into a
silent no-op and a 200.
Adds an engine test (clears the document's tags and its units', runs the same
observation-invalidation cascade, and is a no-op when repeated) and an HTTP test
(PATCH `{"tags": []}` is 200, an omitted `tags` is still 422).
* docs(documents): say that the tags PATCH replaces the array
The endpoint description and the docs page both said only that tags are
"propagated to all associated memory units", which leaves the question a caller
actually has — does sending a tag ADD it, and how do I drop one — unanswered.
The replace semantics were documented in exactly one place: two comments in the
CLI tab of the docs page, which an API or SDK user never reads.
States it where they will see it: the array replaces rather than merges, an
omitted tag is dropped, `[]` clears them all, and only an omitted FIELD is the
422. Regenerates the spec, the clients and the docs skill.
|
||
|
|
f5b3f76a8d |
fix(reflect): say when the structured-output extraction failed (#4230) (#4248)
Reflect with a `response_schema` runs a second LLM call that reshapes the prose answer into the caller's schema. When that call errored or returned something unparseable, the bare `except` swallowed it and the caller got 200 with `structured_output: null` — indistinguishable from an answer that genuinely held nothing matching the schema. The machine-readable half, which is the reason a caller supplied a schema at all, failed invisibly: no retry signal, no alert. Returning 200 with the text answer is still right; the missing piece was saying the structured half did not happen. `StructuredOutputResult` now carries an `error`, and reflect surfaces it as a nullable `structured_output_error` on the response. Present => the extraction broke (retryable); absent with a null `structured_output` => nothing to extract. The mental-model refresh path uses the same helper and now records the reason in its `structured_output_failed` failure detail instead of only "extraction failed". |
||
|
|
1081a2ea4f |
fix(api): expose the bank template import request body (#4247)
`POST /v1/default/banks/{bank_id}/import` read its manifest off the raw `Request`, so
FastAPI emitted no `requestBody` for the operation and every generated SDK's
`import_bank_template` had no parameter to send the manifest in — export returned a
typed `BankTemplateManifest` with nowhere to send it, and import was reachable only by
dropping to raw HTTP.
The schema is published via `openapi_extra` rather than by declaring
`manifest: BankTemplateManifest` as a parameter, which would hand validation to FastAPI
and turn the endpoint's established 400 responses into 422s. The handler is unchanged.
All three generated clients now express the body: Python gains a required
`bank_template_manifest` argument and the `Content-Type` header, Go gains
`BankTemplateManifest()` with a nil guard, and TypeScript's `ImportBankTemplateData.body`
goes from `never` to `BankTemplateManifest`.
Declaring a body also puts the four manifest fields in scope for `cli-coverage-check`;
they are recorded as CLI-skipped because `bank import-template` takes the whole manifest
as a JSON file, so flattening it into flags would defeat the export/import round trip.
Supersedes #4238.
Fixes #4232
|
||
|
|
ef3ccdba3a |
fix(api): type the list and graph rows instead of returning bare dicts (#4218) (#4233)
* fix(api): type the list and graph rows instead of returning bare dicts (#4218) `list_memories`, `list_documents`, `get_graph` and `get_entity_graph` declared their rows as `dict[str, Any]`, so every generated SDK handed callers untyped dicts while the single-fetch siblings returned real models — `listing.items[0].id` failed with an `AttributeError` and a server-side rename became a runtime `KeyError` rather than a build error. Each row now has a model (`DocumentListItem`, `MemoryUnitListItem`, the Cytoscape node/edge envelopes and `MemoryGraphTableRow`), sharing an `OpenRowModel` base that keeps the wire byte-identical: - `extra="allow"`, so a key the server emits and the model does not declare still reaches the client — a memories store that owns its own document or entity registry builds these rows itself. - the routes keep emitting nulls. `ExcludeNoneRoute` was already enabling `response_model_exclude_none` for them, but `exclude_none` never reached inside a `dict` value, so the rows' nulls were always on the wire; typing them would have started dropping those keys. `additionalProperties` is stripped from the published schema: openapi-generator 7.10.0's Python generator crashes on a schema pairing it with a nullable `anyOf` property, which every row here has. The CLI moves to attribute access, which exposes a latent bug in `bank graph`: its node lookups read `node["type"]`/`node["id"]` through the Cytoscape `data` envelope, so the sample always printed "unknown [unknown]" with no text. * docs(examples): read list rows by attribute now that they are typed |
||
|
|
fb94ce0341 |
feat(mental-models): default list to metadata; MCP list returns metadata only (#4225)
* feat(mental-models): default list to metadata; MCP list returns metadata only Listing mental models defaulted to returning every model's full synthesized content (and reflect_response). That bloats a caller's context and lets a single list call pull an entire bank's synthesized knowledge in bulk, when the intended way to read a model's content is the single-model read. - MCP list_mental_models tool: returns metadata only (id, name, tags, staleness); the `detail` parameter is removed. An agent discovers models here and reads a specific model's content with get_mental_model. - HTTP GET .../mental-models: `detail` now defaults to `metadata` instead of `full`. Content stays available opt-in via `detail=content`/`full`, and when requested it is delivered and metered the same as a single-model read. - Engine list_mental_models is unchanged and still honors `detail` for internal callers (bank-template export/import need full content). - Regenerated OpenAPI + clients (Python/TypeScript/Go). Tests: the MCP tool is metadata-only with no `detail` param; the HTTP list defaults to metadata and returns content only when detail=content is passed; is_stale is still reported per model on the list. * fix(mental-models): follow through on the list default flip in every caller Flipping the list endpoint's `detail` default from `full` to `metadata` left the callers that were relying on the old default reading nulls. - Control plane: `MentalModelsView` now asks for `detail=content` — it renders the content preview, source query and trigger chips, and seeds the update dialog from the listed row, so metadata alone crashed the search filter (`m.source_query.toLowerCase()` on null) and would have clobbered every trigger setting on save. The search filter is null-guarded too. - CLI: `hindsight mental-model list` asks for `content` (`--verbose` → `full`), restoring the per-row preview and keeping `--output json` useful to scripts. - Docs: the detail-levels table said `full (default)` for both endpoints and showed a `detail` argument on the `list_mental_models` MCP tool that no longer exists; the three SDK list examples printed `source_query` off a default list. Added an upgrade note. - Wrapper clients: the Python docstring still promised a server-side `full` default; the TS one said nothing. - Dropped the "metered the same as a single-model read" claim from the endpoint docstring — a `detail=content` list still validates as one `LIST_MENTAL_MODELS` bank read, not one read per model. * fix(hindsight-all): let the facade ask for mental-model content `mental_models.list()` in both facade paths (the client wrapper and the embedded namespaces) forwarded no `detail`, so after the list default flipped to metadata a hindsight-all caller got content-free rows with no way to ask for more — the one wrapper where the capability was not just defaulted away but unreachable. Forwards `detail` like the TypeScript and Python wrappers do. --------- Co-authored-by: Nicolò Boschi <boschi1997@gmail.com> |
||
|
|
9e6d9e76cc |
feat(api,control-plane): a prompt tester for retain, and prompt preview for every operation (#4140)
* feat(api,control-plane): a prompt tester for retain, and prompt preview for every operation Closes the "render prompts without calling an LLM" half of #3774. A bank's missions only mean something once you can see the prompt they land in, and today that means tracing Python constants and format calls. `POST /banks/{id}/prompts/preview` returns the messages retain, consolidation or reflect would send — in send order, no LLM call, no writes. The operation is the whole request: everything comes from the bank, and the runtime data an operation would be given is a fixed placeholder. A message arrives as `blocks`. The active ones concatenate back to the exact text sent — enforced by a test against what the extraction path itself builds. Each is identified by machine values only (`field`, a `section` slug, or the `heading` the prompt text carries); the response ships no display copy, so names and explanations live in the UI that localises them. An inactive block has no text and marks a setting switched off at the point it would land, so an unset mission is still visible where it would go. Both messages always come back: retain and consolidation keep their system prompt bank-agnostic so one provider-side cache serves every bank, and carry the mission in the user message instead. Only reflect puts its mission in the system prompt. **Two bugs found on the way, both pre-existing in dry-run extraction:** - Neither dry-run nor the preview applied retain strategies. Both resolved config directly instead of through `_resolve_retain_config`, so they ignored the bank's `retain_default_strategy` too — silently extracting and previewing under settings a real retain would never use. Both now resolve the way retain does and take an optional `strategy`. - The preview read the bank's config without running `validate_bank_read`, so a tenant extension denying `GET_BANK_CONFIG` was bypassed by an endpoint that renders that config as prompt text. Both paths now share `_authorize_bank_config_read`, which also carries the bank-existence check. **Control plane.** The dry-run dialog is gone; its work moved into the prompt tester on the bank Configuration tab, because changing a mission and seeing what it extracts is one loop that was split across two dialogs. Blocks re-render for free as settings change; a sample-text box and a Run button spend the LLM call on demand. A strategy picker renders any of the bank's named strategies. Editing a block saves that setting to the bank; `editable` comes from the config layer's own allowlist, so server-level fields say so rather than offering an edit that would collect a 400. `PromptBlockModel.kind` is required with no default: progenitor rejects a default on an inline enum with TypeError(InvalidValue), which breaks the Rust client. * chore(cli): skip preview_prompt in the OpenAPI coverage manifest |
||
|
|
66992496f5 |
fix(api): 404 bank-scoped reads for a bank that does not exist (#4175) (#4186)
* fix(api): 404 bank-scoped reads for a bank that does not exist (#4175) GET /stats and GET /memories/list answered 200 with zeroed counters and an empty page for a bank nobody ever created — byte-identical to a healthy, empty bank. A monitor built on either kept passing after the bank it watched was renamed, deleted or recreated under another id, and a typo in bank_id was never surfaced. The same held for every other bank-scoped aggregate/list read: /graph, /stats/memories-timeseries, /entities, /entities/graph, /mental-models, /knowledge-base/{tree,export,search}, /directives, /documents, /tags, /operations, /observations/scopes, /config and /webhooks. (Sub-resource GETs already 404 on the missing child.) Each of those engine reads now calls _require_bank_exists after its own authentication and read authorization, so the check neither widens what a request may see nor creates the bank; the profile row is cached per process, so an existing bank costs no extra query. The 404 is declared in the OpenAPI spec on those operations, so generated clients have a documented missing-bank case. The Rust client's build script drops schema-less error responses first: progenitor models at most one error type per operation and the typed 422 is the one worth keeping. * fix(api): derive the 404 endpoint list from the routing table, not by hand Two follow-ups to the same fix. The regression test enumerated the 17 bank-scoped reads by hand, so a bank-scoped collection GET added later would be covered the day someone remembered to extend the file — exactly the sibling-parity trap the reviewed change is about. It now walks the app's own routes and asserts the contract over every one of them, with two commented exemptions (/document-transfer and /profile, both withdrawn endpoints that answer 410 Gone for every bank). The scan immediately found both, which the hand-written list had missed. The rebase onto main also landed the /profile removal underneath the earlier commit, leaving a declared 404 on an endpoint that can now only ever return 410. Dropped it, and regenerated the spec and clients. * fix(tests): create the bank in tests that read a bank they never created CI found three suites that reached an engine read with no bank row, which the new 404 turns from an empty result into an error. All three are test setup gaps, not behaviour the fix gets wrong — a real deployment always has the row, because every write path (retain included) creates it before anything else exists. - test_memories_extension: a store owns the facts, never the bank row itself, so the three seam reads now create the bank the way a retain would. - test_schema_isolation: the bank row goes into each tenant schema alongside the memory_units row it inserts directly — "created" is per schema, which is part of what the test is about. - test_knowledge_search_text_search_disabled: this engine is stubbed down to the one method under test and has no real pool, so the existence read is stubbed alongside _authenticate_tenant. Also carries the docs-skill copy of the OpenAPI spec, which verify-generated-files caught: it mirrors hindsight-docs/static/openapi.json and was left behind when the /profile 404 was dropped. |
||
|
|
b1de1b9418 |
fix(operations): allow cancelling in-flight operations (#4131)
`DELETE /v1/default/banks/{bank}/operations/{id}` only accepted `pending`
operations, so an operation stranded in `processing` — orphaned when a worker
was killed before it could write a terminal status — could only be cleared by
hand-editing `async_operations` and restarting the container.
Cancel now accepts `processing` too. It stays cooperative and is never
immediate: the row is flipped to `cancelled` and the worker running it stops at
its next `_check_op_alive` checkpoint (between retain sub-batches/documents,
between consolidation LLM batches). For the orphaned case nothing is running, so
the flip is the whole fix. No heartbeat and no per-batch bookkeeping is added.
Making the flip stick required guarding the worker writes that had none, and
would otherwise overwrite it:
- `_schedule_retry` — the one that actually resurrected cancelled work: a task
failing after cancellation went back to `pending` and was re-claimed.
- `_mark_failed` (poller and engine), `_defer_operation`.
`_mark_completed` already guarded on `status='processing'`; tests now pin it.
The sibling rollup counted only `completed`/`failed` as done, so a cancelled
child stranded its `batch_retain` parent in `processing` forever — the same
wedge one level up. Both rollup copies now treat `cancelled` as done and settle
the parent on `cancelled` (a real failure still outranks it), cancel performs
the rollup itself so cancelling the last outstanding child terminalizes the
parent, and a cancelled parent is never flipped back by a child finishing later.
Control plane: the Cancel button was gated on `pending`, hiding the fix from the
UI an operator would reach for. It now shows for `processing` rows too.
|
||
|
|
163fbb0ede |
feat(api)!: retire the bank profile and background endpoints (#4127)
* feat(api)!: retire the bank profile and background endpoints
GET/PUT /v1/default/banks/{bank_id}/profile and
POST /v1/default/banks/{bank_id}/background have been deprecated for
several releases. They now answer 410 Gone with the replacement call in
the detail, joining the two endpoints (entity regenerate, synchronous
document export) that already do.
The routes stay in the OpenAPI spec with unchanged signatures, so no
generated SDK method disappears from under a caller — only the behaviour
changes.
Disposition traits and the reflect mission are bank configuration, and
already were: _get_bank_profile_authenticated overlaid config on top of
the legacy DB columns. The `name` these endpoints also returned is a
display-only label available on the bank list.
To make the config API a complete replacement, GET .../config is no
longer gated on HINDSIGHT_API_ENABLE_BANK_CONFIG_API — that flag now
gates only the writes (PATCH/DELETE). A bank must always be able to read
its own resolved settings.
Clients migrated in the same change:
- control plane: bank-profile-view and bank-config-view read disposition
and mission from the config API, the display name comes from the
filtered bank list, and the dead /api/profile proxy route is gone.
- hindsight-cli: `bank disposition`, `bank set-disposition` and the
hidden `bank background` move to the config API. `background` warns
that it now replaces the mission rather than LLM-merging into it —
nothing replaces that merge — and its `--no-update-disposition` flag
is accepted but ignored, as the server stopped inferring disposition
from the mission long ago.
- TS wrapper: getBankProfile carries a @deprecated pointer.
* test(control-plane): cover the composed bank profile, and drop its extra fetch
bank-context only needs the display name, so it reads the id-filtered bank
list directly instead of going through getBankProfile, which would also
fetch the bank config it has no use for.
* feat(cli)!: drop the deprecated `bank background` command
The server endpoint is gone, and the LLM merge it performed has no
replacement — `bank mission` sets the mission outright. Keeping the
command as an alias would have silently turned a merge into an
overwrite, so it is removed rather than repointed.
* fix(ci): update the CLI coverage manifest, doc example and TS client test
- .openapi-coverage.toml: the three retired operations move to [skip]
alongside export_documents_sync_removed, and the stale add_bank_background
/ update_bank_disposition field sections are dropped. The CLI helper is
renamed set_bank_disposition so it no longer satisfies the coverage grep
by name while calling update_bank_config underneath.
- cli-reference.sh: the two `bank background` snippets become one
`bank mission`, the command that replaces them.
- main_operations.test.ts: TestBankProfile asserts the 410 and reads the
same data back from the bank config. try/catch rather than .rejects,
since this file runs under both jest and Deno's @std/expect shim.
* style: rustfmt the CLI edits, and say why the 410 handlers keep unused params
|
||
|
|
280f098202 |
feat(retain): inline images and files as first-class content (#4077)
Makes images and files first-class raw content in `retain`. `content` accepts an ordered list of text/image/file blocks, the extractor reads each attachment in the position it occupies, and every read surface hands back the attachments behind what it returns. A plain string behaves exactly as before — text-only retain is byte-identical, because everything new sits behind an ATTACHMENTS block that is empty when a chunk carries none. Blocks are flattened at the API boundary into one canonical body with atomic placeholders, so `documents.original_text` stays plain text and content_hash idempotency, `update_mode=append`, chunk-delta re-extraction and `reprocess_document` keep working untouched. Bytes live in the existing FileStorage abstraction, content-addressed by sha256. Schema (one migration, both dialects): `attachments` for the blob, `document_attachments` for which documents reference it, and `memory_units.attachment_ids` for which attachments a *fact* came from — a column rather than a third table, because those ids behave exactly like `tags`. Provenance is per fact, not per chunk. Extraction runs one call per chunk, and a chunk holding a screenshot also holds the prose around it, so a chunk-level edge cited the diagram as evidence for the paragraph that never mentioned it. The extractor is asked instead, and a fact stated in the prose carries nothing. Extraction quality was measured against a real image-QA dataset with a raw-VLM ceiling arm before merging: transcribing structured attachments rather than summarizing them, and recording how each value is drawn, took the gap between "the model can read this off the image" and "memory can answer it" from 31.3% to 10.0% on the same 40 charts. The prose-article benchmark went 75% -> 100% over the same change, so it is not chart-specific tuning. Also here: * A vision slot (`HINDSIGHT_API_VLM_*`) so attachment-bearing chunks alone use a vision model and text-only chunks stay on a cheaper retain LLM. A vision call deliberately does not fail over to the retain chain's text models — that would reintroduce the silent omission the 422 gate exists to prevent. * The extension retain hook can now see each attachment (media type, size, kind, filename) and refusing a retain reclaims its bytes, which previously stayed fetchable forever. * A filename lives on the document edge, not the blob: the same PDF can be attached under a different name elsewhere, and content-addressing made the first name win for both. Known limitations, documented rather than hidden: store-owned memory backends get nothing (that retain path is Postgres-free and pre-dates this work), very dense pages are sampled rather than exhausted, and the Python client's ContentBlock is a plain dict where TypeScript gets the real union. Breaking for Go and Rust callers: `content` is now a union, so a bare string no longer satisfies it. Go gains a `TextContent()` helper; Rust uses `Content::Variant0(...)`. |
||
|
|
d20893a83f |
fix(api): paginate GET /observations/scopes and GET /webhooks (#4075)
* fix(api): paginate GET /observations/scopes and GET /webhooks
Both endpoints returned the whole collection: the scopes endpoint grouped
every observation in a bank and shipped one row per distinct tag set, and
the webhooks endpoint selected every webhook row. Neither is bounded by
construction — a bank has as many scopes as it has distinct tag sets — so
the payload and the per-row work grew with the data.
Both now take `limit` (default 100, max 1000) and `offset` and return
`total` / `limit` / `offset` alongside the page, the same shape
`list_tags` and the other paged list endpoints use. The bound reaches the
SQL: the scope histogram is grouped, ordered and paged in one query with
a separate `COUNT(DISTINCT scope)` for `total`, and the webhook listing
gets `LIMIT/OFFSET` plus a count query. Webhook rows now order by
`created_at, id` so a page boundary cannot fall inside a group of rows
sharing a timestamp.
Consumers page with them: the control-plane webhooks view walks every
page (it renders the whole list and its count), the two proxy routes
forward `limit`/`offset`, and the CLI prints "Showing N of M webhooks"
rather than presenting the first page as the whole list.
Clients, OpenAPI spec and the docs skill regenerated.
* fix(tests): page the in-memory store's observation_scope_counts stub
The MemoriesExtension conformance test asserts every stub method accepts
the interface's params, so widening the interface with limit/offset left
the InMemoryMemories stub behind (test-api shards 2/3 and free-threaded).
It now groups, orders and pages its own observation rows and returns the
same {scopes, total, limit, offset} shape as its list_tags neighbour,
rather than the bare list it used to hand back.
* fix(control-plane): page the webhooks view instead of loading every page
The first cut walked every page in loadWebhooks and rendered the lot, so
the bound existed but nothing in the UI showed it — a bank with hundreds
of webhooks still built one unbounded table, and a reader had no way to
tell there was more than a screenful.
It now fetches one 50-row page and exposes the pager entities-view uses:
first/prev/next/last, "1 / 3", and a "51-100 of 120" range line, with the
header count coming from `total` rather than the loaded page. The controls
hide when everything fits on one page.
Two paging hazards handled: a bank switch resets to page 1 (the page
number belongs to the bank being left, and carrying it over lands on an
offset the new bank may not reach), and creating a webhook jumps to the
page it lands on — rows are oldest-first, so reloading the current page
would close the dialog and appear to do nothing. Deleting the last row of
the final page steps back a page rather than stranding an empty one.
|
||
|
|
f747d96c38 |
feat(recall): fuzzy tag matching on tag_groups leaves (#4026) (#4028)
* feat(recall): fuzzy tag matching on tag_groups leaves (#4026) Tags increasingly hold user-facing names, and tag filtering is exact array containment. A caller filtering by what a query mentioned passes `typsecript`, and the memory tagged `typescript` is dropped before ranking runs — so the recall returns empty even though ranking would have found it. Better ranking cannot fix that; the match itself has to tolerate the misspelling. A `tag_groups` leaf gains one optional field, `resolve`, defaulting to `exact` (today's behaviour). Set to `fuzzy`, its tags are matched against the bank's tags by similarity instead of literally. That is the whole API change: no new config, no new response field, no new TagsMatch values. Matching is trigram similarity at 0.45 via `entity_resolver._trigram_similarity`, already verified byte-identical to Postgres `similarity()` (#3107), so Postgres, Oracle and store-owned backends behave the same. Resolves: typescropt/typescript 0.57, kubernets/kubernetes 0.62, user:alcie/user:alice 0.47. Does not: mango/mongo 0.33, k9s/k8s 0.14. Known limit, pinned by a test: similarity is length-sensitive. A short tag has few trigrams and one edit destroys three of them, so kakfa/kafka scores 0.20 and does not resolve. Fuzzy matching is effective on descriptive tags and close to inert on very short ones — and that same property is what keeps different short words apart. Resolution runs above the SQL layer, rewriting the leaf into ordinary exact leaves so only those reach the query builders. The ~20 SQL call sites, the Python mirrors used on the graph path, the GIN(tags) index, the store protocol and the Oracle dialect are untouched. Per mode, for tokens t1..tn resolving to E1..En: any/any_strict becomes one leaf over the union; all/all_strict becomes an AND of one OR-leaf per token, so a memory must carry some spelling of each; exact becomes an OR over the cross product, one tag per token, bounded at 32 branches and checked before enumeration, with combinations carrying fewer distinct tags than tokens dropped. Failing closed: a tag that resolves to nothing stays in the filter as itself, leaving the leaf unsatisfiable. Returning an empty list would read as "no tag filtering" in the builders and hand back the whole bank. The vocabulary comes from the existing `list_tags` store method, so there is no schema change. A bank holding more than 5000 distinct tags is rejected with a 422 rather than resolved against a truncated vocabulary. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk * fix(clients): make tag_groups reachable through the wrapper SDKs `Hindsight.recall(tag_groups=...)` and `.reflect(tag_groups=...)` raised ModuleNotFoundError for every caller. The wrapper imported `hindsight_client_api.models.recall_request_tag_groups_inner`, which the generator does not emit: it produces one union model per tag_groups shape and names it after the first schema that used it, so the class is `MentalModelTriggerInputTagGroupsInner`. Nothing caught it because the wrapper's tests never passed tag_groups and the import sits inside the `if tag_groups is not None` branch, so it only fires when the feature is used. Fixed at both call sites, with mirrored regression tests on the Python and TypeScript wrappers asserting a tag group reaches the request body with the leaf's `resolve` intact — the pair the review checklist asks for, since a capability that exists in one wrapper and not the other is invisible to client-coverage-check (it validates request-body fields, not wrapper surface). Also thread tag_groups through the control-plane recall and reflect proxy routes and their client types. Both accepted every other tag filter and silently dropped this one, so no control-plane caller could use compound tag filtering at all — fuzzy or exact. Two follow-ups from reviewing #4026: - Reject `resolve="fuzzy"` in a mental-model trigger's tag_groups. A trigger's scope is read by two paths that resolve differently: the refresh runs through reflect, which resolves fuzzy leaves, while the staleness check and the scope watermark build SQL straight from the stored groups and do not. A stored fuzzy leaf would build content from the resolved tags while never being marked stale by them, and would drift as the bank's tag vocabulary changes. - Promote `entity_resolver._trigram_similarity` to `trigram_similarity`. Two subsystems now share it — entity resolution and fuzzy tag matching — so the leading underscore misrepresented a real contract between them. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk |
||
|
|
21c2160046 |
feat(entity-labels): add an open-vocabulary multi-valued label type (multi-text) (#4027)
Entity labels could classify a fact against a fixed vocabulary ("value",
"multi-values") or capture one free string ("text"). There was no way to
extract *several* values that cannot be enumerated when the bank is
configured — the names a thing is known by (canonical name plus
abbreviations, acronyms and alternative spellings), ticket references a
fact cites, product codes.
"multi-text" is a list[str] field with no Literal constraint, so the
extractor writes as many values as the content warrants and nothing has
to be declared up front. Each value becomes its own key:value entity and,
with tag: true, a tag — so a bank can derive a classification from
content and then filter on it at recall without the caller supplying the
vocabulary.
Added to MapField as well as LabelGroup: the control plane renders
top-level label groups through the map-field editor, so the two type
unions have to stay in sync or the UI can emit a shape the server
rejects.
Client wrappers could not express this. The TypeScript wrapper's
updateBankConfig had no entityLabels option at all, so TS consumers could
not configure a controlled vocabulary of any type; the Python wrapper
typed entity_labels as list[str], which passes through at runtime but no
typed caller can satisfy. Both fixed, with the mirrored mapping
regression tests the wrappers' parity rule asks for.
Closes #4025
|
||
|
|
921ae824a4 |
fix(reflect): fail the run when a retrieval tool fails, and record refused refreshes (#4003)
A mental-model refresh could replace a document built over months with "I don't have information about that" (#2894). When a reflect tool raised, reflect handed the exception back to the model as a tool result and let the loop continue; the model answered from whatever it had — usually nothing — and that answer was indistinguishable from a run over a bank that genuinely holds nothing on the topic. Non-empty prose, so every emptiness guard on the write path let it through, and the operation was recorded as completed. Reflect now fails the run instead: - A tool that RAISES is an infrastructure failure (the database, the embedder, the reranker), not something the model can retry its way out of, so it raises ReflectToolExecutionError. One failure in a parallel batch fails the run. A tool that RETURNS {"error": ...} for a malformed or unavailable call is the model's own mistake and is still fed back to it, unchanged. - A non-context-overflow LLM error that survives one retry re-raises rather than falling through to a forced final synthesis built on evidence the failed turn never finished gathering. Context overflow keeps its deliberate degradation: that is a prompt-budgeting problem with the gathered evidence intact. - OperationCancelledError passes through untouched on both paths, so a client disconnect stays a 499 (#2122) instead of becoming a 500. The line this draws is between "we could not look" and "we looked and there is nothing there". A retrieval that succeeds and returns nothing is an answer, and still rewrites the document. The refresh re-raises both reflect failures as a typed MentalModelRefreshError (refresh_failed_reflect_error, with reasons retrieval_failed / no_answer), so they reach the operation's typed `details` through the same failure-metadata hook every other refusal already used. Content, structured document and watermark are all left untouched, so the retry re-reads the same window. Every refused refresh — the new reflect-side ones and the existing _preserve_and_fail paths — now also appends a failure record to mental_model_history carrying the reason and the exception. Before this a failed refresh left no trace on the model at all: the History tab kept rendering the last SUCCESSFUL trace as though it were current, and the only record was prose on an async-operation row no mental-model view reads. Retention is applied per kind so a run of failures cannot evict the version history. Control plane: a new Errors tab on the mental-model modal shows those records as a timeline of events — reason, attempt count, time, exception — with a red dot on the tab when there are any. History goes back to being a version browser. Consecutive identical failures collapse into one event (the worker retries each refresh), but a successful refresh between two of them breaks the chain, so separate outages stay separate. Also fixes a metadata bug found while verifying this live: a refresh is retried on the same operation row, and the success path wrote no failure_reason to overwrite the failed attempt's — producing outcome=content_written alongside failure_reason=no_answer. The success now writes it as null explicitly. Claude-Session: https://claude.ai/code/session_01F3i5UVdZRK16AZ9oFQqsg4 |
||
|
|
78d46a7181 |
fix(recall): honour min_scores.keyword on every text-search backend (#3882) (#3938)
* fix(recall): honour min_scores.keyword on every text-search backend (#3882) `min_scores.keyword` was a no-op on four of the six text-search backends, including `native`, the default. A caller asking for `keyword >= 0.30` got rows scoring 0.2 back. `bm25_min_score` was added in #1947 as a VectorChord-specific gate: vchord's `<&>` operator ranks *every* document, so it needed the analogue of native tsvector's boolean `@@` match gate. Default 0, Oracle got it for symmetry, behaviour unchanged everywhere else — correct and complete for that purpose. #2422 then built the public `min_scores.keyword` floor on top of that same parameter and touched no file under `engine/sql/`. From `retrieval.py` the wiring looked finished, but only the vchord and Oracle branches ever read the value; `native`, `pg_textsearch`, `pgroonga` and `pg_search` accepted it and silently dropped it. An internal gate that defaults to off had been promoted to a public per-request floor without the backends being re-audited. - Push the floor into all six backends. pgroonga's `pgroonga_score()` and pg_search's `<schema>.score()` are only valid in the target list, and re-evaluating native's `ts_rank_cd` or pg_textsearch's `<@>` in WHERE would compute the score twice per row (and, for `<@>`, forfeit the index scan the ORDER BY relies on), so those four apply it by filtering the ordered LIMIT slice from the outside. Every arm orders by score DESC, so that keeps exactly the rows an inner predicate would. - Make the floor inclusive. `min_scores` is documented as inclusive and the semantic arm uses `>= min_similarity`, but vchord/Oracle used `>`. The new `bm25_score_gate()` helper resolves the overload: `> 0` at the 0.0 default (the structural match gate #1947 needed), `>=` once a caller sets a floor, which subsumes it. Default behaviour is byte-identical. The second half of #3882 is a documentation bug. "All inclusive, AND-ed" reads as a predicate over each returned result, but `semantic` and `keyword` prune only the arm they name: recall fuses four arms and returns what any of them surfaced, so a result may carry `null` for a stage that did not surface it, and graph/temporal results carry neither. That is deliberate — an intersection would discard the strong single-arm matches hybrid retrieval exists to find. Only `reranker` and `final` are per-result predicates, and they are what a caller wanting abstention should use. Note the two halves interact: once the pushdown is fixed, the union behaviour can only ever surface as a `null`, never as a below-floor number, because fusion copies `semantic` only from the semantic arm and `keyword` only from the BM25 arm. The reporter's `{"keyword": 0.2}` under a 0.30 floor was purely the pushdown bug. `MinScores`, the `RecallRequest` field, both MCP tool descriptions and the recall docs now say exactly that. Tests: `test_bm25_min_score_pushdown.py` asserts the floor reaches the SQL on all six backends, that it is inclusive, and that the 0.0 default is unchanged — SQL-shape assertions, so a new backend branch cannot repeat the omission on a machine with no vchord/pgroonga/pg_search/Oracle available. (`pg_search` gained a configurable function schema on main while this bug was open and would have inherited the same gap.) The backend list is hoisted out of `HindsightConfig.validate()` into `VALID_TEXT_SEARCH_EXTENSIONS` and the test parametrizes over it, so a sixth backend is covered the moment it becomes selectable rather than when someone remembers to update a second copy. Plus DB-level regression tests for the keyword floor and for the per-arm contract. Closes #3882 Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk * fix(recall): inclusive keyword floor — update the vchord contract test, harden the regression test CI caught two things the local run could not. 1. `test_db_abstraction.py::test_build_bm25_arm_vchord_honors_custom_min_score` asserted `> 2.5`. That is the old exclusive gate this PR deliberately replaces, so the assertion is now `>= 2.5` with a comment recording why it changed. The test was doing its job; it encoded the behaviour the parameter had while it was vchord's internal match gate. 2. `test_keyword_floor_prunes_in_retrieval` asserted `len(kws) >= 2` so the floor would discriminate. On the three-fact corpus the keyword arm surfaces only one row for "animals", so the guard failed on its own precondition. Reworked to assert the contract without depending on corpus rank spread: a floor above every observed score must leave nothing keyword-scored (before the fix, native returned those rows with their real below-floor scores), and a floor at exactly the top score must keep that row (inclusivity, end to end). Neither a row count nor a score spread is something to assert on here — ranks can tie and the arm may surface a single row. Also: format the floor with `!r` rather than `:g`. `:g` truncates to six significant digits, so a caller echoing a `scores.keyword` value back as a floor could get a literal that rounds up past its own row and silently drops it — the exact round-trip the new inclusivity assertion exercises. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk * test(recall): drop the DB-level keyword-floor test; it asserts an environment property `test_keyword_floor_prunes_in_retrieval` failed in CI twice, on two different preconditions, for the same underlying reason: the BM25 arm surfaces nothing for this module's seeded fixture, so `scores.keyword` is `null` on every result and there is no floor to exercise. No other test in the repo asserts a non-null `scores.keyword`, so nothing else depends on that arm surfacing rows here. `search_vector` is a GENERATED ALWAYS column, so the fixture's raw INSERT does populate it — the cause is somewhere else in the test configuration and is worth a separate look, but it is not this fix. (The arm demonstrably works in a real deployment: the #3882 reporter's own responses carry keyword scores.) Deleted rather than skipped. The guard for this bug is `test_bm25_min_score_pushdown.py`, which asserts the floor reaches the SQL on all six backends deterministically and is what would have caught the original defect; a DB test that cannot observe a keyword score adds no coverage over it. Also fixes a vacuous assertion in `test_retrieval_floors_are_per_arm_not_per_result`: `any(keyword is None)` holds trivially when every keyword is None. It now asserts that not every result carries a score for both floored arms, which is the union property the test is named for. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk |
||
|
|
8d8dab3469 |
feat: export knowledge base in document transfers (#3855)
* feat: export knowledge base in document transfers * fix: preserve mental model json during document import * fix: preserve observation freshness during transfer * chore: format transfer observation query * fix: restore Deno client patch, locale parity, docs-skill spec Review fixes for the knowledge-base document-transfer PR: - Revert the incidental TypeScript client regeneration. `npm run generate` uses `npx @hey-api/openapi-ts` without a version, so it ran against a newer generator than the pinned 0.97.3 and rewrote the whole client (index.ts alone lost 672 lines). That flattened `beforeRequest`, which moved the anchor generate-clients.sh patches for Deno compatibility (#607); the patch step only warns on a stale anchor, so the fix was silently dropped and Deno failed with "Argument 2 `client` must be a Deno.HttpClient". Only the two real feature edits are kept. - Add exportIncludeKnowledgeBase{Label,Hint} to the 9 non-en locale catalogs. en.json alone gained them, failing locale catalog parity. - Sync skills/hindsight-docs/references/openapi.json, which CI regenerates and diffs. - Populate the existing mental_model_count / knowledge_page_count on the manifest instead of adding a knowledge_base_count aggregate. The new field duplicated the two, and left both reading 0 on a documents archive that did carry a knowledge base. Claude-Session: https://claude.ai/code/session_01DM4ytSkKECFgLtsqEwW5dT * chore: regenerate python client for include_knowledge_base verify-generated-files caught drift: the committed document_transfer_api.py did not match what the pinned openapi-generator v7.10.0 emits for the new include_knowledge_base query param. Regenerated via the script. Claude-Session: https://claude.ai/code/session_01DM4ytSkKECFgLtsqEwW5dT |
||
|
|
cd8d4d3998 |
feat(recall): add a per-bank enable_text_search toggle for pure vector recall (#3819)
* feat(recall): add a per-bank enable_text_search toggle for pure vector recall Recall runs four arms behind three per-bank switches — temporal, graph and reranking. The keyword (BM25) arm had none, so a bank whose queries are conceptual rather than literal could not opt out of it. Semantic and BM25 share a single UNION query, so "disabled" has to mean the BM25 half is never built rather than filtered away afterwards. The gate lands on the existing `_include_bm25` path, which already omitted the arm for a query with no word characters: tokens = tokenize_query(query_text) if enable_text_search else [] so a disabled arm skips its SQL, its query tokenization, its `pg_stats` term-selection round trip and its bind parameters. The dense arm, its thresholds and the graph seeding off it are unchanged. Wired like `enable_graph_retrieval` throughout: `_CONFIGURABLE_FIELDS`, both bank config models, the bank template schema, the OpenAPI spec, the generated and wrapper SDKs (TS + Python), and the control plane's recall section in all ten locales. `HINDSIGHT_API_ENABLE_TEXT_SEARCH` sets the deployment default a bank inherits. Knowledge-page search resolves the same per-bank flag and collapses to its vector half, so a bank that opted out does not keep hitting a text index. Its BM25-only fallback for a missing embedding returns nothing rather than degrading into the arm the bank disabled. Read path only: `search_vector` and its index are still maintained on write, so the flag is reversible without a reindex. Claude-Session: https://claude.ai/code/session_017ufCz6qrNxn36Stug7ek8A * fix(cli): declare enable_text_search in the OpenAPI coverage manifest check-cli-coverage requires every request-body field to be either a CLI arg or listed with a reason. The three sibling recall toggles are all listed as set through `bank set-config`; the new one needs the same entry, on both create_or_update_bank and update_bank. Claude-Session: https://claude.ai/code/session_017ufCz6qrNxn36Stug7ek8A * fix(tests): accept enable_text_search in the in-memory store stub MemoriesExtension.recall_unified gained the parameter, and test_stub_signature_accepts_interface_params guards that every store implementing the interface accepts each of its params. InMemoryMemories is the only other implementation, so it needs the same keyword. Claude-Session: https://claude.ai/code/session_017ufCz6qrNxn36Stug7ek8A |
||
|
|
ebad478240 |
Release v0.9.2
- Update version to 0.9.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.9 |
||
|
|
99a319513f |
fix(knowledge-pages): move a page's scope onto its mental model, and stop trigger patches resetting it (#3687) (#3755)
* docs(knowledge-pages): say that a page's tags are a filter, and let the dialog widen it (#3687) A page's `tags` are the scope it is synthesized from, not labels on it, and a tagged page matches with `all_strict` by default: a memory must carry EVERY tag and untagged memories are excluded outright. Nothing said so. The create-page API documented `tags` as "tags that scope which memories the page is built from" with no match mode, and `type:<x>` is documented as setting the page's rendered *type* — so a page created with `["type:runbook", "homelab", "infrastructure"]` silently required all three on every memory, matched nothing, and generated as "I don't have information about this" while a plain recall for the same query returned 81 results scoring 1.087. The server default stays. What changes is that you can now see it and move it: - The control-plane create/edit dialog states the rule under the tags field and, once there is a tag to widen, offers "Also build from untagged memories" -> `trigger.tags_match: "all"`. The edit dialog prefills from the page's stored mode and only sends `tags_match` when the checkbox actually moved, so a page on `any`/`any_strict`/`exact` survives a rename untouched. - `CreatePageRequest.tags`, `UpdateNodeRequest.tags` and `MentalModelTrigger.tags_match` carry the rule in the OpenAPI spec, so it reaches Swagger UI and the generated SDKs; both hand-written wrappers (TS `HindsightClient`, Python `Hindsight`) get it in parity. - A "Tags Are a Filter" section in the API docs works the reported page through, with the three ways to scope one and the `PATCH {"tags": []}` repair. Also corrects a stale claim in the same three places: a supplied `trigger` has merged over the page defaults since #3506, but the docs and both wrapper docstrings still said it REPLACES them and told you to repeat the fields you wanted to keep. Under that text the fix above reads as destructive. * fix(knowledge-pages): move a page's scope onto its mental model, and stop trigger patches resetting it (#3687) Two changes, one subject: where a knowledge page's retrieval scope is edited, and whether editing it destroys the rest of the page's configuration. 1. The page dialogs no longer take tags. `tags` on a page are the scope it is synthesized FROM, and a tagged page matches with `all_strict`: every tag required, untagged memories excluded. The create dialog offered a bare text input for them next to a hint that a `type:<x>` tag "sets the page's type" — so tags typed there to describe a topic silently became a hard filter that matched nothing, and the page generated as "I don't have information about this". A partial copy of the scope controls was the problem, so the copy is gone rather than extended: the page dialogs now take a name and a source query, and the page links to its backing mental model, which already owns the whole scope (tags, tags_match, tag_groups, fact types, schedule). Pages are created reading the whole bank; narrowing one is now a deliberate step taken where the match mode is visible next to the tags it governs. The edit dialog no longer sends `tags` at all. That is load-bearing: the PATCH applies only the keys present, so a dialog that no longer shows the field must not send it, or every rename would clear the page's scope. 2. `PATCH /mental-models/{id}` patches its trigger instead of replacing it. Which matters much more now that it is where pages send people. The route dumped the whole request model and the engine wrote that dict wholesale, so setting one field stamped `MentalModelTrigger`'s own defaults over every field left unset: a page edited there lost `mode: delta`, its observation-only `fact_types`, and `exclude_mental_models`, quietly becoming a from-scratch rebuild that also reflected over its sibling pages. #3506 fixed exactly this, but only on the two page routes. The MCP `update_mental_model` tool drives the same endpoint with a one-key dict (`{"refresh_after_consolidation": ...}`), so it was wiping triggers too. `_merge_page_trigger` is renamed `_merge_trigger` and is now the shared merge for any mental model; a full trigger still replaces, which keeps bank-template import declarative. Verified against a live API: a page created through the UI, opened via its new "Advanced options" link and saved from the mental-model editor, keeps `mode: delta`, `fact_types: [observation]`, `exclude_mental_models` and `refresh_after_consolidation`. Four of the five new regression tests fail without the engine change; the fifth is the guard that a complete trigger still replaces. * fix(knowledge-pages): open the backing mental model in place, and name it in the link (#3687) The link opened a page's scope editor by navigating to the Mental Models tab. Changing a page's scope is part of working on the page, so sending the reader to another tab — losing the page they were reading, and their place in the tree — was the wrong trade for a shareable URL. It now opens the same detail modal in place, on its configuration, and hands off to the very same `UpdateMentalModelDialog` the Mental Models tab uses (exported for it, not copied). Saving re-pulls the tree and the open page, since tags and trigger drive the chips and the freshness line. The label says "Mental model options" rather than "Advanced options": what opens is that model, and the page's scope living on a mental model is exactly the thing a reader needs told. The ?mentalModel= deep link added for the navigation is removed with it — nothing links there now, and a query param nothing produces is a trap for the next reader. * refactor(knowledge-pages): name the options i18n key for what it opens (#3687) The key was still `advancedOptions` after the label became "Mental model options", and the refresh callback asserted `currentBank!` where guarding is free. |
||
|
|
3de41af867 |
feat(recall): let callers supply the temporal window instead of parsing it (#3678)
* feat(recall): let callers supply the temporal window instead of parsing it
Recall derives the temporal arm's window by parsing dates out of the query
text. A caller that already knows the range it means — a date picker, an agent
that resolved "last quarter" itself — had no way to say so, and had to phrase
it in English and hope dateparser agreed.
Add `temporal_window: {start, end}` to RecallRequest. When set it is used
verbatim and the extraction is skipped entirely, which is also the point: that
work is pure CPU serialised through a single worker and costs up to ~1.3s on
document-sized query text, which is exactly what consolidation and reflect
recall with.
Naming and wording carry weight here, because the obvious reading of a date
range on a search API is "restrict results to this period" and that is not
what this does. The temporal arm is one of four retrieval arms: it surfaces
memories whose own dates fall in the window so fusion ranks them higher, and
the other three arms are untouched, so memories outside the window are still
returned. Every description — model docstring, OpenAPI field, MCP tool, both
wrappers, control plane, docs — says so explicitly.
It does not override `enable_temporal_retrieval`. That per-bank flag gates the
arm itself and stays the single switch for it, so a supplied window cannot
re-enable an arm a bank turned off.
Bounds are inclusive and naive datetimes are read as UTC at parse time, so
both ends are unambiguous before they reach a query; a reversed window is
rejected at the boundary rather than silently returning nothing.
The Rust struct literals in the CLI and the client's doctest have to name the
new field or progenitor's generated RecallRequest stops compiling.
* feat(control-plane): add the temporal window to the Recall Analyzer
The recall UI could not reach the window it now proxies. Adds a Time window
row to the Recall Analyzer: two datetime inputs, a Clear button, and a hint
that states what the window actually does — ranks memories dated in the range
higher, does not hide the ones outside it — since "date range on a search
form" reads as a filter otherwise.
The two rules live in lib/temporal-window.ts rather than the component so they
are testable: a window needs both ends (one alone is an incomplete range, not
a half-open filter), and a reversed range is withheld and flagged inline with
the Recall button disabled, instead of being sent for the API to reject with a
422.
Comparing the raw `datetime-local` strings is exact — they are already
YYYY-MM-DDTHH:mm, which sorts chronologically — so there is no Date parsing
and no local-timezone reinterpretation between the input and the request. The
value is sent with no offset, which the API reads as UTC, matching what
query_timestamp already does from this same form; the hint says so.
Strings added to all ten locales.
* fix(control-plane): reject a reversed window on Enter, not just on the button
Disabling the Recall button left the Enter-key handler on the query input
calling runSearch() directly. With a reversed range that ran the search anyway
and silently dropped the window, which is the failure the inline warning
exists to prevent. Guard in runSearch so every entry point agrees, and toast
the same message rather than doing nothing visible.
* feat(cli): expose the recall temporal window as --window-start/--window-end
check-cli-coverage caught that recall_memories gained a request-body field the
CLI neither exposes nor exempts. The exemption list is for genuinely complex
nested bodies — min_scores' four calibrated floats, tag_groups' boolean tree —
and two datetimes is not that, so expose it rather than write it off.
Flattened into two flags and recorded as such in the coverage manifest, the
same shape `include` already uses.
Both ends are required: one alone is an incomplete range, not a half-open
filter, and running a recall without the window the caller asked for is worse
than refusing. A reversed range is rejected before the request rather than
sent for the API to 422, and a datetime with no offset is read as UTC, which
is how the API reads one.
|
||
|
|
1634a2e174 | docs(api): correct recall fact type descriptions (#3547) | ||
|
|
2b54a1d3cd |
feat(mental-models): a minimum interval between automatic refreshes (#3480) (#3621)
* feat(mental-models): a minimum interval between automatic refreshes (#3480) A bank with several models on refresh_after_consolidation paid for a full agentic rebuild of every stale model on every retain, however small: a three-fact retain cost ~250k tokens, and ordinary conversational traffic reached ~11.6M tokens a day, ~90% of it refresh work. min_refresh_interval_seconds puts a floor on how often that may happen, configurable at all three levels and defaulting to 0 everywhere, so nothing changes for anyone who does not set it: HINDSIGHT_API_MENTAL_MODEL_MIN_REFRESH_INTERVAL_SECONDS server mental_model_min_refresh_interval_seconds tenant / bank trigger.min_refresh_interval_seconds one model The per-model value wins, including an explicit 0 — that is how one model that does need to stay current opts out of a floor its bank imposes. The refresh is parked, not skipped. The handler raises DeferOperation before any recall or LLM work, which leaves the operation queued with next_retry_at set to the end of the window; every trigger that fires while it waits folds into it (#3487), and the eventual run reads everything that accumulated. Skipping the submit instead would drop the work: nothing re-checks the model afterwards, so memories that arrived during a burst would stay unsynthesised until some later, unrelated trigger happened to land outside the window. Only the triggers nobody asked for individually are rate-limited — the after-consolidation flush and the cron scan. An explicit refresh asked for one now and gets one; when it folds into a parked refresh it also releases the park, since otherwise "refresh now" would silently inherit somebody else's wait. Releasing means dropping the automatic marker as well as next_retry_at: left in place, the next claim would park the operation all over again. Built on existing machinery rather than new scheduling: next_retry_at is already indexed and honoured by every claim query on both dialects, and DeferOperation already means "requeue later, not a failure". No new table, no new sweep, and nothing dialect-specific — the payload edit is read-modify-write rather than jsonb `-`, which Oracle's SQL translation does not rewrite. The control-plane trigger form carries the field because update_mental_model replaces the trigger wholesale: without it, editing a model in the UI would silently wipe the interval (the #3549 bug class). The TypeScript wrapper client needed it explicitly too — unlike the Python wrapper, which splats the whole trigger dict, it maps a hand-picked list of trigger fields. * feat(control-plane): a Mental Models section, and show when an operation is parked Two gaps found while testing the refresh floor on a real bank. The bank-level setting had no home in the UI at all — it was reachable only through the config API. It now has its own "Mental Models & Knowledge Pages" section after Reflect: it governs the models and the knowledge pages backed by them rather than consolidation, and the section is where the rest of that surface will go. The operations list showed a parked refresh as plain "pending", which reads as stuck. The dataplane has always returned next_retry_at, but no control-plane type declared it and nothing rendered it, so the one piece of information that explains the wait was invisible. A pending operation held until a known time now renders as "Waiting" with that time, in the list and the detail panel; the badge keys off status AND a future timestamp, because next_retry_at is not cleared when the task finally runs. That last point also corrects the API docs, which claimed next_retry_at is "always null for completed tasks" — a completed operation that was deferred still carries the timestamp of its last wait. |
||
|
|
ba97f8585c |
fix(mental-models): unsay facts a page cites that no longer exist (#3475) (#3618)
* fix(mental-models): unsay facts a page cites that no longer exist (#3475) A knowledge page kept stating facts that had been invalidated or deleted. Retrieval could never return them again — invalidation moves the row to `invalidated_memory_units` and a delete removes it outright — but nothing told an existing page that its grounding had disappeared: - `any_memory_updated_since` is `updated_at > $2` over `memory_units`. A vanished row raises no watermark, so neither the consolidation trigger nor the cron loop could ever see a removal. On a quiet bank the page was frozen at whatever it last said. - The delta prompt only ever receives *new* facts, and explicitly forbids removing content that new facts do not reference. It had no channel for "this was retracted". - `reflect_response.based_on` accumulates forward in delta mode with no existence check, so a retired fact's id *and its text* stayed cited forever (the control plane's provenance dialog, `detail=full`). The retracted set is derived inside the refresh — stored `based_on` minus live memory ids — rather than passed in by whatever removed them. `submit_async_refresh_mental_model` guarantees that refreshes carry no per-request options, which is what makes its pending-dedupe safe (#3487); a payload of ids would break that. Deriving also makes the pass idempotent and covers invalidation, every delete path, and the observation sweep without instrumenting any of them. Invalidated, deleted, and swept-as-stale are not distinguished: from the document's point of view all three mean the same thing. - staleness now also asks whether the grounding is intact, second so the common "already stale" answer costs nothing extra - curation and the delete paths nudge auto-refreshing pages directly, since a quiet bank runs nothing else; the sweep self-gates when consolidation is pending, because that path checks the same thing - delta gains an unsay pass, before the no-new-facts return so a retraction can edit a document on its own, and the citations are then pruned - the unsay is deferred while facts are pending consolidation. Re-ingest deletes a document's facts and re-extracts them under fresh ids, and the replacement observations are minted by a consolidation run *after* retain commits — so waiting for the commit is not enough. Removing prose in that gap would delete claims that are still true, and the ids leave `based_on` with it. - grounding that resolves to *nothing* is treated as a broken provenance link, not a retraction: whole-bank transfer carries `mental_models` rows verbatim while re-creating memories under new ids, so a restored page's every citation is absent on arrival. Reading that as a retraction would gut a correct page. Full mode needs none of this — it regenerates from live facts and rebuilds `based_on` wholesale. * test(mental-models): judge the retraction prompt against a real model (#3475) The stubbed tests prove the pipeline runs the unsay pass, applies its ops and prunes the citations — but the stub decides the answer, so they cannot show that a real model removes what rests on a retracted fact and leaves everything else alone. That is the risky half: the edit is irreversible, and the prompt's central instruction ("when in doubt, keep it") is exactly what no mock can simulate. Provider and model come from the environment rather than being pinned. Pinning is how the neighbouring delta evals ended up defaulting to gemini-2.0-flash, which the provider has since retired — a hardcoded model rots silently because the test is normally skipped. * refactor(mental-models): type the shared delta/retraction LLM handle |
||
|
|
300c546c5d |
feat(mental-models): record what a refresh did on the operation itself (#3609)
`async_operations.result_metadata` is the only per-refresh record kept
indefinitely, and it could not say what a refresh did. A full rewrite, a delta
that emitted no operations, a delta that fell back, and a run that found nothing
to change all wrote `{delta_ops_applied: 0, delta_ops_skipped: 0}` — and a
refresh that preserved the document reported the length of the content it
preserved, so `content_len` and `populated_content` read identically to a
rewrite. Failures wrote nothing at all: the outcome writer derives its fields
from the refreshed document, which a failed refresh never produces, so the raise
left the row with only its submit-time `{mental_model_id, name}` and a prose
`error_message`.
Everything finer lived on `mental_models.reflect_response`, which the next
refresh overwrites, or under `reflect_response.trace`, which is opt-in per model
and sits in that same overwritten column — so measuring the outcome distribution
over a window needed an external sampler polling each model's latest state.
Every async operation now carries a `details` payload, keyed by its own
`operation_type` so each type contributes its own shape rather than flattening
fields onto the shared operation. For refresh it reports `outcome` and, on
failures, `failure_reason` — both already computed and already modelled as
literals for the dry run, just never persisted. They are typed fields on the
operations list and the single read, not only keys inside `result_metadata`,
which is documented as debug-only and unstable.
The outcome is decided by observable effect rather than by which branch produced
it: a candidate identical to the stored document reports `content_unchanged`,
never `content_written`. That matters because a delta whose model emits zero
operations lands in the applied path and re-renders the document
byte-identically — caught on a live run where three consecutive refreshes each
claimed a write while storing the same 124-character document.
An unrecognized outcome degrades to "no details" rather than raising, since the
projection runs per row and would otherwise 500 the whole operations list during
a rolling upgrade — the exact window this change creates by adding a new outcome
value.
The control plane shows the payload as its own section in the operation details
dialog, above the raw metadata dump.
Tests drive all eleven outcome/reason combinations off mocked LLM behaviour and
assert the stored document against the label each run was given, with guards that
fail if the table stops covering either vocabulary.
Fixes #3274
|
||
|
|
a5d00660b5 |
fix(knowledge-base): answer page staleness per scope, not from the bank watermark (#3291) (#3589)
The knowledge tree, the mental-model list and the control-plane stats card derived staleness from one bank-wide write watermark: any write flagged every page that had not read it. Only an in-scope write can move a page's own watermark, so a page whose scope went quiet stayed flagged indefinitely while the refresh gate — which does check each page's own scope — correctly refused to refresh it. The reporter measured 24 of 29 pages flagged for hours against 0 of 29 by the exact check. The approximation existed because the exact check cost a scan of the bank's memories per page: memory_units had no index involving updated_at, so `WHERE bank_id = $1 AND updated_at > $2` could not stop early on the common negative answer. Add (bank_id, updated_at DESC) and that cost goes from 10.7ms (tagged page, fresh) and 29.8ms (untagged model) to 0.046ms and 0.021ms on a 400k-row table. With the index the exact answer is affordable, so those surfaces now ask it, batched: any_memory_updated_since_batch answers a whole bank in one query per scope shape (30 pages in 0.2-0.5ms). Reflect's search_mental_models, which ran the scoped check per model inside every reflect call, uses the same batch. The watermark shortcut is kept for callers holding a live watermark, but the polling surfaces no longer pass the 60s-cached one — it can be older than a model's own last read and would prove a freshness that is not there. _bank_write_watermark had no callers left and is removed. Staleness still asks only what has been *written*; deletions leave no write behind and remain invisible, which the API and docs now state outright. |
||
|
|
6692e38c80 |
fix(api): paginate the bank list (#3586)
* fix(api): paginate the bank list GET /v1/default/banks returned every bank in the system: no limit, no offset, and a query with no LIMIT clause. Beyond the unbounded payload, the per-bank work — config resolution and a live store count for banks whose memories live outside SQL — ran for every bank rather than the ones being shown. The endpoint now takes limit/offset (defaults 100/0, matching list_documents) plus a `q` substring filter on bank id and name, and returns total/limit/offset alongside `banks`. Paging happens after filter_bank_list rather than in SQL: that extension hook can drop any bank, so a SQL page would hand back short pages and a total counting banks the caller can't see. Consumers page instead of taking the first 100: the control-plane bank selector scrolls infinitely and searches server-side, the CLI walks every page, and the Zapier bank dropdown became canPaginate. * fix(api): bound the bank-list probes and keep the selected bank's name Follow-ups from reviewing the pagination change: - the control-plane health probe and the Zapier credential test only need to know the endpoint answers, so they ask for limit=1 instead of a default page - the header showed the raw bank id whenever the selected bank sat past the first page, so its name is fetched directly - limit/offset are clamped in the engine: the page is a Python slice, and the MCP tool takes both straight from a model with no HTTP-layer validation * docs(mcp): document list_banks query/limit/offset * fix(control-plane): make the bank selector actually page and report empty searches Verified against a 130-bank instance: the infinite scroll never fired. The observer effect read listRef.current/sentinelRef.current on the commit that flips the popover open, but Radix mounts the content in a portal afterwards, so both refs were null and nothing re-ran the effect — the selector sat on its first 50 banks forever. Tracking the nodes as state through callback refs re-runs the effect when they attach; paging now walks offset 0/50/100 and stops at the total. An empty result also read "No memory banks yet." after a search that simply matched nothing, so searches get their own message. * feat(control-plane): smooth the bank selector as pages land and searches narrow The list is paged and searched server-side now, so rows appear and vanish in batches — every page landed as a hard 50-row pop, and a search that narrowed to one bank snapped the popover shut from 300px. - rows fade and lift in, staggered within their page and capped so the tail of a 50-row page doesn't crawl; only rows that actually mount animate, so appending page 2 leaves page 1 still - the list height follows cmdk's --cmdk-list-height, easing down to the filtered set instead of jumping - the previous results hold their place and dim while the next set is in flight, rather than blanking on every keystroke The animations are defined in globals.css next to the existing logo keyframes: tailwindcss-animate is a Tailwind v3 plugin declared in tailwind.config.ts, but this app runs Tailwind v4 with the CSS-first config, so `animate-in` and friends compile to nothing here. * refactor(control-plane): tidy the bank row className and import |
||
|
|
df8ac42b52 |
fix(memory): add resolve_entities flag to update_memory and retain (#3576)
* fix(curation): resolve edited entity names exactly, not fuzzily (#3479) update_memory ran the caller's entity names through the same fuzzy resolver retain uses, so an entity name was a *guess* to be reconciled against the graph rather than an instruction. Name identity is worth at most 0.5 of the 0.6 match threshold, while co-occurrence (0.3) and recency (0.2) make up the rest, so a similar-but-wrong entity that is well connected to the other names in the same edit outscores the one the caller actually named — with a 200 and no warning. Curation now resolves exactly: an existing entity is reused only when its canonical name matches case-insensitively, any other name creates its own entity, and same-batch names are never merged with each other. Retain keeps fuzzy resolution, which is right for names that came out of extraction. The exact path skips the trigram/UTL_MATCH probe and the co-occurrence fetch entirely and reuses the existing find-or-create pass, so it is dialect-agnostic and strictly less work than the fuzzy one. * fix(curation): add entity_resolution_mode to update_memory, default fuzzy Make the exact/fuzzy choice the caller's, rather than changing what an edit does. `entity_resolution_mode` defaults to "fuzzy" — retain's behaviour, so every existing caller is unaffected — and "exact" opts into literal matching for hand-authored corrections. Plumbed through the HTTP request model, the MCP tool, the control-plane proxy route and its client, with the engine validating the value for direct callers. The control-plane memory editor sends "exact": a person typing an entity list into the admin UI is naming the entity they mean. * refactor(curation): make the flag a boolean, resolve_entities Replaces the entity_resolution_mode enum with a plain boolean on update_memory. `resolve_entities` defaults to True — retain's behaviour, so existing callers are unaffected — and False takes the submitted names literally. Carries the same change through the resolver, which now takes `fuzzy_matching` rather than a mode string. The engine's value guard goes away with the enum: a bool needs no validation, so the invalid-value test goes too (the HTTP boundary still 422s a non-boolean, which the HTTP test covers). * feat(retain): honour resolve_entities for caller-supplied entities too Same flag, same default, on the retain item — a caller passing explicit entity names there has the same exposure as one correcting a memory: a name close to an existing entity can be matched onto it and quietly replaced. Retain resolves caller-supplied and LLM-extracted names in ONE batch, so a per-batch flag would have turned resolution off for the extractor's names too and filled the bank with near-duplicate entities. The flag is therefore carried per mention: extracted names always resolve, supplied names follow the item's flag, and a supplied name the extractor also produced keeps the caller's intent. The in-batch dedup pass (#3107) skips the literal names for the same reason. Also renames the resolver's `fuzzy_matching` parameter to the per-mention `resolve` key, so one name is used end to end. * fix(clients): carry resolve_entities through the maintained wrappers Code review found two gaps the generated SDKs hide. The TypeScript and Python convenience wrappers rebuild each retain item field by field, so `resolve_entities` was silently dropped for every wrapper caller — the same class of gap #2975/#3042 closed for the mental-model methods, and here it would have quietly restored the substitution the flag prevents. Both wrappers now forward it, with mapping tests on each side. Intake also lost the flag when normalization collapsed two spellings into one: entity_processing dedups caller-supplied against extracted names on the RAW text, so a caller's literal "Acme Corp" and the extractor's "Acme\nCorp" both reach _prepare_entities_for_resolution and only merge there. Keeping the first entry verbatim dropped the caller's resolve=False with it; the merge now keeps the stricter flag. * fix: build the Rust CLI, and keep pg_trgm detection on empty batches Two CI breaks from the retain change. MemoryItem gained a field, and the CLI builds it with a struct literal, so every Rust job failed to compile. The CLI supplies no entities, so `true` (the server default) is the right value there. The skip-the-probe guard also fired on an *empty* batch — `any([])` is False — so _resolve_entities_batch_impl returned before the pg_trgm auto-detection that hangs off the strategy dispatch. Only shortcut when there is data and none of it resolves. * fix(rust): add resolve_entities to the remaining MemoryItem literals The first pass only fixed the CLI's src/ literal — `cargo build` does not compile test targets, so the ones in hindsight-cli/tests/integration_test.rs and hindsight-clients/rust/src/lib.rs went unnoticed until CI. Verified with `cargo check --all-targets` in both crates this time. |
||
|
|
0dd32a3d6d |
feat(knowledge-base): expose a page's refresh trigger on the tree (#3572)
* feat(knowledge-base): expose a page's refresh trigger on the tree A page's trigger decides when it rebuilds itself and what that costs, and it was write-only: nothing in the knowledge-base API returned it, so a client that speaks only that surface -- the control-plane tree, the coding-agents plugin -- could neither show a page's refresh policy nor tell whether its own settings still applied. The only way to read one was the mental-models API, one call per page. `_KP_PAGE_SELECT` now carries `mm.trigger` (the join was already there, for tags/source_query) and `KnowledgeNode` returns it: null on folders, which have no backing mental model, and on a page with no trigger stored. This closes the loop opened by making the trigger patchable: a client can compare what a page has against what it wants and skip the write when they already agree. * fix(test): a folder's absent trigger is absent, not null ExcludeNoneRoute drops null fields from every response whose model has no required-nullable field, so a folder's trigger never reaches the client as `null` -- exactly how is_stale behaves on folders one assertion above. The field description says "absent" now rather than "null". * refactor(knowledge-base): type the page trigger, don't hand back a dict `KnowledgeNode.trigger` was the only `dict[str, Any]` trigger in the API. Every other one -- including MentalModelResponse, which reads the same column -- is a MentalModelTrigger, and a raw dict for structured data is against the project's own type rules besides. Generated clients now get MentalModelTriggerOutput instead of an untyped object. * fix(test): assert the effective trigger, not an exact dict Typing the field changed what a client sees: serializing through MentalModelTrigger reports every setting the page never stored at that model's default -- keep_trace=False, and refresh_after_consolidation=False on a page moved onto a cron schedule (the engine stores no such key; false is the same policy stated a different way, and TestPageDefaults still asserts the storage-level shape). So assert the fields that carry meaning instead of the whole object, which would otherwise pin every future MentalModelTrigger field into this test. The field description now says the returned trigger is the effective one, so nobody compares it whole against a patch they sent. |
||
|
|
b64943d195 |
fix(knowledge-base): patch a page's trigger instead of replacing it, on create and update (#3549)
* fix(knowledge-base): merge a client's page trigger over the page defaults Creating a knowledge page with ANY trigger silently discarded every knowledge-page default. Two things combined to do it: the endpoint dumped the whole request model (`model_dump()` fills every unset field with MentalModelTrigger's own defaults -- mode="full", exclude_mental_models=False), and the engine then replaced KNOWLEDGE_PAGE_DEFAULT_TRIGGER with that dict wholesale. So a client that wanted one setting -- different fact types, a cron schedule -- also gave up `mode: "delta"` and `exclude_mental_models`, and its page became a from-scratch rebuild that reflected over its sibling pages on every refresh. That is what the coding-agents plugin had been doing to every page it created (#3506), and there was no way for it not to: the API offered no partial override. The endpoint now forwards only the fields the client actually set (`exclude_unset=True`) and `_merge_page_trigger` layers them over the defaults -- which is what the engine's docstring already promised. The two refresh triggers stay mutually exclusive: a client asking for a cron schedule drops the default's `refresh_after_consolidation` rather than inheriting a pair `MentalModelTrigger` would have rejected outright. * feat(knowledge-base): let a page's refresh trigger be updated, as a patch The trigger was write-once through the knowledge-base API: `UpdateNodeRequest` carried no `trigger` field and the handler filtered to `{source_query, tags, max_tokens}`, so a page created with one policy was stuck with it -- including every page created before the create-path fix above, which is still doing full from-scratch rebuilds. The engine already supported it end to end; only the HTTP surface didn't expose it. Both endpoints now behave the same way: send the fields you want changed, keep the rest. Create patches over KNOWLEDGE_PAGE_DEFAULT_TRIGGER, update patches over the page's CURRENT trigger -- which matters, because `update_mental_model` overwrites the whole trigger column, so forwarding a partial one straight through would have reintroduced the create-path defect one endpoint over. Exclusivity holds in both directions on update: moving a page onto a cron schedule clears the auto-refresh it was created with, and moving it back clears the cron. Neither pair is expressible in a request, so neither should be reachable by merging. The hand-written TS and Python wrappers both take the new parameter (they are the surface most consumers actually call), each with a mapping test. OpenAPI spec, generated clients and the docs skill regenerated. * fix(cli): carry the new page trigger field through the Rust CLI `types::UpdateNodeRequest` is generated from the OpenAPI spec, so adding `trigger` to it broke the CLI's struct literal (and with it test-rust-cli and the Windows embed build, which builds the CLI). The field is passed as None -- omitted, it leaves the page's current trigger alone -- and recorded in .openapi-coverage.toml with the reason, alongside the same exemption the mental-model commands carry. * fix(control-plane): expose the page trigger on the typed node-update client The proxy route forwards the PATCH body verbatim, so the field already reaches the dataplane -- but lib/api.ts enumerates the body fields, so `trigger` was unreachable from any typed caller in the control plane. |
||
|
|
803a45171d |
fix: report total on the mental-model and directive list endpoints (#3548)
* fix: report total on the mental-model and directive list endpoints Both list endpoints accepted limit/offset but returned a bare `items` array, so a caller could not distinguish a full page from the end of the collection and silently saw only the first 100 rows. They now return `total` (every match, not just the page) with the applied `limit`/`offset`, matching the documents/memories/tags/chunks/operations endpoints. - engine: `list_mental_models` / `list_directives` return a typed page (`MentalModelPage` / `DirectivePage`) with items + total, counted in the same connection as the page query. - engine: tie-break the ORDER BY on `id`. `last_refreshed_at` (models) and `(priority, created_at)` (directives) are not unique — a bank-template import stamps a whole batch at once — so ties could reorder between queries and a paging caller would see one row twice and miss another. - engine: `limit=None` returns every match. Bank-template export and import now use it: under the default page size an export dropped everything past the first 100, and import's create/update decision was made against a partial view of the bank, so it could create duplicates. - mcp: `list_mental_models` / `list_directives` gained limit/offset and report total — agents previously could not reach past the first 100. - control plane: `listAllMentalModels` / `listAllDirectives` page to total; the stats freshness card, mental-models view, bank profile and think view use them. The directives proxy route forwards limit/offset. - clients: both maintained wrappers gained limit/offset on the directive list, with mapping regression tests on each side. * test(control-plane): cover the list-all paging helpers The mental-model and directive paging loops read the new `total` to decide whether to ask again, including the empty-page guard that stops them if rows are deleted mid-page. * fix: update the reflect LLM-config test mock and regenerate the Go client Two CI misses from the paging change: - `test_per_operation_llm_config.py` stubs `list_directives` on the engine and reflect now reads `.items` off it, so the stub has to return a DirectivePage. - The Go client was not regenerated (the generator skips it when Go is absent), leaving `model_directive_list_response.go`, `model_mental_model_list_response.go` and `api/openapi.yaml` without the new total/limit/offset fields. |
||
|
|
3650a6e173 |
docs(recall): say that the created_after/created_before window filters updated_at (#3551)
`created_after` / `created_before` narrow recall on `updated_at`, not `created_at` — the window is "memories that changed in it", so an edited memory re-enters it. That is deliberate and load-bearing: it is what the mental-model delta refresh chases from its watermark, and `tests/test_recall_time_range.py` pins it. Only the names say otherwise. The names stay (they reach the memories-extension interface and a published response schema, so renaming breaks out-of-tree stores for a cosmetic win). What changes is that every place a caller reads now says what the bounds actually mean: - `MemoryEngine.recall` documents both parameters; the internal search entrypoint points at that note. - The SQL-building blocks name their locals `updated_range_*` and no longer carry a `created_at time range filter` comment above a clause that emits `updated_at`. - `MentalModelRefreshWindow` said "Lower bound on memory creation time" in the published schema — the one user-visible wrong statement. It now reads "when a memory last changed", and the regenerated clients follow. Behaviour is unchanged; no SQL, no schema shape. |
||
|
|
8fbdc6bf79 |
fix(mental-models): last_refreshed_at records the refresh, not the source watermark (#3538)
A refresh persisted its source-data watermark into last_refreshed_at, and that watermark is clamped so it never regresses. On a model whose scope gained no new memories the max IS the stored value, so the refresh wrote it back over itself: the document was rewritten, the timestamp never moved, and a client asking "have I already refreshed this?" refreshed it again on every tick — one reporter drove ~6,000 refreshes/day against an intended ~350 for four days. Split the two meanings the column carried: - last_memory_seen_at (new) takes over the watermark. Staleness, the delta window, the knowledge-tree flag and is_stale all key off it, so refresh behaviour is unchanged. - last_refreshed_at reverts to wall-clock, stamped on every refresh that completes — including one that found nothing new and preserved the content. A failed refresh stamps neither, so a retry re-reads the same window. The migration backfills the new column from last_refreshed_at, which today holds the watermark, so the copy is lossless and no bank changes staleness on deploy. BREAKING (semantics, not schema): a client following the v0.9.0-documented rule of comparing last_refreshed_at against last_memory_write_at must switch to last_memory_seen_at, or it will read models as up to date that are not. This reverts semantics that #2866/#2878 changed four weeks ago; the field was wall-clock from inception until v0.8.5. Also surfaces mental_model_id on the operations list — refresh operations return document_id: null and the list carries no result_metadata, so it could not say which model an operation refreshed. |
||
|
|
e5b49eb672 |
Release v0.9.1
- Update version to 0.9.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.9 |
||
|
|
4e5b4ceecc |
fix(api): correct stale OpenAPI examples (#3436)
Keep bank configuration examples limited to fields that can actually be overridden per bank, and replace the static LLM setting examples in the endpoint descriptions. Complete the operations and version response examples with their required fields, and use the real refresh_mental_model operation type. Regenerate the OpenAPI documentation, docs skill mirror, and generated client descriptions. |
||
|
|
8391296af5 | fix(recall): fill source_facts budget in rank order and flag truncation (#3221) (#3419) | ||
|
|
82859af02b |
docs(reflect): clarify tag-scoped directive behavior (#3038)
Explain how tags, tags_match, tag_groups, and directive isolation interact across REST, MCP, versioned docs, and agent skills. Clarify the different defaults used by reflect and directive listing, then regenerate OpenAPI and supported client artifacts. |
||
|
|
dbe0ffb989 |
fix(stats): drop permanently failed memories from pending_consolidation (#3362) (#3397)
`pending_consolidation` counted every fact with `consolidated_at IS NULL`, including the ones stamped `consolidation_failed_at` that the consolidator's own candidate query (`reads.find_unconsolidated`) excludes on purpose. The gauge therefore had a floor no amount of work could clear: it sat above `?consolidation_state=pending` by exactly `failed_consolidation`, and an operator could not tell a real backlog from an abandoned residue. `pending` now carries the consolidator's predicate, so the two buckets are disjoint and a bank with no live backlog reaches zero. The same predicate was missing in five more places: - `get_bank_stats` keeps a second copy of the freshness SQL for the `writes_memory_rows_in_sql` path — fixing only `counts.py` would have left the default Postgres path wrong. - `hindsight.consolidation.backlog` had the same floor, which made "backlog > 0 for N minutes" unalertable on any bank holding a residue. - reflect's `tool_search_observations` derives `is_stale` / `freshness` from this count, so a residue told the model the observations were stale on every call, for ever (fixed transitively via `get_bank_freshness`). - the control plane's consolidation card computed `done = total - pending`, which would have counted the failed rows as done once pending got strict. - the benchmark runner waits for this count to reach 0, so one permanently failed fact burned the full 3000s timeout. Everything else that answers "what is left to consolidate" already excluded them: the memories list filter, `count_unconsolidated`, and the `banks_needing_consolidation()` maintenance routine — so scheduling was never spinning on the residue. |
||
|
|
00b520e592 |
feat(transfer): async document export (#3321) (#3340)
* feat(transfer): async document export (issue #3321) The synchronous GET /banks/{id}/document-transfer loaded the whole bank into memory, held a DB connection for the full request, and blocked the event loop building the ZIP — enough to take down the shared API on a large bank. Make export asynchronous, mirroring the already-async import path: - new document_export operation: submit_export_documents_async enqueues it; the worker builds the archive, stores it in file storage, and records download_url/storage_key/byte_size in result_metadata - POST /banks/{id}/document-transfer/export (202 + operation_id) - the sync GET is removed -> 410, pointing at the async endpoint - GET /v1/default/files/download/{key} streams the archive; retrieval + bank authorization live in MemoryEngine.retrieve_bank_file (IDOR guard) Harden export_documents: batch the entity/causal attach ANY() queries instead of passing hundreds of thousands of UUIDs at once, and move ZIP assembly off the event loop with anyio.to_thread. Regenerate all SDKs; add blocking export_documents convenience helpers to the Python + TS wrappers (submit -> poll -> download), fetching the server-provided download_url to avoid %2F path-encoding. Update the control-plane proxy to orchestrate the async flow and the docs. * refactor(transfer): name the export op export_documents; surface it in the CP - rename the async operation/task type document_export -> export_documents (and _handle_document_export -> _handle_export_documents) so it mirrors the import_documents operation - control plane: add export_documents + import_documents to the operations type filter and localize both (operationType.exportDocuments/importDocuments across all 10 locales) — previously neither appeared in the filter and both rendered as the raw task_type string * feat(transfer): clean up export archives with their operation + add download button Export archives were stored in file storage but never deleted, so they outlived their operation: the retention sweep prunes the async_operations row but left the blob orphaned, and a user delete didn't remove it either. Tie the archive to its operation record: - delete_operation now deletes the export archive along with the row - the retention sweep purges export archives (matching prune's terminal + updated_at < cutoff predicate) before pruning the rows So an export is retained exactly as long as its operation — indefinitely by default, or until HINDSIGHT_API_OPERATION_RETENTION_DAYS prunes it. Control plane: - add a Download button to the export operation's detail dialog (streams the archive through a new /api/files/download proxy, SSRF-guarded to the file-download path) + localize the label across all 10 locales * chore(docs-skill): regenerate references for export retention note * chore(cli): skip new export/download ops in CLI OpenAPI coverage export_documents_sync_removed (the 410 stub) and download_file are served via the API/control plane, not the end-user Rust CLI. * fix(transfer): register export_documents slot config + fix cleanup-sweep tests - add export_documents to WORKER_SLOT_TYPE_DEFAULTS (every operation_type used in memory_engine must be listed there — enforced by test_worker) - stub engine.purge_expired_export_archives in the operation-cleanup test mocks (the sweep now calls it before pruning each schema) * test(transfer): make export-archive purge test xdist-safe purge_expired_export_archives is schema-wide, and CI shares the schema across xdist workers, so a future cutoff purged other concurrent tests' fresh archives (flaky count + cross-test interference). Backdate this op and use a past cutoff so it targets only itself; assert purged >= 1. |
||
|
|
00bad17110 |
fix(api): add a DB-free liveness probe so a slow database stops restarting pods (#3337)
Adds /health/live (no DB access) and /health/ready alongside the existing /health, on the API server and the worker. Helm liveness probes now use /health/live; readiness stays on /health. Worker liveness reports seconds_since_last_poll for alerting without gating on it. Fixes #3329 |
||
|
|
3e3e3f372c |
feat(bank-template): make every bank config field export+importable (#3332)
Seven per-bank configurable fields were not declared on BankTemplateConfig, so export/import silently dropped them: consolidation_llm_parallelism, consolidation_max_memories_per_round, enable_auto_consolidation, memory_defense, recall_chunks_max_tokens, recall_include_chunks, recall_max_tokens. Cloning a bank produced a clone that looked correctly configured while quietly running on the server defaults for those seven — and memory_defense being one of them means a bank's defense policy did not travel with it. All seven are now part of the template engine, so an exported bank reproduces its full configuration on import. The rest of the change is about not needing to notice this again. Adding a per-bank config field is a multi-step flow, and each step now fails until the previous one is done: 1. add it to _CONFIGURABLE_FIELDS -> test_every_configurable_field_is_exportable fails until it is declared on BankTemplateConfig (both directions: a template field that is not configurable fails too, since the engine would reject it); 2. declare it there -> test_sample_values_cover_every_exportable_field fails until it has a value in _SAMPLE_VALUES; 3. give it a value -> the existing round-trip test exercises it end to end; 4. touching BankTemplateConfig moves the OpenAPI spec, the generated clients and bank-template-schema.json, so verify-generated-files fails until those are regenerated. An intentional exclusion is now a decision to record in the guard with a reason, not an omission that no one sees. The docs listed 15 of the 45 fields in a hand-maintained table that was already stale and would contradict "every field is supported" the moment it drifted again. It now states the guarantee and points at the generated schema as the authoritative list, keeping the common fields as examples. Verified by mutation: adding a configurable field without a template field, adding a template field that is not configurable, and adding a template field with no sample value each fail the suite. |
||
|
|
b12646f49e |
Release v0.9.0
- Update version to 0.9.0 in all components - Regenerate OpenAPI spec and client SDKs - Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed - Python client: hindsight-clients/python - TypeScript client: hindsight-clients/typescript - hindsight-all npm wrapper: hindsight-all-npm - Rust CLI: hindsight-cli - Control Plane: hindsight-control-plane - Helm chart - Create documentation version-0.9 |
||
|
|
d3946f17cd |
feat(recall): per-bank toggles for the temporal, graph, and rerank stages (#3223)
Adds three hierarchical config fields — enable_temporal_retrieval, enable_graph_retrieval, enable_reranking — all defaulting to true, so recall behaviour is unchanged unless a bank opts out. enable_reranking reuses the existing RecallReranking strategy by downgrading "cross_encoder" to "rrf"; "interleave" (consolidation dedup) and "rrf" are never overridden. Paired with retain_extraction_mode=chunks and enable_observations=false, a bank behaves like a conventional vector store. Ships as a `plain-retrieval` bank template. Surfaced through the bank config API, both hand-written SDK wrappers, and a Recall Pipeline section in the control plane translated across all ten locales. |
||
|
|
59d3f078cd |
fix(api): publish LabelGroup schema for entity_labels in OpenAPI (#3107) (#3190)
DryRunExtractRequest and BankTemplateConfig typed entity_labels as a bare list / list[dict], so the LabelGroup shape never appeared in the OpenAPI document — callers hit a validation error with no discoverable schema. Type both as list[LabelGroup] with a mode='before' validator that preserves the legacy free_values/multi_value input shape, and normalize the dry-run override back to plain dicts so the resolved-config path is unchanged. Regenerated OpenAPI spec, bank-template schema, docs-skill, and SDK clients. |
||
|
|
ba5a4813b3 |
fix(mental-models): never overwrite a document with a delta-window candidate (#3182)
* fix(mental-models): never overwrite a document with a delta-window candidate A delta refresh runs reflect with created_after = last_refreshed_at, so its candidate only covers memories newer than the last refresh. When the delta operations failed to reach the document, that candidate was written as the whole document and the watermark advanced past it — everything grounded in older memories was gone for good, while the log said "falling back to full synthesis" and the operation completed successfully (#3112). Refuse it instead, keyed on the window rather than on each failure branch so future ones inherit the guard: when delta was requested, was not applied, and the reflect window was narrowed, preserve the document and raise MentalModelRefreshError. The watermark stays put, so the retry reads the same facts again. Also: - Treat "the model emitted operations but every one was rejected" as a delta failure. The document is unchanged, so persisting it looked like a clean refresh while dropping that run's facts outside every future delta window. - Recover from an unusable structured_content by re-parsing the stored markdown instead of giving up — nothing else rewrites that column, so failing there wedged the model permanently. - Record skipped operations even when the delta did not land, count them in the operation's result_metadata, and warn when a partial skip means some of this run's evidence never reached the document. - Route every failure through one preserve-and-fail helper, so the structured-output failure now leaves the same reflect_response audit trail the other two already did. * docs(mental-models): describe what a failed delta refresh does to the document The delta section promised the opposite of what the code now does — "zero valid operations means an identical document … never corrupt it" read as a guarantee while a failed delta was in fact replacing the document with a partial one. Say plainly that the document is kept and the refresh fails, and list the two new diagnostic values. |
||
|
|
0ca0e87a08 |
fix(control-plane): report mental-model freshness from the bank write watermark (#3156)
* fix(control-plane): report mental-model freshness from the bank write watermark The mental-models card compared each model's last_refreshed_at against the bank's last_consolidated_at, so any consolidation after a refresh — nearly always — reported every model as stale, and a bank that had never consolidated reported the opposite. Computing the real per-model answer on a list is the expensive fix: compute_mental_model_is_stale has no index to use (there is none on memory_units.updated_at), so it scans the bank's memories in full, per model — 10ms per model at 100k memories, 101ms at 500k, on a view that polls every 5s. Report a bank-wide watermark instead. MAX(updated_at) rides along on the aggregate _compute_bank_stats already runs and is served from the same cached payload as last_memory_write_at. A model refreshed at or after it is up to date, exactly; older only means something was written, possibly outside its tags, so the card says "may need refresh" rather than asserting stale. The exact answer stays on the single mental-model read, behind the dialog. The knowledge-base tree ran that same scan once per page and polls every 12s — already a full scan per page per tick in production. It now shares the watermark: one cached lookup for the whole tree. Fixes #3139 * perf(reflect): skip the per-model staleness scan below the bank watermark search_mental_models computed staleness with the exact scoped query for every model it returned — up to 5 full scans of the bank's memories per tool call, serially, on a held connection, and the agent can call the tool several times per reflect. get_bank_freshness already computes the bank's write watermark in the same scan it runs once per reflect, and was discarding it. Thread it through: a model refreshed at or after the newest write in the bank cannot be stale whatever its scope, so it skips the query entirely. Everything above the watermark still gets the exact tag-aware answer — the agent only trusts a model without a verifying recall() when is_stale is False, so guessing conservatively here would buy LLM turns to save a query. * chore(docs-skill): re-sync the generated reference copies generate-docs-skill.sh output drifted from the docs pages that landed on main (retain narrator guidance, configuration, coding-agents). Regenerated so verify-generated-files has nothing to report. |
||
|
|
04b7c9a188 |
docs(retain): suggest a distinct document_id per source document (#3173)
* docs(retain): suggest a distinct document_id per source document Clarify the item-level document_id field: items sharing a document_id are grouped into one document, so callers should provide a distinct id per source document (auto-generated when omitted). No behavior change — mixed explicit/implicit batches stay backwards compatible. Refs #3010 * chore(clients): regenerate TS client for document_id doc update * chore(docs-skill): regenerate hindsight-docs skill references Picks up the document_id doc update plus pre-existing drift in the generated skill snapshot (retain.md, coding-agents.md) from prior merges. |
||
|
|
f77f2188d6 |
docs(retain): deprecate bank name as narrator; steer speaker via context (#3138) (#3155)
The bank profile `name` field is documented as a display label only, but at retain time `_resolve_narrator` silently uses it as the narrator (memory owner) whenever it differs from `bank_id` — the undocumented coupling reported in #3138. Stop advertising that path without changing any runtime behavior (100% backward compatible): - retain.md now steers speaker attribution solely through each item's `context`, dropping the advice to set a bank `name` as the agent's name. - The dry-run extract `agent_name` override is marked `deprecated` in the schema (still honored) and repointed to `context`. `_resolve_narrator` and the prompt injection are unchanged, so existing banks behave exactly as before. |