claude-mem-lite 6.13.1 → 6.13.2

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.13.1",
12
+ "version": "6.13.2",
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.13.1",
3
+ "version": "6.13.2",
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
@@ -249,7 +249,8 @@ the database, so reverting is pinning `claude-mem-lite@6.12.2`.
249
249
 
250
250
  **Citation readings drop at this version, and that is a measurement change.** A lesson the
251
251
  agent answers with `#NN n/a` is no longer counted as cited, so `citation-stats` per-face rates
252
- read lower from here on. Do not compare a reading taken before 6.13.0 with one taken after.
252
+ read lower from here on (`--sidechain` excepted: it counts an `n/a` as an answer). Do not compare
253
+ a reading taken before 6.13.0 with one taken after.
253
254
 
254
255
  ## Upgrading to 6.11.0
255
256
 
package/README.zh-CN.md CHANGED
@@ -208,7 +208,7 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
208
208
  仍能打开数据库,回退就是固定到 `claude-mem-lite@6.12.2`。
209
209
 
210
210
  **引用率读数会从这个版本起下降,这是口径变化。** agent 用 `#NN n/a` 回应的 lesson 不再计为引用,
211
- 所以 `citation-stats` 各注入面的引用率从此偏低。不要拿 6.13.0 之前和之后的读数直接比较。
211
+ 所以 `citation-stats` 各注入面的引用率从此偏低(`--sidechain` 除外:它把 `n/a` 算作已回应)。不要拿 6.13.0 之前和之后的读数直接比较。
212
212
 
213
213
  ## 升级到 6.11.0
214
214
 
package/bash-utils.mjs CHANGED
@@ -62,6 +62,7 @@ const GIT_READ_SUBCMDS = new Set([
62
62
  'shortlog',
63
63
  'reflog',
64
64
  'status',
65
+ 'rev-parse',
65
66
  ]);
66
67
 
67
68
  // Hard failure fingerprints — a real crash / thrown exception / non-zero-exit marker,
@@ -92,32 +93,320 @@ const NEUTRAL_VERBS = new Set([
92
93
  'export',
93
94
  'set',
94
95
  'exit',
96
+ // Print a path or a date; used inside `$(…)` to build a read's arguments.
97
+ 'pwd',
98
+ 'date',
99
+ 'basename',
100
+ 'dirname',
101
+ 'realpath',
102
+ 'readlink',
95
103
  ]);
96
104
 
105
+ // code-graph-mcp subcommands that only query the index (plus `snapshot inspect`, handled in
106
+ // WRITES_OR_RUNS). The rest (serve, the index rebuilds, doctor's repairs, benchmark,
107
+ // adopt / unadopt / uninstall, snapshot create) run work whose failures error-recall should see.
108
+ const CODE_GRAPH_READ_SUBCMDS = new Set([
109
+ 'grep',
110
+ 'search',
111
+ 'ast-search',
112
+ 'callgraph',
113
+ 'impact',
114
+ 'affected',
115
+ 'show',
116
+ 'map',
117
+ 'tour',
118
+ 'overview',
119
+ 'deps',
120
+ 'trace',
121
+ 'similar',
122
+ 'refs',
123
+ 'dead-code',
124
+ 'centrality',
125
+ 'cycles',
126
+ 'surprising',
127
+ 'report',
128
+ 'health-check',
129
+ 'stats',
130
+ 'outcome',
131
+ 'help',
132
+ '--help',
133
+ '--version',
134
+ ]);
135
+
136
+ // Forms of a SEARCH_VERBS verb that write a file or run a program, so their output is not
137
+ // file content (v6.13.0 defect review P3-5). Args are the whitespace tokens after the verb;
138
+ // quotes are not stripped, which only matters for a flag written inside quotes.
139
+ const WRITES_OR_RUNS = {
140
+ sed: (args) => args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith('--in-place')),
141
+ sort: (args) => args.some((a) => /^-[A-Za-z]*o/.test(a) || a.startsWith('--output')),
142
+ // `-f` reads the program from a file (or a heredoc on stdin) this check cannot see.
143
+ awk: (args, text) =>
144
+ args.some((a) => /^-f/.test(a) || a.startsWith('--file')) ||
145
+ /\bsystem\s*\(|\|\s*(?:getline\b|")|\|&/.test(text),
146
+ find: (args) => args.some((a) => /^-(?:exec|execdir|ok|okdir|delete|fprint0?|fprintf|fls)$/.test(a)),
147
+ 'code-graph-mcp': (args) =>
148
+ !CODE_GRAPH_READ_SUBCMDS.has(args[0]) && !(args[0] === 'snapshot' && args[1] === 'inspect'),
149
+ };
150
+
97
151
  /** 'read' | 'neutral' | 'other' for one simple command (one element of a pipeline). */
98
152
  function classifySimpleCommand(text) {
99
- const toks = text.trim().split(/\s+/).filter(Boolean);
153
+ const toks = shellWords(text);
100
154
  let i = 0;
101
155
  while (i < toks.length && (/^\w+=/.test(toks[i]) || CMD_WRAPPERS.has(toks[i]))) i++;
102
156
  const first = toks[i];
103
157
  if (!first || NEUTRAL_VERBS.has(first)) return 'neutral';
104
- if (SEARCH_VERBS.has(first)) return 'read';
158
+ if (SEARCH_VERBS.has(first)) return WRITES_OR_RUNS[first]?.(toks.slice(i + 1), text) ? 'other' : 'read';
105
159
  return first === 'git' && GIT_READ_SUBCMDS.has(toks[i + 1]) ? 'read' : 'other';
106
160
  }
107
161
 
162
+ /**
163
+ * Split one simple command into words on whitespace OUTSIDE quotes, so `x="a b" grep …`
164
+ * stays an assignment followed by grep (v6.13.2 delta review FALSE-2). Quotes are kept in
165
+ * the words; only the split point is quote-aware.
166
+ */
167
+ function shellWords(text) {
168
+ const words = [];
169
+ let cur = '';
170
+ let inWord = false;
171
+ let quote = null;
172
+ for (let k = 0; k < text.length; k++) {
173
+ const ch = text[k];
174
+ if (quote) {
175
+ if (ch === '\\' && quote !== "'") {
176
+ cur += ch + (text[k + 1] ?? '');
177
+ k++;
178
+ continue;
179
+ }
180
+ if (ch === quote[quote.length - 1]) quote = null;
181
+ cur += ch;
182
+ continue;
183
+ }
184
+ if (/\s/.test(ch)) {
185
+ if (inWord) words.push(cur);
186
+ cur = '';
187
+ inWord = false;
188
+ continue;
189
+ }
190
+ inWord = true;
191
+ if (ch === '\\') {
192
+ cur += ch + (text[k + 1] ?? '');
193
+ k++;
194
+ continue;
195
+ }
196
+ if (ch === '$' && text[k + 1] === "'") {
197
+ quote = ANSI_QUOTE;
198
+ cur += "$'";
199
+ k++;
200
+ continue;
201
+ }
202
+ if (ch === "'" || ch === '"') quote = ch;
203
+ cur += ch;
204
+ }
205
+ if (inWord) words.push(cur);
206
+ return words;
207
+ }
208
+
209
+ // `$'…'` (ANSI-C quoting): a backslash escapes the next character, including `'`.
210
+ const ANSI_QUOTE = "$'";
211
+ // Stands in for a cut-out substitution. No surrounding spaces: `f=$(…)` must stay one
212
+ // assignment word, or the placeholder becomes the verb.
213
+ const SUBST = '__SUBST__';
214
+ const MAX_SUBST_DEPTH = 32;
215
+
216
+ /**
217
+ * Remove what the shell does not run as a command: heredoc bodies (they are stdin) and
218
+ * `#` comments. Quote-aware. An apostrophe in either used to unbalance the quotes and send
219
+ * the whole line to a first-word fallback (v6.13.0 defect review P3-4).
220
+ *
221
+ * An UNQUOTED delimiter (`<<EOF`) is the exception: bash expands `$(…)` and backticks in
222
+ * that body, so each body line is kept as `: "<line>"`, a no-op whose substitutions
223
+ * splitStatements still cuts out and judges. `$((…))` and `((…))` are copied whole so a
224
+ * `<<` shift inside them is not read as a heredoc (v6.13.2 pre-ship defect review).
225
+ */
226
+ function stripNonCommands(cmd) {
227
+ let out = '';
228
+ let quote = null;
229
+ // Set once a `((` never closes: every later attempt would rescan to the end of the input,
230
+ // which made unclosed parentheses quadratic (v6.13.2 delta review P3-1).
231
+ let arithUnclosed = false;
232
+ const pending = []; // heredoc delimiters opened on the current line: { word, dash, expand }
233
+ for (let k = 0; k < cmd.length; k++) {
234
+ const ch = cmd[k];
235
+ if (quote === ANSI_QUOTE) {
236
+ if (ch === '\\') {
237
+ out += ch + (cmd[k + 1] ?? '');
238
+ k++;
239
+ continue;
240
+ }
241
+ if (ch === "'") quote = null;
242
+ out += ch;
243
+ continue;
244
+ }
245
+ if (quote) {
246
+ // Keep the backslash AND the character it escapes (the old `out += cmd[k++]; out += ch`
247
+ // wrote the backslash twice and dropped the escaped character).
248
+ if (ch === '\\' && quote === '"') {
249
+ out += ch + (cmd[k + 1] ?? '');
250
+ k++;
251
+ continue;
252
+ }
253
+ if (ch === quote) quote = null;
254
+ out += ch;
255
+ continue;
256
+ }
257
+ if (ch === '\\') {
258
+ out += ch + (cmd[k + 1] ?? '');
259
+ k++;
260
+ continue;
261
+ }
262
+ if (ch === '$' && cmd[k + 1] === "'") {
263
+ quote = ANSI_QUOTE;
264
+ out += "$'";
265
+ k++;
266
+ continue;
267
+ }
268
+ if (ch === "'" || ch === '"') {
269
+ quote = ch;
270
+ out += ch;
271
+ continue;
272
+ }
273
+ const opensArith =
274
+ (ch === '$' && cmd[k + 1] === '(' && cmd[k + 2] === '(') ||
275
+ (ch === '(' && cmd[k + 1] === '(' && cmd[k - 1] !== '$');
276
+ if (opensArith && !arithUnclosed) {
277
+ const end = arithmeticEnd(cmd, ch === '$' ? k + 1 : k);
278
+ if (end >= 0) {
279
+ out += cmd.slice(k, end);
280
+ k = end - 1;
281
+ continue;
282
+ }
283
+ if (end === ARITH_UNCLOSED) arithUnclosed = true;
284
+ }
285
+ if (ch === '#' && (k === 0 || /[\s;&|()]/.test(cmd[k - 1]))) {
286
+ while (k + 1 < cmd.length && cmd[k + 1] !== '\n') k++;
287
+ continue;
288
+ }
289
+ if (ch === '<' && cmd[k + 1] === '<' && cmd[k + 2] !== '<' && cmd[k - 1] !== '<') {
290
+ const m = /^<<(-?)[ \t]*((?:'[^'\n]*'|"[^"\n]*"|\\.|[^\s;&|<>()'"\\])+)/.exec(cmd.slice(k));
291
+ if (m) {
292
+ pending.push({
293
+ word: m[2].replace(/\\(.)/g, '$1').replace(/['"]/g, ''),
294
+ dash: m[1] === '-',
295
+ expand: !/['"\\]/.test(m[2]),
296
+ });
297
+ out += m[0];
298
+ k += m[0].length - 1;
299
+ continue;
300
+ }
301
+ }
302
+ if (ch === '\n' && pending.length > 0) {
303
+ out += ch;
304
+ // Consume each body in order, up to and including its delimiter line.
305
+ let pos = k + 1;
306
+ for (const { word, dash, expand } of pending.splice(0)) {
307
+ while (pos < cmd.length) {
308
+ let end = cmd.indexOf('\n', pos);
309
+ if (end === -1) end = cmd.length;
310
+ const line = cmd.slice(pos, end);
311
+ pos = end + 1;
312
+ if ((dash ? line.replace(/^\t+/, '') : line) === word) break;
313
+ if (expand) {
314
+ const esc = line.replace(/\\(?=")/g, '\\\\').replace(/"/g, '\\"');
315
+ // A trailing backslash would escape the wrapper's closing quote.
316
+ const odd = /\\*$/.exec(esc)[0].length % 2 === 1;
317
+ out += `: "${esc}${odd ? '\\' : ''}"\n`;
318
+ }
319
+ }
320
+ }
321
+ k = pos - 1;
322
+ continue;
323
+ }
324
+ out += ch;
325
+ }
326
+ return out;
327
+ }
328
+
329
+ const ARITH_UNCLOSED = -2;
330
+
331
+ /**
332
+ * For `((` whose first `(` is at `open` (after the `$` of `$((`, or a bare `((`): the index
333
+ * just past the closing `))` when bash reads it as arithmetic, -1 when it is a command form
334
+ * instead, ARITH_UNCLOSED when the inner `(` never closes. bash tries arithmetic first and
335
+ * falls back when the inner `(` does not close on `))`, so `$((npm test) | head)` is a
336
+ * command substitution holding a subshell (v6.13.2 delta review P3-3).
337
+ */
338
+ function arithmeticEnd(cmd, open) {
339
+ const inner = closingParen(cmd, open + 2);
340
+ if (inner === -1) return ARITH_UNCLOSED;
341
+ return cmd[inner] === ')' ? inner + 1 : -1;
342
+ }
343
+
344
+ /**
345
+ * Index just past the `)` that closes a substitution whose body starts at `start`, or -1
346
+ * when it never closes. Quote-aware; nested parentheses count.
347
+ */
348
+ function closingParen(cmd, start) {
349
+ let depth = 1;
350
+ let quote = null;
351
+ for (let k = start; k < cmd.length; k++) {
352
+ const ch = cmd[k];
353
+ if (quote) {
354
+ if (ch === '\\' && quote !== "'") k++;
355
+ else if (ch === quote[quote.length - 1]) quote = null;
356
+ continue;
357
+ }
358
+ if (ch === '\\') k++;
359
+ else if (ch === '$' && cmd[k + 1] === "'") {
360
+ quote = ANSI_QUOTE;
361
+ k++;
362
+ } else if (ch === "'" || ch === '"') quote = ch;
363
+ else if (ch === '(') depth++;
364
+ else if (ch === ')' && --depth === 0) return k + 1;
365
+ }
366
+ return -1;
367
+ }
368
+
108
369
  /**
109
370
  * Split a command line into statements (on `;`, newline, `&&`, `||`, `&`), each a list
110
371
  * of its pipeline elements (on `|` and `|&`). Quote-aware, so the `;` in
111
372
  * `grep -E "a;b"` separates nothing, and the `&` of a redirection (`2>&1`, `&>f`) is not
112
- * a statement break. A backslash-newline continues the line. Returns null when the
113
- * quotes do not balance (a heredoc body with an apostrophe, say) — the caller then falls
114
- * back to the one-verb rule rather than guess.
373
+ * a statement break. A backslash-newline continues the line. The bodies of `$(…)`,
374
+ * backticks, `<(…)` and `>(…)` — outside quotes or inside double quotes, where they run
375
+ * all the same — are cut out into `subs` for the caller to judge on their own. Returns
376
+ * null when the quotes or a substitution do not close.
115
377
  */
116
378
  function splitStatements(cmd) {
117
379
  const statements = [];
380
+ const subs = [];
118
381
  let pipeline = [];
119
382
  let cur = '';
120
383
  let quote = null;
384
+ // Cut a substitution body out at `k` (the index of `$`, `<`, `>` or the backtick);
385
+ // returns the index of its last character, or -1 when it never closes.
386
+ const takeSub = (k) => {
387
+ if (cmd[k] === '`') {
388
+ const end = cmd.indexOf('`', k + 1);
389
+ if (end === -1) return -1;
390
+ subs.push(cmd.slice(k + 1, end));
391
+ cur += SUBST;
392
+ return end;
393
+ }
394
+ if (cmd[k] === '$' && cmd[k + 2] === '(') {
395
+ const arithEnd = arithmeticEnd(cmd, k + 1);
396
+ if (arithEnd >= 0) {
397
+ // An arithmetic body is not a command, but a substitution inside it still runs.
398
+ const body = cmd.slice(k + 3, arithEnd - 2);
399
+ if (/\$\(|`/.test(body)) subs.push(`: ${body}`);
400
+ cur += SUBST;
401
+ return arithEnd - 1;
402
+ }
403
+ }
404
+ const end = closingParen(cmd, k + 2);
405
+ if (end === -1) return -1;
406
+ subs.push(cmd.slice(k + 2, end - 1));
407
+ cur += SUBST;
408
+ return end - 1;
409
+ };
121
410
  const endElement = () => {
122
411
  pipeline.push(cur);
123
412
  cur = '';
@@ -129,9 +418,29 @@ function splitStatements(cmd) {
129
418
  };
130
419
  for (let k = 0; k < cmd.length; k++) {
131
420
  const ch = cmd[k];
421
+ const opensSub = ch === '`' || ((ch === '$' || ch === '<' || ch === '>') && cmd[k + 1] === '(');
422
+ if (quote === ANSI_QUOTE) {
423
+ if (ch === '\\') {
424
+ cur += ch + (cmd[k + 1] ?? '');
425
+ k++;
426
+ continue;
427
+ }
428
+ if (ch === "'") quote = null;
429
+ cur += ch;
430
+ continue;
431
+ }
132
432
  if (quote) {
433
+ if (quote === '"' && (ch === '`' || (ch === '$' && cmd[k + 1] === '('))) {
434
+ k = takeSub(k);
435
+ if (k === -1) return null;
436
+ continue;
437
+ }
438
+ if (ch === '\\' && quote === '"') {
439
+ cur += ch + (cmd[k + 1] ?? '');
440
+ k++;
441
+ continue;
442
+ }
133
443
  if (ch === quote) quote = null;
134
- else if (ch === '\\' && quote === '"') cur += cmd[k++];
135
444
  cur += ch;
136
445
  continue;
137
446
  }
@@ -140,11 +449,22 @@ function splitStatements(cmd) {
140
449
  k++;
141
450
  continue;
142
451
  }
452
+ if (ch === '$' && cmd[k + 1] === "'") {
453
+ quote = ANSI_QUOTE;
454
+ cur += "$'";
455
+ k++;
456
+ continue;
457
+ }
143
458
  if (ch === "'" || ch === '"') {
144
459
  quote = ch;
145
460
  cur += ch;
146
461
  continue;
147
462
  }
463
+ if (opensSub) {
464
+ k = takeSub(k);
465
+ if (k === -1) return null;
466
+ continue;
467
+ }
148
468
  const next = cmd[k + 1];
149
469
  if (ch === '|' && next !== '|') {
150
470
  endElement();
@@ -161,7 +481,7 @@ function splitStatements(cmd) {
161
481
  }
162
482
  if (quote) return null;
163
483
  endStatement();
164
- return statements;
484
+ return { statements, subs };
165
485
  }
166
486
 
167
487
  // True when the command only READS: every element of every pipeline is a read/search
@@ -171,18 +491,35 @@ function splitStatements(cmd) {
171
491
  // `git grep`, `cat f | head` and `cd repo && sed -n 1,9p f` are exempt. Every statement
172
492
  // and every pipe consumer is checked, so `grep x f; npm test | tail` and
173
493
  // `printf '…' | node server.mjs` are not exempted on the strength of their first word.
494
+ // Substitution bodies are judged the same way, so `grep x $(npm test)` runs a program.
495
+ // A line that still does not parse once heredoc bodies and comments are gone is 'other':
496
+ // the old fallback judged it by its first word and silenced exactly the heredoc-then-run
497
+ // shape (v6.13.0 defect review P3-4).
174
498
  function isReadOnlyCommand(cmd) {
175
- const statements = splitStatements(cmd);
176
- if (!statements) return classifySimpleCommand(cmd.split('|')[0]) === 'read';
499
+ return commandKind(stripNonCommands(cmd)) === 'read';
500
+ }
501
+
502
+ /** 'read' | 'neutral' | 'other' for a whole command line (see isReadOnlyCommand). */
503
+ function commandKind(cmd, depth = 0) {
504
+ // Each level rescans its body; past this depth the line is judged 'other' rather than
505
+ // recursing into a RangeError on the hot path of every Bash event.
506
+ if (depth > MAX_SUBST_DEPTH) return 'other';
507
+ const parsed = splitStatements(cmd);
508
+ if (!parsed) return 'other';
177
509
  let sawRead = false;
178
- for (const pipeline of statements) {
510
+ for (const body of parsed.subs) {
511
+ const kind = commandKind(body, depth + 1);
512
+ if (kind === 'other') return 'other';
513
+ if (kind === 'read') sawRead = true;
514
+ }
515
+ for (const pipeline of parsed.statements) {
179
516
  for (const element of pipeline) {
180
517
  const kind = classifySimpleCommand(element);
181
- if (kind === 'other') return false;
518
+ if (kind === 'other') return 'other';
182
519
  if (kind === 'read') sawRead = true;
183
520
  }
184
521
  }
185
- return sawRead;
522
+ return sawRead ? 'read' : 'neutral';
186
523
  }
187
524
 
188
525
  // Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
package/hook-handoff.mjs CHANGED
@@ -165,7 +165,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
165
165
  WHERE project = ? AND ${liveObsFilterSql('')}
166
166
  AND COALESCE(importance, 1) >= 3
167
167
  AND ${notLowSignalTitleClause('')}
168
- ORDER BY created_at_epoch DESC LIMIT 1
168
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 1
169
169
  `,
170
170
  )
171
171
  .get(project);
@@ -248,7 +248,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
248
248
  `
249
249
  SELECT title, type, narrative FROM observations
250
250
  WHERE (memory_session_id = ? OR project = ?) AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
251
- ORDER BY created_at_epoch DESC LIMIT 15
251
+ ORDER BY created_at_epoch DESC, id DESC LIMIT 15
252
252
  `,
253
253
  )
254
254
  .all(sessionId, project, ...obsWindowParams);
@@ -348,21 +348,36 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
348
348
  if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach((f) => fileSet.add(f));
349
349
  // Same namespace widening as `completed` above — see the reasoning there. Measured
350
350
  // 2026-09-21: 8 of the 17 live handoff rows stored key_files as the empty array.
351
- const obsFiles = db
351
+ //
352
+ // The cap counts rows that CONTRIBUTE a new file (D#67, the D#40 shape). This read was
353
+ // `LIMIT 10` → isValidFile, so rows the filter empties ('[]', directories, /tmp paths)
354
+ // could use up the window. A latent defect: the v6.13.2 pre-ship claims review replayed
355
+ // the live DB 2026-09-26 with this read's own WHERE and window, and 0 of 45 stored
356
+ // handoffs and 0 of 310 sessions lost a file (the largest window held 11 rows).
357
+ // Streaming keeps the filter exactly as it was.
358
+ let contributing = 0;
359
+ for (const row of db
352
360
  .prepare(
353
361
  `
354
362
  SELECT files_modified FROM observations
355
363
  WHERE (memory_session_id = ? OR project = ?) AND files_modified IS NOT NULL ${obsWindowClause}
356
- ORDER BY created_at_epoch DESC LIMIT 10
364
+ ORDER BY created_at_epoch DESC, id DESC
357
365
  `,
358
366
  )
359
- .all(sessionId, project, ...obsWindowParams);
360
- for (const row of obsFiles) {
367
+ .iterate(sessionId, project, ...obsWindowParams)) {
368
+ let files;
361
369
  try {
362
- JSON.parse(row.files_modified)
363
- .filter(isValidFile)
364
- .forEach((f) => fileSet.add(f));
365
- } catch {}
370
+ files = JSON.parse(row.files_modified);
371
+ } catch {
372
+ continue;
373
+ }
374
+ const valid = Array.isArray(files) ? files.filter(isValidFile) : [];
375
+ const before = fileSet.size;
376
+ valid.forEach((f) => fileSet.add(f));
377
+ // A row that only repeats a listed file (the common case: one file edited again and
378
+ // again) contributes nothing either (v6.13.2 pre-ship defect review P3-7).
379
+ if (fileSet.size === before) continue;
380
+ if (++contributing === 10) break;
366
381
  }
367
382
 
368
383
  // 5. Key decisions — high importance observations (skip low-signal degraded titles).
@@ -116,10 +116,21 @@ const CITATION_RE = citationIdRe();
116
116
  // a false one withholds credit from a lesson that was used, so the marker must follow
117
117
  // the id directly: past the rest of an id list (`#137 / #260 / #54 n/a`, `#1758、E#3520
118
118
  // 与本次无关`) and at most one short parenthetical gloss, never "somewhere nearby".
119
+ // The English adjectives must not be followed by a noun: "#12 irrelevant rows are now
120
+ // filtered" describes the change (defect review P3-6). Only a word from ADJ_VERDICT_NEXT may
121
+ // follow them. Over 1,900 `#NN` mentions (citationIdRe) in 218 top-level transcripts across
122
+ // every project (2026-09-26T06:46Z) this changed exactly one verdict, the review's own
123
+ // example quoted back.
119
124
  const DISMISSAL_LIST_TAIL = /^(?:\*\*|`|\s|[,,、/]|and\b|和|[A-Z]?#\d{1,7}\b)*/;
120
125
  const DISMISSAL_GLOSS = /^\s*(?:([^()\n]{1,80})|\([^()\n]{1,80}\))/;
121
- const DISMISSAL_MARKER =
122
- /^[\s*`::\-—–(([]*(?:(?:本轮|本次|这次|此次|此处)\s*)?(?:n\/a\b|not applicable|does(?:n't| not) apply|did(?:n't| not) apply|irrelevant|not relevant|unrelated|不适用|不相关|无关(?!紧要)|与(?:本|这|此)[^。\n;;]{0,16}无关)/i;
126
+ const ADJ_VERDICT_NEXT =
127
+ 'here|to|for|in|on|now|this|since|because|as|so|and|but|at|with|anyway|anymore|either|too|though|today';
128
+ const DISMISSAL_MARKER = new RegExp(
129
+ "^[\\s*`::\\-—–(([]*(?:(?:本轮|本次|这次|此次|此处)\\s*)?(?:n\\/a\\b|not applicable|does(?:n't| not) apply|did(?:n't| not) apply|" +
130
+ `(?:irrelevant|not relevant|unrelated)(?![ \\t]+(?!(?:${ADJ_VERDICT_NEXT})\\b)[A-Za-z])|` +
131
+ '不适用|不相关|无关(?!紧要)|与(?:本|这|此)[^。\\n;;]{0,16}无关)',
132
+ 'i',
133
+ );
123
134
 
124
135
  /**
125
136
  * True when the `#NN` that ENDS at `end` in `text` is answered as not applying.
@@ -261,8 +272,11 @@ export function classifyCitationContext(transcriptPath, opts = {}) {
261
272
  continue;
262
273
  }
263
274
  // Only `text` blocks count as citing. `thinking` is deliberately excluded:
264
- // extractCitationsFromTranscript scores text only, and the whole point is to
265
- // classify the same numerator the decay loop acts on, not a wider one.
275
+ // extractCitationsFromTranscript scores text only. The numerator is still WIDER than
276
+ // the decay loop's by one class: a `#NN n/a` dismissal counts here, and the decay loop
277
+ // (extractCitationsFromTranscript's default) does not. Left as is because
278
+ // benchmark/citation-live-replay.mjs reads this split, and changing it would move
279
+ // that ruler's caliber (v6.13.0 defect review P3-7).
266
280
  if (block.type !== 'text' || typeof block.text !== 'string') continue;
267
281
  CITATION_RE.lastIndex = 0;
268
282
  let m;
package/mem-cli.mjs CHANGED
@@ -2794,7 +2794,10 @@ function cmdMemdirAudit(args) {
2794
2794
  // main decay loop excludes (it runs mainOnly). aggregateProjectCiteRecall scans THIS
2795
2795
  // project's transcripts: top-level <session>.jsonl = main, and
2796
2796
  // <session>/subagents/agent-*.jsonl = sidechain (descends ONE level into the literal
2797
- // subagents/ dir only, no unbounded recursion). Same methodology, so comparable.
2797
+ // subagents/ dir only, no unbounded recursion). Main and sidechain use the same
2798
+ // methodology here, so they compare with each other. It is a compliance ratio that counts
2799
+ // a `#NN n/a` dismissal as an answer, so it does NOT compare with the per-face rates,
2800
+ // including the `subagent` face, which exclude dismissals since v6.13.0.
2798
2801
  function _reportSidechainCiteRecall({ days, json }) {
2799
2802
  const cutoff = Date.now() - days * 86400 * 1000;
2800
2803
  // memdir = ~/.claude/projects/<encoded>/memory; transcripts are its siblings.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.13.1",
3
+ "version": "6.13.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.13.1",
9
+ "version": "6.13.2",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.13.1",
3
+ "version": "6.13.2",
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",