claude-mem-lite 5.5.0 → 5.6.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": "5.5.0",
13
+ "version": "5.6.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": "5.5.0",
3
+ "version": "5.6.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/deep-search.mjs CHANGED
@@ -191,12 +191,18 @@ export function resolveDeepMode(explicitDeep, { surface, env = process.env } = {
191
191
  * D#3. benchmark/deep-search-holdout.mjs asks the suite's own queries of a corpus with
192
192
  * their relevant_ids deleted, so the correct answer is zero rows and every returned row is
193
193
  * a false positive by construction. It reads mean FP@10 = 10.00 across 12/12 queries: deep
194
- * fills every slot, every time. The single-query baseline returns 1-2 rows on the same
195
- * negatives the flood is the UNION across paraphrase variants, which is also where deep's
196
- * recall win comes from, so this is not a bug to be thresholded away. Three gates were
197
- * tested against both arms and rejected; suppressing OR-fallback on rewrites takes deep
198
- * R@10 from 0.7383 to 0.3962, because the vocab-mismatch win IS that fallback. rrfFuseN
194
+ * fills every slot, every time. THE FLOOD IS NOT THE PARAPHRASE UNION, and an earlier
195
+ * version of this paragraph said it was ("the single-query baseline returns 1-2 rows on the
196
+ * same negatives"). Measured 2026-09-07, same fixture: the single-variant baseline already
197
+ * returns mean 9.42 of 10 (min 5, max 10, n=12), so fusion adds about half a slot to a page
198
+ * that was already full. A counterfactual names the real source disabling the AND->OR
199
+ * fallback in search-engine.mjs takes mean FP@10 from 10.00 to 0.08, with 0/12 queries
200
+ * flooded instead of 12/12. Read that as a MECHANISM PROBE, not a candidate fix: the same
201
+ * fallback IS the vocab-mismatch recall win, and suppressing it on rewrites takes deep R@10
202
+ * from 0.7383 to 0.3962. Three gates were tested against both arms and rejected. rrfFuseN
199
203
  * fuses by RANK, so no magnitude signal survives the merge for a downstream floor to read.
204
+ * The same counterfactual explains why auto-escalation never fires here (D#8): with the
205
+ * fallback off, plain hits drop to min 0 and the escalation reach goes 0/12 -> 12/12.
200
206
  *
201
207
  * The discrimination is not available at this layer, so the honest move is to hand the
202
208
  * caller what the caller cannot otherwise see. Two things were missing:
package/hook-optimize.mjs CHANGED
@@ -129,7 +129,8 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
129
129
  ${projectClause}
130
130
  ORDER BY
131
131
  CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 0 ELSE 1 END,
132
- created_at_epoch DESC
132
+ created_at_epoch DESC,
133
+ id DESC
133
134
  LIMIT ?
134
135
  `);
135
136
  return project ? stmt.all(project, limit) : stmt.all(limit);
@@ -149,7 +150,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
149
150
  AND LENGTH(COALESCE(narrative, '')) > 100
150
151
  AND ${notLowSignalTitleClause('')}
151
152
  ${projectClause}
152
- ORDER BY created_at_epoch DESC
153
+ ORDER BY created_at_epoch DESC, id DESC
153
154
  LIMIT ?
154
155
  `);
155
156
  return project ? stmt.all(project, limit) : stmt.all(limit);
@@ -182,12 +183,15 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
182
183
  AND LENGTH(COALESCE(narrative, '')) > 100
183
184
  AND ${notLowSignalTitleClause('')}
184
185
  ${projectClause}
185
- ORDER BY created_at_epoch DESC
186
+ ORDER BY created_at_epoch DESC, id DESC
186
187
  LIMIT ?
187
188
  `);
188
189
  return project ? stmt.all(project, limit) : stmt.all(limit);
189
190
  }
190
191
  if (scope === 'wide') {
192
+ // This pool's ORDER BY leads with a CASE term and spans lines, which is exactly why the
193
+ // first pass of the D#9 tiebreaker missed it. The full note lives in the default pool at
194
+ // the bottom of this function -- read it before touching any ORDER BY here.
191
195
  const stmt = db.prepare(`
192
196
  SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
193
197
  FROM observations
@@ -200,7 +204,8 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
200
204
  ${projectClause}
201
205
  ORDER BY
202
206
  CASE type WHEN 'decision' THEN 0 WHEN 'bugfix' THEN 1 WHEN 'refactor' THEN 2 ELSE 3 END,
203
- created_at_epoch DESC
207
+ created_at_epoch DESC,
208
+ id DESC
204
209
  LIMIT ?
205
210
  `);
206
211
  return project ? stmt.all(project, limit) : stmt.all(limit);
@@ -215,7 +220,36 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
215
220
  AND search_aliases IS NULL
216
221
  AND optimized_at IS NULL
217
222
  ${projectClause}
218
- ORDER BY created_at_epoch DESC
223
+ -- D#9: the id term is a REACHABILITY guard, not cosmetics. Every pool in this file is
224
+ -- ORDER BY created_at_epoch DESC LIMIT n feeding JS-side work, so a tie AT THE
225
+ -- BOUNDARY decides pool MEMBERSHIP. Measured 2026-09-07: two inserts land in the same
226
+ -- millisecond 272/300 times, and on a tie SQLite returns ASCENDING rowid -- the exact
227
+ -- opposite of the "newest first" this clause states -- so the newest rows fell out of
228
+ -- the pool whenever the clock had not ticked. SQLite's tie order is deterministic here
229
+ -- (8 rows on one epoch, 200 queries, one returned order), so this is not defending
230
+ -- against a varying plan; it is making the stated order total.
231
+ --
232
+ -- TWO DIFFERENT COUNTS, AND AN EARLIER DRAFT OF THIS COMMENT CONFLATED THEM. This
233
+ -- function, findReenrichCandidates, holds FIVE pools -- five db.prepare blocks:
234
+ -- 'scopes', 'aliases', 'concepts', 'wide', and this default 'narrow' one. The FILE
235
+ -- holds SEVEN "ORDER BY ... created_at_epoch DESC" sites: those five plus
236
+ -- extractUniqueConcepts and findMergeCandidates. All seven now carry the id term.
237
+ -- DO NOT GREP FOR THE ONE-LINE FORM: two of the seven ('scopes' and 'wide') lead with
238
+ -- a CASE ... term and span several lines, so grepping the joined
239
+ -- "created_at_epoch DESC, id DESC" spelling sees only five. That is how the first pass
240
+ -- read six and shipped 'wide' untiebroken -- the pool the DAILY unattended path passes
241
+ -- explicitly, on a budget of 6, where a boundary tie decides which rows reach the LLM
242
+ -- on a given run. Caught later by a test driving scope 'wide'; the original boundary
243
+ -- case drove only 'narrow', so nothing went red.
244
+ -- NOT FIXED, AND NAMED SO THE COMPLETENESS CLAIM IS TRUE: findSmartCompressCandidates
245
+ -- carries an eighth ordering, "ORDER BY project, created_at_epoch" -- ASCENDING, no id
246
+ -- term, no LIMIT. It is outside the seven by construction and is left alone under Iron
247
+ -- Law #1: it feeds clusterForCompression, whose vector branch seeds clusters in SQL
248
+ -- order, so a tie could move cluster membership -- but that branch needs
249
+ -- CLAUDE_MEM_VECTORS=1 and is off by default, and no failing case has been built.
250
+ -- Unjudged, not cleared.
251
+ -- This comment is INSIDE a template literal, so it must never contain a backtick.
252
+ ORDER BY created_at_epoch DESC, id DESC
219
253
  LIMIT ?
220
254
  `);
221
255
  return project ? stmt.all(project, limit) : stmt.all(limit);
@@ -632,7 +666,7 @@ export function extractUniqueConcepts(db, limit = 500, { project } = {}) {
632
666
  WHERE ${liveObsFilterSql('')}
633
667
  AND concepts IS NOT NULL AND concepts != ''
634
668
  ${projectClause}
635
- ORDER BY created_at_epoch DESC
669
+ ORDER BY created_at_epoch DESC, id DESC -- D#9: total order, see findReenrichCandidates
636
670
  LIMIT 2000
637
671
  `);
638
672
  const rows = project ? stmt.all(project) : stmt.all();
@@ -807,7 +841,10 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
807
841
  AND title IS NOT NULL AND title != ''
808
842
  AND created_at_epoch > ?
809
843
  ${projectClause}
810
- ORDER BY created_at_epoch DESC
844
+ -- D#9: this pool's head is what the keeper reduce falls back to on a full tie, so an
845
+ -- arbitrary tie order decides WHICH DUPLICATE SURVIVES a merge. Same-episode rows are
846
+ -- exactly that tie (same project, same importance, access_count 0, same millisecond).
847
+ ORDER BY created_at_epoch DESC, id DESC
811
848
  LIMIT 200
812
849
  `);
813
850
  const rows = project ? stmt.all(cutoff, project) : stmt.all(cutoff);
@@ -876,14 +913,27 @@ Return ONLY valid JSON:
876
913
  });
877
914
  if (!parsed || !parsed.should_merge) return { merged: false };
878
915
 
879
- // Keeper = highest importance, then highest access_count. Previously access_count
880
- // alone, so a critical (importance=3) but never-accessed observation lost the keeper
881
- // role to a trivial (importance=1) accessed one and was compressed away.
916
+ // Keeper = highest importance, then highest access_count, then highest id. Previously
917
+ // access_count alone, so a critical (importance=3) but never-accessed observation lost
918
+ // the keeper role to a trivial (importance=1) accessed one and was compressed away.
919
+ //
920
+ // D#9: the third term is the one that makes this TOTAL. Without it a full tie fell
921
+ // through to `cluster[0]` — the SQL head — and same-episode duplicates are exactly a
922
+ // full tie: same project, same importance, access_count 0, and a created_at_epoch in
923
+ // the same millisecond 272 times out of 300 (measured 2026-09-07). On a tie SQLite
924
+ // returns ASCENDING rowid while an untied pool returns the newest first, so which
925
+ // duplicate survived flipped on whether two writes straddled a millisecond. Ordering
926
+ // the pool alone would not have been enough: this reduce is exported to callers that
927
+ // build their own cluster, so it has to be total on its own. Highest id = written last
928
+ // = the version whose content the merged summary should be anchored on.
882
929
  const keeper = cluster.reduce((best, o) => {
883
930
  const oi = o.importance || 1,
884
931
  bi = best.importance || 1;
885
932
  if (oi !== bi) return oi > bi ? o : best;
886
- return (o.access_count || 0) > (best.access_count || 0) ? o : best;
933
+ const oa = o.access_count || 0,
934
+ ba = best.access_count || 0;
935
+ if (oa !== ba) return oa > ba ? o : best;
936
+ return (o.id || 0) > (best.id || 0) ? o : best;
887
937
  }, cluster[0]);
888
938
  const others = cluster.filter((o) => o.id !== keeper.id);
889
939
  // Floor the merged importance at the cluster max — merging must never silently
package/hook.mjs CHANGED
@@ -145,7 +145,7 @@ import {
145
145
  buildUnsavedBugfixHint,
146
146
  countUnsavedBugfixShape,
147
147
  buildCiteRecallNudge as libBuildCiteRecallNudge,
148
- nextCiteLowStreak,
148
+ nextCiteStreakState,
149
149
  } from './lib/cite-back-hint.mjs';
150
150
  import { citeRecallPathFor } from './lib/cite-recall-path.mjs';
151
151
  import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
@@ -1182,7 +1182,11 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1182
1182
  sessionId: ccSessionId,
1183
1183
  subagentInjected: sub.injected,
1184
1184
  });
1185
- const n = bumpCitationAccess(db, ids, project, relevant);
1185
+ // sessionId is the ACCESS channel's idempotency key (R11-B-P1-1). Stop fires once
1186
+ // per assistant turn and `ids` above is a rescan of the WHOLE transcript, so
1187
+ // without it every later turn re-credited the same citation — 7.86x on the real
1188
+ // corpus, straight into boostAccessed's `access_count > 3`.
1189
+ const n = bumpCitationAccess(db, ids, project, relevant, { sessionId: ccSessionId });
1186
1190
  debugLog(
1187
1191
  'DEBUG',
1188
1192
  'handleStop',
@@ -1407,11 +1411,19 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1407
1411
  const dest = citeRecallPathFor(RUNTIME_DIR, project);
1408
1412
  // Carry the consecutive-low-cite streak forward so the SessionStart
1409
1413
  // nag can self-silence after the project has ignored it N times.
1410
- let priorStreak = 0;
1414
+ let prevPayload = null;
1411
1415
  try {
1412
- priorStreak = JSON.parse(readFileSync(dest, 'utf8')).lowStreak || 0;
1416
+ prevPayload = JSON.parse(readFileSync(dest, 'utf8'));
1413
1417
  } catch {}
1414
- const lowStreak = nextCiteLowStreak(priorStreak, stats);
1418
+ // R11-B-P1-2: the streak's unit is the SESSION, which is what the docblock has
1419
+ // always said. Stop fires once per assistant TURN, so incrementing here silenced
1420
+ // the nudge inside the first session — this machine read lowStreak 58 against 26
1421
+ // transcripts before the fix.
1422
+ const { lowStreak, streakBase, lastStreakSession } = nextCiteStreakState(
1423
+ prevPayload,
1424
+ ccSessionId,
1425
+ stats,
1426
+ );
1415
1427
  // G3: finalized-in-conversation + zero deliberate persistence →
1416
1428
  // decisionSignal rides the payload; next SessionStart reminds once.
1417
1429
  let decisionSignal = null;
@@ -1432,7 +1444,16 @@ function trackCitationsAtStop(db, { sessionId, project, ccSessionId, transcriptP
1432
1444
  } catch (e) {
1433
1445
  debugCatch(e, 'handleStop-persist-reminder');
1434
1446
  }
1435
- const payload = { ...stats, ...bugfixStats, lowStreak, decisionSignal, project, savedAt: Date.now() };
1447
+ const payload = {
1448
+ ...stats,
1449
+ ...bugfixStats,
1450
+ lowStreak,
1451
+ streakBase,
1452
+ lastStreakSession,
1453
+ decisionSignal,
1454
+ project,
1455
+ savedAt: Date.now(),
1456
+ };
1436
1457
  writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
1437
1458
  } catch (e) {
1438
1459
  debugCatch(e, 'handleStop-cite-recall-persist');
@@ -46,9 +46,21 @@ export const OBS_ID_DIGITS = '\\d{1,7}';
46
46
  * Returned fresh per call rather than shared: a `/g` regex carries `lastIndex`, so one
47
47
  * exported instance reused by two scanners silently starts mid-string in whichever one
48
48
  * runs second.
49
+ *
50
+ * R11-B-P2-3: the lookbehind is what makes "bare" true. This product renders — and
51
+ * teaches the agent and the user to type back — `E#N` (events), `P#N` (user_prompts),
52
+ * `D#N` (deferred) and `S#N` (sessions), and every INJECTED-side extractor already drops
53
+ * those by construction (they fail INJECTED_ROW_RE / FYI_LINE_ID_RE / UPS_ID_RE). The
54
+ * CITED side did not, so `E#501` was read as observation 501. On this machine 26/26 live
55
+ * observation ids are also event ids AND prompt ids, so the collision needs exactly one
56
+ * co-occurrence to land.
57
+ *
58
+ * It does NOT catch every false positive and is not meant to: `issue #1234` and
59
+ * `[link](#42)` are still matched, because a digit preceded by a space or `(` is
60
+ * indistinguishable from a citation at this layer.
49
61
  */
50
62
  export function citationIdRe() {
51
- return new RegExp(`#(${OBS_ID_DIGITS})\\b`, 'g');
63
+ return new RegExp(`(?<![A-Za-z0-9])#(${OBS_ID_DIGITS})\\b`, 'g');
52
64
  }
53
65
 
54
66
  /**
@@ -58,7 +70,16 @@ export function citationIdRe() {
58
70
  * `citationIdRe()` above is a NUMERATOR caliber. On the numerator side a spurious `#1`
59
71
  * costs nothing, because a cited id only counts once it intersects an injected set that
60
72
  * WAS anchored — every injected-side extractor in this module matches a row shape
61
- * (`INJECTED_ROW_RE`, `FYI_LINE_ID_RE`, `UPS_ID_RE`, `SUBAGENT_INJECT_ID_RE`). On the
73
+ * (`INJECTED_ROW_RE`, `FYI_LINE_ID_RE`, `UPS_ID_RE`, `SUBAGENT_INJECT_ID_RE`).
74
+ *
75
+ * That argument has ONE measured exception, and it is why citationIdRe now carries a
76
+ * lookbehind (R11-B-P2-3): `extractUserTypedIds` feeds the access allow-list through the
77
+ * SAME numerator regex, and nothing anchors a user's own message. So a user typing `D#9`
78
+ * and the assistant writing `D#9` intersect on an unanchored token — 8 of 43 credited
79
+ * (session, id) pairs on the real corpus were sourced only that way, 4 of them landing on
80
+ * live observations. Read the argument as "anchored on the injected side", not "safe".
81
+ *
82
+ * On the
62
83
  * denominator side nothing anchors it, so a prose `#1` is a false positive by
63
84
  * construction — it inflates "injected, never cited" and biases the measured rate DOWN.
64
85
  *
@@ -408,7 +429,13 @@ export function assertRelevanceCoversAllFaces(sourced) {
408
429
  * with ids the user typed themselves. Required.
409
430
  * @returns {number} count of rows incremented
410
431
  */
411
- export function bumpCitationAccess(db, ids, project, relevantIds, env = process.env) {
432
+ export function bumpCitationAccess(
433
+ db,
434
+ ids,
435
+ project,
436
+ relevantIds,
437
+ { env = process.env, sessionId = null } = {},
438
+ ) {
412
439
  if (!db || !ids || !project) return 0;
413
440
  // Revert path for the gate (CLAUDE_MEM_CITATION_RELEVANCE_GATE=off). It restores the
414
441
  // pre-v3.84.0 behaviour — every mention credited — including the missing-argument hole,
@@ -428,7 +455,35 @@ export function bumpCitationAccess(db, ids, project, relevantIds, env = process.
428
455
  // crediting a tombstone instead of the row that absorbed it) with no upside to restore.
429
456
  const idList = gateOff ? [...cited] : [...cited].filter((id) => allowed.has(id));
430
457
  if (idList.length === 0) return 0;
431
- const stmt = db.prepare(`
458
+ // R11-B-P1-1: credit once per CC SESSION, not once per call. `Stop` fires on every
459
+ // assistant turn and rescans the entire transcript, so without this key one citation
460
+ // was re-credited on every later turn of the same session — measured 7.86x over the
461
+ // real corpus (338 credits / 43 distinct (session, id) pairs), which then crosses
462
+ // boostAccessed's `access_count > 3` and lifts importance unattended.
463
+ //
464
+ // The key is the same shape the decay channel uses (last_cited_session_id /
465
+ // last_decided_session_id) and deliberately a SEPARATE column: decay resolves a
466
+ // mainOnly id set behind hasMainThreadAssistantText, this channel resolves the whole
467
+ // transcript, so sharing one key would let either channel silence the other.
468
+ //
469
+ // sessionId null → no session scope exists to be idempotent within (non-CC
470
+ // invocation), so the pre-R11 unconditional bump is the only defined behaviour.
471
+ //
472
+ // Scope the guarantee honestly: the column holds the LAST crediting session, not a set,
473
+ // so two same-project CC sessions interleaving their turns flip the stamp between them
474
+ // (A credits → B credits → A's next Stop sees B's stamp and credits again) and the key
475
+ // degrades toward per-turn counting for that pair. That is the same bound the decay
476
+ // channel accepts at :1634 and lib/edge-attribution.mjs:145, and widening it would mean
477
+ // storing a set per row. "Once per session" is exact for the ordinary single-session
478
+ // case and an upper bound of "once per session PAIR interleave" otherwise.
479
+ const scoped = typeof sessionId === 'string' && sessionId.length > 0;
480
+ const stmt = scoped
481
+ ? db.prepare(`
482
+ UPDATE observations SET access_count = access_count + 1, last_accessed_at = ?,
483
+ last_access_session_id = ?
484
+ WHERE id = ? AND project = ? AND COALESCE(last_access_session_id, '') != ?
485
+ `)
486
+ : db.prepare(`
432
487
  UPDATE observations SET access_count = access_count + 1, last_accessed_at = ?
433
488
  WHERE id = ? AND project = ?
434
489
  `);
@@ -436,7 +491,10 @@ export function bumpCitationAccess(db, ids, project, relevantIds, env = process.
436
491
  let n = 0;
437
492
  for (const id of idList) {
438
493
  try {
439
- const result = stmt.run(now, id, project);
494
+ // 0 changes is a SKIP, not a failure: either the row is not this project's, or
495
+ // this session already credited it. Both must leave `n` alone, because the caller
496
+ // logs it as "obs bumped".
497
+ const result = scoped ? stmt.run(now, sessionId, id, project, sessionId) : stmt.run(now, id, project);
440
498
  if (result.changes > 0) n++;
441
499
  } catch (e) {
442
500
  debugCatch(e, `bumpCitationAccess-id-${id}`);
@@ -1427,6 +1485,19 @@ export function redirectSupersededIds(db, project, ids) {
1427
1485
  * rows are currently boost-eligible; 25 of them are cited nowhere in the corpus, and
1428
1486
  * at most 3 could have crossed `access_count > 3` on citations even under an upper
1429
1487
  * bound that ignores the gate entirely. So the path is real and its effect is small.
1488
+ *
1489
+ * THAT BOUND IS RETRACTED (R11-B-P1-1, 2026-09-07). It was computed as "one citation
1490
+ * contributes at most 1", and that premise was false: `Stop` fires once per assistant
1491
+ * TURN and rescans the whole transcript, so before the `last_access_session_id` key one
1492
+ * citation contributed once per REMAINING TURN of its session — 338 credits over 43
1493
+ * distinct (session, id) pairs = 7.86x across 51 real transcripts, single-session worst
1494
+ * case 18.75x. The "at most 3" therefore understated its own quantity by an unmeasured
1495
+ * factor, and no replacement bound is stated here because none has been measured.
1496
+ *
1497
+ * The premise now holds going FORWARD only. Existing rows keep whatever inflated
1498
+ * `access_count` they accumulated — the true count is not recoverable from the inflated
1499
+ * one, so nothing is back-corrected. Any future reading of `access_count` as "how often
1500
+ * this was cited" must treat pre-v5.6.0 values as an upper bound, not a count.
1430
1501
  * It is untouched here because `access_count` has other writers (explicit recall /
1431
1502
  * get / timeline) and is also an input to noisePenaltyClause, so changing it is a
1432
1503
  * different decision with a different blast radius.
@@ -243,6 +243,52 @@ export function nextCiteLowStreak(priorStreak, stats, env = process.env) {
243
243
  return ratioGateFires(stats, env) ? prior + 1 : 0;
244
244
  }
245
245
 
246
+ /**
247
+ * R11-B-P1-2 — advance the self-silence streak by SESSION, which is the unit the
248
+ * docblock above has always claimed and the code never implemented.
249
+ *
250
+ * `Stop` fires once per assistant TURN, and the writer incremented on every fire, so
251
+ * with a median of ~6 turns per session the default silence-after-3 was reached inside
252
+ * the FIRST session and never recovered. Measured on this machine before the fix:
253
+ * `runtime/cite-recall-dev--claude-mem-lite.json` read lowStreak 58 for a project with
254
+ * 26 transcripts on disk — a per-session counter cannot exceed the session count — and
255
+ * 2 of 3 projects had the cite-#NN nudge permanently silenced.
256
+ *
257
+ * Recomputing from the value the session STARTED at (rather than freezing after the
258
+ * first fire) keeps two properties: a session advances the streak by at most 1, and a
259
+ * citation arriving in a later turn still resets it to 0 — which matters because the
260
+ * whole contract is "cite NEXT time you produce user-visible text".
261
+ *
262
+ * @param {object|null} prev parsed previous payload, or null when absent/unreadable
263
+ * @param {string|null} ccSessionId current CC session id
264
+ * @returns {{lowStreak: number, streakBase: number, lastStreakSession: string|null}}
265
+ */
266
+ export function nextCiteStreakState(prev, ccSessionId, stats, env = process.env) {
267
+ let streakBase = 0;
268
+ if (prev && typeof prev === 'object') {
269
+ if (prev.lastStreakSession === undefined) {
270
+ // Written under the pre-R11 TURN semantics. The stored number counted Stop fires,
271
+ // so it is not convertible to sessions — discard it once, here, rather than
272
+ // leaving every existing install permanently silenced by a number that never
273
+ // meant what it was read as.
274
+ streakBase = 0;
275
+ } else if (ccSessionId && prev.lastStreakSession === ccSessionId) {
276
+ streakBase = Number(prev.streakBase) || 0;
277
+ } else {
278
+ streakBase = Number(prev.lowStreak) || 0;
279
+ }
280
+ }
281
+ return {
282
+ lowStreak: nextCiteLowStreak(streakBase, stats, env),
283
+ streakBase,
284
+ // null when there is no CC session to key on: the same-session branch above then
285
+ // never matches, so those invocations keep the old per-fire behaviour. There is no
286
+ // session identity available to do better, and the field's presence is what tells a
287
+ // later read that this file is post-R11.
288
+ lastStreakSession: ccSessionId || null,
289
+ };
290
+ }
291
+
246
292
  // Env opt-outs:
247
293
  // • CLAUDE_MEM_NO_CITE_NUDGE=1 — disables BOTH gates (full silence)
248
294
  // • CLAUDE_MEM_CITE_NUDGE_THRESHOLD — ratio gate threshold (default 0.6)
@@ -13,8 +13,10 @@
13
13
  // Full round-trippable set: content + value-signals (access/cited/uncited/injection/decay)
14
14
  // + branch + timing. `id` + `memory_session_id` are informational (restore remaps id and
15
15
  // buckets under a synthetic restore session). Session-idempotency keys (last_decided/
16
- // last_cited_session_id, demoted_at, optimized_at) are intentionally NOT exported — they are
17
- // meaningless after a row is re-bucketed under a restore session. Also intentionally NOT
16
+ // last_cited/last_access_session_id, demoted_at, optimized_at) are intentionally NOT
17
+ // exported — they are meaningless after a row is re-bucketed under a restore session.
18
+ // last_access_session_id (v48) joins that set for the same reason: it answers "did THIS cc
19
+ // session already credit this row", which no restored session can have done. Also intentionally NOT
18
20
  // exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
19
21
  // and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
20
22
  // it would freeze a stale value into backups).
@@ -261,7 +261,7 @@ export function searchPromptsFts(
261
261
  ${project ? 'AND s.project = ?' : ''}
262
262
  ${epochFrom !== null ? 'AND p.created_at_epoch >= ?' : ''}
263
263
  ${epochTo !== null ? 'AND p.created_at_epoch <= ?' : ''}
264
- ORDER BY p.created_at_epoch DESC
264
+ ORDER BY p.created_at_epoch DESC, p.id DESC
265
265
  LIMIT ? OFFSET ?
266
266
  `,
267
267
  )
@@ -977,13 +977,22 @@ export async function coreRunSearchPipeline(ctx, opts) {
977
977
  typeWheres.push('COALESCE(importance, 1) >= ?');
978
978
  typeParams.push(importance);
979
979
  }
980
+ // R11-A-P1-1: `branch` was the one caller-supplied filter this rebuilt WHERE did not
981
+ // carry, and nothing downstream compensates — applyTierFilter only reads tier, and
982
+ // finalizeSearchPage only counts. The leak surfaced exactly when FTS matched nothing,
983
+ // so the wrong-branch rows were the ONLY rows the caller saw, with `total` clamped to
984
+ // them so the page read like a legitimate hit.
985
+ if (branch) {
986
+ typeWheres.push('branch = ?');
987
+ typeParams.push(branch);
988
+ }
980
989
  typeParams.push(limit);
981
990
  const typeRows = db
982
991
  .prepare(
983
992
  `
984
993
  SELECT id, type, title, subtitle, project, created_at, importance, files_modified
985
994
  FROM observations WHERE ${typeWheres.join(' AND ')}
986
- ORDER BY created_at_epoch DESC LIMIT ?
995
+ ORDER BY created_at_epoch DESC, id DESC LIMIT ?
987
996
  `,
988
997
  )
989
998
  .all(...typeParams);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "5.5.0",
9
+ "version": "5.6.0",
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": "5.5.0",
3
+ "version": "5.6.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",
package/schema.mjs CHANGED
@@ -156,7 +156,18 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
156
156
  // every existing install at v46 a new index there would simply never be created. Same trap
157
157
  // the FTS5 migration hit — a DDL change that is not reachable from the version the DB
158
158
  // already reports is a no-op with a convincing diff.
159
- export const CURRENT_SCHEMA_VERSION = 47;
159
+ // v48 (R11-B-P1-1): observations.last_access_session_id — the THIRD per-row session key
160
+ // on this table, and it exists for the same reason as the other two. `Stop` fires once
161
+ // per assistant TURN and rescans the whole transcript, so `bumpCitationAccess` re-credited
162
+ // one citation on every later turn of the same session: real-corpus replay over 51
163
+ // transcripts read 338 credits across 43 distinct (session, id) pairs = 7.86x, single
164
+ // session worst case 18.75x. That feeds boostAccessed (access_count > 3 → importance + 1,
165
+ // in DEFAULT_MAINTAIN_OPS, unattended daily) and suppresses noisePenaltyClause, whose
166
+ // predicate reads `injection_count > access_count * 3`. Additive + nullable: legacy rows
167
+ // read NULL and are credited exactly once more, on their next citation, then stamp.
168
+ // The column holds the LAST crediting session, not a set, so two same-project sessions
169
+ // interleaving their turns flip it between them — see the scope note in bumpCitationAccess.
170
+ export const CURRENT_SCHEMA_VERSION = 48;
160
171
 
161
172
  // Sentinel columns for the LATEST migration set(s). The fast-path uses these
162
173
  // to self-heal half-migrated DBs — schema_version bumped but column ALTERs
@@ -177,6 +188,7 @@ export const CURRENT_SCHEMA_VERSION = 47;
177
188
  // pragma_table_info on a missing table returns zero rows (it does not throw), so
178
189
  // naming any column of the new table is a table-presence check.
179
190
  const LATEST_MIGRATION_COLUMNS = [
191
+ { table: 'observations', column: 'last_access_session_id' }, // v48
180
192
  { table: 'observations', column: 'decay_seen_at_first_cite' }, // v46
181
193
  { table: 'citation_surface_log', column: 'surface' }, // v45
182
194
  { table: 'observations', column: 'scope' }, // v44
@@ -397,6 +409,14 @@ const MIGRATIONS = [
397
409
  // destroy the distinction the column exists to record. Legacy rows stay NULL — they
398
410
  // are not evidence of anything and must not be read as first-cite-at-0.
399
411
  'ALTER TABLE observations ADD COLUMN decay_seen_at_first_cite INTEGER DEFAULT NULL',
412
+ // v48 (R11-B-P1-1): the access-channel idempotency key. Sibling of
413
+ // last_decided_session_id (v40, uncited/streak arm) and last_cited_session_id (v41,
414
+ // promote arm) — three channels fire out of one Stop hook, each needs its own key
415
+ // because they resolve different id sets: decay reads mainOnly, access reads the whole
416
+ // transcript including sidechains, and the decay pair is additionally gated on
417
+ // hasMainThreadAssistantText, so a session can credit access while decay never runs.
418
+ // Sharing a key would make one channel silence the other.
419
+ 'ALTER TABLE observations ADD COLUMN last_access_session_id TEXT DEFAULT NULL',
400
420
  ];
401
421
 
402
422
  /**
package/search-engine.mjs CHANGED
@@ -518,7 +518,8 @@ export function findFtsAnchor(
518
518
  AND (? IS NULL OR o.project = ?)
519
519
  AND ${liveObsFilterSql('o')}
520
520
  ORDER BY ${OBS_BM25}
521
- * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: `${halfLifeMs}.0` })}
521
+ * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: `${halfLifeMs}.0` })},
522
+ o.id DESC
522
523
  LIMIT 1
523
524
  `;
524
525
  const stmt = db.prepare(sql);
@@ -579,7 +580,7 @@ export function searchObservationsHybrid(db, ctx) {
579
580
  `
580
581
  SELECT id, type, title, subtitle, project, created_at, created_at_epoch, files_modified, importance, lesson_learned
581
582
  FROM observations ${where}
582
- ORDER BY created_at_epoch DESC
583
+ ORDER BY created_at_epoch DESC, id DESC
583
584
  LIMIT ? OFFSET ?
584
585
  `,
585
586
  )