claude-mem-lite 2.73.2 → 2.75.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/cli.mjs +1 -1
- package/hook.mjs +26 -1
- package/lib/citation-tracker.mjs +143 -1
- package/mem-cli.mjs +96 -1
- package/package.json +1 -1
- package/schema.mjs +48 -3
package/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
|
|
2
|
+
const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'citation-stats', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
|
|
3
3
|
const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
|
|
4
4
|
|
|
5
5
|
const cmd = process.argv[2];
|
package/hook.mjs
CHANGED
|
@@ -45,7 +45,13 @@ import {
|
|
|
45
45
|
} from './hook-shared.mjs';
|
|
46
46
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
|
|
47
47
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
extractCitationsFromTranscript,
|
|
50
|
+
extractInjectedFromPreToolUse,
|
|
51
|
+
bumpCitationAccess,
|
|
52
|
+
computeCiteRecall,
|
|
53
|
+
applyCitationDecay,
|
|
54
|
+
} from './lib/citation-tracker.mjs';
|
|
49
55
|
import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
|
|
50
56
|
import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
|
|
51
57
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
@@ -504,6 +510,12 @@ async function handleStop() {
|
|
|
504
510
|
// P4: scan transcript for `#NN` observation citations in assistant text
|
|
505
511
|
// and bump access_count for matched rows. Closes the loop on the "cite #NN"
|
|
506
512
|
// contract — before P4 this was a one-way obligation with no feedback.
|
|
513
|
+
//
|
|
514
|
+
// CLAUDE_MEM_NO_CITATION_TRACK=1 disables BOTH the P4 access_count bump
|
|
515
|
+
// AND the v32 citation-decay loop nested below — anything that needs the
|
|
516
|
+
// transcript scan lives inside this guard. To disable just the decay
|
|
517
|
+
// loop (keep access_count bumps), use MEM_DISABLE_CITATION_DECAY=1 which
|
|
518
|
+
// applyCitationDecay checks separately.
|
|
507
519
|
try {
|
|
508
520
|
if (transcriptPath && !process.env.CLAUDE_MEM_NO_CITATION_TRACK) {
|
|
509
521
|
const ids = extractCitationsFromTranscript(transcriptPath);
|
|
@@ -512,6 +524,19 @@ async function handleStop() {
|
|
|
512
524
|
debugLog('DEBUG', 'handleStop', `citations: ${ids.size} ids scanned, ${n} obs bumped`);
|
|
513
525
|
}
|
|
514
526
|
|
|
527
|
+
// v32 citation-decay: tighter feedback loop on top of P4. Re-scan
|
|
528
|
+
// transcript with main-thread filter, extract injected IDs from
|
|
529
|
+
// pre-tool-recall attachments only, then mutate importance/streak per
|
|
530
|
+
// applyCitationDecay's contract. Cheap (file still in OS cache).
|
|
531
|
+
try {
|
|
532
|
+
const injected = extractInjectedFromPreToolUse(transcriptPath);
|
|
533
|
+
if (injected.size > 0) {
|
|
534
|
+
const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
535
|
+
const r = applyCitationDecay(db, project, injected, citedMain, sessionId);
|
|
536
|
+
debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
|
|
537
|
+
}
|
|
538
|
+
} catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
|
|
539
|
+
|
|
515
540
|
// Persist cite-recall ratio for the next SessionStart to surface as
|
|
516
541
|
// feedback. We deliberately scan the transcript a second time here
|
|
517
542
|
// (cheap; the file is already in OS cache) rather than threading the
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -22,9 +22,12 @@ const CITATION_RE = /#(\d{1,7})\b/g;
|
|
|
22
22
|
* cited inside assistant text blocks.
|
|
23
23
|
*
|
|
24
24
|
* @param {string} transcriptPath Path to transcript file (.jsonl)
|
|
25
|
+
* @param {object} [opts] Options
|
|
26
|
+
* @param {boolean} [opts.mainOnly=false] If true, skip transcript records where isSidechain === true
|
|
25
27
|
* @returns {Set<number>} unique IDs referenced as `#NN` in assistant text
|
|
26
28
|
*/
|
|
27
|
-
export function extractCitationsFromTranscript(transcriptPath) {
|
|
29
|
+
export function extractCitationsFromTranscript(transcriptPath, opts = {}) {
|
|
30
|
+
const { mainOnly = false } = opts;
|
|
28
31
|
const ids = new Set();
|
|
29
32
|
if (!transcriptPath || !existsSync(transcriptPath)) return ids;
|
|
30
33
|
let raw;
|
|
@@ -35,6 +38,11 @@ export function extractCitationsFromTranscript(transcriptPath) {
|
|
|
35
38
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
36
39
|
// Claude Code transcript: one JSON per line with type='assistant' | 'user' | ...
|
|
37
40
|
if (entry.type !== 'assistant' || !entry.message) continue;
|
|
41
|
+
// Citation-decay loop scopes citation signal to main-thread text only —
|
|
42
|
+
// subagent dispatches run their own session context the parent can't
|
|
43
|
+
// reasonably be held accountable for. Default off preserves the broader
|
|
44
|
+
// access_count-bump semantics of existing callers (P4 bumpCitationAccess).
|
|
45
|
+
if (mainOnly && entry.isSidechain === true) continue;
|
|
38
46
|
const content = entry.message.content;
|
|
39
47
|
if (!Array.isArray(content)) continue;
|
|
40
48
|
for (const block of content) {
|
|
@@ -142,3 +150,137 @@ export function bumpCitationAccess(db, ids, project) {
|
|
|
142
150
|
}
|
|
143
151
|
return n;
|
|
144
152
|
}
|
|
153
|
+
|
|
154
|
+
// Matches a pre-tool-recall lesson line: ` #NN [type] body...`. Bounded type
|
|
155
|
+
// list mirrors observations.type CHECK + the events table's allowed event_type
|
|
156
|
+
// values pre-tool-recall.js can surface.
|
|
157
|
+
const INJECTED_RE = /#(\d{1,7})\s+\[(bugfix|decision|change|discovery|feature|refactor|lesson)\]/g;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Extract observation IDs injected by pre-tool-recall hook in this transcript.
|
|
161
|
+
*
|
|
162
|
+
* Tighter than `computeCiteRecall`'s over-inclusive "any #NN in non-assistant
|
|
163
|
+
* text" — only counts IDs the agent actually saw from us, not user-pasted
|
|
164
|
+
* references or unrelated #NN tokens in tool output.
|
|
165
|
+
*
|
|
166
|
+
* @param {string|null|undefined} transcriptPath
|
|
167
|
+
* @returns {Set<number>} unique injected IDs (empty set on missing path/file)
|
|
168
|
+
*/
|
|
169
|
+
export function extractInjectedFromPreToolUse(transcriptPath) {
|
|
170
|
+
const ids = new Set();
|
|
171
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return ids;
|
|
172
|
+
let raw;
|
|
173
|
+
try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return ids; }
|
|
174
|
+
for (const line of raw.split('\n')) {
|
|
175
|
+
if (!line.trim()) continue;
|
|
176
|
+
let entry;
|
|
177
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
178
|
+
if (entry.type !== 'attachment') continue;
|
|
179
|
+
const att = entry.attachment;
|
|
180
|
+
if (!att || att.type !== 'hook_success') continue;
|
|
181
|
+
if (!(att.command || '').includes('pre-tool-recall')) continue;
|
|
182
|
+
const stdout = att.stdout || '';
|
|
183
|
+
if (!stdout) continue;
|
|
184
|
+
// stdout is JSON wrapping additionalContext OR raw text (legacy);
|
|
185
|
+
// try JSON first and fall back to raw.
|
|
186
|
+
let text = stdout;
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(stdout);
|
|
189
|
+
text = parsed?.hookSpecificOutput?.additionalContext || stdout;
|
|
190
|
+
} catch {}
|
|
191
|
+
INJECTED_RE.lastIndex = 0;
|
|
192
|
+
let m;
|
|
193
|
+
while ((m = INJECTED_RE.exec(text))) {
|
|
194
|
+
const id = Number(m[1]);
|
|
195
|
+
if (Number.isInteger(id) && id > 0 && id < 1e7) ids.add(id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return ids;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const IMPORTANCE_CAP = 3;
|
|
202
|
+
const IMPORTANCE_FLOOR = 0;
|
|
203
|
+
const UNCITED_STREAK_THRESHOLD = 3;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Apply the citation-feedback loop for one session: for each injected obs id,
|
|
207
|
+
* decide cited vs uncited and mutate importance/streak/cited_count per spec.
|
|
208
|
+
*
|
|
209
|
+
* - cited: importance += 1 (cap 3), cited_count += 1, streak = 0.
|
|
210
|
+
* - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 0), streak = 0.
|
|
211
|
+
* - per-(session, obs) idempotent via last_decided_session_id; re-running for
|
|
212
|
+
* the same session is a no-op (Stop hook may fire more than once).
|
|
213
|
+
* - cross-project IDs are silently ignored by the WHERE clause.
|
|
214
|
+
* - MEM_DISABLE_CITATION_DECAY=1 disables all writes; returns zeros.
|
|
215
|
+
*
|
|
216
|
+
* @param {import('better-sqlite3').Database} db
|
|
217
|
+
* @param {string} project
|
|
218
|
+
* @param {Set<number>|Iterable<number>} injectedIds
|
|
219
|
+
* @param {Set<number>|Iterable<number>} citedIds
|
|
220
|
+
* @param {string} sessionId — memory_session_id of the session being resolved
|
|
221
|
+
* @returns {{promoted: number, demoted: number, touched: number}}
|
|
222
|
+
*/
|
|
223
|
+
export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId) {
|
|
224
|
+
const empty = { promoted: 0, demoted: 0, touched: 0 };
|
|
225
|
+
if (process.env.MEM_DISABLE_CITATION_DECAY === '1') return empty;
|
|
226
|
+
if (!db || !project || !sessionId) return empty;
|
|
227
|
+
const injected = injectedIds instanceof Set ? injectedIds : new Set(injectedIds || []);
|
|
228
|
+
if (injected.size === 0) return empty;
|
|
229
|
+
const cited = citedIds instanceof Set ? citedIds : new Set(citedIds || []);
|
|
230
|
+
|
|
231
|
+
const selectStmt = db.prepare(
|
|
232
|
+
'SELECT id, importance, uncited_streak, last_decided_session_id FROM observations WHERE id = ? AND project = ?'
|
|
233
|
+
);
|
|
234
|
+
// decay_seen_count (v34) bumps on every resolution branch — gives
|
|
235
|
+
// citation-stats a denominator that's same-source as cited_count, so the
|
|
236
|
+
// ratio actually means "cite-rate" instead of mixing decay + UserPromptSubmit.
|
|
237
|
+
const updatePromote = db.prepare(`
|
|
238
|
+
UPDATE observations
|
|
239
|
+
SET importance = MIN(?, importance + 1),
|
|
240
|
+
cited_count = cited_count + 1,
|
|
241
|
+
uncited_streak = 0,
|
|
242
|
+
last_decided_session_id = ?,
|
|
243
|
+
decay_seen_count = decay_seen_count + 1
|
|
244
|
+
WHERE id = ?
|
|
245
|
+
`);
|
|
246
|
+
const updateStreakOnly = db.prepare(`
|
|
247
|
+
UPDATE observations
|
|
248
|
+
SET uncited_streak = uncited_streak + 1,
|
|
249
|
+
last_decided_session_id = ?,
|
|
250
|
+
decay_seen_count = decay_seen_count + 1
|
|
251
|
+
WHERE id = ?
|
|
252
|
+
`);
|
|
253
|
+
const updateDemote = db.prepare(`
|
|
254
|
+
UPDATE observations
|
|
255
|
+
SET importance = MAX(?, importance - 1),
|
|
256
|
+
uncited_streak = 0,
|
|
257
|
+
last_decided_session_id = ?,
|
|
258
|
+
demoted_at = ?,
|
|
259
|
+
decay_seen_count = decay_seen_count + 1
|
|
260
|
+
WHERE id = ?
|
|
261
|
+
`);
|
|
262
|
+
|
|
263
|
+
let promoted = 0, demoted = 0, touched = 0;
|
|
264
|
+
const txn = db.transaction(() => {
|
|
265
|
+
for (const id of injected) {
|
|
266
|
+
const row = selectStmt.get(id, project);
|
|
267
|
+
if (!row) continue; // cross-project or deleted
|
|
268
|
+
if (row.last_decided_session_id === sessionId) continue; // idempotent skip
|
|
269
|
+
touched++;
|
|
270
|
+
if (cited.has(id)) {
|
|
271
|
+
updatePromote.run(IMPORTANCE_CAP, sessionId, id);
|
|
272
|
+
promoted++;
|
|
273
|
+
} else {
|
|
274
|
+
const nextStreak = (row.uncited_streak || 0) + 1;
|
|
275
|
+
if (nextStreak >= UNCITED_STREAK_THRESHOLD) {
|
|
276
|
+
updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
|
|
277
|
+
demoted++;
|
|
278
|
+
} else {
|
|
279
|
+
updateStreakOnly.run(sessionId, id);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
try { txn(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
|
|
285
|
+
return { promoted, demoted, touched };
|
|
286
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -2358,6 +2358,96 @@ function cmdMemdirAudit(args) {
|
|
|
2358
2358
|
if (nonCompliant > 0) process.exitCode = 1;
|
|
2359
2359
|
}
|
|
2360
2360
|
|
|
2361
|
+
/**
|
|
2362
|
+
* `citation-stats` — visualize the citation-decay feedback loop:
|
|
2363
|
+
* per-project cite rate + active decay queue + recently promoted.
|
|
2364
|
+
* Read-only over observations.
|
|
2365
|
+
*
|
|
2366
|
+
* Flags:
|
|
2367
|
+
* --json machine-readable output
|
|
2368
|
+
* --days N project cite-rate window (default 7)
|
|
2369
|
+
*/
|
|
2370
|
+
function cmdCitationStats(db, args) {
|
|
2371
|
+
const { flags } = parseArgs(args);
|
|
2372
|
+
const json = flags.json === true || flags.json === 'true';
|
|
2373
|
+
const days = parseIntFlag(flags.days, { name: '--days', defaultValue: 7, max: 365 });
|
|
2374
|
+
|
|
2375
|
+
const cutoff = Date.now() - days * 86400 * 1000;
|
|
2376
|
+
const perProject = db.prepare(`
|
|
2377
|
+
SELECT project,
|
|
2378
|
+
COALESCE(SUM(cited_count), 0) AS cited,
|
|
2379
|
+
COALESCE(SUM(decay_seen_count), 0) AS resolved,
|
|
2380
|
+
SUM(CASE WHEN uncited_streak >= 2 THEN 1 ELSE 0 END) AS at_risk
|
|
2381
|
+
FROM observations
|
|
2382
|
+
WHERE created_at_epoch >= ?
|
|
2383
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
2384
|
+
AND superseded_at IS NULL
|
|
2385
|
+
GROUP BY project
|
|
2386
|
+
ORDER BY resolved DESC
|
|
2387
|
+
`).all(cutoff);
|
|
2388
|
+
|
|
2389
|
+
const decayQueue = db.prepare(`
|
|
2390
|
+
SELECT id, project, type, title, importance, uncited_streak, cited_count
|
|
2391
|
+
FROM observations
|
|
2392
|
+
WHERE uncited_streak >= 2
|
|
2393
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
2394
|
+
AND superseded_at IS NULL
|
|
2395
|
+
ORDER BY uncited_streak DESC, importance ASC
|
|
2396
|
+
LIMIT 20
|
|
2397
|
+
`).all();
|
|
2398
|
+
|
|
2399
|
+
const promoted = db.prepare(`
|
|
2400
|
+
SELECT id, project, type, title, importance, cited_count
|
|
2401
|
+
FROM observations
|
|
2402
|
+
WHERE importance >= 3 AND cited_count >= 1
|
|
2403
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
2404
|
+
AND superseded_at IS NULL
|
|
2405
|
+
ORDER BY cited_count DESC
|
|
2406
|
+
LIMIT 10
|
|
2407
|
+
`).all();
|
|
2408
|
+
|
|
2409
|
+
const demoted = db.prepare(`
|
|
2410
|
+
SELECT id, project, type, title, importance, demoted_at
|
|
2411
|
+
FROM observations
|
|
2412
|
+
WHERE demoted_at IS NOT NULL
|
|
2413
|
+
AND demoted_at >= ?
|
|
2414
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
2415
|
+
AND superseded_at IS NULL
|
|
2416
|
+
ORDER BY demoted_at DESC
|
|
2417
|
+
LIMIT 10
|
|
2418
|
+
`).all(cutoff);
|
|
2419
|
+
|
|
2420
|
+
if (json) {
|
|
2421
|
+
out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted }, null, 2));
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
out(`Cite rate by project (last ${days}d, cited / decay-resolutions):`);
|
|
2426
|
+
for (const r of perProject) {
|
|
2427
|
+
const rate = r.resolved > 0 ? (r.cited * 100 / r.resolved).toFixed(1) + '%' : '—';
|
|
2428
|
+
out(` ${r.project.padEnd(34)} ${String(rate).padStart(6)} cited:${r.cited}/${r.resolved} at_risk:${r.at_risk}`);
|
|
2429
|
+
}
|
|
2430
|
+
out('');
|
|
2431
|
+
out('Active decay queue (uncited_streak >= 2, next miss → demote):');
|
|
2432
|
+
if (decayQueue.length === 0) out(' (none)');
|
|
2433
|
+
for (const r of decayQueue) {
|
|
2434
|
+
out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} streak=${r.uncited_streak}`);
|
|
2435
|
+
}
|
|
2436
|
+
out('');
|
|
2437
|
+
out('Recently promoted (importance=3, cited_count >= 1):');
|
|
2438
|
+
if (promoted.length === 0) out(' (none)');
|
|
2439
|
+
for (const r of promoted) {
|
|
2440
|
+
out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} cited ${r.cited_count}x`);
|
|
2441
|
+
}
|
|
2442
|
+
out('');
|
|
2443
|
+
out(`Recently demoted (last ${days}d, importance ↓):`);
|
|
2444
|
+
if (demoted.length === 0) out(' (none)');
|
|
2445
|
+
for (const r of demoted) {
|
|
2446
|
+
const ago = Math.round((Date.now() - r.demoted_at) / 86400000);
|
|
2447
|
+
out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} ${ago}d ago`);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2361
2451
|
// ─── Help ────────────────────────────────────────────────────────────────────
|
|
2362
2452
|
|
|
2363
2453
|
function cmdHelp() {
|
|
@@ -2498,6 +2588,10 @@ Commands:
|
|
|
2498
2588
|
totals:{working,active,archive,grand_total},
|
|
2499
2589
|
tiers:{working:{count,results:[…]}, …}}
|
|
2500
2590
|
|
|
2591
|
+
citation-stats Citation-decay feedback loop telemetry
|
|
2592
|
+
--days N Cite-rate window in days (default 7)
|
|
2593
|
+
--json Output as JSON: {window_days,per_project:[],decay_queue:[],promoted:[]}
|
|
2594
|
+
|
|
2501
2595
|
registry <action> Manage tool resource registry
|
|
2502
2596
|
list List resources [--type skill|agent] [--limit N] (default 20)
|
|
2503
2597
|
stats Registry statistics
|
|
@@ -2867,7 +2961,7 @@ export async function run(argv) {
|
|
|
2867
2961
|
// text-parsing callers keep working — the note lives in stderr for scripts to
|
|
2868
2962
|
// detect the gap.
|
|
2869
2963
|
const JSON_SUPPORTED_CMDS = new Set([
|
|
2870
|
-
'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export',
|
|
2964
|
+
'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export', 'citation-stats',
|
|
2871
2965
|
]);
|
|
2872
2966
|
// `doctor --benchmark` already emits JSON on its own — don't print the misleading
|
|
2873
2967
|
// "doctor outputs text" note for that subpath. Without --benchmark, doctor is text
|
|
@@ -2896,6 +2990,7 @@ export async function run(argv) {
|
|
|
2896
2990
|
case 'stats': await cmdStats(db, cmdArgs); break;
|
|
2897
2991
|
case 'context': cmdContext(db, cmdArgs); break;
|
|
2898
2992
|
case 'browse': cmdBrowse(db, cmdArgs); break;
|
|
2993
|
+
case 'citation-stats': cmdCitationStats(db, cmdArgs); break;
|
|
2899
2994
|
case 'registry': cmdRegistry(db, cmdArgs); break;
|
|
2900
2995
|
case 'import': await cmdImport(cmdArgs); break;
|
|
2901
2996
|
case 'import-jsonl': await cmdImportJsonl(db, cmdArgs); break;
|
package/package.json
CHANGED
package/schema.mjs
CHANGED
|
@@ -45,7 +45,28 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
|
45
45
|
// items rank HIGHER as tech debt accumulates), different lifecycle (mutable
|
|
46
46
|
// status open→done|dropped vs immutable obs). Closure tied to obs via
|
|
47
47
|
// closed_by_obs_id FK with ON DELETE SET NULL (audit trail preserved).
|
|
48
|
-
|
|
48
|
+
// v32 (v2.73.2): citation-decay columns on observations — uncited_streak,
|
|
49
|
+
// cited_count, last_decided_session_id. Stop hook resolves injected obs as
|
|
50
|
+
// cited|uncited; 3 consecutive uncited → importance -1 (floor 0); 1 cited → +1
|
|
51
|
+
// (cap 3). last_decided_session_id makes Stop idempotent across multi-fire.
|
|
52
|
+
export const CURRENT_SCHEMA_VERSION = 34;
|
|
53
|
+
|
|
54
|
+
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
55
|
+
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
56
|
+
// back (observed once in dev during v2.74.0). Update both the column AND
|
|
57
|
+
// (if needed) the table when adding a new migration batch.
|
|
58
|
+
const LATEST_MIGRATION_COLUMN = { table: 'observations', column: 'decay_seen_count' };
|
|
59
|
+
|
|
60
|
+
function hasLatestMigrationColumn(db) {
|
|
61
|
+
try {
|
|
62
|
+
const row = db.prepare(
|
|
63
|
+
`SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`
|
|
64
|
+
).get(LATEST_MIGRATION_COLUMN.table, LATEST_MIGRATION_COLUMN.column);
|
|
65
|
+
return Boolean(row);
|
|
66
|
+
} catch {
|
|
67
|
+
return false; // table itself missing → caller falls through to CORE_SCHEMA
|
|
68
|
+
}
|
|
69
|
+
}
|
|
49
70
|
|
|
50
71
|
const CORE_SCHEMA = `
|
|
51
72
|
CREATE TABLE IF NOT EXISTS sdk_sessions (
|
|
@@ -151,6 +172,27 @@ const MIGRATIONS = [
|
|
|
151
172
|
// injection_count + low access_count = low-signal, deprioritize.
|
|
152
173
|
'ALTER TABLE observations ADD COLUMN injection_count INTEGER NOT NULL DEFAULT 0',
|
|
153
174
|
'ALTER TABLE observations ADD COLUMN last_injected_at INTEGER DEFAULT NULL',
|
|
175
|
+
// v32 (citation-decay): per-obs feedback loop for pre-tool-recall injection
|
|
176
|
+
// pool. Stop hook resolves each session's injected IDs as cited|uncited.
|
|
177
|
+
// 3 consecutive uncited sessions → importance -1 (floor 0). 1 cited session →
|
|
178
|
+
// importance +1 (cap 3). last_decided_session_id makes Stop idempotent across
|
|
179
|
+
// multi-fire scenarios (Claude may fire Stop more than once per session).
|
|
180
|
+
'ALTER TABLE observations ADD COLUMN uncited_streak INTEGER NOT NULL DEFAULT 0',
|
|
181
|
+
'ALTER TABLE observations ADD COLUMN cited_count INTEGER NOT NULL DEFAULT 0',
|
|
182
|
+
'ALTER TABLE observations ADD COLUMN last_decided_session_id TEXT DEFAULT NULL',
|
|
183
|
+
// v33 (citation-decay telemetry): timestamp of the most recent demote event.
|
|
184
|
+
// Powers `claude-mem-lite citation-stats`'s "Recently demoted" section.
|
|
185
|
+
// Set in applyCitationDecay's demote branch when streak hits threshold.
|
|
186
|
+
// Single-shot (only the latest demote is preserved); use a decay_log table
|
|
187
|
+
// if historical trend is ever needed.
|
|
188
|
+
'ALTER TABLE observations ADD COLUMN demoted_at INTEGER DEFAULT NULL',
|
|
189
|
+
// v34 (citation-decay denominator fix): per-obs counter of decay-loop
|
|
190
|
+
// resolutions (cited + uncited paths). Used by citation-stats as the
|
|
191
|
+
// denominator for "cite rate" — bumped only by applyCitationDecay, so it
|
|
192
|
+
// doesn't get polluted by UserPromptSubmit / hook-memory injections that
|
|
193
|
+
// share the unrelated injection_count column. Same-source numerator
|
|
194
|
+
// (cited_count) + same-source denominator = meaningful ratio.
|
|
195
|
+
'ALTER TABLE observations ADD COLUMN decay_seen_count INTEGER NOT NULL DEFAULT 0',
|
|
154
196
|
];
|
|
155
197
|
|
|
156
198
|
/**
|
|
@@ -167,7 +209,10 @@ export function initSchema(db) {
|
|
|
167
209
|
try {
|
|
168
210
|
const row = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
|
|
169
211
|
if (row && typeof row.version === 'number') {
|
|
170
|
-
|
|
212
|
+
// Self-heal: version-row says CURRENT but latest migration column may be
|
|
213
|
+
// absent (rolled back / never applied). Fall through to migration apply
|
|
214
|
+
// when the sentinel is missing — duplicates are caught in the loop.
|
|
215
|
+
if (row.version === CURRENT_SCHEMA_VERSION && hasLatestMigrationColumn(db)) return db;
|
|
171
216
|
if (row.version > CURRENT_SCHEMA_VERSION) {
|
|
172
217
|
throw new Error(
|
|
173
218
|
`DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
|
|
@@ -190,7 +235,7 @@ export function initSchema(db) {
|
|
|
190
235
|
db.exec('BEGIN IMMEDIATE');
|
|
191
236
|
try {
|
|
192
237
|
const underlock = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
|
|
193
|
-
if (underlock && underlock.version === CURRENT_SCHEMA_VERSION) {
|
|
238
|
+
if (underlock && underlock.version === CURRENT_SCHEMA_VERSION && hasLatestMigrationColumn(db)) {
|
|
194
239
|
db.exec('COMMIT');
|
|
195
240
|
return db;
|
|
196
241
|
}
|