claude-mem-lite 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.9.0",
13
+ "version": "3.10.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.9.0",
3
+ "version": "3.10.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/hook.mjs CHANGED
@@ -553,7 +553,11 @@ async function handleStop() {
553
553
  // Union closed by extractAllInjected — one integration point so the
554
554
  // contract test in tests/citation-tracker-userprompt.test.mjs covers it.
555
555
  try {
556
- const injected = extractAllInjected(transcriptPath);
556
+ // mainOnly: the injected denominator must use the same thread
557
+ // filter as citedMain (the numerator, below) — an obs injected only
558
+ // inside a subagent (sidechain) would otherwise enter the denominator
559
+ // but never the numerator and streak-demote despite being used there.
560
+ const injected = extractAllInjected(transcriptPath, { mainOnly: true });
557
561
  // P5 ①: cite-back signals — observations whose warned file the agent
558
562
  // edited this session. Union into injected so they're resolved (they
559
563
  // were injected via pre-tool-recall) and, below, into cited so the
@@ -182,8 +182,15 @@ function normalizeHookCommand(command) {
182
182
  *
183
183
  * @param {string|null|undefined} transcriptPath
184
184
  * @param {(ctx: {command: string, text: string}) => void} fn
185
+ * @param {object} [opts]
186
+ * @param {boolean} [opts.mainOnly=false] If true, skip attachments on sidechain
187
+ * (subagent) transcript records. Mirrors extractCitationsFromTranscript's
188
+ * mainOnly so the citation-decay injected DENOMINATOR uses the same thread
189
+ * filter as the cited NUMERATOR — an obs injected only inside a subagent must
190
+ * not enter the denominator, else it streak-demotes despite being used there.
185
191
  */
186
- function eachHookAttachment(transcriptPath, fn) {
192
+ function eachHookAttachment(transcriptPath, fn, opts = {}) {
193
+ const { mainOnly = false } = opts;
187
194
  if (!transcriptPath || !existsSync(transcriptPath)) return;
188
195
  let raw;
189
196
  try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return; }
@@ -192,6 +199,7 @@ function eachHookAttachment(transcriptPath, fn) {
192
199
  let entry;
193
200
  try { entry = JSON.parse(line); } catch { continue; }
194
201
  if (entry.type !== 'attachment') continue;
202
+ if (mainOnly && entry.isSidechain === true) continue;
195
203
  const att = entry.attachment;
196
204
  if (!att || att.type !== 'hook_success') continue;
197
205
  const stdout = att.stdout || '';
@@ -217,14 +225,14 @@ function eachHookAttachment(transcriptPath, fn) {
217
225
  * @param {string|null|undefined} transcriptPath
218
226
  * @returns {Set<number>} unique injected IDs (empty set on missing path/file)
219
227
  */
220
- export function extractInjectedFromPreToolUse(transcriptPath) {
228
+ export function extractInjectedFromPreToolUse(transcriptPath, opts = {}) {
221
229
  const ids = new Set();
222
230
  eachHookAttachment(transcriptPath, ({ command, text }) => {
223
231
  if (!command.includes('pre-tool-recall')) return;
224
232
  INJECTED_RE.lastIndex = 0;
225
233
  let m;
226
234
  while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
227
- });
235
+ }, opts);
228
236
  return ids;
229
237
  }
230
238
 
@@ -251,7 +259,7 @@ const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
251
259
  * @param {string|null|undefined} transcriptPath
252
260
  * @returns {Set<number>}
253
261
  */
254
- export function extractInjectedFromUserPromptSubmit(transcriptPath) {
262
+ export function extractInjectedFromUserPromptSubmit(transcriptPath, opts = {}) {
255
263
  const ids = new Set();
256
264
  eachHookAttachment(transcriptPath, ({ command, text }) => {
257
265
  if (!command.includes(UPS_COMMAND_SUFFIX)) return;
@@ -265,7 +273,7 @@ export function extractInjectedFromUserPromptSubmit(transcriptPath) {
265
273
  if (matches.length === 0) continue;
266
274
  addObsId(ids, matches[matches.length - 1][1]);
267
275
  }
268
- });
276
+ }, opts);
269
277
  return ids;
270
278
  }
271
279
 
@@ -280,7 +288,7 @@ export function extractInjectedFromUserPromptSubmit(transcriptPath) {
280
288
  * @param {string|null|undefined} transcriptPath
281
289
  * @returns {Set<number>}
282
290
  */
283
- export function extractInjectedFromErrorRecall(transcriptPath) {
291
+ export function extractInjectedFromErrorRecall(transcriptPath, opts = {}) {
284
292
  const ids = new Set();
285
293
  eachHookAttachment(transcriptPath, ({ command, text }) => {
286
294
  if (!command.includes('post-tool-use')) return;
@@ -290,7 +298,7 @@ export function extractInjectedFromErrorRecall(transcriptPath) {
290
298
  INJECTED_RE.lastIndex = 0;
291
299
  let m;
292
300
  while ((m = INJECTED_RE.exec(text))) addObsId(ids, m[1]);
293
- });
301
+ }, opts);
294
302
  return ids;
295
303
  }
296
304
 
@@ -311,7 +319,7 @@ const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
311
319
  * @param {string|null|undefined} transcriptPath
312
320
  * @returns {Set<number>}
313
321
  */
314
- export function extractInjectedFromFyi(transcriptPath) {
322
+ export function extractInjectedFromFyi(transcriptPath, opts = {}) {
315
323
  const ids = new Set();
316
324
  eachHookAttachment(transcriptPath, ({ command, text }) => {
317
325
  if (!command.includes('user-prompt-search')) return;
@@ -320,7 +328,7 @@ export function extractInjectedFromFyi(transcriptPath) {
320
328
  const m = FYI_LINE_ID_RE.exec(fyiLine);
321
329
  if (m) addObsId(ids, m[1]);
322
330
  }
323
- });
331
+ }, opts);
324
332
  return ids;
325
333
  }
326
334
 
@@ -330,14 +338,18 @@ export function extractInjectedFromFyi(transcriptPath) {
330
338
  * user-prompt-search FYI block. Single integration point the Stop handler calls.
331
339
  *
332
340
  * @param {string|null|undefined} transcriptPath
341
+ * @param {object} [opts]
342
+ * @param {boolean} [opts.mainOnly=false] Skip sidechain-injected IDs. The
343
+ * citation-decay caller passes true so the injected denominator matches the
344
+ * mainOnly cited numerator; the P4 access-bump caller omits it (broader).
333
345
  * @returns {Set<number>}
334
346
  */
335
- export function extractAllInjected(transcriptPath) {
347
+ export function extractAllInjected(transcriptPath, opts = {}) {
336
348
  return new Set([
337
- ...extractInjectedFromPreToolUse(transcriptPath),
338
- ...extractInjectedFromUserPromptSubmit(transcriptPath),
339
- ...extractInjectedFromErrorRecall(transcriptPath),
340
- ...extractInjectedFromFyi(transcriptPath),
349
+ ...extractInjectedFromPreToolUse(transcriptPath, opts),
350
+ ...extractInjectedFromUserPromptSubmit(transcriptPath, opts),
351
+ ...extractInjectedFromErrorRecall(transcriptPath, opts),
352
+ ...extractInjectedFromFyi(transcriptPath, opts),
341
353
  ]);
342
354
  }
343
355
 
@@ -59,9 +59,26 @@ export function parseDateBounds(fromRaw, toRaw) {
59
59
  * per-source SQL double-applied it and gapped/overlapped pages, because the
60
60
  * obs hybrid path (AND→OR fallback / vector / concept stages) re-adds rows the
61
61
  * SQL OFFSET already skipped (#8217/#8638).
62
+ *
63
+ * D#30 re-audit (reopened): perSourceLimit MUST be offset-independent. The old
64
+ * `offset + limit + 10` term grew the pool for deeper pages, and because RRF
65
+ * fusion of FTS + vector ranks is candidate-pool-sensitive (an item present in
66
+ * BOTH lists outranks one in a single list), a larger pool RE-RANKS the prefix —
67
+ * so page(offset=0) and page(offset=N) sliced DIFFERENT orderings and overlapped
68
+ * /gapped on the real (vector-populated) DB. #8642 missed this because its guard
69
+ * test seeds no vectors (FTS-only is prefix-stable). The fix makes the pool a
70
+ * function of `limit` only:
71
+ * - MIN_FUSION_POOL floor (= default limit 20 × the 3× buffer): every limit ≤ 20
72
+ * fuses ONE 60-candidate pool, so same-limit --offset pages are disjoint/stable
73
+ * and top-N is limit-stable (top-5 ⊂ top-10 ⊂ top-20). limit=20 offset=0 stays
74
+ * byte-identical to before (60) → no change on the benchmarked single-page path.
75
+ * - limit > 20 keeps limit*3 (the over-fetch buffer the fallback stages need).
76
+ * Trade-off: pages beyond the pool now return empty instead of the (overlapping,
77
+ * wrong) rows the offset-scaling used to surface — stability over deep reach.
62
78
  */
63
- export function computePerSourceWindow(limit, offset) {
64
- return { perSourceLimit: Math.max(limit * 3, offset + limit + 10), perSourceOffset: 0 };
79
+ export const MIN_FUSION_POOL = 60;
80
+ export function computePerSourceWindow(limit, offset) { // eslint-disable-line no-unused-vars
81
+ return { perSourceLimit: Math.max(limit * 3, MIN_FUSION_POOL), perSourceOffset: 0 };
65
82
  }
66
83
 
67
84
  /** obs-side total query: when the AND→OR fallback fired, count the OR set. */
package/mem-cli.mjs CHANGED
@@ -133,7 +133,15 @@ async function cmdSearch(db, args, { llm } = {}) {
133
133
  // --deep proceeds even when the literal query sanitizes to nothing — its LLM
134
134
  // rewrite may still produce searchable variants (F3, parity with server.mjs).
135
135
  if (!ftsQuery && deepMode === 'normal') {
136
- fail(`[mem] No valid search terms in "${query}"`);
136
+ // A query that sanitizes to an empty FTS expression (only operators/punctuation/
137
+ // sub-min-length tokens) is a zero-result search, not a malformed one. In --json
138
+ // mode emit the same empty envelope as the no-match path below so programmatic
139
+ // consumers always get parseable stdout (the human path keeps the stderr hint).
140
+ if (jsonOutput) {
141
+ out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: false, results: [] }));
142
+ } else {
143
+ fail(`[mem] No valid search terms in "${query}"`);
144
+ }
137
145
  return;
138
146
  }
139
147
  // --deep ignores --or: each variant runs AND + the engine's built-in
@@ -607,7 +615,20 @@ function cmdTimeline(db, args) {
607
615
  if (flags.anchor !== undefined && flags.anchor !== true) {
608
616
  const resolved = resolveAnchorToken(db, flags.anchor, { project });
609
617
  if (!resolved.ok) {
610
- fail(formatAnchorError(resolved.error, 'cli'));
618
+ // --json must always emit a parseable envelope. An explicit-but-missing anchor is
619
+ // a direct-lookup miss (like `get` on a bad id) → anchor:null + error code, rc=1.
620
+ if (jsonOutput) {
621
+ process.exitCode = 1;
622
+ out(JSON.stringify({
623
+ anchor: null,
624
+ anchor_note: formatAnchorError(resolved.error, 'mcp'),
625
+ before: [],
626
+ after: [],
627
+ error: resolved.error.code || 'anchor_resolution_failed',
628
+ }));
629
+ } else {
630
+ fail(formatAnchorError(resolved.error, 'cli'));
631
+ }
611
632
  return;
612
633
  }
613
634
  anchorId = resolved.anchorId;
@@ -663,7 +684,20 @@ function cmdTimeline(db, args) {
663
684
  // Window fetch (access-count bump + project auto-scope) shared with MCP.
664
685
  const win = fetchTimelineWindow(db, anchorId, { before, after, project });
665
686
  if (!win) {
666
- fail(`[mem] Observation #${anchorId} not found`);
687
+ // Anchor resolved to a real id but the window fetch found no row (e.g. project
688
+ // mismatch). Same --json contract as the resolution-miss path above.
689
+ if (jsonOutput) {
690
+ process.exitCode = 1;
691
+ out(JSON.stringify({
692
+ anchor: null,
693
+ anchor_note: `Observation #${anchorId} not found.`,
694
+ before: [],
695
+ after: [],
696
+ error: 'id-not-found',
697
+ }));
698
+ } else {
699
+ fail(`[mem] Observation #${anchorId} not found`);
700
+ }
667
701
  return;
668
702
  }
669
703
  const { anchor, beforeRows, afterRows } = win;
@@ -1405,8 +1439,11 @@ function cmdUpdate(db, args) {
1405
1439
  `Prompts and sessions are append-only.`);
1406
1440
  return;
1407
1441
  }
1442
+ // Strict parseIdToken gate (aligned with cmdDelete): a bare parseInt fallback
1443
+ // truncated "3.9" → 3 and silently UPDATE'd the WRONG row #3 (no preview, no
1444
+ // --confirm). Require an exact obs-id token; non-matching input → usage error.
1408
1445
  const parsed = raw ? parseIdToken(raw) : null;
1409
- const id = parsed && parsed.source === null ? parsed.id : parseInt(raw, 10);
1446
+ const id = parsed && parsed.source === null ? parsed.id : NaN;
1410
1447
  if (!id || isNaN(id)) {
1411
1448
  fail('[mem] Usage: claude-mem-lite update <id> [--title T] [--type T] [--importance N] [--lesson T] [--narrative T] [--concepts T]');
1412
1449
  return;
@@ -2331,6 +2368,8 @@ Commands:
2331
2368
  --project P Filter by project
2332
2369
  drop <D#N|ordinal>[,...] Drop one or more deferred items (no fix needed)
2333
2370
  --reason "..." Required audit trail
2371
+ --project P Project for ordinal resolution (default: current; must
2372
+ match the "defer list --project P" you read ordinals from)
2334
2373
 
2335
2374
  delete <id1,id2,...> Delete observations by ID
2336
2375
  --confirm Execute deletion (preview by default)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.9.0",
3
+ "version": "3.10.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",