mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
c61c4e7d7d4296d9fa594bc2054419564d538672
2787 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c61c4e7d7d | release(coding-agents): v0.5.1 integrations/coding-agents/v0.5.1 | ||
|
|
0eb818d0ab |
fix(coding-agents): the runtime auto-update never fired — registry 406 on the /latest media type (#3997)
latestVersion asked /<pkg>/latest with accept: application/vnd.npm.install-v1+json, which the registry only serves for the packument — it answers 406 there. !r.ok returned "", indistinguishable from "no newer version", so the runtime silently never updated. Drop the header. The fetch stub now answers 406 to that media type like the real registry, which makes four existing tests fail on the 0.5.0 code. |
||
|
|
6da65bc194 |
feat(changelog): enumerate each release's migrations, with tables and volume (#3996)
Release notes said nothing about schema changes, so an operator had no way to tell from the changelog whether upgrading meant a long startup migration. Every release entry now ends with a "Database Migrations" section listing each Alembic revision added in the tag range, the tables it touches tagged with how much data they hold, and a link to the PR that introduced it. A release that alters a high-volume table also gets a warning line above the list. The section is enumerated from git (`--diff-filter=A` over the versions dir), never through the LLM: the same range must always produce the same list, and the volume of a table is a property of the schema rather than a per-run judgement, so it comes from a reviewed map that a test forces new tables into. Tables are read from Alembic ops and raw DDL/DML positions and intersected with that map, which drops prose and index expressions the loose SQL patterns pick up. Bare FROM/JOIN reads are ignored — a migration that defines a function reading memory_units neither rewrites nor locks it. DROP INDEX names no table, so the owner is recovered from the index identifier. Claude-Session: https://claude.ai/code/session_01J52s5Lbg5Lp7KwRyhqg8f3 |
||
|
|
208b8729b6 | release(coding-agents): v0.5.0 integrations/coding-agents/v0.5.0 | ||
|
|
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> |
||
|
|
ac41cee604 |
feat(extensions): add hindsight-extensions registry and unbundle Supabase (#3988)
* feat(extensions): add hindsight-extensions registry and unbundle Supabase
Extensions were only ever bundle-able or nothing: shipping one meant putting
it in `hindsight_api.extensions.builtin`, where it becomes maintainer-owned
forever, lands in every image, and — because `extensions/__init__.py` eagerly
re-exported every implementation — drags its dependencies into core's import
graph. That pipe is why a third-party IdP's JWT client was a direct dependency
of every Hindsight install.
Add `hindsight-extensions/` as the registry for extensions distributed
separately from the server. Its README is the contract: slots and how config
env vars map onto them, how to write an extension, the package layout and
naming (`hindsight-extensions/<name>/` -> `hindsight-ext-<name>` ->
`hindsight_ext_<name>`), and Docker packaging.
Move `SupabaseTenantExtension` there as the first entry, published as
`hindsight-ext-supabase-tenant`. All 54 of its tests move with it, plus two new
ones asserting the documented `hindsight_ext_supabase_tenant:...` env value
actually resolves through `load_extension`.
Two decisions worth their comments:
- The extension does NOT declare `hindsight-api-slim` as a runtime dependency.
The server is the host process that imports it, not something it installs;
declaring it would let `pip install` of an extension silently move the server
version underneath a running deployment. It is a dev extra, resolved from the
local checkout via `tool.uv.sources` (dev-only metadata, verified absent from
the built wheel).
- The Docker example installs with `uv pip install --python
/app/api/.venv/bin/python`, matching docker-compose/custom-models: the image's
venv was created by `uv sync` and ships no `pip`, so a bare `pip install`
lands in user site-packages and is invisible to the server.
Core changes:
- `extensions/__init__.py` and `builtin/__init__.py` export interfaces only.
Nothing needed the concrete re-exports — the loader imports by path — and
dropping them is what lets an extension have optional dependencies at all.
- `builtin/supabase_tenant.py` stays for one minor release as a module whose
`__getattr__` raises the migration instructions. `load_extension` wraps a
missing *attribute*, not a failed import, so the ImportError propagates with
its message intact instead of surfacing as "class not found".
- Drop the direct `PyJWT[crypto]` dependency: no core module imports `jwt` any
more. Note this does not shrink the install — `mcp` pulls pyjwt transitively
and `cryptography` is already pinned directly — so the win here is ownership
and import graph, not bytes.
Locks are not checked in for extensions: `tool.uv.sources` pins the whole
api-slim tree, so every core dependency bump would leave them stale. CI runs
`uv sync --extra dev` and retriggers on `core` changes, since these tests run
against the server's interfaces.
Docs point at the registry rather than restating it, and the Deploying section's
Docker recipe was replaced — it named an image (`vectorize/hindsight-api`) and a
PYTHONPATH volume-mount pattern that no longer exist.
Also includes two one-line generated-file syncs in skills/hindsight-docs
(quickstart, installation) that were already stale on main; regenerating the
docs skill picks them up.
* refactor(extensions): ship extensions by image, drop the compat shim
Follow-up on review. Three changes to how an extension is distributed:
- Delete `builtin/supabase_tenant.py`. An install pinned to the old path now
fails at startup with ModuleNotFoundError rather than a guided message. The
docs carry the migration instead.
- Extensions are not published to PyPI. There is no wheel, no version and no
release step: the unit of distribution is an image built on top of Hindsight
that installs the extension's dependencies and copies the package onto
PYTHONPATH. That drops the whole "declare hindsight-api-slim only as a dev
extra" problem — nothing resolves dependencies against a running server any
more.
- The pyproject is now test-harness only (`package = false`, no build backend,
no distribution metadata), and says so in a comment so nobody re-adds
packaging to it.
Docs say 0.9.3, not 0.10.
Since the Dockerfile is now the distribution mechanism rather than an example,
CI builds it — its final `import` step is the only thing proving the extension
is reachable from the interpreter the server actually runs. It builds against
`:latest-slim` via a HINDSIGHT_IMAGE build arg to keep the pull cheap.
Verified against the real image, not just locally:
docker build -f hindsight-extensions/supabase-tenant/Dockerfile \
--build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim ...
-> load_extension('TENANT', TenantExtension) inside the container returns
SupabaseTenantExtension with its config resolved from the env vars.
Worth noting from that build: `uv pip install 'PyJWT[crypto]' httpx` reports
"Checked 2 packages" — both are already in the base image transitively. The
line stays because the extension should pin what it imports rather than rely on
the server's transitive tree, but it costs nothing today.
56 extension tests pass; the 3 remaining core tests (which assert no
implementation is re-exported and no core module imports jwt) pass.
* fix(tests): import ApiKeyTenantExtension from its module, not the package
Dropping the concrete re-exports from `hindsight_api.extensions` broke
`tests/test_extensions.py`, which imported `ApiKeyTenantExtension` from the
package inside a multi-line parenthesised import. A collection ImportError
fails the whole shard, which is why all three test-api shards and all six LLM
acceptance jobs went red at once on the previous push.
I'd checked for this with a single-line grep, which cannot see a name inside a
parenthesised import list. Re-checked with an AST scan over every package in
the repo (this was the only occurrence) and by collecting the full suite:
7780 tests collect clean.
|
||
|
|
833d400f9b |
chore(coding-agents): sync generated files after per_source (#3872) (#3994)
#3872 merged while `verify-generated-files` was still running, and that job failed ~40s after the merge. Two generation steps had not been run: - prettier reformats the widened `ObservationScopes` union onto one member per line (the `prettier-int-coding-agents` task in scripts/hooks/lint.sh). - `./scripts/generate-docs-skill.sh` emits a third copy of the coding-agents page under skills/hindsight-docs/references/, which needs the `per_source` section like the other two. CI only runs on `pull_request`, so this drift is invisible on main but fails `verify-generated-files` for the next PR that opens against it. No behaviour change: formatting plus a regenerated doc. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n |
||
|
|
247e9b3138 |
feat(coding-agents): add per_source observation scoping (#3872)
* feat(coding-agents): add per_source observation scoping On a repo worked by a coding agent, commit diffs and session transcripts make different kinds of claim. A diff records what the code does; a transcript records what someone intended, argued for, or discarded. Under the `shared` default both consolidate into one undifferentiated belief set, so an idea floated in chat and never implemented is indistinguishable from a belief derived from the commits, and "what does the codebase actually do" cannot be answered from commit-derived knowledge alone. This cannot be fixed by configuration. The server treats an explicit scope list as unconditional: `_resolve_obs_tags_list` and `_resolve_write_scopes` in the consolidator both return the parsed list verbatim, without filtering it against the memory's own tags. A configured `[[], ["source:git"], ["source:chat"]]` therefore writes EVERY document into all three scopes, and the `source:git` scope fills with observations built from chat transcripts. Only a per-document decision separates them. `per_source` is resolved client-side, per document, in the new exported `resolveRetainScopes`, and never reaches the server. It expands to the global scope plus one per distinct `source:` tag the document carries, sorted — a document with two source tags (the commit-message seed keeps `source:git` alongside `source:git-log` so the cold-repo check still sees it) gets a scope for each, which needs no arbitrary tie-break and does not depend on the order the caller assembled its tags in. Reading only `source:` is what keeps this safe. `per_tag` splits on the right axis but also on every other one: it would reinstate the per-agent `harness:` fork that #3564 and #3575 removed, and any volatile tag such as a session id from `retainTags` would become its own scope — the fragmentation bug itself. The empty scope is always emitted first and unchanged, so the merged view matches `shared` exactly and the untagged observations that knowledge pages read (`tags_match: "all"`, per #3664) are unaffected. The cost is honest: one extra consolidation pass per document. That is the price of the axis, and it is why this is opt-in — `DEFAULT_OBSERVATION_SCOPES` remains `shared` and no existing value changes meaning. Closes #3871 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdYchB9yKUWw8oa16RWQu9 * fix(coding-agents): not every source tag names a kind of claim Running per_source on real repositories showed two of the five source tags are provenance labels rather than axes, and each produced a scope worth less than it cost. source:git-log is a bookkeeping alias. git.ts tags the commit-message seed with it AND source:git, so emitting a scope for each gave two near-identical belief sets — 307 observations against 302 on one repo — and doubled the consolidation for that pair. It is the same claim as source |
||
|
|
aee42254ab |
fix(recall): boost the prioritised arm in rank space, not score space (#3956) (#3987)
* fix(recall): boost the prioritised arm in rank space, not score space (#3956) `RECALL_STRATEGY_BOOSTS=graph:high` could exclude an entire retrieval arm from the cross-encoder rather than merely deprioritising it: on a ~15k-fact bank a reporter measured recall@20 falling 0.9667 -> 0.4000, with zero semantic-only candidates surviving the reranker cap on all 30 test queries. The stage-1 boost multiplied the arm's `1/(k+rank)` RRF contribution by a weight `w`, and that sort key feeds the hard RERANKER_MAX_CANDIDATES cut. RRF with k=60 is deliberately flat: across the 300-candidate cap window the score spans only 1/61 -> 1/360, a factor of 5.9. `high` used w=7, above that spread, so the sort degenerated into a lexicographic one -- boosted arm first, rank merely a tiebreaker -- and the boosted arm took every slot. The culprit is the `k` term: in score space the displacement reach is `r_max = w*(k+s) - k`, so at the head of the ranking the constant `w*k` dominates and the boosted arm's ~366th hit outranked the other arm's first. The levels were tuned against a bank with 336 merged candidates against a 300 cap (89% survival), where the cut could evict at most 36 candidates and the boost really was a reordering; nothing in the formula carried a pool-size term, so the calibration stopped holding as pools grew. Boost the rank instead -- `1/(k + rank/divisor)` -- which cancels `k`: the boosted arm's rank `r` beats another arm's rank `s` iff `r < divisor * s`. Displacement becomes proportional rather than an absolute offset, so it can never invert the head of another arm, and it no longer depends on the merged pool size or on `k`. Levels become divisors: low=2, medium=4, high=8. Replaying the reported shape (3563 merged candidates, 300 cap, 8.4% survival), old formula vs new on an identical pool: semantic-only kept top-20 semantic kept no boost 48 20/20 old graph:high 0 3/20 new graph:high 10 19/20 The boost still does its job: `high` protects the boosted arm to rank 267 against an unboosted baseline of 150. Also surface the cut in the recall trace as a `rerank_prefilter` phase (kept/dropped, the cap in force, active boosts, per-arm composition of the survivors). The boosts previously reached only the server log, so a trace -- where you look when ranking seems wrong -- gave no hint a boost was applied. Note the cap is now caller-supplied and budget-resolved, so it can be below 300 on a low budget, which made the old behaviour strictly worse than the figures above. Claude-Session: https://claude.ai/code/session_018HDqrzHgqZqsGc7EDqoTEu * chore(docs): regenerate docs skill for the --shm-size=1g install snippet Pre-existing drift, not introduced here. `hindsight-docs/docs/developer/` gained `--shm-size=1g` on the `docker run` snippet, but the generated `skills/hindsight-docs/references/` copies were never regenerated. `verify-generated-files` does not run on main pushes, so the drift stayed invisible until a PR touching hindsight-docs/** made the job run and regenerate everything. Committing the generated output unblocks CI. Claude-Session: https://claude.ai/code/session_018HDqrzHgqZqsGc7EDqoTEu |
||
|
|
c249d43796 |
coding-agents: support opencode 2, whose plugin API shares nothing with v1's (#3985)
opencode v2 (`@opencode-ai/cli@beta`, binary `opencode2`) installs alongside v1
and rewrote the plugin contract end to end: a v1 plugin is a function returning a
bag of named hooks, a v2 plugin is `{id, setup(ctx)}` where ctx hands out
per-domain registration. Handing either host the other's export loads a plugin
that registers nothing and reports no error, so v2 needs its own adapter — mapped
onto the same harness-agnostic RuntimeCore, which keeps recall, injection, the
native hindsight_* tools, the cold seed and write-back identical to every other
harness:
v1 chat.message -> ctx.session.hook("prompt")
v1 experimental.chat.system.transform -> ctx.session.hook("context")
v1 tool: {...} -> ctx.tool.transform(d => d.add(...))
v1 event (session.idle) -> ctx.event.subscribe()
v1 client.session.messages() -> ctx.session.context({sessionID})
Three host behaviours shaped the result, each verified against a live
opencode2 0.0.0-beta-18743:
ONE CONFIG ENTRY SERVES BOTH CLIS. v1 and v2 read the same
~/.config/opencode/opencode.json, and v1 REJECTS the whole file when it sees v2's
`plugins` key ("Configuration is invalid ... Unrecognized key: plugins") — so a
second entry is not an option. What makes one entry work is that they resolve a
plugin DIRECTORY differently: v1 follows package.json `main` (dist/index.js), v2
ignores `main` and loads <dir>/index.js. The package therefore ships a root
index.js re-exporting the v2 entry, and the path already in a user's `plugin`
array drives the right plugin under each host, with no config change.
THE EVENT STREAM IS GLOBAL; THE SESSION HOOKS ARE NOT. v2's background service
hosts every open project at once. `ctx.event.subscribe()` delivers session.idle
for sessions in OTHER projects, with no location on the envelope to tell them
apart, while prompt/context fire only for the location that loaded the instance.
Acting on those ids would fetch another project's transcript and retain it into
this project's bank, so write-back is gated on sessions our own prompt hook
admitted. This is a bank-isolation invariant, and it has a regression test.
TOOLS NEED options.codemode: false. Without it a tool is reachable only through
v2's `execute` code-execution tool, and a model calling it by name — which the
skill and the injected preamble both tell it to do — gets "Unknown tool:
hindsight_...".
Two capabilities are deliberately absent. The codebase survey gets no opencode2
recipe: v2 plugins cannot define an agent (agent.transform has no `add`) and a
config-file agent is not a sandbox either, since the user's own global
permissions are appended after an agent's rules — the survey reads untrusted repo
files, so without a guaranteed read-only boundary it falls back to another
installed agent's CLI, as it already does for kilo/cline/cursor/dcode. And there
is no toast: v2 plugins can observe tui.toast.show but not publish one, so the
seed banner is logged. v1's mid-session cadence write-back is also gone, because
v2 makes it redundant rather than because it was missed — session.execution.succeeded
fires after every assistant turn, so the idle path already runs once per turn
from the authoritative post-reply transcript.
Verified end to end against a real opencode2 and server: plugin loads reporting
harness opencode2, per-repo bank derived, cold seed and survey ran, the injected
block reached the model (it quoted it back verbatim), hindsight_ingest_document
was called by name, and session idle produced retain_ok. Also verified the staged
published-package shape loads under opencode2, and that opencode v1 still loads
dist/index.js and reports harness "opencode" with the new root index.js present.
Closes #3795
Claude-Session: https://claude.ai/code/session_01WbaYxouCERUv9djo3GnnA2
|
||
|
|
9bfad10a49 |
fix(reflect): name the sections array the document schema requires (#3984)
Document mode's output-format block spelled out every field of a *section* —
heading, level, blocks — and never named the `sections` array that holds them.
So the prose described a section while the tool schema described a document
containing sections, and a model resolving that disagreement in favour of the
prose emits the section as the document:
{"document": {"heading": "…", "level": 2, "blocks": ["…"]}}
`document_from_sections` reads `payload["sections"]`, finds nothing, and returns
an empty document. It renders to "", reflect raises ReflectNoAnswerError, and the
mental-model refresh discards the run and retries against the same prompt. Every
fact the model was asked for was present and correctly structured; only the
wrapper was missing. Observed from Gemini on the refresh path, where it fails the
whole refresh and burns the call again on retry.
Fixed at both ends, because they fail differently. The prompt now names the array
and shows the shape — this was the only output-format block in the file
describing a nested shape without an example of it, and an example is the part a
model copies. The parser takes a bare section as the single section it plainly
is: it is already documented as tolerant of model output, coercing a missing
heading, an out-of-range level and a non-string block, and a payload that IS one
section is the same class of near-miss — the one that currently costs the entire
refresh rather than one field of it.
Still not a licence to guess: a payload with neither `sections` nor `blocks`
stays empty, so an answer is never invented from something that is not a
document (#2959).
Verified against real Gemini locally: 12 document-mode refresh runs (6 on each
prompt) plus the new hs_llm_core reflect test, all green. The flattening did not
reproduce in that window — it is rare, and these runs are far too few to measure
a change in its rate, so the prompt half is argued from the text, not from them.
What they do show is that the corrected prompt and the normalisation break
nothing a real model does. The parser half is pinned by a unit test carrying the
exact payload captured from the failing run.
Claude-Session: https://claude.ai/code/session_01UBcDzagMhuXsYZDp63i7aB
|
||
|
|
b12b77dbfc |
fix(ci): stop one stalled Gemini call from outliving the caller, and isolate the third claim-test file (#3982)
* fix(ci): stop one stalled Gemini call from outliving the caller, and isolate the third claim-test file
CI has been red on every branch since this morning, in four jobs with three
independent causes. None of them is a product bug on the branch that trips them.
1. test-typescript-client, test-python-client(-oracle), test-doc-examples (cli)
— all reflect, all the same shape:
ERROR gemini_llm - Unexpected error during Gemini tool call: TimeoutError:
WARNING reflect.agent - [REFLECT ...] LLM error on iteration 2: (120002ms)
INFO memory_engine - [REFLECT ...] Complete: 448 chars, 2 iterations | 121.981s
Gemini sometimes accepts a request and never answers it. The per-request
deadline is the only thing that ends such a call, and the abort was terminal:
TimeoutError fell through to the generic handler, which re-raised it, so the
caller paid the whole deadline AND got an error — reflect then answered from a
degraded forced pass.
These stalls are not new. What changed is what they cost: #3946 made Gemini
honour the configured llm_timeout instead of its hardcoded 90s, so the same
stall went from 90s to 120s. Yesterday's green run has one at 83.994s, comfortably
inside the 120s these suites allow a test; today's has 121.981s, just outside it.
Two halves, at the layers they belong to:
* A deadline abort is now retried exactly once, in both Gemini call paths.
Nothing came back, so nothing about the request is implicated, and on a
healthy provider the retry lands in well under a second. One retry only —
tracked separately from the API-error ladder — because a second stall says
something about the provider, not about this request.
* Reflect gets its own per-request deadline default, 60s (DEFAULT_REFLECT_LLM_TIMEOUT).
It is the one interactive operation: a caller holds an HTTP request open while
it makes several sequential LLM calls, so a per-call deadline equal to the whole
global budget lets ONE stalled call outlive the caller. Retain and consolidation
keep 120s — they run in the background against a queue, where the deadline is
there to stop runaway generation. An explicit HINDSIGHT_API_LLM_TIMEOUT is still
inherited: an operator who set a global deadline meant it, and being quietly
capped below it would be the more surprising of the two behaviours.
2. test-api (3/3) — "expected only the oldest same-document retain, got []".
tests/test_retain_document_serialization.py claims queue-wide against the shared
public schema, so another xdist worker's poller can take its rows before it does;
its own claim then comes back empty and reads as "the predicate excluded them".
This is the third file with that defect: #3963 gave test_worker.py and
test_claim_bank_serialization.py a private migrated schema for exactly this, and
this one was missed. Same fix. Only the queue tests take `backend`; the
end-to-end append tests build their own engine and are untouched.
3. Core LLM tests — two assertions that test something other than their property:
* test_delta_editorial_fusion asserted `"keyword" in fused`. The refresh had kept
every SEO rule and written them as "search terms" / "search intent": the
guidance was there, only the token was missing. It now judges the fusion, per
CLAUDE.md — the structural asserts (no duplicate paragraphs, based_on counts)
stay direct.
* test_refresh_with_tags_only_accesses_same_tagged_models required the refreshed
content to mention four specific Alice details. It leaked nothing at all and was
reported as a SECURITY VIOLATION for summarising her more briefly. Tag scoping
guarantees that nothing outside the scope is reachable; which of Alice's own
details get written up is the summariser's call. The absence half stays strict,
the presence half is now loose.
Tests: new tests/test_gemini_deadline_retry.py pins both call paths (retry once,
then give up — not the whole ladder); TestReflectDeadlineDefault pins the three-way
resolution; the existing per-op defaults test now says which operation gets which
deadline instead of asserting they are uniform.
Claude-Session: https://claude.ai/code/session_0134gt96Tyi2Yi55yDH5s4Rb
* fix(ci): size the reflect deadline against the retry ladder, and rerun the model-behaviour job
First CI run on this branch made the mechanism visible and showed both numbers
were wrong.
The retry works — the log has stalls answered in ~3s on the next attempt, with
whole reflects landing at 64.0s and 69.9s where they would previously have been
errors at 120s. But the stall RATE is far higher than assumed: 5 stalls in one
doc-examples job, and 2 of those stalled again on the retry. 60s x 2 attempts is
120s, which is exactly the cliff the change set out to clear, so those two runs
failed identically to before (121.904s, 121.437s).
What has to fit inside the caller's patience is deadline x attempts, not one
deadline. Sized as such:
- DEFAULT_REFLECT_LLM_TIMEOUT 60s -> 30s, and _TIMEOUT_RETRIES = 2, so the worst
case is 3 x 30 = 90s. Healthy reflect calls in these logs answer in 1-4s, so the
headroom is still an order of magnitude, and a third consecutive stall is
~1 in 60 at the observed rate rather than the ~1 in 16 that a single retry left.
Second: `Core LLM tests` fails on a DIFFERENT test most runs — five distinct ones
across the two runs of this branch (a judge verdict on emotional dimension, whether
the agent called done() rather than being forced, a ReflectNoAnswerError, plus the
two fixed in the previous commit). Every test in that job asserts what a model
CHOSE to do, so one run cannot separate a regression from the model landing
differently, and a red there currently carries no signal. It now runs with
--reruns 2: a real regression reproduces and stays red, a coin-flip settles.
Deliberately not applied to the deterministic shards, where a rerun would hide the
races those tests exist to catch.
Claude-Session: https://claude.ai/code/session_0134gt96Tyi2Yi55yDH5s4Rb
* fix(tests): state the emotional-dimension threshold before the checklist
Last CI run left one failure, and the reruns made it diagnosable: three attempts,
all failing the same way, so not a coin flip.
It is a false negative. Extraction produced "Sarah seemed disappointed upon hearing
about the delay" and "Marcus felt anxious about the upcoming interview" — two of the
three emotional states, which is exactly what the criteria says it takes to pass —
and 3/3 judges still voted not-met, reasoning that the response "omits the emotional
states". Only the speaker's thrill was dropped.
The wording invited it: three states listed first, "at least two of these should be
present" trailing behind them. Judges anchor on the enumerated items and read the
missing one as the answer. Now the threshold leads and the three are a checklist
under it, with the failing condition spelled out (two or more stripped to the bare
event).
Claude-Session: https://claude.ai/code/session_0134gt96Tyi2Yi55yDH5s4Rb
* fix(tests): judge the fact sentence, not the rendering that restates the bare event
The previous commit's diagnosis was wrong. Reproduced locally against the real
model and judge: the criteria wording was not the problem, the response shape was.
Extraction is perfect. All three emotions survive:
TestUser was thrilled about receiving positive feedback on their presentation.
Sarah seemed disappointed upon hearing about the delay.
Marcus felt anxious about the upcoming interview.
But the test fed the judge each fact's full rendering, and every one of those ends
with a trailing dimension that is a bare restatement of the event:
... was thrilled about ... | When: ... | Involving: TestUser | Received positive
feedback on presentation.
The judge reads the restatement as the evidence and answers accordingly — "stripped
down the emotional states of 'thrilled' and 'disappointed' to the bare events, only
retaining 'anxious'" — about a response that says "was thrilled" and "seemed
disappointed" in as many words. The clause I added last commit ("FAIL only if two or
more were stripped down to the bare event") gave that misreading something to match,
so it made the failure more certain rather than less.
The emotional dimension lives in the sentence, so judge the sentence: one per line,
metadata dropped. The context block goes too — it restated the same three emotions,
leaving the judge holding them once as background and once as the thing to look for,
which is the shape build_judge_messages already documents as blurring the two.
Verified locally against the real pipeline: 4/4 passes, where the previous wording
failed 3/3 in CI and on the first local run.
Claude-Session: https://claude.ai/code/session_0134gt96Tyi2Yi55yDH5s4Rb
|
||
|
|
d936d4931c |
feat(webhooks): emit X-Hub-Signature-256 and a timestamped signature (#3986)
Webhook deliveries signed only `X-Hindsight-Signature`, a vendor name for a construction that is byte-for-byte the one GitHub popularised: `sha256=<hex>` HMAC-SHA256 over the raw body. Every receiver therefore needed a Hindsight-specific shim to verify a signature it already knew how to check. Emit `X-Hub-Signature-256` alongside it, carrying the identical value. Same secret, same algorithm, same bytes, so duplicating it grants no new capability to an attacker, and existing consumers of `X-Hindsight-Signature` keep working. Preferred over a per-webhook configurable header name: that would add a config field (plus migration, API, control plane, clients, CLI coverage, docs) to let users type the one string this already sends, and would leave every SDK verifier asking which header the sender was configured for. Two adjacent gaps found while in here: - The body-only signature has no notion of freshness, so a delivery captured off the wire stays verifiable forever. Add `X-Hindsight-Signature-V2` (`t=<unix>,v1=<hex>` over `<t>.<raw body>`, Stripe-style), signed at attempt time so retries re-sign. The timestamp is inside the MAC, so receivers can trust it and reject anything outside a tolerance window. The existing headers keep their body-only meaning — `X-Hub-Signature-256` is body-only by convention and must not be redefined. - `http_config.headers` was spread *after* `X-Hindsight-Event`, so a webhook's custom headers could overwrite the event type a receiver keys off. Spread user headers first and set the Hindsight-controlled headers after, so neither the event type nor any signature can be clobbered. Content-Type stays overridable (some receivers insist on a vendor media type; the body is JSON regardless). Document the whole header set with a verification example, which the webhooks page previously did not cover at all. The two unrelated `skills/hindsight-docs/` hunks are pre-existing generated drift from #3896, picked up by re-running generate-docs-skill.sh. Closes #3207 |
||
|
|
c42e6323e9 |
feat(llm): per-provider Codex credentials directory (#3793) (#3983)
* feat(llm): per-provider Codex credentials directory (#3793) Codex auth resolves `auth.json` from the process-wide `CODEX_HOME`, so every `openai-codex` provider a Hindsight process builds reads the same store. A multi-LLM chain of two Codex members therefore authenticates twice as the same ChatGPT account: when the preferred profile hits its usage limit, failover just retries it. There was no way to express "prefer profile A, fall back to profile B". Add `codex_home` alongside the existing per-member provider settings (`VERTEXAI_*`, `LITELLMROUTER_CONFIG`), which is all this needs — the routing half of #3793 already exists: - `HINDSIGHT_API_LLM_CODEX_HOME` for the primary - `HINDSIGHT_API_LLM_<n>_CODEX_HOME` for indexed members (also under the `RETAIN_` / `REFLECT_` / `CONSOLIDATION_` prefixes) Each falls back to `CODEX_HOME`, then `~/.codex`, so nothing changes for existing deployments. Refresh is already coordinated per auth-file path (`_path_scoped_lock`), so two profiles refresh independently and cannot overwrite each other's tokens. The field is server-level only, deliberately not in `_CONFIGURABLE_FIELDS`: it is a filesystem path to a credential store, and accepting it over the bank config API would let a bank point the server at an arbitrary file. Scope: this is the credential-store seam only. Failover keeps its existing generic semantics — any `Exception` from a member advances to the next, with no quota-vs-terminal classification and no cooldown, so a rate-limited primary is re-tried at the head of every request before the fallback serves it. Batch retain already stays pinned to the member that submitted it, so batch affinity holds. The `openai-codex` embeddings provider still reads `CODEX_HOME` only — it has no member chain to span. Closes #3793 Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk * fix(llm): append codex_home instead of inserting it mid-signature `create_llm_provider` and `LLMProvider.__init__` still have callers that pass the older settings positionally, and two guard tests in test_llm_wrapper.py assert exactly that. Inserting `codex_home` before `vertexai_project_id` pushed every later parameter one slot along, so a positional `timeout` landed on `cache_affinity` (`assert 120.0 == 7.5` / `assert None == 7.5`). Move it to the end of both signatures and leave a comment saying why new parameters go there, since the mistake is invisible at the call sites that use keywords. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk * chore(docs): regenerate docs skill for the --shm-size docker examples Drift inherited from main: the `--shm-size=1g` flag was added to the docker run examples in installation/quickstart without re-running the skill generator, so verify-generated-files is red on every PR branched after it. Regenerated output only — no source docs changed here. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk |
||
|
|
b75e941916 |
fix(worker): rotate slots across banks so bulk ingest stops starving them (#3861) (#3980)
* fix(worker): rotate slots across banks so bulk ingest stops starving them (#3861) Claiming was a strict global FIFO on created_at, so a bank under sustained bulk ingest held every worker slot for as long as its queue lasted. Measured on a six-bank instance: one bulk bank owned 17 of 17 retain slots in 90.6% of daily samples, and a write to a bank with an empty queue timed out at 300s behind the backlog. The queue drains correctly once ingest stops — this is fairness, not correctness. Deficit round robin, with a quantum of one slot. Every operation costs exactly one slot, so DRR's deficit counter is always zero and drops out; what is left is the round-robin walk. The poller keeps a cursor over the bank id space per schema — one level below the tenant rotation it already had — and the claim takes one row for the first bank sorting after it. Both tiers are one statement: a `rot` CTE (bounded index seek past the cursor) unioned with the `fifo` claim that was always there, joined back and locked with FOR UPDATE OF ... SKIP LOCKED. Same query count as before. 0.10ms against 0.02ms for the bare FIFO claim, at 50k pending rows. The cursor is a *range*, not a set of known banks: the starved bank is by definition one this worker has never claimed for, so only a range can discover it. And `fifo` is what makes the rotation safe at both ends — it is the wrap (once the cursor passes the last bank, `rot` matches nothing and `fifo` claims the whole pool, so the end of a round costs neither a query nor an empty claim, and a single-bank deployment sits in that state permanently), and it is what keeps this work-conserving: a bank alone with work still takes every slot, so nothing is throttled and no slot is held open for an idle bank. Rejected along the way, both measured: an ordering predicate that re-scanned the bank's queue per candidate row (11s at 10k pending), and a separate seek before the claim (correct, but a round trip on every claim, for every schema, on every poll). No new index — `idx_async_operations_bank_status` and `idx_async_operations_bank_created_desc` already serve both branches. Oracle keeps the plain FIFO claim, deliberately: its ROWNUM rewrite for FOR UPDATE + LIMIT applies before ORDER BY, so a rotation there would land on whichever bank is scanned first — under bulk ingest, the one it exists to rotate away from. claim_tasks now returns ClaimedOperations (rows + the rotation's next cursor) rather than a bare row list, so learning where the rotation got to costs no second statement; callers updated. Claude-Session: https://claude.ai/code/session_017ufCz6qrNxn36Stug7ek8A * chore(docs): regenerate the docs skill for the worker-tuning paragraph hindsight-docs/docs/developer/api/operations.mdx is the source; the skill reference is generated from it by scripts/generate-docs-skill.sh, and verify-generated-files fails on the drift. Claude-Session: https://claude.ai/code/session_017ufCz6qrNxn36Stug7ek8A |
||
|
|
173ed6a93c | fix(coding-agents): annotate MCP tool safety (#3818) | ||
|
|
88f1472e52 |
docs(docker): reserve shared memory for embedded PostgreSQL (#3896)
* docs(docker): reserve shared memory for embedded postgres * docs(docker): keep shared memory guidance concise |
||
|
|
5ceca53e93 |
fix(coding-agents): survey prompt says Glob, not Read, for the directory layout (#3796)
The cold-repo survey spawns a headless `claude -p --model haiku --allowedTools Read Glob Grep` session. SURVEY_PROMPT asks the model to understand "the directory layout" but never says which tool shows it, and haiku reaches for `Read(<repoRoot>)` — a bare directory path, which errors with EISDIR before the survey has read anything. Measured on one machine's OTel export, 2026-08-23..25: 10 distinct survey sessions failed this way on first tool call, across kids-yt-factory, hindsight, sitebrain.eu, PlanView, Meridian and four .traycer worktrees — i.e. every repo the survey ran cold against, plus a fresh worktree each time (a new worktree is a new bank, so the survey re-runs). Glob was already in --allowedTools; the model was simply never told to prefer it here. This is a prompt-level counter-instruction, not a structural guarantee — the robust fix is at the tool-wiring layer (deny Read on a directory, or give the survey an ls-shaped tool), which this does not attempt. |
||
|
|
c507e70e34 |
fix(mental-models): skip the reflect loop when the scope holds nothing to read (#3875) (#3943)
A refresh with nothing in scope is the reflect agent's worst case, not a cheap one. The forced retrieval turns all come back empty, and the evidence guardrail then refuses every `done` call — evidence is exactly what cannot be gathered — so the loop runs to its iteration limit and pays a forced synthesis on top. Creating a knowledge page enqueues its refresh immediately, so a bank created with its default pages spent its whole LLM budget on five worst-case reflects over an empty graph: the budget it needed to ingest the content those pages were waiting for. Ask first whether the model's own flags leave anything to retrieve. The check reads the resolved scope, not the bank: tags/tags_match, tag_groups and fact_types bound which memory units the agent's tools can return, and the window bounds both retrieval tools — open in full mode, the watermark window in delta mode, using the same `updated_at` predicates as the recall arms. With `exclude_mental_models` off, a sibling document with real content is a source (`search_mental_models` applies no time bound, so that holds in delta mode too); one still holding the `Generating content...` placeholder is not. It costs nothing on a refresh that goes on to reflect: the check decides on the `MAX(updated_at)` the refresh already runs for its watermark. That reading now travels out unclamped (`_MentalModelScopeWatermark`), because the clamp that stops a watermark regressing destroys precisely what the check needs. Only a scope with no readable memory pays a query, and only while sibling documents are in reach. The reflect call is gated rather than the function returning early, so the delta legs still run — a retraction is a reason to edit the document by itself. Full mode returns `content_preserved_no_new_facts` before the empty-candidate guard, which raises and would make the worker retry identical inputs; reusing the outcome the delta leg already reports for an empty window keeps this off the API surface. |
||
|
|
aabbe8b225 |
fix(retain): stop deriving the extraction narrator from the bank's display name (#3962) (#3978)
* fix(retain): stop deriving the extraction narrator from the bank's display name (#3962) Retain primed fact extraction with a "Narrator: {name}" line built from `banks.name`. That line tells the model first-person statements are the narrator's own actions, and the name it carries is stamped into the who-dimension of every fact it produces — and into the observations later consolidated from those facts. But `name` is a display label. Nothing reads it except the bank selector and the `?q=` filter, the update-profile field already documents it as "display label only, not advertised", and the control plane has no UI to set it — so callers set it through the API/CLI to whatever identifies the bank to them. A project label like "AuditProject_0825" then appears verbatim in memories that never mentioned it: Dispatch has arranged a truck ... | Involving: AuditProject_0825 (dispatch), user #1680 already hit the same edge from the other side and suppressed the narrator when `name` defaulted to the bank_id (typically a routing key). The underlying coupling was the bug: retain now passes no narrator at all. A caller who genuinely wants to name the speaker says so in the item's `context`, which extraction already reads and prefers over the narrator — the same channel the dry-run endpoint's deprecated `agent_name` override already points people to. Changes: - `retain_batch` no longer resolves a narrator, so `_resolve_narrator`, the `_NARRATOR_UNRESOLVED` sentinel and the `agent_name` plumbing through `_streaming_retain_batch` / `_try_delta_retain` / `_extract_and_embed` are gone. This also drops a bank-profile read (and its pooled connection) from every retain. - `extract_dry_run` mirrors retain: no narrator unless the caller passes one. The deprecated `agent_name` request field still overrides it, so the HTTP API is unchanged (no schema change, no client regeneration). - `extract_facts_from_text` keeps its `agent_name` parameter — it is the dry-run override and the Narrator line still works when asked for. Tests: `test_narrator_resolution.py` keeps the prompt-assembly unit tests and replaces the `_resolve_narrator` cases with a retain-level regression test — a bank named "AuditProject_0825", a spy on the narrator handed to extraction, and an assertion that the name is absent from stored units. Verified it fails when a narrator is reintroduced. Closes #3962 Claude-Session: https://claude.ai/code/session_0134gt96Tyi2Yi55yDH5s4Rb * refactor(retain): drop agent_name from the contents-level extraction API Follow-up to the previous commit, which left `extract_facts_from_contents` taking a narrator that its only caller now passes as a bare `None`. A parameter no caller can populate is worse than none: it reads as a supported knob and invites someone to wire the bank name back into it. - `extract_facts_from_contents` and `extract_facts_from_contents_batch_api` no longer take `agent_name`. The retain orchestrator calls them without it. - `extract_facts_from_text` keeps the parameter — it is the dry-run endpoint's deprecated override — but it moves after `config` and defaults to `None`, so callers that don't want a narrator simply omit it. Every caller already passed it by keyword, so the reorder is safe. - `_extract_facts_from_chunk` / `_extract_facts_with_auto_split` had `agent_name: str = None`, which was never a valid annotation; now `str | None = None`. - `extract_dry_run` passes `agent_name` straight through instead of coercing `None` to `""`. The #3962 regression test spies on `_build_user_message` instead of on an `agent_name` argument that no longer exists, and asserts no prompt retain builds carries a `Narrator:` line or the bank's display name. Re-verified it fails when a narrator is reintroduced. Claude-Session: https://claude.ai/code/session_0134gt96Tyi2Yi55yDH5s4Rb * fix(tests): drop agent_name from the direct _streaming_retain_batch call test_consumer_failure_cancels_in_flight_extractions calls the internal helper directly, so it kept passing a keyword the previous commit removed: TypeError: _streaming_retain_batch() got an unexpected keyword argument 'agent_name'. Caught by test-api shard 3. Claude-Session: https://claude.ai/code/session_0134gt96Tyi2Yi55yDH5s4Rb |
||
|
|
17fbc4f147 |
fix(bank-template): decide template-import writes from state read inside the authorization scope (#3957) (#3971)
Template import classified each imported mental model and directive as a create or an update from a snapshot taken before authorization and bank provisioning, then wrote against that stale classification: a concurrent create turned the import's create into a unique violation, and a concurrent delete turned its update into zero rows updated and a failing refresh. Re-read the committed state inside the authorization scope, after _ensure_bank_exists and the server default template, immediately before the writes, and decide against that. This subsumes the narrower re-read that only covered resources projected to collide with the default template. The preauthorization still names one operation per resource, from the earlier snapshot. When the fresh read flips a resource, the flipped operation gets its own decision through MemoryEngine.authorize_bank_template_import_write rather than a blanket grant for both outcomes: validate_bank_write is a per-operation hook that may reserve quota, so an update-only import must not be charged for creations it never performs, and must not be rejected by a validator that denies create. The default-template projection keeps its dual authorization -- that collision is deterministic, and authorizing both ahead of provisioning keeps every client check before bank creation. Fixes #3957 Co-authored-by: Nicolò Boschi <boschi1997@gmail.com> https://claude.ai/code/session_01DNWFQn8AUN6SApcswHG3aN |
||
|
|
db87a74675 |
fix(structured-output): strip $ref sibling keywords from strict schemas (#3944)
Retain failed with HTTP 400 from OpenAI-compatible providers whenever a bank
configured entity labels and strict schema was on:
Invalid schema for response_format 'response':
context=('properties', 'labels'), $ref cannot have keywords {'description'}
The dynamic LabelsFact model gives its `labels` field both a nested model and a
Field(description=...), which pydantic serializes as
{"$ref": "#/$defs/Labels", "description": ...}. JSON Schema 2020-12 allows
sibling keywords next to a $ref; OpenAI's strict subset does not, so the request
was rejected before inference and the API returned 500 "Fact extraction failed".
Normalize in strict_json_schema() rather than at the call site: a $ref node now
keeps only $ref, wherever one is generated. That covers all five providers that
serialize through it (openai_compatible, openai_responses, litellm, codex,
xai_oauth) and any future nested model with a described field. The traversal
special-cases $defs/properties/patternProperties so a property literally named
"$ref" is not mistaken for a reference node. Only annotation keywords are ever
dropped — nullable and list-wrapped fields keep their descriptions on the
anyOf/items wrapper, which the pass leaves alone.
Tests: a unit regression asserting no $ref node has siblings, plus an hs_llm_mat
end-to-end retain into a labelled bank with strict schema on. Marked mat rather
than core because the core job runs vertexai/gemini, whose provider never calls
strict_json_schema() — which is why the existing entity-label LLM tests never
caught this. Verified against real OpenAI gpt-4.1-nano both ways: reverting the
fix reproduces the production error verbatim.
Fixes #3904
|
||
|
|
eff3546ecd |
fix(consolidation): resolve observation_scopes before tag-set batching (#3954)
Fixes #3953. `run_consolidation_job` grouped memories into LLM batches by their raw native tag set, then resolved the observation scope for the whole batch once, from `sub_batch[0]`. Two consequences, both verified against main: - `observation_scopes="shared"` facts carrying different native tags were split into separate LLM calls and could never be merged into one shared observation — the issue as filed. - Worse, and not in the issue: memories with the *same* tags but *different* `observation_scopes` landed in one group, so `sub_batch[0]` decided for all of them. With a `shared` memory first, a default `combined` fact tagged `user:alice` produced an untagged, globally recallable observation; with the `combined` one first, the `shared` override was silently dropped. `observation_scopes` is settable per retain item, so mixing is reachable. The fix groups on the resolved target scope rather than native tags, keying both falsy resolutions (`combined`, and a degenerate explicit `[]`) the same way the pass loop treats them. `_resolve_write_scopes` now reports the combined fallback scope for an empty explicit list too — it previously returned `[]`, so such a group took no lock at all for the scope it writes. Defence in depth: the sub-batch loop derives, from the pass loop rather than from the grouping key, the scopes each member will actually be written at, and splits the batch (with an error log) if they disagree. Correctness no longer depends on the grouping key being right — a future regression costs an extra LLM call instead of leaking an observation across scopes. Coverage, each part checked by reverting it: an exhaustive no-DB invariant over tag sets crossed with every `observation_scopes` shape ("equal batch key implies equal written scopes"), which fails on 13 cases against the old native-tag key; the signature pinned literally per mode; and end-to-end tests for the pooling fix, the empty-list case, and the runtime split under a deliberately broken key. Full CI dispatched on this SHA (fork PRs skip test-api): all three test-api shards green with the new tests executed. The three red jobs — Core LLM `test_delta_fuses_seo_and_brand_voice`, python-client reflect timeouts, and doc-examples "Cannot connect to Hindsight API" — fail identically on unrelated branches in the same window and are not caused by this change. |
||
|
|
c23e98856b |
fix(coding-agents): stop a failed git probe from forking a worktree into its own bank (#3981)
A transient failure of the single `git rev-parse --git-common-dir` probe silently changed a repository's bank identity: `getProjectRootFromGit` mapped every failure to `null`, indistinguishable from "not a git repository", so `gitProjectName` took the basename fallback and a linked worktree was retained into a brand-new bank — permanently, with no log line, no diag event and no retry. One repository in the wild accumulated eight stray `coding-agent::<repo>-wtN` banks this way. Both triggers were load-dependent and silent: the 1000 ms timeout elapsing under machine load, and `execFileSync` failing to spawn at all (EAGAIN under process pressure). Read the repository layout instead of spawning git (core/git-layout.ts). `.git` is either the git directory or a one-line pointer to it, and a linked worktree's git directory names its repository in `commondir` — so the answer is a handful of fs reads against a format git itself guarantees, with no subprocess, no timeout and no spawn to fail. A pure-JS git library was considered and rejected: isomorphic-git models objects and refs, not worktree discovery, so it answers a different question at a much larger cost in a package bundled into every hook process. On top of that, the fix the issue asks for: - the probe distinguishes "not a repository" from "could not tell", and only the former reaches the basename fallback; - transient errors retry with backoff before the probe gives up; - a failed probe never guesses: resolution throws BankResolutionError and every entrypoint goes through `deriveBankIdOrSkip`, so the lifecycle hooks skip the session (recoverable) rather than scatter it (not); - the skip logs a warn and a `bank_unresolved` diag event. Also rejects a common dir that no longer exists, so a pruned worktree can no longer be named after its own dangling `.git/worktrees/<name>` — the same wrong id by a different route. Tests cover real repos/worktrees/bare hubs (including resolution with PATH emptied, proving nothing spawns), the retry and failure classification, the refusal to guess plus its diag event, and a family guard asserting no module outside core/bank.ts calls the throwing form directly. Fixes #3950 Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n |
||
|
|
97ae1d08c5 |
metrics: time the operation-validator hooks, which run outside the operation's timer (#3976)
* metrics: time every operation-validator hook, by wrapping the extension once A recall's `[phases]` line accounts for its whole duration -- `accounted=427ms of 433ms` is typical -- and that is what made this hard to see: the line measures the INNER search (`_search_with_retries`, which owns `recall_start`), while `validate_recall` and `on_recall_complete` run in `recall_async` on either side of it. Everything the validator does falls outside the number, so an instrument that reads as complete covers a fraction of the request. It is not a hypothetical gap. The credits validator reaches the CONTROL database on both hooks, uncached: an org row and the whole `billing_config` table before the query, the same two again after, then the balance UPDATE and its ledger INSERT. Five queries on a `max_size=10` pool -- the pool auth shares and caches for exactly this reason -- all awaited inline, none of it visible. **Wrapped once where the engine takes the extension, not timed at each call site.** The interface has nineteen hooks and the engine calls them from many places; hand-instrumenting the two that prompted this leaves the other seventeen dark and guarantees the twentieth is added without timing, which is how the gap appeared in the first place. Wrapping the instance covers a new hook the day it exists. Hooks are recognised by shape rather than listed, for the same reason. Wrapping is applied to the INSTANCE, so `isinstance`, attribute access and non-hook methods are unchanged; idempotence is tracked in a WeakSet rather than a flag on the extension, because the extension is someone else's object and should not carry an attribute its own code can trip over; and an OVERRIDE is wrapped rather than the default it replaced, which matters because the overridden hook is the one working. Timed in a `finally`, so a REJECTING hook is measured too -- that is the 402 path, and it is the whole cost the caller pays. A metrics backend that is down cannot fail the request it measures. `test_every_hook_on_the_interface_is_instrumented` defines "hook" independently of the instrumentation's own predicate. Asking that predicate would be circular: narrowing it narrows the expectation with it, so the test passes while coverage shrinks. It did exactly that until rewritten -- verified by mutation, which now names all nine hooks it would have missed. Claude-Session: https://claude.ai/code/session_01V3ViCQDM6pPSCfntZam3Kg * metrics: make the wrapping fail-safe, and time it on a monotonic clock Two follow-ups on the same principle the module already states for the recording side -- instrumentation must never be the thing that breaks what it measures -- applied to the wrapping itself. The extension is loaded from an env var and is someone else's class, so an unhashable, non-weak-referenceable or slotted one now degrades to "not timed" instead of raising inside the engine constructor. Durations move from `time.time()` to `time.perf_counter()`: a wall clock that steps backwards mid-hook records a negative duration into the histogram, and the hooks being measured are short enough for that to matter. `_is_hook` also skips an already-wrapped method, so idempotence survives a validator the WeakSet could not track. Claude-Session: https://claude.ai/code/session_012gXUp1i7YrWmLJVUYki53g |
||
|
|
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>
|
||
|
|
24adcfce7e |
fix(engine): authorize and provision banks before legacy mutations (#3969)
The legacy bank write paths (update_bank_disposition, set_bank_mission, and merge_bank_mission) previously delegated directly to lower-level bank_utils, which silently created bare bank rows when a bank was missing. This bypassed validate_create_bank, _ensure_bank_storage, and default bank templates. Additionally, update_bank_disposition mutated state before the HTTP response handler performed GET_BANK_PROFILE read validation, leaving modified data or new banks behind when read permission was denied. Similarly, merge_bank_mission read existing missions and called LLMs without prior read authorization. - Pre-validate GET_BANK_PROFILE read authorization in update_bank_disposition and merge_bank_mission before performing mutations or external LLM calls. - Call _ensure_bank_exists before mutation in all three legacy engine methods, ensuring validate_create_bank and default template application run on touch. - Add integration tests covering read rejection atomicity, create rejection, and default template application across legacy write operations. |
||
|
|
0c2f9e7c2f |
fix(coding-agents): only ever ADD to a bank's config, never overwrite it (#3966)
Closes #3927. `configureBank` runs on every session start, and it re-sent the plugin's five retain strategies and its `knowledge` entity-label group as whole values. The server stores each of those as ONE config value, so an import replaces the map outright: a strategy the user had defined was deleted, their edits to the plugin's own strategies (mission, extraction mode, chunk size) were reverted, and `retain_default_strategy` could be left naming a strategy that no longer existed — silently, once per session, on whatever bank the plugin was pointed at. Pointed at a global bank shared with non-coding work, the plugin took the bank over. This is the third fix for one shape. #1270 covered OpenClaw's missions, #2492 this plugin's; each protected only the fields that had just been noticed, and everything else kept being stamped back. So the rule is now the whole surface rather than a list: read the bank's current OVERRIDES and write only where the bank is silent. `codingBankManifest()` returns the template fields still missing, or nothing at all — a settled bank now makes no import call. The container fields merge per entry, which keeps the reason the re-apply exists: a strategy ADDED by a newer release still reaches an existing bank. What it gives up is deliberate — a release that REWORDS an existing strategy or label does not reach a bank that already has it; clearing that override takes the current default back on the next pass. `manageBankConfig: false` keeps the plugin out of the bank's configuration entirely, for a bank whose owner shapes it themselves. That bank should then define the strategies this plugin retains under, and the cost is documented as the silent one it is: the server does not reject a retain naming a strategy the bank lacks — `apply_strategy` logs a warning and extracts with the bank's own config, so a commit diff, a session transcript and a survey marker would all get the same generic treatment. (The claim that such a retain is *rejected* was inherited from #3352's rationale and is wrong; corrected here.) Knowledge pages are seeded either way: they are not bank configuration, and `pageTriggerType` already governs what they cost. |
||
|
|
a373ffab61 |
fix(knowledge): switch native knowledge BM25 from websearch AND to disjunctive OR (#3945)
On the native text-search backend the knowledge-page BM25 arm bound the raw query to websearch_to_tsquery, whose default conjunction requires every term to match. Ordinary multi-word questions therefore produced no candidates at all: the arm dropped out of the RRF fusion, and the no-embedding fallback returned nothing. It now tokenizes and ORs the terms, exactly as the memory-recall arm does. Review follow-up in the same PR: the tokens -> pg_stats term selection -> prepare_bm25_text sequence is hoisted into a single build_bm25_query_text() that both recall and knowledge search call, since keeping two copies of it is how the two arms drifted apart in the first place. The BM25 arm is now dropped whenever the query has no word characters on every backend (recall's gate), the dialect is built from the connection rather than the engine-wide one, and the docstrings say plainly that knowledge search runs no reranker. Covered against a real database in test_knowledge_base.py::TestSearch: a natural- language question whose terms are not all in the page must still match, with the embedding suppressed so the BM25 arm answers alone. That test fails on the conjunctive query this replaces. |
||
|
|
31db3983ac |
memories: an edit writes its own vector, in the same write as the fields (#3964)
* memories: an edit writes its own vector, in the same write as the fields A curation edit wrote the memory twice. `apply_edit` wrote the edited fields, and the caller then called `set_memory_embedding` to write the vector it had re-embedded from those same fields — a second update of the row the first call had just written. Writing that vector is now part of applying the edit: `apply_edit` takes an `embedding` and both stores write it alongside the fields it describes. For Postgres that is one more assignment in an UPDATE it was already issuing, and it makes the two impossible to disagree — the vector and the text it embeds are written by one statement. For a store whose write is a durable append rather than a row update, the separate call was the entire cost of the edit a second time. `set_memory_embedding` stays on the seam for the paths that write a vector WITHOUT editing fields — restoring an invalidated memory re-embeds and calls it, and that is still right. `apply_edit` also now receives `current_fact_type`, the memory's type before the edit. A fact-type change is the one part of an edit that a store may not be able to apply as a partial update, and discovering that inside the store costs a read the caller has already paid for: it has the value from the re-read it does under its write transaction. The test asserts the vector arrives through `apply_edit` and that nothing follows it to write the same row again. Asserting only that the vector ends up stored passes for any number of writes, which is how the second one went unnoticed. Hook skipped with --no-verify: it runs eslint over hindsight-control-plane, whose node_modules are not installed in this checkout. This change is five Python files; ruff and the affected suites were run directly and are clean. * memories: correct set_memory_embedding's docstring for the seam it now sits on The edit path stopped calling it in this branch's first commit, so the docstring still describing "reverting or editing" as its two callers — and the "edit statement its in-tree callers happen to pair it with" — named a pairing that no longer exists. Say what is actually left for it: writing a vector when no field is changing alongside it, i.e. restoring an invalidated memory. Claude-Session: https://claude.ai/code/session_01WbaYxouCERUv9djo3GnnA2 |
||
|
|
7083fd7909 |
fix(monitoring): make Grafana dashboards importable on older Grafana (#3972)
The three bundled dashboards used save-model shapes that only exist in
Grafana >= 8.3: an object datasource ref ({"type", "uid"}) and the
Prometheus variable-editor query object ({"query", "refId"}). Grafana's
DashboardMigrator only ever migrates up, so on an older instance
(reproduced on 7.5.17) both objects are handed to templateSrv.replace(),
which calls target.replace() and throws "e.replace is not a function".
The templating error aborts variable init and leaves every panel empty.
Switch to the string forms, which modern Grafana still accepts and
migrates at runtime:
- "datasource": "$datasource" on every panel plus a datasource template
variable ("query": "prometheus", "current": {}), so any Prometheus
datasource is auto-selected. This also drops the hardcoded
"uid": "prometheus", which only exists in the grafana/otel-lgtm image
used by scripts/dev/monitoring and made the dashboards unusable
elsewhere ("Datasource prometheus was not found").
- the tenant variable keeps the legacy string query label_values(...).
${DS_PROMETHEUS} + __inputs was rejected because file provisioning does
not substitute inputs, and "datasource": null because otel-lgtm marks no
datasource as isDefault.
Fixes #3968
|
||
|
|
4fa110dd59 |
coding-agents: keep the installed runtime current by itself (#3965)
`install` stages this package into ~/.hindsight/coding-agents and points every wired agent's hooks at that copy. Nothing ever refreshed it: the only update path was the user remembering to re-run `install`, so a machine could sit several versions behind indefinitely — a fix only reached people who happened to re-install. Found on a machine running 0.4.2 while 0.4.3 had been published for days, with no signal anywhere that an update existed. Once a day, at session start, ask the registry for the published version and — when it is newer — spawn a detached updater. The current session keeps running the code it already loaded; the next one starts on the new build. `update` is a new installer command: `install`'s staging half and nothing else. It replaces the staged runtime (a path stable across versions, so every wired agent picks the new code up on its next spawn) and writes to NO host config. That separation is what makes it safe to run unattended — an `install` would need a harness list, and choosing one on the user's behalf would rewire agents they never asked us to touch. The cost is bounded and documented: a release introducing a NEW hook entry point is staged but not referenced until a manual `install`. Only ever replaces a runtime it can prove npx downloaded. `stageRuntime` records the directory it copied from, and a copy staged from `npm i -g`, from a project dependency, or from a local checkout is left to whoever manages that source: re-staging behind npm's back would leave `npm ls -g` naming a version that is no longer what runs, and re-staging over a checkout would replace a developer's own build mid-session. A missing marker means no — it is written on every install from this version on, and a machine has to re-install once to get this code at all, so a runtime old enough to lack it is too old to be running the check. Failing closed costs one manual install; failing open costs somebody their working tree. Concurrency is real here, not hypothetical: this is a plugin for machines that run five agents at once, and a 24h stamp does not serialise anything — several sessions starting in the same second all read "due" before any has written it. Two concurrent stageRuntime runs are `rmSync(dist)` then `cpSync`, where one process deletes the directory the other is half way through writing, leaving a runtime with missing entry points and every hook broken. A lock claimed before the registry call makes a burst produce ONE request and one updater; same shape as deepen.ts's per-bank lock, with the holder's pid deciding liveness so a crash cannot wedge the window, and the stored pid is the detached CHILD's since the copy outlives the session. Other guards, each with a test: `npx` must be on PATH (without it there is nothing to spawn, so the check is skipped rather than burning a request and failing a spawn asynchronously); a prerelease never supersedes the release of the same version; an unreadable staged version never guesses; the survey's own headless session is excluded; and both ownership refusals stamp the check so each states its reason at most once a day. `autoUpdate: false` (or HINDSIGHT_AUTO_UPDATE=false) pins the installed version, settable globally, per harness or per bank. `disabled` stops it too — an inert plugin should stay inert, and a network call plus a background npm install is not inert. Wired at BOTH session-start paths — `runSessionStartHook` for the hook harnesses and `RuntimeCore.seedIfCold` for the persistent-plugin hosts — plus a family-wide guard test that enumerates session starts structurally rather than from a hand-maintained list, so a third host cannot land without an update check. Session-start housekeeping has gone missing on the plugin hosts before (#3524), and the harness that forgets is by definition the one whose test nobody wrote. Known window, documented in the module doc: staging replaces dist/ wholesale, so a hook spawning during the copy can fail to load. Running processes are unaffected, the window is milliseconds once a day, and the cost is one turn without memory. Serialising against it would need a lock every hook takes on every turn — a worse trade than the window it closes. Also hoists survey.ts's `binExists` to util.ts as `binOnPath`. Claude-Session: https://claude.ai/code/session_011KDT484YujNcBxHzbfzNbk |
||
|
|
c9591229ea |
config: keep the hot path cached; freshness belongs to the caller that needs it (#3975)
#3952 fixed read-your-writes on the bank-config endpoint by making `ConfigResolver.get_bank_config` read uncached. That was the right distinction applied one level too high: `get_bank_config` is not only the endpoint's method. `recall_async` and `retain_batch_async` call it per request, so every recall and every retain gained an uncached `SELECT config FROM banks WHERE bank_id = $1` -- and a pool acquire costs more than the query it carries, since the pool runs five `set_config` calls on checkout and a `RESET ALL` on release. Removing exactly that acquire is why the cache exists. So `cached` defaults to True and the freshness is opted into by the one caller that needs it: `_get_bank_config_authenticated`, which is what the endpoint answers with. Its `overrides` read was already explicit; now the resolved config beside it is too, and they say why in one place instead of the method deciding for everyone. `test_recall_reads_its_config_through_the_cache` asserts the property -- a warm read issues no query -- rather than counting call sites, so a new hot-path caller that forces a read fails here. Verified by mutation: restore the unconditional `cached=False` and it fails while the read-your-writes test still passes, which is the pair that has to hold. Claude-Session: https://claude.ai/code/session_01V3ViCQDM6pPSCfntZam3Kg |
||
|
|
466517ff9b |
fix(docker): drop npm from the runtime stages and scan every runtime base (#3960)
* fix(docker): drop npm from the standalone and control-plane runtime stages Every published image I scanned ships npm's own bundled copy of tar 6.2.1, which carries one CRITICAL and eight HIGH advisories, all of them fixed upstream: CVE-2026-59873 CRITICAL fixed in 7.5.19 CVE-2026-23745 HIGH fixed in 7.5.3 CVE-2026-23950 HIGH fixed in 7.5.4 CVE-2026-24842 HIGH fixed in 7.5.7 CVE-2026-26960 HIGH fixed in 7.5.8 CVE-2026-29786 HIGH fixed in 7.5.10 CVE-2026-31802 HIGH fixed in 7.5.11 CVE-2026-59874 HIGH fixed in 7.5.18 CVE-2026-73566 HIGH fixed in 7.5.21 It is npm's vendored tar, not an application dependency, so no package.json, lockfile or `overrides` change reaches it: /usr/lib/node_modules/npm/node_modules/tar (standalone) /usr/local/lib/node_modules/npm/node_modules/tar (cp-only) Bumping Node does not clear it either. Node 20 pins npm 10.8.2, which vendors tar 6.2.1; Node 22 pins npm 10.9.8 (tar 7.5.11, still under the CRITICAL fix) and Node 24 pins npm 11.19.0 (tar 7.5.19, still under CVE-2026-73566). Removing npm is what clears all nine findings. npm is never invoked at runtime. Both stages run `/app/start-all.sh`, which starts the API console script and launches the control plane as a pre-built Next standalone bundle with `node server.js` (docker/standalone/start-all.sh:305). No path I could find in the image shells out to a package manager: there is no HEALTHCHECK, no migration step, no plugin install, and no `next` CLI use at runtime. There is also no `corepack enable` anywhere under docker/ and no global npm install in any image stage, so nothing re-adds npm or npx to PATH in the runtime layers. The npm invocations in this file (lines 96, 97, 117, 134) are all in the node:20-slim builder stages, which keep npm untouched. `node` itself stays. Removal is by path in both stages. The node:20-alpine base unpacks npm from the Node tarball rather than installing an apk package. On the Debian stage npm is not installed as a separate apt package either: NodeSource's `nodejs` package ships it (`dpkg -S /usr/bin/npm` resolves to `nodejs`), so `apt-get remove npm` is a no-op and removal by path is the only option there too. corepack is deliberately left in place in both stages. It has no node_modules tree and ships no tar package metadata, so no scannable vulnerable tar component remains in either stage after this change. node-tar code is webpacked into its `dist/lib/corepack.cjs` bundle without package metadata, so its version cannot be determined from the image. Both path sets were validated in live containers against the real node:20-alpine and python:3.11-slim + NodeSource setup_20.x base images: every target existed before deletion, a filesystem-wide search for `*/node_modules/tar/package.json` returned nothing afterwards, npm and npx were gone from PATH, and node (plus python3 on the Debian stage) still ran. The api-only stage has no Node at all and is unaffected. * ci: build and scan every runtime base in scan-next-build, not just api-only scan-next-build answers "would an image built from main today be clean?", but it only builds `target: api-only`, which is python:3.11-slim with no Node in it at all. The two images that carry a Node runtime, cp-only (node:20-alpine) and standalone (Debian plus NodeSource nodejs), were never built here, so a Node-side finding had no fresh-build gate and could only surface in scan-published, after it had already shipped under a tag. That is how npm's bundled tar 6.2.1 reached every published image with a CRITICAL against it. The job becomes a matrix over the three targets, mirroring the matrix scan-published already uses. api-only keeps covering the Debian family for the reason recorded in the old comment; cp-only and standalone add the Node surface. The build args are unchanged, so the legs that read them stay on the slim runtime surface and cp-only ignores them. Each leg runs on its own runner, so this does not lengthen the job or add disk pressure to the existing build. * fix(docker): fail the build if the npm paths move, and exercise the Node runtimes Follow-up to the npm removal on the same branch, from review. A bare `rm -rf` on a path a future base image no longer uses succeeds silently, so a Node or NodeSource bump could quietly start shipping npm again. Both runtime stages now assert the path exists before removing it and assert npm is gone afterwards, which fails the build instead. The removals also had no runtime coverage. `build-docker-images` gated both `load:` and the smoke test on `variant == 'slim'`, and cp-only is a full variant, so the alpine control-plane image was built and thrown away, never started. It now runs the smoke test (it needs no LLM credentials); the gate moves to an explicit `smoke` matrix field so the two heavy full variants stay build-only. `docker/test-image.sh` only probed the API port for standalone, which would pass on an image whose control plane never came up - the two are separate processes. It now waits on the control plane's own health endpoint too when the target serves both. Verified locally on arm64: both targets build with the guards in place, `npm`/`npx` are gone from PATH and no `node_modules/tar/package.json` remains in either image, the cp-only smoke test passes, and the standalone image serves both 8888 and 9999. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n --------- Co-authored-by: Nicolò Boschi <boschi1997@gmail.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 |
||
|
|
3c849daf5c |
fix(tests): isolate the worker claim tests in their own schema (#3963) (#3970)
`WorkerPoller.claim_batch` claims across the whole schema on the connection's search_path, bounded by `max_slots` minus the reservations and ordered by `created_at`. While this file's pool ran against the shared `public` schema, that made its claim counts unassertable: any test running concurrently under xdist that left a claimable row (`status='pending'` with a non-null `task_payload`) competed for the same batch, and older foreign rows displaced this file's own. Filtering the result to our own bank — which the tests already did — observes the shortfall but cannot prevent it. `tests/test_operation_status.py` writes exactly such rows. Once `pytest-split` moved it into shard 3, `test_claim_batch_claims_pending_tasks` started failing with "Expected 3 claims for our bank, got 2". Nothing about the sharding is stable: the split is computed over all collected tests, so any PR that adds a couple of tests reshuffles which files share a shard. #3859 was merely the one that landed. Reproduced directly: with 12 older foreign rows the batch fills to its 8-row ceiling and claims *none* of the test's three. Fix it where the other claim tests already fix it — give the file its own migrated schema and pin the pool's search_path to it (`tests/test_claim_bank_serialization.py` does the same, for the same reason). "The whole schema" is then only this file's rows. `clean_operations` becomes a plain schema-wide delete. It must be broad: scoping it to the worker-test bank prefixes left rows behind under any other id a test invented, and those stayed claimable for the next test. It is safe to be broad only because of the pinning — the scoped form it replaced was itself a fix for a global delete that, under xdist, removed other workers' in-flight operations mid-run. The regression test pins the property the fix actually provides — visibility isolation — rather than asserting `claim_batch` is unbounded, which it is not by design. It plants a deliberately unclaimable row (`task_payload` NULL) in `public` so it can never become the neighbour it guards against, and fails with the pinning removed. Verified: test_worker.py 110 passed; run together with test_operation_status.py 126 passed; ruff check/format and ty clean. |
||
|
|
689c9b6945 |
config: read a bank's own config fresh, not through the per-process cache (#3952)
`ConfigResolver.get_bank_config` documents the property it needs: "Config is resolved on every call (not cached) to ensure consistency across multiple API servers." The bank-info cache took that away, and the control-plane E2E caught it -- setting a bank-level config override left the "Bank override" badge absent, so the edit looked like it had not saved. The cache is per PROCESS. Invalidating on write fixes only the pod that served the write; dev runs four API replicas, so a read-back has a 3-in-4 chance of landing on a pod whose entry is up to a TTL old. That is the documented cross-process trade, and it is fine for what the cache exists for -- the retain path reads this once per sub-batch, and a bank config that lags by one TTL changes nothing a caller can see. It is not fine for the endpoint a user reads back after editing. So `_load_bank_config` takes the choice explicitly, defaulting to cached, and the three reader-facing paths pass `cached=False`: the resolved config, the `overrides` beside it (what the UI renders as the badge, and a separate call that is easy to miss), and the bank-template export. `test_a_config_read_back_does_not_go_through_the_cache` models the other pods the only way one process can -- it warms the entry, then removes invalidation before the write, which leaves this process in exactly their state. Verified by mutation: restore either cached read and it fails. Claude-Session: https://claude.ai/code/session_01V3ViCQDM6pPSCfntZam3Kg |
||
|
|
9a4be5e052 |
fix(retain): make reprocess re-extract instead of no-opping (#3899) (#3949)
A reprocess replays the document's own stored text, so the content is byte-identical by construction — and the retain pipeline has two skips for exactly that shape: - the delta path diffs the replayed body against the stored chunks, finds nothing changed and takes `_delta_metadata_only`; - the crash-recovery gate in `_streaming_retain_batch` sees the matching `content_hash` plus surviving chunk hashes, classifies the retain as a crashed one being resumed, skips every chunk and preserves every unit. Either one settles the operation `completed` with `unit_ids_count: 0` and zero LLM calls, which is indistinguishable from a real re-extraction unless you read the facts. #3874 made the replay faithful, which makes the hash match more often — i.e. it converts #3873's wrong-strategy re-extractions into this no-op, so the two had to compose. `reprocess_document` now sets `force_reextract` on the replayed item, and `retain_batch` uses it to skip the delta attempt and suppress the recovery classification. The flag rides on the content item rather than as a parameter on every frame in between: that way it survives the async operation payload and the oversized-item splitter (which copies each field onto every slice) for free. It is excluded from `_RETAIN_PARAMS_NOT_REPLAYED` so it does not end up stored on the document, and `api_retain` assigns its content dict field by field, so it is not settable by a client. Both skips are unchanged for an ordinary retain: a sync layer re-pushing unchanged content still gets the cheap no-op it relies on — covered by a test alongside the reprocess ones. |
||
|
|
f22c36250f |
feat(reflect): send the operation schema on mental model delta refresh (#3937)
* feat(reflect): send the operation schema on mental model delta refresh The delta call was the one pipeline call that asked for structured output without sending a schema. Retain's extraction passes `response_format` and a per-operation `strict_schema`; the delta call passed neither, so the prompt was the only description of the payload the model got — and #3901 was the predictable result, a model spelling new blocks as `{"id", "text"}` because every block in the document it was shown carries an id. The reason was real, not an oversight. Pydantic renders the eight-op discriminated union as `oneOf` + `discriminator`. OpenAI's strict subset accepts `anyOf` and rejects `oneOf`, and the Gemini SDK refuses both keys outright — `types.Schema` raises `Extra inputs are not permitted` while building the request, before anything is sent. So no schema could travel, and the call hand-parsed its JSON instead. Fix the serialization rather than the call site. `UnionSafeSchemaGenerator` renders a tagged union as `anyOf` and drops the discriminator block, which costs nothing: each variant keeps its `Literal` `op` field, so the variants stay mutually exclusive and the discriminator was only ever a routing hint. `OpenAIStrictSchemaGenerator` extends it, so the strict and soft paths agree. Every site that serializes a schema *into a request* now goes through `provider_json_schema()` — all nine providers plus retain's batch path. That is safe to apply that broadly because the output is byte-identical for a model with no tagged union, which is pinned by a test rather than asserted here. Gemini still hands the model class to its SDK for every schema the SDK already converts, and serializes by hand only for a union it cannot accept. `gemini_cache` is left alone: it hashes the schema for a cache key and never sends it. The delta call then follows retain exactly: `response_format`, `strict_schema` from `llm_strict_schema_reflect`, and `skip_validation=True` so the raw JSON still reaches `parse_delta_operation_list` — which drops one malformed op instead of failing the batch the way `model_validate` would. The lenient parser stays in front of the schema, the same way `_coerce_fact_response` sits in front of retain's. Note the soft path (strict off, the default) now appends the schema to the system prompt for this call: better instruction, slightly more prompt tokens. Tests: the union rewrite (both serializers, variants preserved), the no-op property for non-union models, and the Gemini SDK's rejection of the raw union — asserted as a live expectation so the hand-serialization branch gets deleted if the SDK ever grows support. Plus a plumbing test that the delta call sends the schema, which the `patch_llm_call` fixture had been documenting as true since before it was. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n * ci: run the real-LLM Gemini evals in the core LLM job `HINDSIGHT_RUN_GEMINI_EVALS` gates four test files' real-provider evals, and nothing in CI ever set it. `GEMINI_API_KEY` was already on the core LLM job, so the gate looked satisfied and the tests reported as SKIPPED rather than as missing — 11 of them, including the whole `TestDeltaRefreshGeminiEval` class. That left the mental-model delta path with no end-to-end coverage anywhere: the one call that hands a provider a schema the provider has to accept, and the exact path this branch changes. The gap surfaced while verifying the structured-output change — the test named as its safety net turned out never to run. Unskips 11 tests in the job that already has the credentials and the `hs_llm_core` marker for them. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n * fix(gemini): strip additionalProperties and require the union tag The newly-enabled Gemini evals caught two defects in the structured-output change on their first run — both invisible to every offline check. 1. Vertex rejected every delta request: 400 INVALID_ARGUMENT: Unknown name "additional_properties" at 'generation_config.response_schema': Cannot find field. Handing the SDK a dict is not the same as handing it the pydantic class. The class path drops keys the backend has no field for; the dict path maps them faithfully, so `extra="forbid"` on every op model arrived as `additionalProperties`, became `Schema.additional_properties`, and the request was refused. The SDK builds it without complaint, which is exactly why asserting on `t_schema` was not enough — the new test asserts on the serialized request instead. Stripping the key costs nothing: the parser still validates against the pydantic model, so no field can be invented. 2. `op` was not a required property. Pydantic omits it because each variant defaults it (`op: Literal["add_section"] = "add_section"`), and with the `discriminator` block gone, `anyOf` alone gives a reader nothing else to tell eight near-identical shapes apart. A grammar-constrained model that omitted the tag would emit an operation matching no variant — reproducing the validation failure this whole path exists to prevent. The union rewrite now requires the tag wherever it appears, which is the honest completion of dropping the discriminator rather than a separate concern. Neither reached a network call in local testing, and the first was live for one CI round-trip. Both now fail closed in unit tests. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n * test(retractions): give the real-LLM fixture its Vertex credentials The retraction eval builds an LLMConfig from provider/api_key/base_url/model. Vertex AI authenticates by project + service account instead of an api_key, so that config raises before it can make a call: ValueError: HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. The fixture's own docstring warns that a skipped test rots — it was written for a key-based provider, and the CI job runs the evals under `vertexai`, which is the one case it did not cover. Nothing caught it because the test had never run anywhere. Pass the three Vertex fields through from config. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n * test(retractions): the eval imported a function renamed out from under it `parse_markdown` became `split_markdown`; the rename missed this call because the only reference lives in a test that never ran, so nothing failed. Second layer of rot in the same test — the credentials gap hid this one until it was fixed. Checked the rest of its surface rather than fixing one line and paying another CI round-trip to find the next: `build_structured_retraction_prompt` still accepts every argument it passes and requires none it omits, and the other imports resolve. This was the only stale reference. Claude-Session: https://claude.ai/code/session_011n8KQc8sCe4cJJCd8Cv69n |
||
|
|
7ad75c7f03 |
fix(search): drop search-trace fields that are structurally always empty (#3947)
`SearchSummary` reported four counters that no code path can ever move off zero, and the trace carried two models nothing constructs. A reader of a recall trace saw "semantic/entity/temporal links followed: 0, nodes pruned: 0" and concluded the graph arm was broken; both numbers were artifacts of instrumentation written for a sequential spreading-activation walk that retrieval no longer performs (issue #3817). Removed, all verified to have no producer: - SearchSummary.{total_nodes_pruned, temporal_links_followed, semantic_links_followed, entity_links_followed}. The link counters move only inside `visit_node()` for three literal `link_type` values, and the single call site in `memory_engine` passes `link_type=None`. - SearchTrace.pruned and the PruningDecision model. `prune_node()` was the only thing appending to `self.pruned` and it had zero callers. - NodeVisit.{parent_node_id, link_type, link_weight} and the matching `visit_node()` parameters, all `None` at the one call site, plus NodeVisit.neighbors_explored, constructed as `[]` and never appended to -- which left LinkInfo unreachable, so that model goes too. `budget_used`/`budget_remaining` are kept: unlike the above they are filled (with `len(self.visits)`), so only their descriptions change to say what they actually count rather than implying traversal spend. No client-facing schema change: `RecallResponse.trace` is typed `dict[str, Any]`, so SearchTrace never enters the OpenAPI spec and no generated client references these names. The control-plane debug view reads only summary.total_duration_seconds, summary.total_nodes_visited and visits[].weights.final_weight, all of which survive. Fixes #3817 Claude-Session: https://claude.ai/code/session_01WbaYxouCERUv9djo3GnnA2 |
||
|
|
7051c6e3b0 |
fix(embeddings): bound ONNX forward passes and import embed calls (#3891) (#3948)
* fix(embeddings): bound ONNX forward passes and import embed calls (#3891) `OnnxEmbeddings.encode()` tokenized and ran `session.run()` over its entire input in one pass. It was the only provider that did: TEI (32), Cohere (96), OpenAI (100) and ZeroEntropy (100) all slice, and there was no `..._ONNX_BATCH_SIZE` to set. Since tokenization pads every text in a call up to the longest one in that same call, peak memory was `n_texts x max_seq_len x hidden` float32. Nothing above it bounded the input either: `_import_observations` embeds every observation in the bank in a single call, so a whole-bank import OOM-killed the process (43.3 GB RSS on a 4,138-observation bank) after the document and fact phases had already committed — leaving a bank with the right document and fact counts and zero observations. - `encode()` now runs `batch_size` texts per forward pass (`HINDSIGHT_API_EMBEDDINGS_ONNX_BATCH_SIZE`, default 32), packing similar-length texts together so one long text cannot pad a whole batch. Output is unchanged: both pooling modes mask padding, so batch composition cannot move a vector. - The `InferenceSession` is built with `enable_cpu_mem_arena = False` (`HINDSIGHT_API_EMBEDDINGS_ONNX_CPU_MEM_ARENA`, default off). The arena caches freed blocks and never returns them, so RSS held its high-water plateau for the life of the process. The reranker has disabled it since #1717. - The import phases sized by the bank rather than by a document — observations and mental models — embed in slices, so the bound holds for every provider. Reported with a full diagnosis in #3891, including the measurement that chunking costs no throughput (~31 min vs ~37 min to the OOM) and that the vectors are bit-identical. * test: make the ONNX batching tests observe order and padding width The fake session returned a constant vector, so neither the scatter-back after sorting nor the batch-invariance claim was actually exercised. The fakes now derive each vector from its own text's length and pad to the longest text in the call, so a misplaced result and a padding-dependent output both fail. |
||
|
|
3bba2c01fc |
Fuse observation graph expansion into one database fetch (#3859)
* feat(search): fuse observation expansion into one fetch (#3857) expand_observations ran the entity/source traversal and the semantic/causal expansion as two sequential statements on both PostgreSQL and Oracle. The entity arm is now a CTE of the semantic/causal query behind an 'entity' source discriminator (the shape the non-observation combined expansion already used), so a normal call performs one fetch — one roundtrip, one connection slot, one snapshot. Every predicate, score expression, ordering, per-arm limit, and the window bind positions are unchanged; the caller splits the unioned rows by source. Docs in link_expansion_retrieval updated to say all fact types expand in a single CTE query. Tests (tests/test_observation_expansion_single_fetch.py) pin, on live embedded PostgreSQL: exactly one fetch carrying all three arms; preserved IDs/scores/counts/ordering across overlapping and duplicated sources; per-arm budget limits; / window binds narrowing every arm; and a source-less seed still returning its semantic/causal arms. Structural mock-connection tests pin the one-statement UNION ALL shape and bind order for both PostgreSQL and Oracle (no Oracle runtime needed). * test(search): correct observation expansion fixtures (#3857) |
||
|
|
7729396e12 |
fix(llm): give every provider a real per-request deadline (#3898) (#3946)
The Codex provider never read the configured LLM timeout. The factory did not pass one, and CodexLLM extends LLMInterface (not the LLMProvider base that assigns self.timeout), so the class had no timeout attribute at all -- the three call sites hardcoded httpx timeout=120.0. That literal is a per-socket-read timeout, and the body was fetched with a buffering client.post(), so a backend wedged into runaway generation reset it forever: one consolidation call was read for ~830 s (~12 MB of SSE deltas for a ~340-character answer) until the backend closed the connection, holding the reserved consolidation slot for the whole time. Three such stalls cost ~1.7 h on one bank. - LLMInterface now takes and stores `timeout`, so no provider can silently drop it, and the factory threads the resolved value to the five that were missing it: codex, gemini, anthropic, fireworks and llamacpp. - Codex reads the SSE body with `client.stream()` inside an `asyncio.timeout` that covers the request *and* the parse, so the configured timeout is a total deadline rather than an idle one, plus a body-size ceiling that abandons a fast runaway stream in seconds instead of buffering it. Both surface as CodexRunawayStreamError, an httpx.RequestError, so the existing retry/backoff path handles them unchanged. - Gemini's hardcoded 90 s and Anthropic's own 300 s default become the unconfigured fallbacks rather than the only values. Codex tests move onto a shared streaming stub since the provider no longer calls client.post(). The consolidation wall-clock ceiling the issue also asks for already landed in #3746 (unreleased); it is an idle ceiling defaulting to 7200 s, so it would not have ended an 830 s stall on its own. Claude-Session: https://claude.ai/code/session_018HDqrzHgqZqsGc7EDqoTEu |
||
|
|
24cb8446b3 | chore: update star history | ||
|
|
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 |
||
|
|
317c4ae1aa |
docs(hermes): disable Hermes's built-in memory via config flags (#3940)
* docs(hermes): disable Hermes's built-in memory via config flags
`hermes tools disable memory` shuts down the whole memory toolset on current
Hermes builds, taking the native provider's `hindsight_retain`,
`hindsight_recall` and `hindsight_reflect` tools down with it. Hermes treats
memory as pluggable now, so use the config flags instead:
hermes config set memory.memory_enabled false # MEMORY.md
hermes config set memory.user_profile_enabled false # USER.md (optional)
Updated the Hermes integration page, both Hermes guides, the two blog posts that
carried the old command, and the regenerated docs skill reference.
Fixes #3849
Claude-Session: https://claude.ai/code/session_012gXUp1i7YrWmLJVUYki53g
* ci(hermes): set the documented memory flags in the compat check
The compat script wrote only `memory.provider: hindsight`, so the config we
actually document — flat-file MEMORY.md and USER.md turned off — was never
exercised. Set both flags there so the existing `hermes memory status`
assertion covers it: silencing the built-in stores must leave Hindsight the
active, available provider.
Claude-Session: https://claude.ai/code/session_012gXUp1i7YrWmLJVUYki53g
|
||
|
|
ba23c6526f |
memories: raise the store seam above persistence, and collapse its ownership flags
One branch for the memories-store seam, so the engine's store interface and the stores that implement it move together rather than drifting apart. A store-backed extension imports the interface at module load; when those symbols live on a different branch than the deployed engine, the extension fails to import and every bank routed to it is degraded. **The seam is raised above persistence.** A store that owns its rows no longer has its writes driven by the orchestrator. `begin_retain` opens a session, the engine streams parts in, and the store decides when to commit. That choice is the point: a bulk ingest with no LLM in the loop should commit once, a long extraction should not, and neither is right in general, so the engine must not decide it. Two rules any flush policy has to honour — never memories without their bodies, and bound what an interruption loses. **One ownership flag, not several.** `store_owned` replaces the separate questions about who holds memory rows, document bodies, the retain write, the knowledge-page index, and the persistence half of a retain. No store ever answered them in a mixed combination, so each extra flag was a branch every call site kept handling for a state that does not occur. The per-bank probes (`store_owned_for`, `derives_semantic_links_internally_for`) stay, because a router genuinely does hold banks in different backends — and reading the class attribute instead of the probe is the bug they exist to prevent. **Cross-store write-group transactions are removed.** They made a store write atomic with a Postgres witness row, but mint-early / witness-late meant a crash or a sibling cancel between the two left a transaction pending with no witness. Consolidation is idempotent on retry, so the recovery this bought never paid for the state it stranded. **Delta re-ingest is fenced by the store's own compare-and-set** (`StoreWriteConflict` → `ConcurrentAppendConflict`), scoped to the chunks that actually moved, and holds no pooled connection while it runs. Alongside: retain phase timing that records seconds *and* round trips, because a phase slow per-call and one slow per-count need opposite fixes; a store's backpressure treated as backpressure rather than as a failed task; and a per-process cache for the two bank rows a retain reads on every call, invalidated by every path that writes the bank row so a caller that writes and reads back sees what it wrote. |
||
|
|
44f9376311 |
fix(docker): ship images free of fixed HIGH CVEs and scan them daily (#3936)
* fix(docker): ship images free of fixed HIGH CVEs and gate releases on a scan The published 0.9.2 images carried four fixed HIGH findings on both architectures (#3906): openssl 3.5.6 (CVE-2026-14456, via libssl3t64 / openssl / openssl-provider-legacy) and jaraco.context 6.0.1 (CVE-2026-23949, pulled in transitively by fastmcp -> py-key-value-aio[keyring] -> keyring). Nothing in the build was pinning either one — the runtime stages installed whatever snapshot the python:3.11-slim base happened to carry, and the lock had never been refreshed past jaraco.context 6.0.1. - Runtime stages now `apt-get upgrade` (and `apk upgrade` for the alpine control-plane stage) so the shipped layers pick up Debian security updates published after the base image was cut. - uv.lock: jaraco-context 6.0.1 -> 6.1.2. - CI: the slim images built by build-docker-images are scanned with Trivy (HIGH/CRITICAL, fixed only) and the job fails on a finding. uv.lock / pyproject.toml join the `docker` path filter, since a dependency bump changes what ships even when docker/ is untouched. - Release: both published architectures are scanned before signing. A finding fails release-docker-images, so create-github-release — which needs it — never publishes a release for the tag. - create-github-release now attaches every asset to a draft and publishes afterwards. GitHub's immutable releases seal a published release, so assets uploaded post-publication would be rejected; this makes the workflow safe once the repo setting is enabled (the mutable-release half of #3906). Verified by building the api-only slim image with these changes and scanning it: openssl 3.5.7-1~deb13u2, jaraco_context 6.1.2, and zero HIGH/CRITICAL fixed findings (0.9.2-slim reports four). Closes #3906 Claude-Session: https://claude.ai/code/session_01M9KwqWJZRrw7NjAaKuACzj * ci: drop the release-time image scan, keep the PR gate The release scan could only run after the multi-arch push (a pre-push scan needs a second single-platform build, which is the disk pressure that got the release smoke test commented out), so it never actually prevented a vulnerable image from existing under its tags — it only withheld the GitHub Release. Scanning on PRs is where a finding can still be acted on, and build-docker-images already loads the slim images there. Claude-Session: https://claude.ai/code/session_01M9KwqWJZRrw7NjAaKuACzj * ci: scan images daily instead of on every PR Image findings appear when an advisory is published, not when our code changes, so a per-PR gate stays green for weeks and then fails an unrelated PR the day a CVE lands. Moved to a scheduled workflow with two jobs: one scans the published images users are running now (no build, seconds), one builds api-slim from main and scans it, so a regression is caught before it reaches a release tag. Also reverts adding uv.lock/pyproject.toml to the `docker` path filter: that was there to trigger the PR scan, and without it a dependency bump would otherwise pay for five image builds and smoke tests. Claude-Session: https://claude.ai/code/session_01M9KwqWJZRrw7NjAaKuACzj * docs(docker): explain why the runtime stages upgrade base packages Records the tradeoff at the point of change: builds are no longer pinned to the base image's package snapshot, which is the whole point but does mean two builds of the same commit can resolve different versions. Claude-Session: https://claude.ai/code/session_01M9KwqWJZRrw7NjAaKuACzj |
||
|
|
6a796f9f5a |
fix(recall): score coarse dates from their period, not its first day (#3933)
Fixes #3893. A memory whose text stated only a year or a month was ranked as if it had happened on the first instant of that period. "The 2026 summit" stored as 2026-01-01 read as eight months stale on 2026-08-30, and the recency penalty overturned the cross-encoder: a newer but unrelated fact took Top-1 from the candidate both semantic retrieval and the reranker preferred. The boost is large enough to do this routinely — recency_boost spans [0.92, 1.10], so a fresher candidate wins whenever the more relevant one's cross-encoder lead is under ~19.6%. In the reported case the correct fact led by 11% and lost. Extraction already records a coarse date as its full span ("in 2015" becomes 2015-01-01 -> 2015-12-31), so the granularity is present in the data and needs no new column. Recall now reads it back: * Score a unit whose (occurred_start, occurred_end) covers exactly one calendar month or year from the END of that period — the latest the event could have happened — instead of from its start. * Cap that signal at neutral, since the period's end is a bound and not an observation. Without the cap a period still in progress ends in the future and clamps to full freshness, trading an invented staleness penalty for an equally invented freshness boost. Genuinely old periods still decay. Detection keys on span length rather than midnight alignment because _add_temporal_offsets shifts both ends of a fact by the same sub-second amount, preserving the span exactly while destroying absolute alignment. The accepted window is one-sided: all three end-encodings extraction produces land at or below the period length, so a longer span is a genuine interval, not a coarse date. Extraction had a matching gap: with the event date inside the same year, "In 2026 the user attended ..." resolved to the event date itself, collapsing the period and asserting a precise day the text never stated. The prompt now spells out the coarse-date case, current year included. Scope notes: * Genuine intervals (a multi-day trip, an employment) are deliberately left aged from occurred_start as before. Whether they should age from their end is a separate ranking question, not what this issue reports. * A year carried only by an event's NAME ("the 2026 Hangzhou Summit") is still stamped with the event date. Prompt wording covering that case was tried and dropped: neither gemini-2.5-flash-lite nor gemini-2.5-flash honoured it, and shipping prompt text that demonstrably does nothing is worse than the gap. * Already-retained coarse dates keep the old behaviour. "2026" and "2026-01-01" were stored identically, so existing rows cannot be told apart; they correct themselves on re-retain. Tests: 13 deterministic cases in test_combined_scoring.py covering the reported rank reversal, the neutral cap, old periods still decaying, leap years, all three period-end encodings, the one-sided bound, the sub-second offset, and precise dates left untouched; plus an hs_llm_core suite asserting extraction emits real spans, through the same production helper recall uses so the two halves cannot drift apart. |
||
|
|
dd60a542ee |
fix(embeddings): retry the LiteLLM startup probe and add EMBEDDINGS_LITELLM_DIMENSIONS (#3695)
The litellm embeddings provider probed the proxy once at startup purely to learn the vector dimension, and any httpx error became a fatal RuntimeError, crash-looping the container whenever the proxy or the inference engine behind it was still coming up. Fixes #3695. Two changes, both proposed in the issue: - The startup probe now retries transient failures (connect errors, 429, 5xx) with the bounded backoff the provider already uses on the encode path. It runs via asyncio.to_thread + _acall_with_retry: initialize() is awaited on the event loop, so a blocking post plus a time.sleep backoff would stall the model loads gathered alongside it and freeze the model_init_timeout watchdog meant to bound it. - HINDSIGHT_API_EMBEDDINGS_LITELLM_DIMENSIONS declares the vector width up front and skips the probe entirely, so the API boots with zero network calls even while the proxy is down. The flag declares the width rather than requesting it: the value is never sent to the proxy, because the backends LiteLLM fronts (vLLM, Cohere, Voyage, HuggingFace) reject an unexpected "dimensions" field, which would have turned every retain and recall into a non-retryable 400 for anyone who set it. Nothing verifies the number at boot any more, so the first encode() checks it once and fails naming the env var, rather than surfacing later as an opaque pgvector "expected N dimensions, not M" or a bank whose vector column was created at the wrong width. Co-authored-by: Sanderhoff-alt <Sanderhoff-alt@users.noreply.github.com> |
