claude-mem-lite 6.5.0 → 6.7.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 +12 -3
- package/README.zh-CN.md +7 -1
- package/cli.mjs +73 -2
- package/hook-context.mjs +78 -4
- package/hook-llm.mjs +32 -27
- package/hook-memory.mjs +11 -16
- package/hook-optimize.mjs +7 -0
- package/hook.mjs +39 -7
- package/install.mjs +151 -35
- package/lib/cite-back-hint.mjs +68 -7
- package/lib/file-edge-match.mjs +34 -1
- package/lib/hook-stdin.mjs +5 -1
- package/lib/save-observation.mjs +1 -1
- package/lib/search-core.mjs +13 -10
- package/lib/ups-query.mjs +14 -1
- package/mem-cli.mjs +3 -3
- package/nlp.mjs +23 -4
- package/npm-shrinkwrap.json +228 -149
- package/package.json +3 -3
- package/project-utils.mjs +13 -4
- package/scripts/post-tool-recall.js +6 -1
- package/scripts/pre-tool-recall.js +56 -13
- package/scripts/user-prompt-search.js +24 -6
- package/search-engine.mjs +9 -3
- package/search-scoring.mjs +10 -4
- package/utils.mjs +4 -2
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.7.0",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
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)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.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
|
@@ -549,7 +549,8 @@ node install.mjs install # Install and configure
|
|
|
549
549
|
node install.mjs uninstall # Remove (keep data)
|
|
550
550
|
node install.mjs uninstall --purge # Remove and delete all data
|
|
551
551
|
node install.mjs status # Show current status
|
|
552
|
-
node
|
|
552
|
+
node cli.mjs doctor # Diagnose issues (cli.mjs, not install.mjs — see note)
|
|
553
|
+
node cli.mjs repair # Recover a broken install from the latest signed release
|
|
553
554
|
node install.mjs cleanup-hooks # Remove only stale claude-mem-lite hooks from settings.json
|
|
554
555
|
node install.mjs update # Force-check for updates and install them (direct install / npx mode)
|
|
555
556
|
|
|
@@ -559,6 +560,13 @@ npx claude-mem-lite uninstall # Remove (keep data)
|
|
|
559
560
|
npx claude-mem-lite doctor # Diagnose issues
|
|
560
561
|
```
|
|
561
562
|
|
|
563
|
+
> `doctor` and `repair` are spelled `cli.mjs`, not `install.mjs`, on purpose. Those two are
|
|
564
|
+
> the commands you reach for when the install is already broken, and `install.mjs` resolves
|
|
565
|
+
> around a dozen static imports before its first line runs — one missing file and it exits
|
|
566
|
+
> with a Node stack instead of telling you which file. `cli.mjs` has no static local imports
|
|
567
|
+
> and catches that, naming the file and a repair command. Everything else in the list is
|
|
568
|
+
> unaffected either way.
|
|
569
|
+
|
|
562
570
|
Notes:
|
|
563
571
|
- Plugin mode only reports available updates; it does not self-update plugin files.
|
|
564
572
|
To upgrade an installed plugin to the latest published version, run **inside Claude Code**:
|
|
@@ -594,7 +602,7 @@ git fetch --tags && git checkout v3.62.0
|
|
|
594
602
|
# 4. To leave the pin later: git checkout main, then the normal update flow.
|
|
595
603
|
```
|
|
596
604
|
|
|
597
|
-
Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node
|
|
605
|
+
Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node cli.mjs doctor` before trusting search results.
|
|
598
606
|
|
|
599
607
|
### doctor
|
|
600
608
|
|
|
@@ -896,7 +904,8 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
896
904
|
| `MEM_DISABLE_CITATION_DECAY` | `1` disables only the decay writes, keeping access-count bumps. | _(enabled)_ |
|
|
897
905
|
| `CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD` | **Removed — inert.** Tuned the per-project adoption gate, which is gone (D#204). Setting it warns on stderr and changes nothing. | _(n/a)_ |
|
|
898
906
|
| `CLAUDE_MEM_NO_CITE_NUDGE` | `1` fully silences the cite-back nudge. | _(enabled)_ |
|
|
899
|
-
| `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.
|
|
907
|
+
| `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.4` |
|
|
908
|
+
| `CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR` | `1` judges the wide cite-recall ratio (every `#NN`-shaped token the model saw) instead of the lessons the hooks injected. **Half of the revert**: the threshold moved too, so pre-v6.6.0 gating needs this **and** `CLAUDE_MEM_CITE_NUDGE_THRESHOLD=0.6`. This switch alone gives you the wide ratio judged at 0.4, which is neither release's behaviour. | unset |
|
|
900
909
|
| `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED` | Minimum injection volume before the ratio gate is judged at all. | `5` |
|
|
901
910
|
| `CLAUDE_MEM_CITE_NUDGE_SILENCE_AFTER` | Consecutive low-cite sessions before the nudge goes quiet; `0` = never silence. | `3` |
|
|
902
911
|
| `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)_ |
|
package/README.zh-CN.md
CHANGED
|
@@ -461,7 +461,8 @@ node install.mjs install # 安装并配置
|
|
|
461
461
|
node install.mjs uninstall # 移除(保留数据)
|
|
462
462
|
node install.mjs uninstall --purge # 移除并删除所有数据
|
|
463
463
|
node install.mjs status # 显示当前状态
|
|
464
|
-
node
|
|
464
|
+
node cli.mjs doctor # 诊断问题(用 cli.mjs 而非 install.mjs,见下方说明)
|
|
465
|
+
node cli.mjs repair # 从最新签名发布恢复损坏的安装
|
|
465
466
|
node install.mjs cleanup-hooks # 只清理 settings.json 中残留的 claude-mem-lite hooks
|
|
466
467
|
node install.mjs update # 强制检查并安装更新(direct install / npx 模式)
|
|
467
468
|
|
|
@@ -472,6 +473,11 @@ npx claude-mem-lite doctor # 诊断问题
|
|
|
472
473
|
```
|
|
473
474
|
|
|
474
475
|
说明:
|
|
476
|
+
- `doctor` 与 `repair` 写成 `cli.mjs` 而不是 `install.mjs`,是有意的。这两条恰恰是安装
|
|
477
|
+
已经坏掉时才会用到的命令,而 `install.mjs` 在执行第一行代码之前要解析十几个静态
|
|
478
|
+
import——少一个文件就直接吐一段 Node 栈,而不是告诉你少了哪个文件。`cli.mjs` 没有
|
|
479
|
+
任何本地静态 import,会捕获这种失败并说出缺失的文件和修复命令。列表中其余命令两种
|
|
480
|
+
写法都一样。
|
|
475
481
|
- 插件模式只提示可用更新,不会自更新插件文件。
|
|
476
482
|
- direct install / npx 模式保留自动更新,并使用 staged replacement;若依赖安装失败会回滚。
|
|
477
483
|
- 如果你禁用了插件,但 `~/.claude/settings.json` 里还有旧的 mem hooks,可运行 `node install.mjs cleanup-hooks`。
|
package/cli.mjs
CHANGED
|
@@ -30,6 +30,77 @@ const CLI_COMMANDS = new Set([
|
|
|
30
30
|
// Kept as a named set so a stale script or muscle-memory invocation gets the reason rather
|
|
31
31
|
// than a bare "Unknown command" plus a misleading edit-distance suggestion.
|
|
32
32
|
const REMOVED_COMMANDS = new Set(['registry', 'import', 'enrich']);
|
|
33
|
+
|
|
34
|
+
// D#26 / R12 P1-1, second half. `doctor` and `repair` exist to tell a user which
|
|
35
|
+
// file their install is missing. Until now, on exactly that install, they did not
|
|
36
|
+
// run: install.mjs's ~13 static imports resolve BEFORE its first line executes, so
|
|
37
|
+
// one absent module killed the command with a bare ERR_MODULE_NOT_FOUND and zero
|
|
38
|
+
// bytes of stdout. A half-finished update, a trimmed tarball (this repo has
|
|
39
|
+
// shipped three) or a hand-deleted file all land there — CLAUDE.md's "a recovery
|
|
40
|
+
// path must not import the thing it recovers", on the startup edge.
|
|
41
|
+
//
|
|
42
|
+
// A static import cannot be caught inside the module that declares it, so the
|
|
43
|
+
// catch lives one entry up. THIS file is the host because it is the published
|
|
44
|
+
// `bin` and because its own static closure is one file — itself; every route
|
|
45
|
+
// below is an `await import()`. Whatever this prints must therefore rely on
|
|
46
|
+
// nothing but the language, the same charter scripts/hook-launcher.mjs follows:
|
|
47
|
+
// no local import may appear here, or the fallback shares the fate it reports on.
|
|
48
|
+
//
|
|
49
|
+
// The remedy is deliberately NOT `install.mjs repair` — that is the file that
|
|
50
|
+
// would not load. It has to come from outside the broken tree.
|
|
51
|
+
// THREE shapes, not one. The first cut caught only ERR_MODULE_NOT_FOUND, and pre-ship
|
|
52
|
+
// review pointed out that an interrupted write leaves a file PRESENT and truncated far
|
|
53
|
+
// more often than it leaves it absent: that arrives as a SyntaxError, and a truncated
|
|
54
|
+
// install.mjs arrives as neither — the module loads and simply has no `main`, which
|
|
55
|
+
// died at the call site with "main is not a function". All three are the same fact
|
|
56
|
+
// about the world (this install's files are not intact) and get the same answer.
|
|
57
|
+
// Anything else is rethrown: this is a classifier, not a swallow.
|
|
58
|
+
function explainBrokenInstall(what) {
|
|
59
|
+
const w = (s) => process.stderr.write(`[claude-mem-lite] ${s}\n`);
|
|
60
|
+
w(`This install is incomplete — ${what}`);
|
|
61
|
+
w('That is why this command cannot run: these files load before any of their code executes.');
|
|
62
|
+
w('Repair: npm install -g claude-mem-lite@latest --force');
|
|
63
|
+
w('Or, in Claude Code: /plugin uninstall claude-mem-lite && /plugin install claude-mem-lite@sdsrss');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Absolute path out of an error that carries one, or null.
|
|
69
|
+
*
|
|
70
|
+
* ERR_MODULE_NOT_FOUND carries `url`. An ESM SyntaxError carries NOTHING —
|
|
71
|
+
* measured on Node 26: `url` and `code` both undefined, message a bare
|
|
72
|
+
* "Unexpected end of input", every stack frame a node-internal loader. So the
|
|
73
|
+
* caller must be able to say its piece without a filename rather than printing
|
|
74
|
+
* "undefined", and naming the damaged file would take a `node --check` scan of
|
|
75
|
+
* the install, which is a bigger thing than this line.
|
|
76
|
+
*/
|
|
77
|
+
function fileFromError(e) {
|
|
78
|
+
const m = String(e?.url || e?.stack || e?.message || '').match(/file:\/\/(\/[^\s:)'"]+)/);
|
|
79
|
+
if (m) return m[1];
|
|
80
|
+
const quoted = String(e?.message || '').match(/'([^']+\.mjs)'/);
|
|
81
|
+
return quoted ? quoted[1] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadInstaller() {
|
|
85
|
+
let mod;
|
|
86
|
+
try {
|
|
87
|
+
mod = await import('./install.mjs');
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (e?.code === 'ERR_MODULE_NOT_FOUND') {
|
|
90
|
+
explainBrokenInstall(`it is missing: ${fileFromError(e) || 'a module'}`);
|
|
91
|
+
}
|
|
92
|
+
// Never reprint the parser's own output: the stack is what this exists to replace.
|
|
93
|
+
if (e instanceof SyntaxError) {
|
|
94
|
+
const at = fileFromError(e);
|
|
95
|
+
explainBrokenInstall(`this file is damaged or truncated: ${at || 'one of its modules'}`);
|
|
96
|
+
}
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
if (typeof mod?.main !== 'function') {
|
|
100
|
+
explainBrokenInstall('install.mjs loaded but exports no `main` — it is truncated');
|
|
101
|
+
}
|
|
102
|
+
return mod;
|
|
103
|
+
}
|
|
33
104
|
const INSTALL_COMMANDS = new Set([
|
|
34
105
|
'install',
|
|
35
106
|
'uninstall',
|
|
@@ -90,11 +161,11 @@ if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
|
|
|
90
161
|
const { run } = await import('./mem-cli.mjs');
|
|
91
162
|
await run(['help']);
|
|
92
163
|
} else {
|
|
93
|
-
const { main } = await
|
|
164
|
+
const { main } = await loadInstaller();
|
|
94
165
|
await main([]);
|
|
95
166
|
}
|
|
96
167
|
} else if (INSTALL_COMMANDS.has(cmd)) {
|
|
97
|
-
const { main } = await
|
|
168
|
+
const { main } = await loadInstaller();
|
|
98
169
|
await main(process.argv.slice(2));
|
|
99
170
|
} else if (REMOVED_COMMANDS.has(cmd)) {
|
|
100
171
|
// Released-artifact discoverability signal for the skill-registry removal. Deliberately
|
package/hook-context.mjs
CHANGED
|
@@ -336,6 +336,69 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
336
336
|
* removes the paired hint comment if present, and normalizes residual whitespace
|
|
337
337
|
* at the seam. Uses atomic tmp+rename write.
|
|
338
338
|
*/
|
|
339
|
+
/**
|
|
340
|
+
* Half-open [start, end) ranges of every fenced code span in a markdown document.
|
|
341
|
+
*
|
|
342
|
+
* A fence opens on a line whose first non-space run is three or more backticks or tildes,
|
|
343
|
+
* and closes on the next line opening with at least as many of the SAME character — CommonMark's
|
|
344
|
+
* rule, and the reason a ```` ```` ```` block can contain a ``` line. An unterminated fence
|
|
345
|
+
* runs to EOF.
|
|
346
|
+
*/
|
|
347
|
+
function fencedRanges(content) {
|
|
348
|
+
const ranges = [];
|
|
349
|
+
let open = null;
|
|
350
|
+
let offset = 0;
|
|
351
|
+
for (const line of content.split('\n')) {
|
|
352
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
353
|
+
if (m) {
|
|
354
|
+
if (!open) {
|
|
355
|
+
open = { char: m[1][0], len: m[1].length, start: offset };
|
|
356
|
+
} else if (m[1][0] === open.char && m[1].length >= open.len) {
|
|
357
|
+
ranges.push([open.start, offset + line.length]);
|
|
358
|
+
open = null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
offset += line.length + 1; // +1 for the '\n' split removed
|
|
362
|
+
}
|
|
363
|
+
if (open) ranges.push([open.start, content.length]);
|
|
364
|
+
return ranges;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** True when `idx` sits inside a backtick-delimited inline code span on its own line. */
|
|
368
|
+
function insideInlineCode(content, idx) {
|
|
369
|
+
const lineStart = content.lastIndexOf('\n', idx - 1) + 1;
|
|
370
|
+
// Count backtick RUNS before the position; an odd count means the position is inside a
|
|
371
|
+
// span. Runs, not characters: ``code with ` inside`` is one span opened by two ticks.
|
|
372
|
+
const before = content.slice(lineStart, idx);
|
|
373
|
+
const runs = before.match(/`+/g);
|
|
374
|
+
return runs !== null && runs.length % 2 === 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The last index of `needle` that is a real block's tag, or -1.
|
|
379
|
+
*
|
|
380
|
+
* Three exclusions, and the pre-ship review is why there are three rather than one. A
|
|
381
|
+
* legacy block written by the old `updateClaudeMd` put its tags at COLUMN 0 on their own
|
|
382
|
+
* lines, so anything else is prose ABOUT the tag:
|
|
383
|
+
* - inside a fenced code span (```/~~~) — the first cut, and only a third of it
|
|
384
|
+
* - not at the start of a line — an inline mention mid-sentence
|
|
385
|
+
* - indented four or more spaces — CommonMark's other code block
|
|
386
|
+
* Measured before the widening, with the shipped function: an inline span went 101 -> 55
|
|
387
|
+
* bytes and an indented block 95 -> 33, atomically, with no backup.
|
|
388
|
+
*/
|
|
389
|
+
function lastIndexOutsideFences(content, needle, ranges) {
|
|
390
|
+
let idx = content.lastIndexOf(needle);
|
|
391
|
+
while (idx !== -1) {
|
|
392
|
+
const lineStart = content.lastIndexOf('\n', idx - 1) + 1;
|
|
393
|
+
const indent = content.slice(lineStart, idx);
|
|
394
|
+
const atLineStart = indent.length === 0;
|
|
395
|
+
const inFence = ranges.some(([a, b]) => idx >= a && idx < b);
|
|
396
|
+
if (atLineStart && !inFence && !insideInlineCode(content, idx)) return idx;
|
|
397
|
+
idx = content.lastIndexOf(needle, idx - 1);
|
|
398
|
+
}
|
|
399
|
+
return -1;
|
|
400
|
+
}
|
|
401
|
+
|
|
339
402
|
export function cleanupClaudeMdLegacyBlock() {
|
|
340
403
|
// v2.48 P2-4: idempotent marker. First run (whether it finds a block or not,
|
|
341
404
|
// whether CLAUDE.md exists or not) drops a project-scoped marker in RUNTIME_DIR.
|
|
@@ -369,10 +432,21 @@ export function cleanupClaudeMdLegacyBlock() {
|
|
|
369
432
|
const startTag = '<claude-mem-context>';
|
|
370
433
|
const endTag = '</claude-mem-context>';
|
|
371
434
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
435
|
+
// A4 (audit 2026-09-08): `lastIndexOf` alone was NOT the protection the old comment
|
|
436
|
+
// here claimed. It shields a documentation reference only when a real block sits AFTER
|
|
437
|
+
// it; when the file's only occurrence IS the reference — the ordinary case for someone
|
|
438
|
+
// who wrote down what this plugin emits — both searches land on it, `startIdx < endIdx`
|
|
439
|
+
// holds, and the user's fenced sample is deleted with its two fences spliced into a
|
|
440
|
+
// broken ``````. Atomic write, no backup, once per project, so it shows up as a small
|
|
441
|
+
// stray diff days later. Measured on a plain 226-byte CLAUDE.md: 226 → 145.
|
|
442
|
+
//
|
|
443
|
+
// "The tag is alone on its line" does not discriminate — inside a fence it usually is.
|
|
444
|
+
// The fence itself is the signal, so fenced spans are excluded before the search, and an
|
|
445
|
+
// UNTERMINATED fence is treated as running to EOF: that errs toward leaving the file
|
|
446
|
+
// alone, which is the safe direction for a write into someone's own notes.
|
|
447
|
+
const fenced = fencedRanges(content);
|
|
448
|
+
const startIdx = lastIndexOutsideFences(content, startTag, fenced);
|
|
449
|
+
const endIdx = lastIndexOutsideFences(content, endTag, fenced);
|
|
376
450
|
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
377
451
|
dropMarker();
|
|
378
452
|
return;
|
package/hook-llm.mjs
CHANGED
|
@@ -279,7 +279,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
279
279
|
`
|
|
280
280
|
SELECT title FROM observations
|
|
281
281
|
WHERE project = ? AND created_at_epoch > ?
|
|
282
|
-
ORDER BY created_at_epoch DESC LIMIT 10
|
|
282
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT 10
|
|
283
283
|
`,
|
|
284
284
|
)
|
|
285
285
|
.all(project, fiveMinAgo);
|
|
@@ -313,7 +313,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
313
313
|
`
|
|
314
314
|
SELECT title FROM observations
|
|
315
315
|
WHERE project = ? AND created_at_epoch > ? AND created_at_epoch <= ?
|
|
316
|
-
ORDER BY created_at_epoch DESC LIMIT 60
|
|
316
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT 60
|
|
317
317
|
`,
|
|
318
318
|
)
|
|
319
319
|
.all(project, threeDaysAgo, fiveMinAgo);
|
|
@@ -331,7 +331,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
331
331
|
`
|
|
332
332
|
SELECT minhash_sig FROM observations
|
|
333
333
|
WHERE project = ? AND created_at_epoch > ? AND minhash_sig IS NOT NULL
|
|
334
|
-
ORDER BY created_at_epoch DESC LIMIT 200
|
|
334
|
+
ORDER BY created_at_epoch DESC, id DESC LIMIT 200
|
|
335
335
|
`,
|
|
336
336
|
)
|
|
337
337
|
.all(project, sevenDaysAgo);
|
|
@@ -1383,7 +1383,7 @@ export async function handleLLMSummary() {
|
|
|
1383
1383
|
FROM observations
|
|
1384
1384
|
WHERE memory_session_id = ?
|
|
1385
1385
|
AND ${notLowSignalTitleClause('')}
|
|
1386
|
-
ORDER BY created_at_epoch DESC
|
|
1386
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
1387
1387
|
LIMIT 30
|
|
1388
1388
|
`,
|
|
1389
1389
|
)
|
|
@@ -1585,28 +1585,33 @@ ${obsList}`;
|
|
|
1585
1585
|
// spinning up the full LLM dispatcher. Lets the e2e leak test verify that
|
|
1586
1586
|
// the observations INSERT path scrubs all configured text fields.
|
|
1587
1587
|
export const __insertObservationForTest = (db, obs) => {
|
|
1588
|
+
// R11 C-P3-3: this used to hand-spell an 18-column INSERT of its own while the
|
|
1589
|
+
// shipping writer above went through insertObservationRow's 19-column list, so the
|
|
1590
|
+
// secret-scrub leak test asserted on a REPLICA of the write point rather than the
|
|
1591
|
+
// write point. Column-list drift between the two is exactly what
|
|
1592
|
+
// lib/observation-write.mjs exists to make impossible, and a test copy is the one
|
|
1593
|
+
// caller that can drift without anyone noticing — it has no user.
|
|
1588
1594
|
const safe = scrubRecord('observations', obs);
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
safe.
|
|
1596
|
-
|
|
1597
|
-
safe.
|
|
1598
|
-
safe.
|
|
1599
|
-
safe.
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
obs.
|
|
1603
|
-
obs.
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
);
|
|
1595
|
+
const now = new Date();
|
|
1596
|
+
return insertObservationRow(db, {
|
|
1597
|
+
memory_session_id: obs.session_id,
|
|
1598
|
+
project: obs.project,
|
|
1599
|
+
text: safe.text,
|
|
1600
|
+
type: 'change',
|
|
1601
|
+
title: safe.title,
|
|
1602
|
+
subtitle: safe.subtitle,
|
|
1603
|
+
narrative: safe.narrative,
|
|
1604
|
+
concepts: safe.concepts,
|
|
1605
|
+
facts: safe.facts,
|
|
1606
|
+
files_read: obs.files_read,
|
|
1607
|
+
files_modified: obs.files_modified,
|
|
1608
|
+
importance: obs.importance,
|
|
1609
|
+
minhash_sig: obs.minhash_sig,
|
|
1610
|
+
lesson_learned: safe.lesson_learned,
|
|
1611
|
+
search_aliases: safe.search_aliases,
|
|
1612
|
+
branch: obs.branch,
|
|
1613
|
+
created_at: now.toISOString(),
|
|
1614
|
+
created_at_epoch: now.getTime(),
|
|
1615
|
+
scope: normalizeScope(obs.scope),
|
|
1616
|
+
});
|
|
1612
1617
|
};
|
package/hook-memory.mjs
CHANGED
|
@@ -8,12 +8,9 @@ import {
|
|
|
8
8
|
OBS_BM25,
|
|
9
9
|
notLowSignalTitleClause,
|
|
10
10
|
noisePenaltyClause,
|
|
11
|
-
tokenizeHandoff,
|
|
12
|
-
HANDOFF_STOP_WORDS,
|
|
13
|
-
extractCjkKeywords,
|
|
14
11
|
neutralizeContextDelimiters,
|
|
15
12
|
} from './utils.mjs';
|
|
16
|
-
import { upsFtsQuery } from './lib/ups-query.mjs';
|
|
13
|
+
import { upsFtsQuery, upsQueryTerms } from './lib/ups-query.mjs';
|
|
17
14
|
import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
|
|
18
15
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
19
16
|
import { recordMetric } from './lib/metrics.mjs';
|
|
@@ -169,17 +166,6 @@ function getCrossProjectBoost() {
|
|
|
169
166
|
const n = parseFloat(raw);
|
|
170
167
|
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : 0.4;
|
|
171
168
|
}
|
|
172
|
-
function extractQueryTerms(text) {
|
|
173
|
-
if (!text) return [];
|
|
174
|
-
const ascii = tokenizeHandoff(text).filter((t) => !HANDOFF_STOP_WORDS.has(t));
|
|
175
|
-
let cjk = [];
|
|
176
|
-
try {
|
|
177
|
-
cjk = extractCjkKeywords(text) || [];
|
|
178
|
-
} catch {
|
|
179
|
-
/* CJK extraction best-effort */
|
|
180
|
-
}
|
|
181
|
-
return [...new Set([...ascii, ...cjk.map((t) => String(t).toLowerCase())])];
|
|
182
|
-
}
|
|
183
169
|
// v2.41: hay spans every FTS column whose BM25 weight is >=5 in OBS_BM25
|
|
184
170
|
// (title=10, subtitle=5, narrative=5, lesson_learned=8). Pre-v2.41 was only
|
|
185
171
|
// title + lesson_learned — rows that matched on narrative but happened to
|
|
@@ -509,7 +495,16 @@ export function searchRelevantMemories(
|
|
|
509
495
|
let coverageFiltered = aboveThreshold;
|
|
510
496
|
const coverageThreshold = getCoverageThreshold();
|
|
511
497
|
if (coverageThreshold > 0) {
|
|
512
|
-
|
|
498
|
+
// A1: the denominator is the terms the query was actually BUILT from, via the one
|
|
499
|
+
// source (lib/ups-query.mjs -> nlp.mjs::ftsQueryTokens). Counting anything else
|
|
500
|
+
// counts terms that were never searched, so no matched row can cover them and the
|
|
501
|
+
// ratio falls with prompt length until the whole surface goes silent.
|
|
502
|
+
//
|
|
503
|
+
// The first cut of this fix shared only maxChars and the pre-ship review measured
|
|
504
|
+
// the hole it left: sanitizeFtsQuery also caps at maxTokens = 64, so a 1511-character
|
|
505
|
+
// prompt — under the char cap, where the shared cut is a no-op — still returned []
|
|
506
|
+
// against a row whose narrative was the entire prompt.
|
|
507
|
+
const queryTerms = upsQueryTerms(userPrompt);
|
|
513
508
|
if (queryTerms.length >= COVERAGE_MIN_QUERY_TERMS) {
|
|
514
509
|
coverageFiltered = aboveThreshold.filter(
|
|
515
510
|
(r) => candidateCoverage(r, queryTerms) >= coverageThreshold,
|
package/hook-optimize.mjs
CHANGED
|
@@ -1517,6 +1517,13 @@ export async function executeSmartCompressCluster(db, observations, project) {
|
|
|
1517
1517
|
//
|
|
1518
1518
|
// optimized_at is not in OBS_COLUMNS, so it stays a separate UPDATE below — the
|
|
1519
1519
|
// summary must be marked processed so the re-enrich pools do not pick it up.
|
|
1520
|
+
//
|
|
1521
|
+
// R11 C-P3-2 read the three-item list above as a completeness claim and asked why
|
|
1522
|
+
// the fourth OBS_COLUMNS entry, `scope`, is absent. It is absent on purpose: the
|
|
1523
|
+
// merge prompt does not ask for one, so there is no value to pass, and NULL is the
|
|
1524
|
+
// `scopes` backfill pool's own predicate (findReenrichCandidates, scope==='scopes')
|
|
1525
|
+
// — which is deliberately NOT gated on optimized_at, so stamping this row processed
|
|
1526
|
+
// does not evict it. Writing a guessed scope here would.
|
|
1520
1527
|
const newId = insertObservationRow(db, {
|
|
1521
1528
|
memory_session_id: sessionId,
|
|
1522
1529
|
project,
|
package/hook.mjs
CHANGED
|
@@ -1406,6 +1406,34 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
|
|
|
1406
1406
|
// which was ~all of the cost.)
|
|
1407
1407
|
try {
|
|
1408
1408
|
const stats = computeCiteRecall(transcriptPath);
|
|
1409
|
+
// D#19: the RATIO GATE's own denominator, alongside the wide one. `stats` counts
|
|
1410
|
+
// every `#NN`-shaped token in non-assistant text — tool_result bodies, file
|
|
1411
|
+
// contents, CLI output, pasted reports — which is the right caliber for "what has
|
|
1412
|
+
// the model seen" and the wrong one for "did it cite back what the hooks gave it"
|
|
1413
|
+
// (measured 11.2x inflation, R11-B-P2-2). Both are persisted; buildCiteRecallNudge
|
|
1414
|
+
// gates on this pair and falls back to the wide one for older payloads.
|
|
1415
|
+
//
|
|
1416
|
+
// Cost is an array iteration, not a parse: this block runs after the decay loop's
|
|
1417
|
+
// own extractInjectedBySurface on the same path, and lib/transcript-scan.mjs memoizes
|
|
1418
|
+
// the parse. mainOnly mirrors the decay loop — an id injected only inside a subagent
|
|
1419
|
+
// would otherwise enter the denominator while its citation lands in another
|
|
1420
|
+
// transcript, scoring a miss the main thread never had a chance to avoid.
|
|
1421
|
+
let gate = { gateInjected: null, gateRecalled: null, gateRatio: null };
|
|
1422
|
+
try {
|
|
1423
|
+
const gateInjectedIds = unionSurfaces(extractInjectedBySurface(transcriptPath, { mainOnly: true }));
|
|
1424
|
+
const gateCited = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
1425
|
+
let hit = 0;
|
|
1426
|
+
for (const id of gateInjectedIds) if (gateCited.has(id)) hit++;
|
|
1427
|
+
gate = {
|
|
1428
|
+
gateInjected: gateInjectedIds.size,
|
|
1429
|
+
gateRecalled: hit,
|
|
1430
|
+
gateRatio: gateInjectedIds.size > 0 ? hit / gateInjectedIds.size : 0,
|
|
1431
|
+
};
|
|
1432
|
+
} catch (e) {
|
|
1433
|
+
// Leaving the gate* keys null is the defined fallback, not a silent loss: the
|
|
1434
|
+
// reader treats a payload without them exactly like a pre-release one.
|
|
1435
|
+
debugCatch(e, 'handleStop-cite-recall-gate-denominator');
|
|
1436
|
+
}
|
|
1409
1437
|
// B2 (v2.83.1): also persist the bugfix-shape nudge/save delta so
|
|
1410
1438
|
// the next SessionStart can surface "N unsaved bugfix-shape edits"
|
|
1411
1439
|
// alongside cite-recall. Same scan target (transcript already in OS
|
|
@@ -1422,11 +1450,13 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
|
|
|
1422
1450
|
// always said. Stop fires once per assistant TURN, so incrementing here silenced
|
|
1423
1451
|
// the nudge inside the first session — this machine read lowStreak 58 against 26
|
|
1424
1452
|
// transcripts before the fix.
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1453
|
+
// D#19: the streak advances on the SAME gate the nudge fires on, so the merged
|
|
1454
|
+
// stats go in. Handing it the wide triple alone would let the streak climb on a
|
|
1455
|
+
// verdict the SessionStart surface never reached — two policies, one counter.
|
|
1456
|
+
const { lowStreak, streakBase, lastStreakSession } = nextCiteStreakState(prevPayload, ccSessionId, {
|
|
1457
|
+
...stats,
|
|
1458
|
+
...gate,
|
|
1459
|
+
});
|
|
1430
1460
|
// G3: finalized-in-conversation + zero deliberate persistence →
|
|
1431
1461
|
// decisionSignal rides the payload; next SessionStart reminds once.
|
|
1432
1462
|
let decisionSignal = null;
|
|
@@ -1449,6 +1479,7 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
|
|
|
1449
1479
|
}
|
|
1450
1480
|
const payload = {
|
|
1451
1481
|
...stats,
|
|
1482
|
+
...gate,
|
|
1452
1483
|
...bugfixStats,
|
|
1453
1484
|
lowStreak,
|
|
1454
1485
|
streakBase,
|
|
@@ -1593,8 +1624,9 @@ async function handleStop() {
|
|
|
1593
1624
|
|
|
1594
1625
|
// Build the SessionStart nudge line shown when the prior session's cite-recall
|
|
1595
1626
|
// fell below threshold. Empty string = no surface (insufficient signal, recall
|
|
1596
|
-
// already healthy, or feature opted-out via env). Default threshold 0.
|
|
1597
|
-
// min injected 5 — both env-overridable for
|
|
1627
|
+
// already healthy, or feature opted-out via env). Default threshold 0.4 against the
|
|
1628
|
+
// HOOK-INJECTED denominator (v6.6.0, D#19), min injected 5 — both env-overridable for
|
|
1629
|
+
// ops tuning + tests; CLAUDE_MEM_CITE_NUDGE_WIDE_DENOMINATOR=1 restores the wide one.
|
|
1598
1630
|
// Thin wrapper: lib/cite-back-hint.mjs owns the logic so it stays unit-tested.
|
|
1599
1631
|
// Passing module-level RUNTIME_DIR keeps the call site identical to pre-v2.83.1.
|
|
1600
1632
|
function buildCiteRecallNudge(project) {
|