Files
Ben Younes 1c6c187688 fix(hooks): dedupe file-context injection per session (#3480)
* fix(hooks): dedupe file-context injection per session (#3480)

The PreToolUse file-context handler re-injected the same "prior
observations" timeline on every Read/Grep of an unchanged file within a
session — the only gate was file-mtime staleness, with no per-(session,
file) memoization. Repeatedly reading the same file (iterative debugging,
long sessions) multiplied context cost with zero new information.

Add a small persistent per-session store (one JSON file per session under
DATA_DIR/file-context-seen) keyed by resolved file path → newest injected
observation epoch. A repeated unchanged Read is now skipped; re-injection
happens only when a newer observation has been recorded since. In-memory
memoization is impossible here because each hook runs in a freshly spawned
process. Stale session files are pruned (7-day TTL) on a session's first
write so the store stays bounded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): make file-context dedupe keys collision-free (#3480)

Address two P1 review findings on the per-session file-context dedupe store:

- Session-id store filenames: replace the non-injective char-replace scheme
  (`[^A-Za-z0-9_-] -> _`) with a sha256 hex digest of the complete session id.
  `a.b` and `a:b` no longer collapse to the same `a_b.json` and cross-suppress
  each other's initial injection. Still path-safe and traversal-proof.

- Dedupe path key: normalize with `path.resolve(cwd, filePath)` for absolute
  inputs too, so dot-segment aliases (`/p/src/../src/f.ts` vs `/p/src/f.ts`)
  collapse to one canonical key instead of double-injecting the same file.

RED -> GREEN: two new regression tests in tests/hooks/file-context.test.ts fail
on the pre-fix code (collision suppresses the distinct session; alias bypasses
dedupe) and pass after. Full file-context suite 17 pass; typecheck clean; full
`bun test tests` baseline unchanged (27 pre-existing failures before and after,
+2 new passing tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): order file-context store setup before prune check (#3486)

* fix(hooks): persist the file-context gate in SQLite, not a JSON side-store (#3480)

Review feedback on #3486 asked for plan-20 (#3608) step 4: the file-context
injection gate must be a row in the main database keyed per (session, file,
observation epoch), not a JSON file under DATA_DIR.

What changed:

- The gate moves from one JSON file per session under `file-context-seen/` to
  a `file_context_injections` table in `claude-mem.db`. The table is device-
  local: sync enumerates the tables it pushes explicitly, and this is not one.
- `wasFileContextInjected` + `recordFileContextInjection` collapse into a
  single `claimFileContextInjection`. Claiming IS recording, in one
  `INSERT ... ON CONFLICT ... RETURNING` statement, so two concurrent Reads of
  the same file in one session can no longer both inject — the read-before-write
  race the previous store shipped with (and documented as acceptable) is gone.
- The upsert guard `excluded.observation_epoch > stored` keeps the stored epoch
  monotonic, so a hook that finishes late carrying an older epoch neither wins
  the claim nor rolls the row back to a stale value.
- The claim moved to the very end of `buildFileContextTimeline`, after every
  other reason to bail out, so a suppressed injection never burns the claim.
- `resolveDbPath()` resolves the database path at call time. `DB_PATH` freezes
  `DATA_DIR` at import, which is right for the long-lived worker but wrong for
  a per-invocation hook process that must honour a `CLAUDE_MEM_DATA_DIR` set
  after the module loaded (and is what lets the tests point at a temp dir).

Failing open is preserved: an unopenable database or a failed claim injects
rather than throwing, which is never worse than the un-deduped behaviour.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-10 20:17:01 -07:00
..