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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +11 -5
- package/README.zh-CN.md +8 -4
- package/hook-context.mjs +3 -8
- package/hook-llm.mjs +52 -19
- package/hook-shared.mjs +36 -4
- package/hook.mjs +78 -9
- package/lib/citation-tracker.mjs +152 -5
- package/lib/cite-back-hint.mjs +15 -8
- package/lib/cooldown-path.mjs +42 -0
- package/lib/edge-attribution.mjs +4 -7
- package/lib/events-injection.mjs +10 -3
- package/lib/hook-telemetry.mjs +20 -4
- package/lib/private-strip.mjs +37 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/project-utils.mjs +20 -1
- package/scripts/post-tool-use.sh +31 -1
- package/scripts/pre-tool-recall.js +14 -8
- package/source-files.mjs +3 -0
- package/utils.mjs +29 -9
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
|
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
|
|
359
|
-
|
|
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
|
|
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
|
|
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
|
-
>
|
|
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
|
-
-
|
|
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`
|
|
618
|
-
| `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
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
|
-
|
|
950
|
-
|
|
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
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}`);
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 = ?
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/edge-attribution.mjs
CHANGED
|
@@ -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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
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
|
package/lib/events-injection.mjs
CHANGED
|
@@ -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, {
|
|
56
|
+
export function searchInjectableEvents(db, { ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
|
|
50
57
|
if (!db || !project) return [];
|
|
51
|
-
const q = ftsQuery ||
|
|
58
|
+
const q = ftsQuery || null;
|
|
52
59
|
if (!q) return [];
|
|
53
60
|
try {
|
|
54
61
|
const rows = searchEventsFts(db, {
|
package/lib/hook-telemetry.mjs
CHANGED
|
@@ -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'
|
|
84
|
-
|
|
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 });
|
package/lib/private-strip.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
/**
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
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 || '')}: "${
|
|
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: ${
|
|
230
|
+
return `Notebook cell: ${scrubTruncate(input.new_source || '', 60)}`;
|
|
211
231
|
case 'Bash': {
|
|
212
|
-
const cmd =
|
|
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 =
|
|
236
|
+
const snippet = scrubTruncate(resp, 60);
|
|
217
237
|
return isErr ? `${cmd} → ERROR: ${snippet}` : `${cmd} → ${snippet}`;
|
|
218
238
|
}
|
|
219
239
|
case 'Grep':
|
|
220
|
-
return `Search "${
|
|
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
|
|
244
|
+
return scrubTruncate(input.description || '', 60);
|
|
225
245
|
case 'WebSearch':
|
|
226
|
-
return `Web: ${
|
|
246
|
+
return `Web: ${scrubTruncate(input.query || '', 50)}`;
|
|
227
247
|
case 'WebFetch':
|
|
228
|
-
return `Fetch: ${
|
|
248
|
+
return `Fetch: ${scrubTruncate(input.url || '', 50)}`;
|
|
229
249
|
default:
|
|
230
|
-
return `${toolName}: ${
|
|
250
|
+
return `${toolName}: ${scrubTruncate(resp, 50)}`;
|
|
231
251
|
}
|
|
232
252
|
}
|
|
233
253
|
|