mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
docs-skill-openapi-0.10.0
454 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 |
||
|
|
7b25993f22 |
docs: changelog and blog post for v0.10.0 (#4245)
* docs: changelog and blog post for v0.10.0 * fix(changelog): batch a release's commits and summarize them in parallel The 0.10.0 range summarized to 37 entries, against 65 for the 145-commit release before it: whole user-facing fixes were unlisted. Measured on that range, holding model and prompt fixed, one call over the whole release produces 120 entries against 152 for five batches — 40 commits get an entry only when batched, against 8 only when not. The input was never the constraint: a 245-commit release is ~8500 tokens of commit subjects in total, so every arrangement fits the context window many times over. What batching bounds is how many commits the model weighs at once before it starts summarizing them away. * Commits are batched by token count and the batches summarized concurrently, followed by a dedup pass that merges the same change described twice across a batch boundary. The dedup result is re-ordered by the release's own commit order and filtered to commit_ids that were in the input, so a dropped or invented id cannot reorder the changelog or point a reader at a commit that isn't there. * max_completion_tokens=16000 was a *shared* budget on a reasoning model — reasoning bills against it, so a long commit list spends most of it before the first entry is emitted. Worth 8 entries on its own (37 -> 45). Raised to 100000. * The grouping rule read as licence to merge unrelated fixes that touch the same area, and "focus on user-facing changes" as licence to drop anything whose commit title sounds internal. Both are now scoped; this was the largest single effect (45 -> 126). Default model is now gpt-5.6-terra. |
||
|
|
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.
|
||
|
|
1f513a6393 |
feat(coding-agents): add ZCode as a supported harness (#4240) (#4258)
feat(coding-agents): add ZCode as a supported harness (#4240) Adds ZCode (Z.ai's GLM coding agent) to the shared coding-agents package, so its users get the same knowledge-page, reflect and configuration experience as every other supported agent instead of the standalone hindsight-zcode integration. Three hook registrations, a stdio MCP server and the companion skill, all in ZCode's own CLI config and home (~/.zcode) — never the user's real Claude Code settings, even though ZCode embeds the Claude Code agent runtime and speaks its hook protocol. Config hooks ship disabled, so the installer flips hooks.enabled; uninstall removes the block again when nothing else is registered there. The one genuinely new mechanism is a per-session TURN JOURNAL (core/turn-journal.ts). Every other hook harness hands its Stop hook a file holding the whole conversation, which is what the incremental write-back needs: retainLiveSession re-reads the full transcript and the retain cursor sends only the turns added since the last write. ZCode has no such file — Stop carries the reply plus a temp, assistant-only transcript it deletes as the hook returns, and no user prompt at all. So the plugin keeps the conversation itself: the prompt hook appends the user turn, the Stop hook appends the reply, and retain then reads it exactly as any host transcript. Nothing downstream changes. Two host quirks worth knowing, both verified against the runtime rather than assumed: - hook budgets are `timeoutMs` MILLISECONDS (30000/30000/60000), like qwen-code and unlike everything else; the declared timeoutUnit makes that checkable. - registrations use ZCode's "process" argv shape, not a command string — it spawns hooks without a shell, so `node "…/zcode-hook.js"` would be looked up verbatim as one executable name and never run. `--import-conversations` is deliberately unsupported and says why: ZCode persists no session transcripts, so there is no history on disk to backfill from. Verified against real ZCode 0.16.5: all three hooks fire with correct bank derivation, the journal captures the user/assistant pair, `zcode skills list` finds the companion skill, and all 8 hindsight MCP tools reach the model. A Docker E2E (e2e/Dockerfile.zcode) asserts injection AND retention end to end against the published tarball — fuller than grok-build, factory-droid and qwen-code, which are retention-only. Its entry script merges the stub provider into the config rather than rendering it, because that file is the same one the installer owns. Guard tests, since the sibling that forgets is the one nobody tests: a family-wide check that journalPrompt and retain.journal are declared together, and that a journal harness parses neither a host transcript path nor a Stop-event reply — both would be applied on top of the journal, and ZCode's own payload carries last_assistant_message. |
||
|
|
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 |
||
|
|
254bc988d4 |
feat(coding-agents): add Factory Droid harness (#4011)
Adds Factory Droid as a supported harness: three hook entry points on Droid's Claude-shaped protocol, a transcript reader for its JSONL sessions, installer wiring for ~/.factory (hooks.json, mcp.json, skills/), and the control-plane logo. Also lands, on top of the original contribution: - One harness roster for the docs site. The gallery card and the sidebar preview kept separate hand-maintained lists and both had drifted — dcode, opencode2, pi and prime-agent were each missing from one of them. Both now derive from src/lib/coding-agent-harnesses.ts, bound by a test to the registry that decides what `install <harness>` accepts, so a new harness cannot ship without its logo or its README entry. - A Docker E2E for the harness. Droid needs no Factory account for it: with a BYOK customModels entry it runs fully offline against the stub model. - A fix to the E2E runner, which drove the container with spawnSync and so starved the stub model living in the same process. That silently broke dcode, qwen-code and dsh; qwen-code's E2E passes again with it. Verified end to end against a real Droid session (recall injected, session retained) and with full CI on ci/4011-factory-droid: 109/109 jobs green. |
||
|
|
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.
|
||
|
|
7e17a120e2 |
chore: update the tagline to "Agent Memory That Learns" (#4093)
The startup banner, package descriptions, docs-site tagline and llms.txt still carried "Agent Memory That Works Like Human Memory". The project's one-liner is now "Agent Memory That Learns" — align every published copy so they don't keep drifting apart. Covered: the banner printed on API startup, the four package descriptions that surface on PyPI, the two bundle READMEs, the Docusaurus tagline, the llms.txt header, and the header the llms-full.txt generator emits. Adds tests/test_banner.py — there was no coverage of banner.py at all, and the tagline is the first thing a user sees. The gradient wraps every character in an ANSI escape, so the tests strip the escapes and assert on the text that actually reads, plus the gradient endpoints and the logo-above-tagline ordering. Confirmed they fail if the tagline regresses. Claude-Session: https://claude.ai/code/session_011mnzArjiCdBxa8dh1H47Fa |
||
|
|
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.
|
||
|
|
82729887a0 |
blog: Enterprise Features in Hindsight Cloud (#4004)
* blog: Enterprise Features in Hindsight Cloud An overview of the enterprise capabilities Hindsight Cloud offers, with each entry stating the failure it prevents rather than only what it does: - Single sign-on over OIDC or SAML, with domain verification, provisioning on first sign-in, and no password fallback once a domain is routed. - Organization-wide MFA enforcement across TOTP, WebAuthn, SMS and email, gated on the session having cleared a factor rather than merely having one enrolled. - Idle session timeout. - Owner, Admin and Member roles. - API key expiry and per-bank scoping, plus scoped child keys as the isolation primitive for multi-tenant applications. - Audit logging, opt-in per bank. - Memory Defense on the retain path, and the Enterprise detector set. - Bring your own cloud, with PrivateLink on AWS. Describes capabilities only. No compliance or certification claims, no regional hosting claims, and no implementation detail or internal identifiers. Cover is w03 (aperture): layers closing around a memory graph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: apply review feedback on enterprise features Four corrections from review: - MFA factors were wrong. The post listed authenticator apps, WebAuthn passkeys, SMS and email. Cloud offers two: an authenticator app (TOTP) and an emailed one-time code. The four-factor list was the underlying identity provider's capability, not what Cloud exposes. - Idle session timeout is now enabled for every organization, so it moves from Enterprise to all plans. - Webhooks were missing entirely. Added as an Enterprise section: events on completed retains and consolidations, HMAC-signed payloads, asynchronous delivery with retries and per-delivery history, and Memory Defense violation alerts that can be routed to a SIEM. - On-premise deployment was missing. Added alongside bring-your-own-cloud, for the case where data cannot leave your own hardware rather than just your own cloud account. The at-a-glance table, description, and FAQ are updated to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: describe private connectivity as provider-neutral The previous wording put the infrastructure template and private connectivity behind "on AWS that includes...", which reads as though those are AWS-only capabilities. They are not tied to one provider. Both are now described as part of the bring-your-own-cloud deployment generally: the deployment ships the templates you apply in your own account, and traffic uses whichever private-endpoint mechanism your provider offers. The FAQ answer is updated the same way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: drop the infrastructure-template paragraph from bring your own cloud Leaves the section on the two claims that matter: the deployment goes into your own cloud account, and private connectivity keeps traffic off the public internet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: date the enterprise features post 2026-09-03 The post was drafted on 09-01 and changed substantially after that across four rounds of review: MFA factors corrected, idle session timeout moved to all plans, webhooks added, on-premise added, and private connectivity described as provider-neutral. Dating it to publish day rather than draft day, before it goes live rather than after. Filename, frontmatter date and slug move together. The 09-01 cross-link to the June-August roundup is unchanged and correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f5f61fb48c |
blog: Four More Coding Agents That Remember Your Project (#4042)
* blog: Four New Coding Agents, and Four Ways to Fail Silently Covers coding-agents 0.5.0, which adds pi, Qwen Code, DeepAgents Dcode and opencode 2, bringing the package to 16 harnesses. The angle is that three of the four integrations, wired the obvious way, failed without producing an error: - opencode 2 rewrote the plugin contract, and handing either host the other's export loads a plugin that registers nothing and reports no error. Both CLIs also read the same config file, which v1 rejects outright if it contains v2's plugins key. - Qwen Code speaks Claude Code's hook protocol field for field, but its timeouts are milliseconds, so the usual values register 30ms hooks. It looks fine in testing because orphaned processes still finish. - Dcode's last_assistant_message is a Python repr, so encrypted reasoning payload was retained as the assistant turn and re-appended every turn. - pi and Prime Agent shared one package.json key, so the host it did not name loaded the other's bundle under the wrong harness. Screenshots are the install output and the three new hosts at startup. Captions describe what each frame actually shows and do not claim to show memory injection, which these captures do not. Cover is c03: the four harness marks converging on one memory core. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: reframe around the agents, not the integration work The first draft read as a bug-fix changelog: four sections, each about a trap we hit wiring a host. That is inward-facing, and it buries what a reader actually wants to know. Rewritten around three things: - How a coding agent works, and what it loses on every session reset. The mechanical part is rederivable from the code; the last mile is a decision that was never in the code at all. - What memory adds to that loop: cold seed at session start, recall per prompt, native tools during, write-back at the end. - Sixteen agents sharing one bank per repository, so switching agents costs nothing. This is the strongest point in the release and the first draft did not make it at all. Each new agent now gets described for what it is rather than for how it broke. Qwen Code's QWEN.md convention and Dcode's headless mode are used to make the memory argument concrete, which also gives the screenshots something to support. The integration difficulties are kept as one short closing aside with a single example, rather than the spine of the post. Title changed to match. Adds a comparison table for the four agents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
e393c4ceb2 |
blog: What's New in Hindsight Cloud — June-August Updates (#3951)
* blog: What's New in Hindsight Cloud — June-August Updates Resumes the "What's New in Hindsight Cloud" series, which last ran on 2026-05-19. Covers thirteen releases (v0.7.0 through v0.9.2): the client-managed knowledge base, cron-scheduled mental model refreshes, per-bank recall pipeline controls, the refreshed design system, Constellation, and bank-to-bank transfers. Every feature was verified to be an ancestor of the v0.9.2 tag, and each capability was checked against what Hindsight Cloud actually offers today, so the post describes only what a Cloud customer can use. Where a capability is available through the API rather than the console, the post says so. Cover is r02 (cream editorial). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: title the cover "What's New in Hindsight Cloud" Matches the post title and the series name, rather than referring to the console generically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: apply review feedback and re-verify every claim Review feedback: - Drop reversible curation. It is not in the console. - Cloud offers document export, not import. Rewrote the transfer section as export only and said so explicitly. - Drop recall pipeline controls. - Fact-check the rest for both API and console exposure. The audit found two more claims that are not in the console and removed them: the API version in the sidebar, and refresh operations recording what they changed. Re-verified as present and kept: knowledge base tree, page editor, page-level search, refresh triggers and staleness badges; the mental model refresh-trigger selector, cron schedule preview and next-refresh display; Constellation animation, inline detail and fullscreen; document tag filtering, entity timelines and in-flight retain badges; background document export with the optional-observations toggle; operation progress; observation scopes; and per-bank audit log. Dry-run refresh and dry-run extraction are labelled as API-side because that is where they exist. Cover updated to match the revised feature list. Three cross-links added and verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: add the enterprise features shipped in this window Adds four sections covering enterprise work that landed since the last roundup: - Single sign-on and MFA: bring-your-own IdP over OIDC or SAML, email domain claiming with DNS verification, provisioning on first sign-in, organization-wide MFA enforcement across TOTP/WebAuthn/SMS/email, and an idle session timeout for orgs with session-management obligations. - Audit logging: a queryable trail of retain, recall and reflect, opt-in per bank rather than all-or-nothing. - Memory Defense Enterprise: prompt injection guard, size anomaly, protected document tags, the broad pattern catalog, base64 decoding, and LLM screening. - Bring your own cloud: deployment into a customer AWS, GCP or Azure account, with PrivateLink from the application VPC on AWS. Described at the capability level only. Implementation details, internal identifiers and customer names are deliberately excluded. Cover and closing CTA updated to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: drop the enterprise CTA from the closing section The "Try it" section goes back to the signup link alone, matching the rest of the series. Enterprise gating is still stated inline where it matters, in the SSO/MFA and audit logging sections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: drop the bring-your-own-cloud section BYOC is not new, so presenting it as part of this window's updates overstates it. Removed the section along with its table-of-contents entry and the mentions in the intro and description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
71a9a7008f |
feat(coding-agents): first-class pi support, sharing one extension adapter and installer with Prime Agent (#3993)
Adds pi (@earendil-works/pi-coding-agent) as a harness and extracts createPiExtension(harness), the extension adapter pi and its fork Prime Agent share. Supersedes #3775. The `pi` key is removed from package.json: both hosts read that same key, so it could only ever name one bundle, and the host it did not name loaded the other's and reported the wrong harness. `hindsight-coding-agents install pi|prime-agent` is now the only route for both. Also makes the companion skill self-update for every host that installs one — the paths now live once in core/skill-dirs.ts, which both the installer and skill-sync read. Co-authored-by: Sebastian Otaegui <feniix@gmail.com> |
||
|
|
1aef228f7b |
feat(coding-agents): add qwen-code as a hook harness (#3979)
* feat(coding-agents): add qwen-code as a hook harness
Qwen Code speaks Claude Code's hook protocol field for field -- same stdin
envelope (session_id / transcript_path / cwd / hook_event_name), same
hookSpecificOutput.additionalContext + systemMessage output, same exit
semantics, and a settings.json shape byte-for-byte what cmdHook() already
emits. So HOOK_HARNESSES["qwen-code"] is claude-code's spec with three deltas,
none of which is visible from the diff:
1. TIMEOUTS ARE MILLISECONDS. Qwen passes a command hook's `timeout` straight
to setTimeout ("Timeout in milliseconds, default 60000" -- its own bundled
docs/features/hooks.md). Every other harness here is seconds, so the
installed values are 30000/30000/60000. Writing 30/60 would register 30ms
hooks, and that misconfiguration LOOKS fine in testing: Qwen spawns without
detached:true and kills only the direct child, so the orphaned work still
completes. HookHarnessSpec therefore declares `timeoutUnit`, and the
lifecycle tests normalise through it -- changing 30_000 to 30, or dropping
the unit, now fails a test instead of shipping dead hooks. The prompt budget
must also clear core/hook.ts's HOOK_REFLECT_CAP_MS (25_000).
2. parse reads `submitted_prompt`, not `prompt`. UserPromptSubmit also fires on
tool-result continuations, where `prompt` holds model-bound tool output;
keying on it would recall ~20x per user turn against tool results.
`submitted_prompt` is attached only when the turn is both the first and a
genuine userQuery. Accepted cost: it is the interactive TUI's projection, so
headless (`qwen -p`), serve, SDK and ACP sessions seed and retain but never
recall. The E2E declares injectsIntoModel: false for exactly that reason --
a different reason from grok-build's passive hook.
3. type:"user" is NOT a user turn. `provenance` is the discriminator: across a
22-transcript corpus, synthetic records (notification 285, cron 21,
goal_runtime 1) outnumber real_user ones (69) by 4.4:1. transcript-qwen.ts
gates on it, rejecting a present-but-malformed value outright rather than
falling through to a subtype heuristic, and requiring a positive subagent
marker (agentId/isSidechain) before accepting an absent one -- subagent
transcripts use a third envelope with neither provenance nor subtype.
Qwen also echoes injected context back into its own transcript, wrapped in
<qwen:user-prompt-submit-context> with the inner tags HTML-escaped, so the
shared stripInjectedMemory (which matches raw tags) cannot see it. The reader
removes it using the two forms of pairing evidence Qwen's contract defines:
systemPayload.hookContext present -> use systemPayload.displayText, the host's
own pre-hook projection, with no tag matching at all; otherwise a COMPLETE
tagged context in the FINAL part after at least one other part. It deliberately
does NOT match the tag as a substring -- the contract is explicit that the tag
is "a provenance marker, not ... a general trust boundary" and that consumers
"must not infer that arbitrary tag-like user text is hook provenance", so a
prompt merely quoting the tag is preserved intact.
Also here:
- registry.test.ts gains the REVERSE parity check. The existing one is
directional (installer -> registry), so a harness present in the registry but
missing from INSTALLERS passes it while `install <name>` returns "unknown
harness". That is exactly how this change was briefly broken.
- readQwenTranscript catches lazy-read faults. readJsonlTail guards
statSync/openSync, but the generator reads at iteration, and runRetainHook
calls the reader outside buildRetain's catch -- so a directory passed as
transcript_path (which passes both guards, then throws EISDIR on the first
readSync) rejected the whole Stop hook. NOTE: the underlying gap is in the
shared jsonl.ts and affects every harness; it deserves its own fix rather
than riding along here.
- Logo asset from homarr-labs/dashboard-icons (Apache-2.0, svg/qwen.svg).
Tests: 675 pass. Note HINDSIGHT_BANK_ID must be unset in the environment, or
config.test.ts's "missing files yield defaults" fails on the leaked value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WkVVSuv7j1FwTtVpkzzmWR
* chore(coding-agents): regenerate docs artifacts and apply prettier
Address the two CI-blocking review items on #3769:
- Regenerate the two artifacts derived from the README's new Qwen Code
section: hindsight-docs/docs-integrations/coding-agents.md (via
sync-coding-agents-doc.mjs) and the docs-skill copy (via
generate-docs-skill.sh).
- Run prettier over the package with 3.7.4 -- the version CI's root
`npm ci` pins -- covering the four files the review names, plus
package.json, where it restores the literal ellipsis in `description`
that the branch had accidentally left as a unicode escape sequence.
Verified: sync-coding-agents-doc.mjs --check, build-skill.mjs --check,
and prettier --check all pass; 675 tests pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qx1ByUMo3SJhXP7MBxEQ1g
* fix(coding-agents): close the qwen-code harness parity gaps
Rebased onto main (the branch was 98 commits behind and conflicted with the
dcode harness) and closed the sites a new hook harness must also appear in,
found by sweeping every place an existing harness is registered:
- `SKILL_DIRS` (core/skill-sync.ts) did not map qwen-code, so the skill the
installer copies into ~/.qwen/skills would have been installed once and then
never refreshed by `npm update -g`. Verified ~/.qwen/skills is Qwen's real
user-level skills root (Storage.getUserSkillsDirs, SKILL_PROVIDER_CONFIG_DIRS
= [".qwen", ".agents"]). Added a family-wide guard asserting SKILL_DIRS covers
every `installSkill` call site, with the five pre-existing gaps (copilot-cli,
grok-build, cline-cli, dsh, prime-agent) listed explicitly so a new harness
cannot silently join them.
- The docs site had no qwen-code icon, so the README's own logo would 404, and
the harness was absent from both coding-agent logo rosters.
- package-lock.json did not carry the three new bins.
Two comments asserted things that are not true of qwen-code 0.22.3 and are
corrected against its source: the hook runner spawns `detached` and calls
`terminateHookProcessTree` on timeout, so a milliseconds/seconds mix-up loses
the retain outright rather than orphaning it; and headless `qwen -p` DOES carry
`submitted_prompt` — the E2E is retention-only because the stub model echoes
only the first 20 000 characters of the request, which Qwen's system prompt
alone exceeds.
Verified end to end against a local API: installed the published tarball into a
container, ran `qwen -p`, and confirmed session_start / reflect_ok / pages_ok /
retain_ok, a document tagged `harness:qwen-code`, and zero injected-memory
leakage in the retained text.
Claude-Session: https://claude.ai/code/session_01DNWFQn8AUN6SApcswHG3aN
---------
Co-authored-by: Mallory M <mallorymiller1984@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4388e0f95b |
feat(coding-agents): add native DeepAgents Dcode integration (#3887)
Registers `dcode` (LangChain's deepagents-code) as a native Agent Plugin: root `plugin.json` contributing the shared skill, the Hooks V2 SessionStart/UserPromptSubmit/Stop lifecycle, and the `hindsight_*` MCP server, installed through Dcode's own marketplace/plugin manager. Includes fixes found by running it against deepagents-code 0.1.65: - Decode `last_assistant_message`. Dcode's transcript lags the Stop event, so that field is load-bearing, but it is computed as `str(content)` — a Python repr whenever the provider returns content blocks. It was retaining ~1.8KB of encrypted reasoning payload as the assistant turn, and never comparing equal to the transcript's clean text, so an already-flushed reply was appended again every turn. Guarded family-wide: any harness surfacing the field must declare a decoder. - Annotate the six read-only MCP tools with `readOnlyHint`. Dcode rejects unannotated MCP calls in headless mode, which made recall and the knowledge-page tools unusable under `dcode -n`. - Parity: local history import (attributed via `dcode threads list --json`, since the transcripts carry no cwd), a Docker E2E adapter and image, the harness icon on the docs site as well as the control plane, and a clean uninstall that also retires the marketplace it registered. - Document that Dcode cannot host the codebase survey: `hindsight_ingest_document` writes, so its headless runtime gates it by design; it falls back to another agent's CLI like eight other harnesses. Supersedes #3887. Co-authored-by: Paritoshdagar <paritoshdagar@gmail.com> Claude-Session: https://claude.ai/code/session_01DNWFQn8AUN6SApcswHG3aN |
||
|
|
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 |
||
|
|
56e8962fe9 |
blog: Cross-Encoder Reranking — The Last Stage of Agent Memory Recall (#3868)
* blog: Cross-Encoder Reranking — The Last Stage of Agent Memory Recall Deep dive on the reranking stage: cross-encoder vs bi-encoder, conditional score normalization, the multiplicative recency/temporal/proof-count boosts, the RRF-passthrough seeding fix, the candidate budget, and the reranker failover chain. All claims verified against the v0.9.2 tree, so nothing here documents an unreleased setting. Cover image is a placeholder pending final art. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja * blog: final cover for the cross-encoder reranking post Replaces the placeholder with c01 (navy funnel): 300 fused candidates narrowing to a reranked shortlist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AE4NCnCKXUzNDF3N9FaQja --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
944e793591 |
blog: How We Made Retain's Peak Memory Flat, from 4 MB to 90 MB Documents (#3839)
* blog: How We Made Retain's Peak Memory Flat, from 4 MB to 90 MB Documents * blog: use isometric before/after cover with v0.9.2 badge * blog: emphasize v0.9.2 on the isometric retain cover * blog: drop redundant v0.9.2 badge from retain cover |
||
|
|
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 |
||
|
|
dc1664d0c7 |
blog: Your Agent Can Now Manage Its Own Knowledge Base Over MCP (#3822)
* blog: Your Agent Can Now Manage Its Own Knowledge Base Over MCP * blog: use teal-indigo gradient cover matching the Knowledge Pages series * blog: use the versioned knowledge-page graphic on the gradient cover |
||
|
|
cea8caba66 |
docs: changelog and blog post for v0.9.2 (#3765)
* docs: changelog and blog post for v0.9.2 * docs: rework the 0.9.2 blog post and flag the last_refreshed_at break - drop the 'largest reliability release' framing and the movie-style section titles; plain, specific headings for a point release - Recall: name the actual field and shape, one bullet per change - replace the correctness section with Mental Models, covering the delta markdown corruption, retracted-fact unsay, the last_refreshed_at semantics, refresh pacing/dedupe and the new refresh outcome - retitle 'Built for Many Banks' -> 'Running Many Banks' - add 'Retain on Large Documents' for #3763 - promote last_refreshed_at to Breaking Changes in the changelog, where its commit already labelled it * docs: keep last_refreshed_at a bug fix, not a breaking change * docs: concrete opener and specific section titles for the 0.9.2 post - open on the actual defects (welded table, 6000 refreshes/day) instead of framing the release as four things the engine decided by itself - every section title now names what changed rather than the area it touched - drop the 15-of-16-constructs statistic from the markdown-corruption bullet * docs: lead the 0.9.2 post with features, not bug fixes Rebuild the post around what 0.9.2 adds: knowledge-base MCP tools, the caller-supplied recall window, the mental-model refresh floor and typed refresh outcome, the Copilot provider and asymmetric embedding prefixes, and the two new operator ceilings (retain memory budget, vector-index row threshold). The mental-model corruption, retracted-fact and last_refreshed_at narratives are gone; fixes now collapse into one closing line pointing at the changelog, which carries all 44 of them. * docs: regenerate the 0.9.2 changelog on the rebased main Picks up #3763, #3764, #3770 and #3783, which landed after the first run. Re-adds eight entries the summarizer dropped again: the Copilot provider and knowledge-base MCP tools (both headlined in the blog post), bank-list pagination, worker tracing, last_refreshed_at, the maintenance DDL deadlock, Vertex AI multi-region, and the Windows daemon windows. Also notes the sub-batch split streaming in the retain section of the post. * docs: add the 0.9.2 release infographic to the blog post * docs: sync the skill openapi.json to 0.9.2 release.sh regenerates the docs skill before bumping the OpenAPI version, so the skill's copy lags the release commit by one version. |
||
|
|
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 |
||
|
|
21fbb9f935 |
blog: Knowledge Graphs vs. Vector Search for Agent Memory (#3766)
* blog: Knowledge Graphs vs. Vector Search for Agent Memory * blog: use teal split-panel cover for KG-vs-vector post * blog: address BM25 objection (exact match is keyword's job, not the graph's) |
||
|
|
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. |
||
|
|
94017ede34 | docs: use the elizaOS brand mark for the integration icon (#3707) | ||
|
|
a9287a03a4 |
blog: Give Your elizaOS Agent a Long-Term Memory (#3705)
* blog: Give Your elizaOS Agent a Long-Term Memory * blog: use slate co-brand cover for elizaOS post |
||
|
|
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.
|
||
|
|
1f98da79da |
blog: Introducing Hindsight Academy — learn agent memory by doing (#3682)
* blog: Introducing Hindsight Academy — learn agent memory by doing * blog: use badge cover for Hindsight Academy |
||
|
|
a544b1965e | blog: Give Every Hermes Bot Its Own Memory (Bot Mode + Hindsight) (#3648) | ||
|
|
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. |