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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/deep-search.mjs +11 -5
- package/hook-optimize.mjs +61 -11
- package/hook.mjs +27 -6
- package/lib/citation-tracker.mjs +76 -5
- package/lib/cite-back-hint.mjs +46 -0
- package/lib/export-columns.mjs +4 -2
- package/lib/search-core.mjs +11 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schema.mjs +21 -1
- package/search-engine.mjs +3 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
880
|
-
// alone, so a critical (importance=3) but never-accessed observation lost
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1414
|
+
let prevPayload = null;
|
|
1411
1415
|
try {
|
|
1412
|
-
|
|
1416
|
+
prevPayload = JSON.parse(readFileSync(dest, 'utf8'));
|
|
1413
1417
|
} catch {}
|
|
1414
|
-
|
|
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 = {
|
|
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');
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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(
|
|
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`).
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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)
|
package/lib/export-columns.mjs
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
// meaningless after a row is re-bucketed under a restore session.
|
|
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).
|
package/lib/search-core.mjs
CHANGED
|
@@ -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);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
)
|