feat(coding-agents): add per_source observation scoping - #3872
feat(coding-agents): add per_source observation scoping#3872shauneccles wants to merge 3 commits into
Conversation
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 vectorize-io#3564 and vectorize-io#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 vectorize-io#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 vectorize-io#3871 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdYchB9yKUWw8oa16RWQu9
Strix Security ReviewWarning This pull request has 2 commits after the last Strix review ( No security issues found. Updated for Reviewed by Strix |
koriyoshi2041
left a comment
There was a problem hiding this comment.
The per-document resolution keeps the existing shared scope while separating only the controlled source axis, and it avoids sending the plugin-only mode to the server. I also checked the multi-source and no-source cases at a9a729f: 85 focused tests pass, along with typecheck and the package build.
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:git: what the commits say. Mapped onto it rather than dropped, because that seed is where a cold repo's entire commit history arrives. source:survey-baseline marks the "researching…" status document, whose retain strategy is meant to extract nothing. It still yielded a scope holding exactly one observation — a belief set that exists only to be noise. Excluded. Emitting a scope per DISTINCT source tag, sorted, is still right: taking the first made the result depend on the order git.ts assembled its tags, which a test pins. It is the vocabulary that carries two non-semantic entries, not the rule.
This reverts 0786de8. The evidence behind it was an artefact. The claim was that source:git and source:git-log produced near-identical belief sets (307 observations against 302). They did — but only because at the time of measurement the bank held exactly ONE git document, the commit-message seed, which carries both tags. The per-commit diff backfill under gitIngest: "full" had not run yet, so one document was feeding both scopes. Once it does, the two diverge and are genuinely different questions: source:git-log is fed only by the seed — what the commit MESSAGES say — while source:git also collects every per-commit diff. Intent against implementation. The reasoning was wrong at a deeper level too. Overlapping content across scopes is not duplication to be engineered away: consolidation already distills and deduplicates WITHIN each scope, and a fact that legitimately answers two questions belonging to both is the design working. Excluding tags to avoid overlap misreads what a scope is for. source:survey-baseline goes back for the same reason. Its scope holding a single observation suggests the marker document's retain strategy is extracting facts it is meant to suppress — which is worth fixing where it happens, not papering over in the scope resolver.
|
Sorry for the noisy last two commits - I missed the root cause of another issue (#3874) and got a bit confused. It's been a big day 😅 |
koriyoshi2041
left a comment
There was a problem hiding this comment.
The two follow-up commits leave the implementation unchanged and make the multi-tag intent clearer: the git-log seed and per-commit diffs answer different questions, so retaining both controlled source scopes is correct. Re-reviewed at 1a98249.
Closes #3871.
The problem
On a repo worked by a coding agent, this plugin writes documents from genuinely different origins into one bank: commit diffs (
source:git), the commit-message seed (source:git+source:git-log), session transcripts (source:chat), uploads (source:upload).A commit diff records what the code actually does. A transcript records what someone intended, argued for, or discarded. Under the
shareddefault these consolidate into one undifferentiated belief set, so:Why this cannot be done with configuration
This is the crux, and it is why the change is code rather than a docs note.
observationScopesalready accepts an explicitstring[][], so the obvious answer is "just configure[[], ["source:git"], ["source:chat"]]". That does not work. The server treats an explicit list as unconditional — it is never filtered against the memory's own tags. Inhindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py, both resolvers end the same way:parsedcomes back verbatim. So that configuration writes every document into all three scopes: thesource:gitscope fills with observations built from chat transcripts and vice versa. The scopes exist but mean nothing, which is worse than not having them.Every scalar mode the server offers derives its scopes from the memory's own tags, but none derives from part of the tag set. Only a per-document decision, made where the tags are known, separates these.
The change
"per_source"becomes an acceptedobservationScopesvalue. It is resolved client-side, per document, in a new exportedresolveRetainScopes(tags, configured)insrc/core/hindsight.ts, and never reaches the server. It expands to the global scope plus one per distinctsource:tag the document carries, sorted:source:chat[[], ["source:chat"]]source:git[[], ["source:git"]]source:git,source:git-log[[], ["source:git"], ["source:git-log"]]knowledge:convention[[]]A document may legitimately carry more than one source tag — the commit-message seed keeps
source:gitalongsidesource:git-logso the cold-repo check (listDocumentIds("source:git")) still sees it — so every distinct one gets a scope. Taking all of them, sorted, is the only rule that needs no arbitrary tie-break and does not silently depend on the ordergit.tshappens to emit its tags in.this.observationScopesstill flows to the single call site that sets the field; only the value it resolves to is now per document.On "double the consolidation cost"
That is the explicit rationale of #3575, so it deserves a direct answer rather than a footnote.
Yes:
per_sourcecosts one extra consolidation pass per document (two for the commit-message seed). The difference from the case #3575 was right to refuse is what the extra pass buys. Splitting onharness:<id>is provenance — which agent typed it — and the extra pass buys two copies of the same belief, each blind to the other. Splitting onsource:is semantic: the origin changes what kind of claim the document is making, and once a commit-derived belief and a chat-derived belief merge into one observation, no read-side filter can separate them again, because the merged observation has a single origin-free identity.So the cost is real, it is the price of the axis, and it is why this is opt-in.
Why not
per_tagper_tagsplits on the right axis but also on every other one —_resolve_obs_tags_listmaps it to[[t] for t in tags], unconditionally. It would reinstate the per-agentharness:fork that #3564 and #3575 removed, and any volatile tag (a session id added throughretainTags) would become its own scope, which is the fragmentation bug itself. Reading onlysource:is what keeps the split safe: a small, closed, meaningful set of values the plugin itself controls.What is deliberately unchanged
DEFAULT_OBSERVATION_SCOPESis still"shared". A user who never sets this sees no behavior change.tags_match: "all", per fix(coding-agents): let knowledge pages see the observations they ask for, and stop tagging initiative markers with a page id #3664) keep being written exactly as undershared.One test was widened — deliberately
The source-text guard in
hindsight.test.ts("keeps that call site insideretain(), with the scoping on the item it posts") went fromto
Flagging it explicitly so it does not read as weakening a test. The guard's intent is intact: the field must still be set inside
retain(), on the item it posts, and still derive fromthis.observationScopesrather than being left to the server default. The regex only allows the value to be wrapped in a resolver. A call site that dropped the scoping, moved it out ofretain(), or hardcoded a value still fails.Lineage
harness:<id>tag splits observations per agent, undoing the harness-neutral bank — please make it optional #3564 — theharness:<id>tag splitting observations per agentobservationScopesand the"shared"defaulttags_match: "all"Test plan
npx tsc --noEmit— cleannpx vitest run— 55 files passed, 665 passed / 23 skippednpm run build— succeedsnpm run skill:buildandnode hindsight-docs/scripts/sync-coding-agents-doc.mjsre-run, generated output committed.prettierrc.jsonover every changed file (matching theprettier-int-coding-agentstask inscripts/hooks/lint.sh) — clean. Three files under this package (src/core/retain-cursor.ts,src/dsh.ts,src/install-ui.ts) are flagged by that task onmainas well; they are untouched here and left alone.src/docs-freshness.test.ts— 12 tests passSix new tests cover
per_sourceend to end at the wire level (the global-plus-source expansion, git vs chat separation, the untagged fallback, the two-source-tag seed, order independence, and a volatile provenance tag never becoming a scope), plus one covering config acceptance.I could not run
./scripts/hooks/lint.shin full — it starts withuv syncfor the whole monorepo and my checkout is sparse — so the Python and control-plane tasks are unverified, though nothing here touches them.