claude-mem-lite 3.83.0 → 3.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.83.0",
13
+ "version": "3.84.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.83.0",
3
+ "version": "3.84.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -163,7 +163,9 @@ Plugin mode manages its own hooks/runtime. On session start it only **checks and
163
163
 
164
164
  > **The plugin install is complete on its own** — hooks, MCP tools, and the bundled slash commands (`/mem`, `/lesson`, `/bug`, `/adopt`) all run from the plugin with no second step. The slash commands invoke the bundled CLI by an absolute path resolved from the plugin directory (`${CLAUDE_PLUGIN_ROOT}/cli.mjs <cmd>`), so they work without anything on your `PATH`. A global `claude-mem-lite` **shell** command (for running queries yourself in a terminal) is **optional** — `npm i -g claude-mem-lite` — and is a *separate* npm install: the plugin's auto-update does **not** refresh it, so re-run `npm i -g claude-mem-lite@latest` if you want that shell command kept in sync. You do **not** need it for the plugin to be fully functional.
165
165
 
166
- > **Auto-adopt fires on the first SessionStart per project (v2.82.1+).** The plugin automatically writes the **invited-memory sentinel** (a system-authority pointer that boosts Claude's proactive use of `mem_recall` / `mem_save`) into the project's memdir **no manual `/adopt` needed**, regardless of install path (npm, npx, `/plugin`, manual). Per-project opt-out: `claude-mem-lite adopt --disable` (`--enable` to re-arm). Global opt-out: `export MEM_NO_AUTO_ADOPT=1`. Manual `/adopt` remains available for re-applying after edits and for the `--all` batch path.
166
+ > **Auto-adopt writes into your project, on every SessionStart (v3.13+).** The plugin adds a slug-scoped **managed block** to your project's own **`<cwd>/CLAUDE.md`** — a file that is normally committed to git — plus a `<cwd>/.claude/plugin_claude_mem_lite.md` detail file. The block is a system-authority pointer that boosts Claude's proactive use of `mem_recall` / `mem_save`. Everything outside the block is preserved verbatim, and it coexists with other plugins' blocks in the same file ([details](#invited-memory-v232)). This happens on **every** SessionStart, not just the first: the sync is idempotent and re-applies the block if it is edited away, and refreshes it when the shipped template changes. It applies regardless of install path (npm, npx, `/plugin`, manual), so **no manual `/adopt` is needed**.
167
+ >
168
+ > Opt out per project with `claude-mem-lite adopt --disable` (`--enable` to re-arm), globally with `export MEM_NO_AUTO_ADOPT=1`, or freeze an already-adopted block against template refreshes with `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1`. `claude-mem-lite unadopt` removes the block and the detail file. Manual `/adopt` remains available for re-applying after edits and for the `--all` batch path.
167
169
 
168
170
  ### Method 2: npx (one-liner)
169
171
 
@@ -355,8 +357,11 @@ Slash commands `/adopt` and `/unadopt` wrap the same CLI.
355
357
  ever rewritten, and duplicate / CRLF-orphaned copies are collapsed to one.
356
358
  Unlike the legacy `MEMORY.md` scheme there is no line-budget gate — `CLAUDE.md`
357
359
  has no truncation cap.
358
- - **Auto-adopt fires on the first SessionStart per project for any install
359
- path (v2.82.1+).** Per-project opt-out: `claude-mem-lite adopt --disable`
360
+ - **Auto-adopt runs on EVERY SessionStart, for any install path (v2.82.1+;
361
+ target moved from the memdir to `<cwd>/CLAUDE.md` in v3.13).** The sync is
362
+ idempotent — it re-applies the managed block if it was edited away and
363
+ refreshes it when the shipped template changes (freeze with
364
+ `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1`). Per-project opt-out: `claude-mem-lite adopt --disable`
360
365
  (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker
361
366
  deletion / plugin reinstalls). Global opt-out: `MEM_NO_AUTO_ADOPT=1`.
362
367
  Pre-v2.82.1 the `CLAUDE_PLUGIN_ROOT` gate left auto-adopt unreachable for
@@ -783,8 +788,8 @@ claude-mem-lite.
783
788
  | `OPENROUTER_MODEL` | Overrides the OpenRouter model slug for **all** background calls (e.g. `openai/gpt-4o-mini`, `qwen/qwen-2.5-72b-instruct`). When unset, the `CLAUDE_MEM_MODEL` tier maps to `anthropic/claude-haiku-4.5` (haiku) or `anthropic/claude-sonnet-4.5` (sonnet). | _(tier default)_ |
784
789
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
785
790
  | `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
786
- | `MEM_NO_AUTO_ADOPT` | Global opt-out for auto-adopt (v2.82.0+). `1` prevents the first-SessionStart auto-write of the invited-memory sentinel across **all** projects. For per-project opt-out use `claude-mem-lite adopt --disable` instead (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker deletion). | _(disabled)_ |
787
- | `MEM_NO_ADOPT_HINT` | Silences the one-line "Invited-memory 未启用:`claude-mem-lite adopt`…" hint that SessionStart appends when the current project hasn't been adopted. Since v2.82.1 auto-adopt fires on first SessionStart for any install path, so this hint typically surfaces only when you've explicitly opted out (`MEM_NO_AUTO_ADOPT=1` or `claude-mem-lite adopt --disable`). | _(disabled)_ |
791
+ | `MEM_NO_AUTO_ADOPT` | Global opt-out for auto-adopt (v2.82.0+). `1` prevents the per-SessionStart auto-write of the `CLAUDE.md` managed block across **all** projects. For per-project opt-out use `claude-mem-lite adopt --disable` instead (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker deletion). | _(disabled)_ |
792
+ | `MEM_NO_ADOPT_HINT` | Silences the one-line "Invited-memory 未启用:`claude-mem-lite adopt`…" hint that SessionStart appends when the current project hasn't been adopted. Since v2.82.1 auto-adopt runs on every SessionStart for any install path, so this hint typically surfaces only when you've explicitly opted out (`MEM_NO_AUTO_ADOPT=1` or `claude-mem-lite adopt --disable`). | _(disabled)_ |
788
793
 
789
794
  ### What gets injected into your context
790
795
 
@@ -841,6 +846,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
841
846
  | `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.6` |
842
847
  | `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED` | Minimum injection volume before the ratio gate is judged at all. | `5` |
843
848
  | `CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER` | Consecutive low-cite sessions before the nudge goes quiet; `0` = never silence. | `3` |
849
+ | `CLAUDE_MEM_CITATION_RELEVANCE_GATE` | Stop credits an `access_count` to a memory the session cited only when something made that memory relevant to the session — it was injected, or you typed its `#NN` yourself. `off` restores the pre-v3.84.0 behaviour of crediting every `#NN` the assistant wrote, which over-counts sessions that discuss memories in prose (release notes, audit reports): measured on real transcripts, 267 of 859 credited (id, session) pairs — 31.1% — were mentions nothing had put in front of the model. Superseded citations are redirected to their keeper on both settings. | _(on)_ |
844
850
  | `CLAUDE_MEM_SUBAGENT_DECAY` | The `subagent` injection face feeds the decay loop: memories handed to a dispatched agent enter the denominator, and the citation that agent makes in its own transcript counts as the numerator. `0` returns the face to metered-but-never-decaying (v3.77–v3.82). | _(on)_ |
845
851
  | `CLAUDE_MEM_METRICS` | `1` records feature-injection counters surfaced by `claude-mem-lite stats`. | _(off)_ |
846
852
 
package/README.zh-CN.md CHANGED
@@ -152,7 +152,9 @@ node install.mjs install
152
152
  1. **安装依赖** -- `npm install --omit=dev`(编译原生 `better-sqlite3`)
153
153
  2. **注册 MCP 服务器** -- `mem-lite` 服务器,包含 20 个工具(9 个核心通过 `tools/list` 暴露 + 11 个隐藏但可调;完整表见 Usage 段)。v2.78 前服务器名为通用的 `mem`,现已改名为 `mem-lite` 避免与用户其它 `.mcp.json` 冲突;工具名(`mem_search`/`mem_recall` 等)保持不变。
154
154
 
155
- > **首次 SessionStart 自动 adopt(v2.82.1+)。** 插件会自动向该项目 memdir 写入**邀请式 memory 哨兵**(一条提升 Claude 主动调用 `mem_recall` / `mem_save` 的 system-authority 指针),**任何安装路径都生效**(npm、npx、`/plugin`、手动),**无需再手动跑 `/adopt`**。项目级关闭:`claude-mem-lite adopt --disable`(重新启用用 `--enable`)。全局关闭:`export MEM_NO_AUTO_ADOPT=1`。手动 `/adopt` 仍保留用于编辑后重写或 `--all` 批量场景。
155
+ > **自动 adopt 会写进你的项目,且每次 SessionStart 都跑(v3.13+)。** 插件向**项目自己的 `<cwd>/CLAUDE.md`**(通常是会进 git 的文件)写入一个 slug 限定的**托管块**,外加 `<cwd>/.claude/plugin_claude_mem_lite.md` 详情文件。该块是一条提升 Claude 主动调用 `mem_recall` / `mem_save` 的 system-authority 指针;块以外的内容逐字保留,也能与其它插件的块共存于同一文件。这是**每次** SessionStart 都做的幂等同步,不只是第一次——块被删掉会重新写回,出货模板变了会刷新。**任何安装路径都生效**(npm、npx、`/plugin`、手动),**无需再手动跑 `/adopt`**。
156
+ >
157
+ > 关闭方式:项目级 `claude-mem-lite adopt --disable`(重新启用用 `--enable`);全局 `export MEM_NO_AUTO_ADOPT=1`;只冻结模板刷新用 `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1`。`claude-mem-lite unadopt` 可移除托管块与详情文件。手动 `/adopt` 仍保留用于编辑后重写或 `--all` 批量场景。
156
158
  3. **配置钩子** -- `PostToolUse`、`PreToolUse`、`SessionStart`、`Stop`、`UserPromptSubmit` 生命周期钩子
157
159
  4. **创建数据目录** -- `~/.claude-mem-lite/`(隐藏目录),存放数据库、运行时和托管资源文件
158
160
  5. **自动迁移** -- 自动检测 `~/.claude-mem/`(原版 claude-mem)或 `~/claude-mem-lite/`(v0.5 前的非隐藏目录),将数据库和运行时文件迁移到 `~/.claude-mem-lite/`,原目录保持不变
@@ -297,7 +299,9 @@ Slash 命令 `/adopt` 和 `/unadopt` 是上述 CLI 的包装。
297
299
  - Hash 守护:你手动改了 sentinel 段 → 下一次 adopt 报 `UserEditedError`,
298
300
  除非显式 `--force`。
299
301
  - 预算门:MEMORY.md 已 >180 行时拒绝新增(避开 Claude Code 200 行截断)。
300
- - **任何安装路径首次 SessionStart 自动 adopt(v2.82.1+)。** 项目级关闭:
302
+ - **任何安装路径每次 SessionStart 都自动 adopt(v2.82.1+;v3.13 起写入目标由
303
+ memdir 改为 `<cwd>/CLAUDE.md`)。** 同步是幂等的——托管块被删会写回,出货模板
304
+ 变化会刷新(用 `CLAUDE_MEM_NO_TEMPLATE_REFRESH=1` 冻结)。项目级关闭:
301
305
  `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,
302
306
  存活于 marker 删除 / 插件重装)。全局关闭:`export MEM_NO_AUTO_ADOPT=1`。
303
307
  v2.82.1 前因 `CLAUDE_PLUGIN_ROOT` gate 与 `install.mjs` 写出的 hook 命令
@@ -614,8 +618,8 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
614
618
  | `OPENROUTER_MODEL` | 覆盖**所有**后台调用的 OpenRouter 模型 slug(如 `openai/gpt-4o-mini`、`qwen/qwen-2.5-72b-instruct`)。未设时按 `CLAUDE_MEM_MODEL` 分层映射到 `anthropic/claude-haiku-4.5`(haiku)或 `anthropic/claude-sonnet-4.5`(sonnet)。 | _(分层默认)_ |
615
619
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
616
620
  | `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。**v2.82.0 起此 env 不再阻挡 auto-adopt——如需关闭 auto-adopt 用 `MEM_NO_AUTO_ADOPT=1`。** | _(禁用)_ |
617
- | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止首次 SessionStart 在**所有**项目自动写入邀请式 memory 哨兵。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
618
- | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1 起任何安装路径首次 SessionStart 都自动 adopt,所以该提示一般只在你显式 opt out(`MEM_NO_AUTO_ADOPT=1` 或 `claude-mem-lite adopt --disable`)的项目才会出现。 | _(禁用)_ |
621
+ | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止每次 SessionStart 在**所有**项目自动写入 `CLAUDE.md` 托管块。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
622
+ | `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1 起任何安装路径每次 SessionStart 都自动 adopt,所以该提示一般只在你显式 opt out(`MEM_NO_AUTO_ADOPT=1` 或 `claude-mem-lite adopt --disable`)的项目才会出现。 | _(禁用)_ |
619
623
 
620
624
  ## 许可证
621
625
 
package/hook-context.mjs CHANGED
@@ -16,19 +16,14 @@ import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet,
16
16
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
17
17
  import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
18
18
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
19
+ // The canonical one (v3.84.0): this file carried a byte-identical private copy, which is
20
+ // the same one-home rule this release enforced for the cooldown path and the dashboard.
21
+ import { inferProjectDir } from './project-utils.mjs';
19
22
  // Single source for the type-quality weights (audit 2026-08-22 P2-10) — this table used
20
23
  // to be hand-copied here and in hook-memory.mjs, kept equal only by comment convention.
21
24
  import { TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
22
25
 
23
26
  import { DAY_MS } from './lib/time-constants.mjs';
24
- /**
25
- * Infer the project directory from environment variables or cwd.
26
- * @returns {string} Absolute path to the project directory
27
- */
28
- function inferProjectDir() {
29
- return process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
30
- }
31
-
32
27
  /**
33
28
  * Compute adaptive recall time windows based on project activity velocity.
34
29
  * High activity -> shorter windows (recent data more relevant).
package/hook-llm.mjs CHANGED
@@ -25,6 +25,7 @@ import { episodeHasSignificantContent } from './hook-episode.mjs';
25
25
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
26
26
 
27
27
  import { DAY_MS } from './lib/time-constants.mjs';
28
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
28
29
  // T9: memdir-incompatible types live in the `events` table, not `observations`.
29
30
  // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
30
31
  const EVENT_TYPE_SET = new Set(EVENT_TYPES);
@@ -351,7 +352,18 @@ export function persistHaikuSummary(db, summary, ctx) {
351
352
 
352
353
  if (ctx.preSavedObsId) {
353
354
  const id = db.transaction(() => {
354
- db.prepare(`DELETE FROM observations WHERE id = ?`).run(ctx.preSavedObsId);
355
+ // Same live-row guard as the in-place upgrade (FLOW-7). If auto-dedup superseded
356
+ // or compressed the pre-saved row while this worker was in flight, that row is no
357
+ // longer ours to hard-delete — a keeper may have absorbed it, and children can
358
+ // point at it through compressed_into. Leave it to the maintenance path, which
359
+ // recovers children before deleting; the event still gets written either way.
360
+ const removed = db.prepare(
361
+ `DELETE FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
362
+ ).run(ctx.preSavedObsId).changes;
363
+ if (removed === 0) {
364
+ debugLog('DEBUG', 'llm-episode',
365
+ `upgrade-delete: pre-saved obs #${ctx.preSavedObsId} no longer live — left in place`);
366
+ }
355
367
  return insertEvent();
356
368
  })();
357
369
  return { table: 'events', id };
@@ -1014,12 +1026,18 @@ ${actionList}`;
1014
1026
  lesson_learned: obs.lessonLearned || null,
1015
1027
  search_aliases: obs.searchAliases || null,
1016
1028
  });
1017
- db.prepare(`
1029
+ // The live-row guard (FLOW-7, 2026-08-29 audit). This worker is 2-5s behind the
1030
+ // foreground pre-save, and auto-dedup can supersede or compress that row inside
1031
+ // the window. An unguarded `WHERE id = ?` then writes the whole enrichment onto a
1032
+ // tombstone: `changes` is 1, nothing looks wrong, and the row it landed on is
1033
+ // excluded from every read face by liveObsFilterSql. Same clause as those read
1034
+ // faces, so "what the update may touch" and "what a query may return" cannot drift.
1035
+ const upgraded = db.prepare(`
1018
1036
  UPDATE observations SET type=?, title=?, subtitle=?,
1019
1037
  narrative=COALESCE(NULLIF(?, ''), narrative), concepts=?, facts=?,
1020
1038
  text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?,
1021
1039
  scope=COALESCE(?, scope)
1022
- WHERE id = ?
1040
+ WHERE id = ? AND ${liveObsFilterSql('')}
1023
1041
  `).run(
1024
1042
  obs.type, safe.title, safe.subtitle,
1025
1043
  safe.narrative,
@@ -1031,23 +1049,38 @@ ${actionList}`;
1031
1049
  safe.search_aliases,
1032
1050
  normalizeScope(obs.scope),
1033
1051
  episode.savedId
1034
- );
1035
- savedId = episode.savedId;
1036
- savedTable = 'observations';
1037
- debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
1038
-
1039
- // Update TF-IDF vector with enriched content
1040
- try {
1041
- const vocab = getVocabulary(db);
1042
- if (vocab) {
1043
- const vecText = vecTextForRow({ title: obs.title, narrative: obs.narrative, concepts: conceptsText, lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases });
1044
- const vec = computeVector(vecText, vocab);
1045
- if (vec) {
1046
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1047
- .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
1052
+ ).changes;
1053
+
1054
+ if (upgraded === 0) {
1055
+ // The pre-saved row is gone or tombstoned. Dropping the enrichment here is the
1056
+ // silent-loss shape this repository keeps paying for, so save it as a fresh row
1057
+ // and let the normal dedup path decide whether it merges into the keeper.
1058
+ debugLog('DEBUG', 'llm-episode',
1059
+ `pre-saved obs #${episode.savedId} no longer live — saving enrichment as a fresh row`);
1060
+ const result = persistHaikuSummary(db, obsToSummary(obs), {
1061
+ project: episode.project,
1062
+ session_id: episode.sessionId,
1063
+ });
1064
+ savedId = result.id;
1065
+ savedTable = result.table;
1066
+ } else {
1067
+ savedId = episode.savedId;
1068
+ savedTable = 'observations';
1069
+ debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
1070
+
1071
+ // Update TF-IDF vector with enriched content
1072
+ try {
1073
+ const vocab = getVocabulary(db);
1074
+ if (vocab) {
1075
+ const vecText = vecTextForRow({ title: obs.title, narrative: obs.narrative, concepts: conceptsText, lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases });
1076
+ const vec = computeVector(vecText, vocab);
1077
+ if (vec) {
1078
+ db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1079
+ .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
1080
+ }
1048
1081
  }
1049
- }
1050
- } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
1082
+ } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
1083
+ }
1051
1084
  }
1052
1085
  } else {
1053
1086
  // Clean insert (no pre-save) — dispatcher routes by type.
package/hook-shared.mjs CHANGED
@@ -30,6 +30,16 @@ export const EPISODE_TIME_GAP_MS = 5 * 60 * 1000; // 5 min
30
30
  export const SESSION_EXPIRY_MS = 12 * 60 * 60 * 1000; // 12h
31
31
  export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
32
32
  export const STALE_LOCK_MS = 30000; // 30s
33
+
34
+ // The background-maintenance mutex, defined HERE next to the sweeper policy it has to
35
+ // escape. cleanStaleLockFiles() below unlinks any `*.lock` older than STALE_LOCK_MS
36
+ // WITHOUT checking whether the holder is alive — right for the episode lock's millisecond
37
+ // critical section, fatal for a maintenance pass that runs for seconds to minutes. The
38
+ // name therefore ends in `.proclock`, and `tests/auto-maintain-proc-lock.test.mjs` asserts
39
+ // that against THIS constant rather than a re-typed copy: the first version of that test
40
+ // built its own path from a literal, so renaming the lock left it green with the hazard
41
+ // back. proc-lock's own staleness policy (age OR provably-dead pid) is the correct one.
42
+ export const AUTO_MAINTAIN_LOCK = 'auto-maintain.proclock';
33
43
  export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
34
44
  export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
35
45
  export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
@@ -108,13 +118,35 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
108
118
  const readsCutoff = now - readsAgeMs;
109
119
  let count = 0;
110
120
  for (const f of entries) {
111
- // `.claim-` = handleStop's lock-contended fallback claim file (ep-<project>.json.claim-<pid>-<ts>),
112
- // which leaks if the process dies between rename and unlink; sweep it on the same 1h cutoff.
113
- const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-') || f.includes('.claim-');
121
+ // Crash residue: this runtime dir writes four families of temp name, each the middle
122
+ // of a rename-or-unlink pair that leaks if the process dies between the two steps.
123
+ // The predicate covered only `.claim-`, whose comment states the reason it exists —
124
+ // and the other three are that same window (audit FLOW-3):
125
+ // .claim- handleStop's lock-contended fallback (hook.mjs)
126
+ // .collect- the reads-file rename a flush performs (hook.mjs)
127
+ // .trim- the reads-file truncation (hook.mjs)
128
+ // .tmp- every atomic write (hook-episode.mjs, atomicWrite)
129
+ // Neither of the old clauses could reach them: `reads-<p>.txt.collect-<ts>` does not
130
+ // end in `.txt`, and `ep-<p>.json.tmp-<pid>` does not start with `ep-flush-`.
131
+ //
132
+ // Anchored to the END of the name, and the reason is not the one first written here.
133
+ // The original note claimed it protected `reads-x.tmp-y.txt` from the short clock; it
134
+ // does not — that name ends in `.txt`, so `isReads` picks the 24h cutoff either way,
135
+ // and dropping the anchor killed no test (caught by a pre-tag reviewer). What the
136
+ // anchor actually protects is the LIVE episode buffer of a project whose sanitized
137
+ // name contains the token: `ep-x.tmp-y.json` matches an unanchored pattern, and would
138
+ // then be swept as residue one hour into a session that is still using it.
139
+ const isCrashResidue = /\.(claim|collect|trim|tmp)-[^.]*$/.test(f);
140
+ const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
114
141
  const isReads = f.startsWith('reads-') && f.endsWith('.txt');
115
- if (!isEpisode && !isReads) continue;
142
+ if (!isCrashResidue && !isEpisode && !isReads) continue;
116
143
  const full = join(runtimeDir, f);
117
144
  try {
145
+ // Residue takes the short cutoff and a live tracker takes the 24h one, with no
146
+ // tie-break needed: residue always APPENDS its suffix, so it never ends in `.txt`
147
+ // and `isReads` is already false for it. (A `&& !isCrashResidue` tie-break was
148
+ // written here first and no mutation could kill it — it was guarding a state the
149
+ // two predicates cannot both be in.)
118
150
  if (statSync(full).mtimeMs < (isReads ? readsCutoff : cutoff)) {
119
151
  unlinkSync(full);
120
152
  count++;
package/hook.mjs CHANGED
@@ -29,6 +29,13 @@ import {
29
29
  formatErrorRecallHints,
30
30
  MAX_HOOK_STDIN_BYTES,
31
31
  } from './utils.mjs';
32
+ // Direct import (not via the utils.mjs barrel): the barrel's re-exports are a v2.21
33
+ // backward-compat surface that knip already lists as unused; new shared symbols go to
34
+ // their canonical module.
35
+ import { inferProjectDir } from './project-utils.mjs';
36
+ // Aliased: `acquireLock` from hook-episode.mjs below is the episode buffer's own
37
+ // (argument-less) lock — a different mutex with a different staleness policy.
38
+ import { acquireLock as acquireProcLock } from './lib/proc-lock.mjs';
32
39
  import {
33
40
  readEpisodeRaw, episodeFile,
34
41
  acquireLock, releaseLock, readEpisode, writeEpisode,
@@ -39,7 +46,7 @@ import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-con
39
46
  import { entry as preCompactEntry } from './hook-precompact.mjs';
40
47
  import {
41
48
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
42
- SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
49
+ SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, AUTO_MAINTAIN_LOCK,
43
50
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
44
51
  sessionFile, getSessionId, createSessionId, openDb,
45
52
  spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
@@ -56,6 +63,7 @@ import { snapshotDb } from './lib/db-backup.mjs';
56
63
  import {
57
64
  extractCitationsFromTranscript,
58
65
  extractInjectedBySurface,
66
+ buildCitationRelevanceSet,
59
67
  unionSurfaces,
60
68
  extractInjectedFromKeyContext,
61
69
  bumpCitationAccess,
@@ -70,6 +78,7 @@ import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attri
70
78
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
71
79
  import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
72
80
  import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
81
+ import { upsFtsQuery } from './lib/ups-query.mjs';
73
82
  import { formatTaskImperative } from './lib/task-imperative.mjs';
74
83
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
75
84
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
@@ -946,8 +955,19 @@ async function handleStop() {
946
955
 
947
956
  const ids = extractCitationsFromTranscript(transcriptPath);
948
957
  if (ids.size > 0) {
949
- const n = bumpCitationAccess(db, ids, project);
950
- debugLog('DEBUG', 'handleStop', `citations: ${ids.size} ids scanned, ${n} obs bumped`);
958
+ // Gate the access-count channel on relevance (audit FLOW-2 / D#179). The cited
959
+ // set is every `#NN` in this session's assistant text and cannot tell a
960
+ // citation from a mention; in this repository a CHANGELOG or audit-writing
961
+ // session names dozens of ids in prose, and access_count > 3 promotes a row a
962
+ // tier via boostAccessed. The population to credit — all seven faces, and why
963
+ // extractAllInjected alone is the wrong five — lives in the builder.
964
+ const relevant = buildCitationRelevanceSet({
965
+ transcriptPath, runtimeDir: RUNTIME_DIR, project,
966
+ sessionId: ccSessionId, subagentInjected: sub.injected,
967
+ });
968
+ const n = bumpCitationAccess(db, ids, project, relevant);
969
+ debugLog('DEBUG', 'handleStop',
970
+ `citations: ${ids.size} ids scanned, ${relevant.size} relevant, ${n} obs bumped`);
951
971
  }
952
972
 
953
973
  // v32 citation-decay: tighter feedback loop on top of P4. Re-scan
@@ -1550,14 +1570,52 @@ function scheduleSessionStartAutoMaintain(project) {
1550
1570
  if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
1551
1571
  }
1552
1572
 
1573
+ // The maintenance mutex deliberately does NOT end in `.lock`: cleanStaleLockFiles()
1574
+ // below unlinks every `*.lock` in RUNTIME_DIR whose age exceeds STALE_LOCK_MS (30s)
1575
+ // WITHOUT consulting the holder's pid — a policy written for the episode lock, whose
1576
+ // critical section is milliseconds. A maintenance pass is seconds to minutes (VACUUM INTO
1577
+ // snapshot, purge, decay, dedup over the whole DB), so that sweeper would strip this lock
1578
+ // mid-pass and hand the exclusion straight back to the race it exists to close.
1579
+ // proc-lock brings its own staleness policy (age OR provably-dead pid), which is the
1580
+ // correct one here.
1581
+ // Generous upper bound on one pass; a crashed holder is normally reclaimed sooner via the
1582
+ // dead-pid check, so this only matters for a holder killed on another host.
1583
+ const AUTO_MAINTAIN_LOCK_STALE_MS = 10 * 60 * 1000;
1584
+
1553
1585
  // Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
1554
1586
  // pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
1555
1587
  // gate + the compress/optimize spawns at its tail.
1588
+ //
1589
+ // Cross-process mutual exclusion (2026-08-29 audit FLOW-1). The pass is shaped
1590
+ // read-gate → long work → write-gate, so two Claude Code windows booting either side of
1591
+ // the 24h boundary both see "due" and both spawn a worker. That breaks a documented
1592
+ // in-process invariant: decayAndMarkIdle marks BEFORE it decays precisely so an imp-2 row
1593
+ // cannot be decayed 2→1 and marked COMPRESSED_PENDING_PURGE in the same pass (MED-1, see
1594
+ // its docblock — each importance tier is supposed to buy a grace cycle). Across two
1595
+ // processes the ordering is gone: worker A decays 2→1, worker B's mark-idle then sees a
1596
+ // qualifying imp-1 row and hides it, 37 days from a hard delete. The same overlap
1597
+ // double-runs the cascade below it (duplicate weekly summaries from compressGroup, whose
1598
+ // UPDATE has no compressed_into guard; doubled llm-optimize spend).
1599
+ //
1600
+ // Lock at the worker entry rather than around the individual ops: the cascade spawns sit
1601
+ // inside the pass, so one gate covers the whole family. Not acquiring is a plain no-op —
1602
+ // a peer is already doing exactly this work.
1556
1603
  function handleAutoMaintain(project) {
1557
- const db = openDb();
1558
- if (!db) return;
1559
- try { runSessionStartAutoMaintain(db, project); }
1560
- finally { try { db.close(); } catch { /* ignore */ } }
1604
+ const release = acquireProcLock(join(RUNTIME_DIR, AUTO_MAINTAIN_LOCK), {
1605
+ staleMs: AUTO_MAINTAIN_LOCK_STALE_MS,
1606
+ });
1607
+ if (!release) {
1608
+ debugLog('DEBUG', 'auto-maintain', 'skipped — a live peer holds the maintenance lock');
1609
+ return;
1610
+ }
1611
+ try {
1612
+ const db = openDb();
1613
+ if (!db) return;
1614
+ try { runSessionStartAutoMaintain(db, project); }
1615
+ finally { try { db.close(); } catch { /* ignore */ } }
1616
+ } finally {
1617
+ release();
1618
+ }
1561
1619
  }
1562
1620
 
1563
1621
  function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
@@ -1686,7 +1744,11 @@ async function buildStartupDashboardText(db, project) {
1686
1744
  // tests/session-start-stdout-envelope.test.mjs.
1687
1745
  try {
1688
1746
  const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
1689
- let dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
1747
+ // projectPath MUST come from the same place `project` does (inferProjectDir), not from
1748
+ // process.cwd(): otherwise the dashboard renders directory A's git state and task list
1749
+ // under directory B's project name whenever the hook process was not spawned at the
1750
+ // project root. See inferProjectDir()'s docblock for the case this closed.
1751
+ let dashboardText = buildDashboard({ db, project, projectPath: inferProjectDir() });
1690
1752
  const citeNudge = buildCiteRecallNudge(project);
1691
1753
  if (citeNudge) {
1692
1754
  dashboardText = dashboardText ? `${citeNudge}\n${dashboardText}` : citeNudge;
@@ -2158,7 +2220,14 @@ async function handleUserPrompt() {
2158
2220
  // ranking and citation extractors (bare-`#` anchored) never read an event id as
2159
2221
  // an obs id. Nested try so an events failure can't suppress the imperative pick.
2160
2222
  try {
2161
- const events = searchInjectableEvents(db, { prompt: promptText, project });
2223
+ // upsFtsQuery, not the raw prompt (audit ALGO-1). lib/ups-query.mjs declares
2224
+ // itself "the ONE query-cap definition for the UserPromptSubmit event", and both
2225
+ // OTHER legs of this same event go through it — but this leg, wired in v3.48
2226
+ // before that module existed, handed searchInjectableEvents the whole prompt and
2227
+ // let it call the uncapped sanitizeFtsQuery. Measured here: a 250KB CJK prompt
2228
+ // (path B's stdin cap is 256KB) costs 356ms uncapped against 5.5ms capped, all of
2229
+ // it synchronous, before the model sees the turn.
2230
+ const events = searchInjectableEvents(db, { ftsQuery: upsFtsQuery(promptText), project });
2162
2231
  if (events.length > 0) {
2163
2232
  const elines = ['<memory-context relevance="events">'];
2164
2233
  for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { readFileSync, readdirSync, statSync } from 'fs';
14
14
  import { join } from 'path';
15
- import { debugCatch } from '../utils.mjs';
15
+ import { debugCatch, debugLog } from '../utils.mjs';
16
16
  import { keyContextIdsFileName } from './injected-ids.mjs';
17
17
  import { readTranscriptEntries } from './transcript-scan.mjs';
18
18
  // The emitter's own prefix — see SURFACE_MATCHERS.task_imperative. Importing it rather
@@ -169,21 +169,168 @@ export function computeCiteRecall(transcriptPath) {
169
169
  return { injected: injected.size, cited: cited.size, recalled, ratio };
170
170
  }
171
171
 
172
+ /**
173
+ * Observation ids the USER typed in their own messages this session.
174
+ *
175
+ * Half of the relevance gate bumpCitationAccess takes (audit FLOW-2). Someone writing
176
+ * `look at #10716` is naming that memory deliberately, which is at least as strong a
177
+ * relevance signal as an automatic injection — dropping it would have been the cost of
178
+ * the narrower "injected only" gate.
179
+ *
180
+ * Hook injections ride the `attachment` channel (see eachHookAttachment), not user
181
+ * message text, so this reads a different stream than the surface extractors do. It does
182
+ * not filter injected content out of the result: the only consumer unions this with the
183
+ * injected set, where a duplicate is free.
184
+ *
185
+ * `tool_result` blocks are program output echoed back inside a user turn — not something
186
+ * the user wrote — so only `text` blocks count.
187
+ *
188
+ * @param {string|null|undefined} transcriptPath
189
+ * @param {{mainOnly?: boolean}} [opts]
190
+ * @returns {Set<number>}
191
+ */
192
+ export function extractUserTypedIds(transcriptPath, opts = {}) {
193
+ const { mainOnly = false } = opts;
194
+ const ids = new Set();
195
+ for (const entry of readTranscriptEntries(transcriptPath)) {
196
+ if (entry.type !== 'user' || !entry.message) continue;
197
+ if (mainOnly && entry.isSidechain === true) continue;
198
+ const content = entry.message.content;
199
+ const blocks = typeof content === 'string'
200
+ ? [{ type: 'text', text: content }]
201
+ : (Array.isArray(content) ? content : []);
202
+ for (const block of blocks) {
203
+ if (block.type !== 'text' || typeof block.text !== 'string') continue;
204
+ CITATION_RE.lastIndex = 0;
205
+ let m;
206
+ while ((m = CITATION_RE.exec(block.text))) addObsId(ids, m[1]);
207
+ }
208
+ }
209
+ return ids;
210
+ }
211
+
212
+ /**
213
+ * The set of ids this session actually put in front of the model, across ALL SEVEN faces.
214
+ *
215
+ * This is the population `bumpCitationAccess` credits against, and it lives here rather
216
+ * than inline at the call site because getting it from `extractAllInjected` alone is
217
+ * wrong in a way nothing would have caught. That helper unions the five faces with a hook
218
+ * attachment to walk; the two it omits are NON_ATTACHMENT_SURFACES, and each is omitted
219
+ * for a reason that does not apply to the promotion channel:
220
+ *
221
+ * * `keyctx` — kept out of extractAllInjected because an unconditional SessionStart
222
+ * render is no evidence for the decay DENOMINATOR. `extractInjectedFromKeyContext`'s
223
+ * docblock then names this exact caller: "Callers must therefore intersect with the
224
+ * cited set (see handleStop) so a CITED Key Context row is credited while an uncited
225
+ * one is left alone." Dropping it inverts that contract — and does so INVISIBLY on an
226
+ * adopted project, where the marker is empty by construction. The loss lands on a
227
+ * non-adopted project, the default for a new install, where that block is the most
228
+ * prominent injection surface there is.
229
+ * * `subagent` — a lesson handed to a dispatched agent was still shown by this session.
230
+ *
231
+ * Derived from CITATION_SURFACES rather than re-enumerated, so a new face cannot be added
232
+ * to the store and silently miss this gate (`assertRelevanceCoversAllFaces` binds it).
233
+ *
234
+ * @param {object} ctx
235
+ * @param {string|null|undefined} ctx.transcriptPath
236
+ * @param {string} [ctx.runtimeDir]
237
+ * @param {string} [ctx.project]
238
+ * @param {string|null} [ctx.sessionId]
239
+ * @param {Iterable<number>} [ctx.subagentInjected] Already-collected subagent ids
240
+ * (collectSubagentSurface parses sidechains and evicts the transcript memo, so the
241
+ * caller runs it once and hands the result in rather than paying for it twice).
242
+ * @returns {Set<number>}
243
+ */
244
+ export function buildCitationRelevanceSet({
245
+ transcriptPath, runtimeDir, project, sessionId = null, subagentInjected = [],
246
+ } = {}) {
247
+ const out = new Set();
248
+ for (const id of extractAllInjected(transcriptPath)) out.add(id); // 5 attachment faces
249
+ if (runtimeDir && project) {
250
+ for (const id of extractInjectedFromKeyContext({ runtimeDir, project, sessionId })) out.add(id);
251
+ }
252
+ for (const id of subagentInjected) out.add(id);
253
+ for (const id of extractUserTypedIds(transcriptPath)) out.add(id); // the user named it
254
+ return out;
255
+ }
256
+
257
+ /**
258
+ * Every face in CITATION_SURFACES must have a source in buildCitationRelevanceSet.
259
+ *
260
+ * Structural, because the behavioural version cannot exist: the faces are read off
261
+ * different streams (attachments, a runtime marker, sidechain files, user text), so no one
262
+ * fixture drives all seven. Throws rather than returns, so a face added without a source
263
+ * fails loudly at test time instead of quietly losing its promotion channel.
264
+ *
265
+ * @param {ReadonlyArray<string>} [sourced] Faces the builder reads, for the test to pass in.
266
+ */
267
+ export function assertRelevanceCoversAllFaces(sourced) {
268
+ const covered = new Set(sourced ?? [...ATTACHMENT_SURFACES, ...NON_ATTACHMENT_SURFACES]);
269
+ const missing = CITATION_SURFACES.filter((f) => !covered.has(f));
270
+ if (missing.length) {
271
+ throw new Error(`buildCitationRelevanceSet has no source for face(s): ${missing.join(', ')} — `
272
+ + 'a face with no source here loses its access-count promotion channel silently.');
273
+ }
274
+ return true;
275
+ }
276
+
172
277
  /**
173
278
  * Increment `access_count` (and `last_accessed_at`) for each cited observation
174
- * that belongs to `project`. Returns the count of successful increments.
279
+ * that belongs to `project` AND is relevant to this session. Returns the count of
280
+ * successful increments.
281
+ *
282
+ * `relevantIds` is REQUIRED, and it is the whole point (audit FLOW-2 / D#179).
283
+ *
284
+ * The cited set is "every `#NN` that appears in this session's assistant text", which
285
+ * cannot tell a citation from a mention. In this repository — whose subject matter IS the
286
+ * memory store — a session that writes a CHANGELOG or an audit report names dozens of ids
287
+ * in prose, and each one was being credited with a use. Downstream that is not cosmetic:
288
+ * `access_count > 3` promotes the row a tier via boostAccessed (maintain-core.mjs), so
289
+ * discussing a memory made it more likely to be injected, and the same inflation fed the
290
+ * cite-rate instrumentation that product decisions are read off. citation-tracker's own
291
+ * notes record #10716 promoted by 21 mentions from the session that WROTE it.
292
+ *
293
+ * The gate is "did anything make this row relevant to this session" — injection did, and
294
+ * the user naming an id in their own message did. An agent mentioning an id it just wrote
295
+ * about did not. Deliberately NOT a context regex trying to separate "citing" from
296
+ * "mentioning": that distinction is not enumerable, and trying was rejected up front.
297
+ *
298
+ * Passing no set is a no-op rather than an open gate: the caller must name the population
299
+ * it is crediting, so a future call site cannot reopen this by omission. It says so in the
300
+ * telemetry rather than failing silently.
301
+ *
302
+ * Superseded ids are redirected to their keeper first (FLOW-6), matching
303
+ * applyCitationDecay and recordCitationSurfaces — this was the last access-side surface
304
+ * still crediting a tombstone instead of the row that absorbed it. Both sets are
305
+ * redirected, or a keeper id in one would not meet its superseded twin in the other.
175
306
  *
176
307
  * Per-row UPDATE in try-catch so a single FTS-corrupted row can't abort the
177
308
  * scan. Cross-project IDs are silently ignored by the WHERE clause.
178
309
  *
179
310
  * @param {import('better-sqlite3').Database} db
180
- * @param {Iterable<number>} ids
311
+ * @param {Iterable<number>} ids ids cited in this session's assistant text
181
312
  * @param {string} project
313
+ * @param {Set<number>|Iterable<number>} relevantIds ids injected this session, unioned
314
+ * with ids the user typed themselves. Required.
182
315
  * @returns {number} count of rows incremented
183
316
  */
184
- export function bumpCitationAccess(db, ids, project) {
317
+ export function bumpCitationAccess(db, ids, project, relevantIds, env = process.env) {
185
318
  if (!db || !ids || !project) return 0;
186
- const idList = Array.isArray(ids) ? ids : [...ids];
319
+ // Revert path for the gate (CLAUDE_MEM_CITATION_RELEVANCE_GATE=off). It restores the
320
+ // pre-v3.84.0 behaviour — every mention credited — including the missing-argument hole,
321
+ // because a half-reverted gate is a third behaviour nobody has measured.
322
+ const gateOff = String(env.CLAUDE_MEM_CITATION_RELEVANCE_GATE || '').toLowerCase() === 'off';
323
+ if (!gateOff && (relevantIds === undefined || relevantIds === null)) {
324
+ debugLog('WARN', 'bumpCitationAccess', 'no relevantIds passed — refusing to credit ungated mentions');
325
+ return 0;
326
+ }
327
+ const cited = redirectSupersededIds(db, project, ids instanceof Set ? ids : new Set(ids));
328
+ const allowed = redirectSupersededIds(
329
+ db, project, relevantIds instanceof Set ? relevantIds : new Set(relevantIds ?? []),
330
+ );
331
+ // The superseded redirect is NOT part of the flag: it was a separate defect (FLOW-6,
332
+ // crediting a tombstone instead of the row that absorbed it) with no upside to restore.
333
+ const idList = gateOff ? [...cited] : [...cited].filter((id) => allowed.has(id));
187
334
  if (idList.length === 0) return 0;
188
335
  const stmt = db.prepare(`
189
336
  UPDATE observations SET access_count = access_count + 1, last_accessed_at = ?
@@ -15,6 +15,13 @@ import { basename, join } from 'path';
15
15
  import { readFileSync } from 'fs';
16
16
  import { readTranscriptEntries } from './transcript-scan.mjs';
17
17
  import { EDIT_TOOLS } from '../utils.mjs';
18
+ // SEC-6 (2026-08-29 audit): these two hints were the only one of the injection surfaces
19
+ // whose text cells reach the model undefanged. Every sibling neutralizes (events-injection
20
+ // titles/lessons, hook-context's whole block, hook-handoff — which defangs the output of
21
+ // basename() specifically, at hook-handoff.mjs:473). A filename is attacker-influenceable
22
+ // in the ordinary case: it is whatever the repository being worked on happens to contain.
23
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
24
+ import { cooldownPathFor as sharedCooldownPathFor } from './cooldown-path.mjs';
18
25
  // One caliber for `#NN`. citation-tracker.mjs does NOT import this module, so the edge
19
26
  // is acyclic.
20
27
  import { citationIdRe } from './citation-tracker.mjs';
@@ -60,7 +67,7 @@ export function buildCiteBackHint(episode, cooldown) {
60
67
  `${CITE_BACK_HINT_LEADER} edited ${matches.length} file(s) with ${totalLessons} prior lesson(s) this session. Save now if any was the root cause:`,
61
68
  ];
62
69
  for (const m of matches) {
63
- const fname = basename(m.file);
70
+ const fname = neutralizeContextDelimiters(basename(m.file));
64
71
  const idList = m.ids.map(id => `#${id}`).join(', ');
65
72
  lines.push(` • ${fname} ← ${idList} — /lesson --file ${fname} "<root cause + fix>"`);
66
73
  }
@@ -105,17 +112,17 @@ export function buildUnsavedBugfixHint(episode) {
105
112
  if (!hasError || !hasEdit || editedFiles.size === 0) return null;
106
113
 
107
114
  const files = [...editedFiles];
108
- const displayed = files.slice(0, MAX_DISPLAY_FILES).map(f => basename(f));
109
- const firstFname = basename(files[0]);
115
+ const displayed = files.slice(0, MAX_DISPLAY_FILES).map(f => neutralizeContextDelimiters(basename(f)));
116
+ const firstFname = neutralizeContextDelimiters(basename(files[0]));
110
117
  return `[mem] ⚠ Unsaved bugfix-shape: error+edit across ${files.length} file(s) in ${entries.length} entries (${displayed.join(', ')}). Save now if it was a real fix: /lesson --file ${firstFname} "<root cause + fix>"`;
111
118
  }
112
119
 
113
- // Path scheme MUST mirror scripts/pre-tool-recall.js cooldownPathFor() drift
114
- // silently zeros cite-back across the release. Pinned by tests/cite-back-hint.test.mjs
115
- // 'sanitizes the sessionId the same way pre-tool-recall.js does'.
120
+ // Path scheme comes from lib/cooldown-path.mjs, the single definition shared with the
121
+ // writer (scripts/pre-tool-recall.js) and the other reader (lib/edge-attribution.mjs).
122
+ // Argument order is flipped from the shared helper's, so keep this thin adapter rather
123
+ // than re-ordering every call site (ARCH-2).
116
124
  function cooldownPathFor(sessionId, runtimeDir) {
117
- const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
118
- return join(runtimeDir, `pre-recall-cooldown-${safe}.json`);
125
+ return sharedCooldownPathFor(runtimeDir, sessionId);
119
126
  }
120
127
 
121
128
  // ─── countUnsavedBugfixShape (B2, v2.83.1) ──────────────────────────────────
@@ -0,0 +1,42 @@
1
+ // lib/cooldown-path.mjs — the ONE definition of the pre-recall cooldown file's path.
2
+ //
3
+ // The rule (sanitize the session id, cap it at 64 chars, join it to RUNTIME_DIR under a
4
+ // fixed prefix) had three independent copies: scripts/pre-tool-recall.js writes the file,
5
+ // lib/cite-back-hint.mjs and lib/edge-attribution.mjs read it. Two of the three carried a
6
+ // comment saying the copies MUST agree and that drift silently zeros the surface that
7
+ // depends on them — a writer and a reader disagreeing does not error, it just reads a
8
+ // file nobody wrote. Only the pre-tool-recall/cite-back pair was pinned by a test; the
9
+ // edge-attribution copy, whose drift silently zeros Stop-side attribution, was not.
10
+ //
11
+ // The original reason for copying (#8447: keep the standalone hook fast-path free of
12
+ // imports) was retired by v3.80.0 — scripts/pre-tool-recall.js already imports
13
+ // lib/resolve-data-dir.mjs and lib/hook-telemetry.mjs on its startup path. This module is
14
+ // two pure functions over `node:path` and adds nothing measurable to that.
15
+
16
+ import { join } from 'path';
17
+
18
+ /** Filename prefix. Exported so a sweeper can match the family without re-deriving it. */
19
+ export const COOLDOWN_FILE_PREFIX = 'pre-recall-cooldown-';
20
+
21
+ /**
22
+ * Session id → filesystem-safe key. The 64-char cap and the character class are part of
23
+ * the contract, not defensive hygiene: writer and readers must derive the same name from
24
+ * the same id, so a change here is a change to all three at once.
25
+ *
26
+ * @param {unknown} sessionId
27
+ * @returns {string}
28
+ */
29
+ export function cooldownSessionKey(sessionId) {
30
+ return String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
31
+ }
32
+
33
+ /**
34
+ * Absolute path to a session's pre-recall cooldown file.
35
+ *
36
+ * @param {string} runtimeDir Absolute RUNTIME_DIR.
37
+ * @param {unknown} sessionId Claude Code session id.
38
+ * @returns {string}
39
+ */
40
+ export function cooldownPathFor(runtimeDir, sessionId) {
41
+ return join(runtimeDir, `${COOLDOWN_FILE_PREFIX}${cooldownSessionKey(sessionId)}.json`);
42
+ }
@@ -15,16 +15,13 @@
15
15
  // resolveEdgeAttribution inside the same text-floor-gated block.
16
16
 
17
17
  import { readFileSync, existsSync } from 'fs';
18
- import { join } from 'path';
19
18
  import { debugCatch } from '../utils.mjs';
20
19
  import { fileMatchClause, fileMatchParams } from './file-edge-match.mjs';
20
+ import { cooldownPathFor } from './cooldown-path.mjs';
21
21
 
22
- // Mirrors pre-tool-recall.js cooldownPathFor sanitization the two MUST agree
23
- // or Stop-side resolution reads a different file than the hook wrote.
24
- function cooldownFileFor(runtimeDir, ccSessionId) {
25
- const safe = String(ccSessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
26
- return join(runtimeDir, `pre-recall-cooldown-${safe}.json`);
27
- }
22
+ // Path scheme comes from lib/cooldown-path.mjs. This copy was the untested one of the
23
+ // three, and its drift zeros Stop-side attribution without a single error (ARCH-2).
24
+ const cooldownFileFor = cooldownPathFor;
28
25
 
29
26
  /**
30
27
  * Read the session cooldown file and return the file→obsIds edge list for
@@ -23,7 +23,6 @@
23
23
 
24
24
  import { searchEventsFts } from './search-core.mjs';
25
25
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
26
- import { sanitizeFtsQuery } from '../utils.mjs';
27
26
 
28
27
  const DEFAULT_LIMIT = 3;
29
28
  const DEFAULT_MIN_IMPORTANCE = 2;
@@ -44,11 +43,19 @@ function normalizeRow(r) {
44
43
  /**
45
44
  * FTS-matched events for a prompt (UserPromptSubmit surfaces). Superseded events are
46
45
  * excluded by searchEventsFts; importance floor drops low-value rows. Never throws.
46
+ *
47
+ * Takes a BUILT query, never raw prompt text (audit ALGO-1). The old `prompt` option ran
48
+ * the uncapped sanitizeFtsQuery, and the one caller used it — so this leg of
49
+ * UserPromptSubmit paid 356ms on a 250KB CJK prompt while the event's other two legs
50
+ * went through lib/ups-query.mjs's cap. Removing the option rather than capping it here
51
+ * is what stops the next caller walking back in: prompt-time callers must name the cap
52
+ * they are using, and `claude-mem-lite search` stays deliberately uncapped.
53
+ *
47
54
  * @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
48
55
  */
49
- export function searchInjectableEvents(db, { prompt, ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
56
+ export function searchInjectableEvents(db, { ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
50
57
  if (!db || !project) return [];
51
- const q = ftsQuery || (prompt ? sanitizeFtsQuery(prompt) : null);
58
+ const q = ftsQuery || null;
52
59
  if (!q) return [];
53
60
  try {
54
61
  const rows = searchEventsFts(db, {
@@ -25,6 +25,10 @@ import { join } from 'path';
25
25
  // lightweight standalone scripts" property stated above.
26
26
  import { isNativeBindingError } from './binding-probe.mjs';
27
27
  import { recordNativeBindingBreakage } from './native-binding-hint.mjs';
28
+ // secret-scrub.mjs is pure regex over one pure-`node:` import (lib/private-strip.mjs),
29
+ // so it preserves the "usable from the lightweight standalone scripts" property above.
30
+ // lib/err-sampler.mjs — the sibling sink — already depends on it for the same reason.
31
+ import { scrubSecrets } from '../secret-scrub.mjs';
28
32
 
29
33
  import { DAY_MS } from './time-constants.mjs';
30
34
  const RETENTION_MS = 14 * DAY_MS;
@@ -76,12 +80,24 @@ export function recordHookError(scope, err, runtimeDir, ctx) {
76
80
  const dir = hookErrorsDir(runtimeDir);
77
81
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
78
82
 
83
+ // Scrub BEFORE truncating, on every field — same caliber as lib/err-sampler.mjs, which
84
+ // is the twin of this sink and has carried the scrub since it was written. This one had
85
+ // not: an error message is a documented carrier for the input that provoked it, and the
86
+ // hook scripts hand it exactly that. `scripts/pre-tool-recall.js:319` records
87
+ // `pre-recall:json` from a JSON.parse of raw hook stdin, and Node >= 20 quotes the
88
+ // offending text back inside the SyntaxError message ("Unexpected token 's',
89
+ // \"{\"a\": sk-proj-AB\"... is not valid JSON") — that stdin is the user's prompt and
90
+ // the tool output around it. Truncating first would also let a secret straddling the
91
+ // slice boundary survive as a fragment. `scope` is an in-code literal today; scrubbing
92
+ // it costs nothing and removes the exception that would otherwise need re-arguing.
79
93
  const line = JSON.stringify({
80
94
  ts: new Date().toISOString(),
81
- scope: String(scope || '').slice(0, 80),
82
- msg: String(err?.message ?? err ?? '').slice(0, 500),
83
- stack: typeof err?.stack === 'string' ? err.stack.split('\n').slice(0, 6).join('\n') : undefined,
84
- ctx: ctx === undefined ? undefined : JSON.stringify(ctx).slice(0, 240),
95
+ scope: scrubSecrets(String(scope || '')).slice(0, 80),
96
+ msg: scrubSecrets(String(err?.message ?? err ?? '')).slice(0, 500),
97
+ stack: typeof err?.stack === 'string'
98
+ ? scrubSecrets(err.stack.split('\n').slice(0, 6).join('\n'))
99
+ : undefined,
100
+ ctx: ctx === undefined ? undefined : scrubSecrets(JSON.stringify(ctx)).slice(0, 240),
85
101
  }) + '\n';
86
102
 
87
103
  appendFileSync(join(dir, `${today()}.jsonl`), line, { mode: 0o600 });
@@ -19,18 +19,53 @@
19
19
  // Case-insensitive on the tag (`<PRIVATE>`, `<Private>` all work) since users
20
20
  // type by hand. Non-greedy match handles multiple blocks correctly.
21
21
 
22
- const PRIVATE_BLOCK_RE = /<private>([\s\S]*?)<\/private>/gi;
22
+ // Tag scanner, NOT a block matcher. The block form this replaced —
23
+ // /<private>([\s\S]*?)<\/private>/gi — is quadratic on opener-dense input: every one of
24
+ // N openers costs the engine a lazy `[\s\S]*?` walk to the end of the string looking for
25
+ // a close that is not there. Measured before the rewrite: 20k unclosed openers (180KB)
26
+ // 545ms, 28k (252KB — the PostToolUse/UserPromptSubmit stdin cap) 891ms, against 0.6ms
27
+ // for 1MB of plain text. stripPrivate is the FIRST step of every scrubSecrets() call and
28
+ // sits on the synchronous UserPromptSubmit path, so that is per-prompt latency the model
29
+ // waits on; lib/import-jsonl.mjs feeds it user files with no cap at all.
30
+ //
31
+ // "Return early when there is no close tag" does not fix it: `'</private>' + N openers`
32
+ // has a close and still costs 456ms. The alternation below has no quantifier to back off
33
+ // into, so the scan is linear in the input regardless of tag density.
34
+ const PRIVATE_TAG_RE = /<(\/?)private>/gi;
23
35
  const REDACTION_MARKER = '[redacted]';
24
36
 
25
37
  /**
26
38
  * Replace each well-formed <private>...</private> block with [redacted].
27
39
  * Returns input unchanged if no closed block is present.
28
40
  *
41
+ * Pairing rule reproduces the leftmost-then-lazy semantics of the block regex exactly:
42
+ * the EARLIEST unmatched opener claims the next close (so `<private>a<private>b</private>`
43
+ * collapses whole, as the regex did), a close with no open ahead of it stays intact, and
44
+ * an opener with no close after it stays intact.
45
+ *
29
46
  * @param {unknown} text Input string (non-string passes through)
30
47
  * @returns {string|unknown} Stripped text, or input unchanged if not a string
31
48
  */
32
49
  export function stripPrivate(text) {
33
50
  if (typeof text !== 'string') return text;
34
51
  if (!text.includes('<')) return text; // fast path — most prompts have no tags
35
- return text.replace(PRIVATE_BLOCK_RE, REDACTION_MARKER);
52
+
53
+ PRIVATE_TAG_RE.lastIndex = 0;
54
+ let out = null; // stays null until the first replacement — no-op inputs return as-is
55
+ let cursor = 0; // end of the last emitted span
56
+ let openAt = -1; // index of the earliest opener not yet paired
57
+ let m;
58
+ while ((m = PRIVATE_TAG_RE.exec(text)) !== null) {
59
+ if (m[1] !== '/') {
60
+ if (openAt < 0) openAt = m.index;
61
+ } else if (openAt >= 0) {
62
+ if (out === null) out = [];
63
+ out.push(text.slice(cursor, openAt), REDACTION_MARKER);
64
+ cursor = m.index + m[0].length;
65
+ openAt = -1;
66
+ }
67
+ }
68
+ if (out === null) return text;
69
+ out.push(text.slice(cursor));
70
+ return out.join('');
36
71
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.83.0",
3
+ "version": "3.84.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.83.0",
9
+ "version": "3.84.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.83.0",
3
+ "version": "3.84.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -65,6 +65,7 @@
65
65
  "lib/edge-attribution.mjs",
66
66
  "lib/file-edge-match.mjs",
67
67
  "lib/cite-back-hint.mjs",
68
+ "lib/cooldown-path.mjs",
68
69
  "lib/tmp-fixture-sweep.mjs",
69
70
  "lib/summary-extractor.mjs",
70
71
  "lib/id-routing.mjs",
package/project-utils.mjs CHANGED
@@ -29,7 +29,26 @@ const _cache = new Map();
29
29
  * @returns {string} Sanitized project identifier safe for use in filenames
30
30
  */
31
31
  export function inferProject() {
32
- return projectNameFromDir(process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd());
32
+ return projectNameFromDir(inferProjectDir());
33
+ }
34
+
35
+ /**
36
+ * The DIRECTORY `inferProject()` derives its name from — the session's project root.
37
+ *
38
+ * Callers that read the filesystem on behalf of the current project (git state, tasks,
39
+ * adoption sentinel) must use THIS, not `process.cwd()`. The two diverge whenever the
40
+ * process was not spawned with cwd == project root, and the result is a surface that
41
+ * labels directory A's git/tasks with directory B's project name. That was live in the
42
+ * startup dashboard: `buildDashboard({ project: inferProject(), projectPath: process.cwd() })`
43
+ * — the identity came from the env, the filesystem root from the process. In production the
44
+ * two happen to coincide, so only the test face showed it (a hook subprocess spawned with the
45
+ * repo root as cwd read the REAL repo's git state, and the assertion pinning the dashboard
46
+ * leg passed or failed on whether the host tree was dirty).
47
+ *
48
+ * @returns {string} Absolute project root directory.
49
+ */
50
+ export function inferProjectDir() {
51
+ return process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
33
52
  }
34
53
 
35
54
  /**
@@ -77,7 +77,37 @@ if [[ "$tool" == "Read" ]]; then
77
77
  # hook.mjs flushEpisode reads reads-<project>.txt from CLAUDE_MEM_DIR/runtime; if this
78
78
  # bash fast-path wrote to $HOME unconditionally, a relocated install would drop all
79
79
  # Read context from episodes AND grow an uncollected reads file in $HOME forever.
80
- runtime_dir="${CLAUDE_MEM_DIR:-$HOME/.claude-mem-lite}/runtime"
80
+ _data_dir="${CLAUDE_MEM_DIR:-$HOME/.claude-mem-lite}"
81
+ # Test containment, mirroring containInTests() in lib/resolve-data-dir.mjs (audit
82
+ # ENG-1). That guard sits at the NODE exit of this channel, and this channel has two:
83
+ # the Read fast path above never reaches Node, so a test that spawned the prefilter
84
+ # without setting CLAUDE_MEM_DIR appended straight into the developer's live runtime
85
+ # dir. That is not hypothetical — it is what v3.83.0 had to clean up, and the fix
86
+ # there was a single-file canary keyed on one fingerprint, so any other test using
87
+ # any other project name still walked through.
88
+ #
89
+ # Same three conditions as the Node side, same order: guard armed, target IS the real
90
+ # directory (not merely "outside tmp" — suites legitimately point HOME at fixtures),
91
+ # and an absolute sandbox to redirect into. Pure builtins; no spawn on this ~5ms path.
92
+ if [[ "${CLAUDE_MEM_TEST_GUARD:-}" == "1" ]]; then
93
+ _real_dir="${CLAUDE_MEM_TEST_REALDIR:-$HOME/.claude-mem-lite}"
94
+ # Node compares resolve(dir) !== resolve(real); a raw string compare here let
95
+ # `CLAUDE_MEM_DIR="$HOME/.claude-mem-lite/"` (trailing slash) walk straight through
96
+ # the guard and append into the live runtime dir — the exact leak this exists to
97
+ # close. Trailing-slash strip only, with the same builtin loop used for `_dir` above:
98
+ # a realpath spawn would blow the ~5ms budget, and a trailing slash is the spelling
99
+ # difference that actually occurs.
100
+ while [[ "$_data_dir" == */ && ${#_data_dir} -gt 1 ]]; do _data_dir="${_data_dir%/}"; done
101
+ while [[ "$_real_dir" == */ && ${#_real_dir} -gt 1 ]]; do _real_dir="${_real_dir%/}"; done
102
+ if [[ "$_data_dir" == "$_real_dir" ]]; then
103
+ if [[ "${CLAUDE_MEM_TEST_SANDBOX:-}" == /* ]]; then
104
+ _data_dir="$CLAUDE_MEM_TEST_SANDBOX"
105
+ else
106
+ _data_dir="${TMPDIR:-/tmp}"; _data_dir="${_data_dir%/}/claude-mem-test-fallback"
107
+ fi
108
+ fi
109
+ fi
110
+ runtime_dir="${_data_dir}/runtime"
81
111
  # Owner-only (0700 dir / 0600 file): reads-<project>.txt lists captured file
82
112
  # paths, so on a shared host the default umask leaked them to every local user.
83
113
  # umask is a shell builtin — no extra process on this ~5ms per-tool-call path
@@ -12,6 +12,7 @@ import { injectedIdsFileName } from '../lib/injected-ids.mjs';
12
12
  import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
13
13
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
14
14
  import { recordHookError } from '../lib/hook-telemetry.mjs';
15
+ import { cooldownPathFor as sharedCooldownPathFor } from '../lib/cooldown-path.mjs';
15
16
  import { citeFactorClause } from '../scoring-sql.mjs';
16
17
  import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
17
18
  import { fileIntelFor } from '../lib/file-intel.mjs';
@@ -53,12 +54,13 @@ import { DAY_MS } from '../lib/time-constants.mjs';
53
54
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
54
55
  const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(DATA_DIR, 'claude-mem-lite.db');
55
56
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
56
- // A3 (v2.83): cross-hook dedup window must mirror DEDUP_STALE_MS in
57
- // scripts/prompt-search-utils.mjs. UPS writes
58
- // `runtime/.claude-mem-injected-<project>` after each inject; we read it to
59
- // drop IDs the agent already saw in this 5-min window. Standalone fast-path
60
- // (#8447) so we inline the constant rather than importing the helper.
61
- const CROSS_HOOK_DEDUP_MS = 5 * 60 * 1000;
57
+ // A3 (v2.83): cross-hook dedup window. UPS writes
58
+ // `runtime/.claude-mem-injected-<project>` after each inject; we read it to drop IDs the
59
+ // agent already saw in this window. Imported, not inlined (ARCH-3): the copy's stated
60
+ // reason keep this standalone fast path import-free (#8447) was retired by v3.80.0,
61
+ // which already imports lib modules here, and the inlined value silently encoded the
62
+ // same premise twice.
63
+ import { DEDUP_STALE_MS as CROSS_HOOK_DEDUP_MS } from './prompt-search-utils.mjs';
62
64
  // v2.33.1: cooldown path is session-scoped so same-file-twice within one
63
65
  // session never re-injects (was: global file, 5-min window). Cross-session:
64
66
  // fresh file, fresh nudges — this is intended. No session_id → fall back to
@@ -160,10 +162,14 @@ const REREAD_MIN_TOKENS = Math.max(1,
160
162
  // Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
161
163
  // which is enough to keep RUNTIME_DIR from growing unbounded.
162
164
 
165
+ // Path rule lives in lib/cooldown-path.mjs — this script WRITES the file that
166
+ // lib/cite-back-hint.mjs and lib/edge-attribution.mjs read, and a writer/reader
167
+ // disagreement does not error, it silently reads a file nobody wrote (ARCH-2). The
168
+ // no-session legacy fallback stays here: it is this script's own back-compat, not part
169
+ // of the shared naming rule.
163
170
  function cooldownPathFor(sessionId) {
164
171
  if (!sessionId) return LEGACY_COOLDOWN_PATH;
165
- const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
166
- return join(RUNTIME_DIR, `pre-recall-cooldown-${safe}.json`);
172
+ return sharedCooldownPathFor(RUNTIME_DIR, sessionId);
167
173
  }
168
174
 
169
175
  // Comprehension-bridge (CLAUDE_MEM_SALIENCE=bridge): rewrite the top bound lesson
package/source-files.mjs CHANGED
@@ -61,6 +61,9 @@ export const SOURCE_FILES = [
61
61
  // scripts/pre-tool-recall.js (hook fast-path) and lib/edge-attribution.mjs.
62
62
  'lib/file-edge-match.mjs',
63
63
  'lib/cite-back-hint.mjs',
64
+ // The one definition of the pre-recall cooldown path — shared by its writer
65
+ // (scripts/pre-tool-recall.js) and both readers (cite-back-hint, edge-attribution).
66
+ 'lib/cooldown-path.mjs',
64
67
  // v2.85: stale test-fixture sweeper. Imported by install.mjs (cleanup) + cli.mjs.
65
68
  // Missing from manifest → tarball ships install.mjs that ERR_MODULE_NOT_FOUND on cleanup.
66
69
  'lib/tmp-fixture-sweep.mjs',
package/utils.mjs CHANGED
@@ -5,6 +5,9 @@
5
5
  import { basename, dirname, resolve, sep } from 'path';
6
6
  import { execSync } from 'child_process';
7
7
  import { buildLowSignalRegex } from './lib/low-signal-patterns.mjs';
8
+ // Local binding for internal use: the `export … from './secret-scrub.mjs'` re-export below
9
+ // is a pass-through and creates no binding in this module's scope.
10
+ import { scrubSecrets as _scrubSecrets } from './secret-scrub.mjs';
8
11
 
9
12
  // ─── Re-exports from extracted modules ──────────────────────────────────────
10
13
  // Backward compatibility: all consumers import from utils.mjs
@@ -200,34 +203,51 @@ export function isRelatedToEpisode(episode, newFiles) {
200
203
  * @param {boolean} [opts.isError] If provided, overrides inline error regex detection
201
204
  * @returns {string} Concise description of the action
202
205
  */
206
+ // SEC-3 (2026-08-29 audit): scrub BEFORE truncating, inside the function that truncates.
207
+ //
208
+ // The caller wraps this whole result in scrubSecrets(), which is one step too late: every
209
+ // field below is already cut to 40-60 characters by then, so a secret straddling the cut
210
+ // has lost the tail its value-length-gated pattern needs and the head survives verbatim.
211
+ // The prompt path fixed this ordering (hook.mjs) and this path kept the old one.
212
+ //
213
+ // The scrub input is windowed rather than whole: `resp` is an uncapped tool response (a
214
+ // Bash stdout can be megabytes) and this runs on every PostToolUse. 4096 is two orders of
215
+ // magnitude above the longest cut here, so a secret that begins before the cut is still
216
+ // seen whole by the patterns, at bounded cost.
217
+ const DESC_SCRUB_WINDOW = 4096;
218
+ function scrubTruncate(str, max) {
219
+ if (typeof str !== 'string' || str === '') return truncate(str, max);
220
+ return truncate(_scrubSecrets(str.slice(0, DESC_SCRUB_WINDOW)), max);
221
+ }
222
+
203
223
  export function makeEntryDesc(toolName, input, resp, opts) {
204
224
  switch (toolName) {
205
225
  case 'Edit':
206
- return `${basename(input.file_path || '')}: "${truncate(input.old_string || '', 40)}" → "${truncate(input.new_string || '', 40)}"`;
226
+ return `${basename(input.file_path || '')}: "${scrubTruncate(input.old_string || '', 40)}" → "${scrubTruncate(input.new_string || '', 40)}"`;
207
227
  case 'Write':
208
228
  return `Created ${basename(input.file_path || '')} (${(input.content || '').length} chars)`;
209
229
  case 'NotebookEdit':
210
- return `Notebook cell: ${truncate(input.new_source || '', 60)}`;
230
+ return `Notebook cell: ${scrubTruncate(input.new_source || '', 60)}`;
211
231
  case 'Bash': {
212
- const cmd = truncate(input.command || '', 50);
232
+ const cmd = scrubTruncate(input.command || '', 50);
213
233
  // Use caller-provided bashSig.isError (word-boundary aware) when available;
214
234
  // fall back to inline regex only for standalone callers (tests, etc.)
215
235
  const isErr = opts?.isError ?? (/\berror\b|\bfail(ed|ure)?\b|\bexception\b|\bpanic\b/i.test(resp) && resp.length > 30);
216
- const snippet = truncate(resp, 60);
236
+ const snippet = scrubTruncate(resp, 60);
217
237
  return isErr ? `${cmd} → ERROR: ${snippet}` : `${cmd} → ${snippet}`;
218
238
  }
219
239
  case 'Grep':
220
- return `Search "${truncate(input.pattern || '', 20)}" → ${truncate(resp, 60)}`;
240
+ return `Search "${scrubTruncate(input.pattern || '', 20)}" → ${scrubTruncate(resp, 60)}`;
221
241
  case 'LSP':
222
242
  return `${input.operation || ''} ${basename(input.filePath || '')}`;
223
243
  case 'Task': case 'Agent':
224
- return truncate(input.description || '', 60);
244
+ return scrubTruncate(input.description || '', 60);
225
245
  case 'WebSearch':
226
- return `Web: ${truncate(input.query || '', 50)}`;
246
+ return `Web: ${scrubTruncate(input.query || '', 50)}`;
227
247
  case 'WebFetch':
228
- return `Fetch: ${truncate(input.url || '', 50)}`;
248
+ return `Fetch: ${scrubTruncate(input.url || '', 50)}`;
229
249
  default:
230
- return `${toolName}: ${truncate(resp, 50)}`;
250
+ return `${toolName}: ${scrubTruncate(resp, 50)}`;
231
251
  }
232
252
  }
233
253