claude-mem-lite 3.66.2 → 3.68.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/haiku-client.mjs +75 -0
- package/hook-optimize.mjs +6 -6
- package/hook-semaphore.mjs +74 -13
- package/hook.mjs +38 -8
- package/lib/citation-tracker.mjs +329 -113
- package/mem-cli.mjs +37 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/registry-enricher.mjs +5 -2
- package/rerank.mjs +7 -4
- package/schema.mjs +69 -5
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.68.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.68.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/haiku-client.mjs
CHANGED
|
@@ -286,6 +286,29 @@ export async function callHaikuJSON(prompt, opts) {
|
|
|
286
286
|
return parseJsonFromLLM(result.text);
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Non-blocking sibling of callHaikuJSON for callers reachable from an MCP request
|
|
291
|
+
* handler (registry enrichment: mem_registry `enrich` / `import_url`). Same
|
|
292
|
+
* provider priority; the CLI leg — primary AND post-provider-failure fallback —
|
|
293
|
+
* is the async spawn, so a keyed-provider outage cannot freeze the server event
|
|
294
|
+
* loop for BG_LLM_TIMEOUT_MS (D#138 MEDIUM-3).
|
|
295
|
+
*
|
|
296
|
+
* `resolveModel().cli`, NOT the literal 'haiku': despite the name, callHaikuJSON
|
|
297
|
+
* reaches the model through resolveModel() on ALL three legs (callHaikuAPI,
|
|
298
|
+
* callOpenRouterAPI, callHaikuCLI), so it honours the documented CLAUDE_MEM_MODEL
|
|
299
|
+
* knob. Pinning 'haiku' here would silently downgrade registry enrichment for
|
|
300
|
+
* every user who set CLAUDE_MEM_MODEL=sonnet — pre-tag review finding, v3.68.0.
|
|
301
|
+
*
|
|
302
|
+
* Defaults also mirror callHaiku (10s / 500 tokens), not callModelJSONAsync's
|
|
303
|
+
* 15s / 1000: a caller that omits opts must get the sync twin's budget.
|
|
304
|
+
* @param {string|{system?:string,user:string}} prompt
|
|
305
|
+
* @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
|
|
306
|
+
* @returns {Promise<object|null>} Parsed JSON or null
|
|
307
|
+
*/
|
|
308
|
+
export async function callHaikuJSONAsync(prompt, { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
|
|
309
|
+
return callModelJSONAsync(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
|
|
310
|
+
}
|
|
311
|
+
|
|
289
312
|
// ─── Model-Selectable API ────────────────────────────────────────────────────
|
|
290
313
|
|
|
291
314
|
/**
|
|
@@ -328,6 +351,44 @@ export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 1500
|
|
|
328
351
|
catch (e) { debugCatch(e, `callLLMWithModel:cli-fallback:${resolvedModel}`); return null; }
|
|
329
352
|
}
|
|
330
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Non-blocking sibling of callLLMWithModel — returns the RAW {text} envelope
|
|
356
|
+
* without JSON-parsing it. For MCP-reachable callers whose answer is not
|
|
357
|
+
* guaranteed to be an object: rerank accepts a bare `[2,1,3]` array, which a
|
|
358
|
+
* JSON-parsing dispatcher would keep but whose contract (rerank.mjs:72) is the
|
|
359
|
+
* envelope, not the parse. Both CLI legs use the async spawn, so a keyed-provider
|
|
360
|
+
* outage cannot freeze the server event loop (D#138 MEDIUM-3).
|
|
361
|
+
*
|
|
362
|
+
* Behaviourally identical to callLLMWithModel otherwise — same `if (primary)`
|
|
363
|
+
* test, same headless-flag compat retry and budget arithmetic, same timeout
|
|
364
|
+
* salvage. Only the CLI transport differs.
|
|
365
|
+
* @param {string|{system?:string,user:string}} prompt
|
|
366
|
+
* @param {'haiku'|'sonnet'} model
|
|
367
|
+
* @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
|
|
368
|
+
* @returns {Promise<{text: string}|null>} Response or null on failure
|
|
369
|
+
*/
|
|
370
|
+
export async function callLLMWithModelAsync(prompt, model = 'haiku', { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {}) {
|
|
371
|
+
if (!prompt) return null;
|
|
372
|
+
const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
|
|
373
|
+
const mode = detectMode();
|
|
374
|
+
|
|
375
|
+
// CLI is terminal — no provider to fall back to.
|
|
376
|
+
if (mode === 'cli') return callModelCLIAsync(prompt, resolvedModel, { timeout });
|
|
377
|
+
|
|
378
|
+
let primary = null;
|
|
379
|
+
try {
|
|
380
|
+
primary = mode === 'api'
|
|
381
|
+
? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
|
|
382
|
+
: await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
|
|
383
|
+
} catch (e) {
|
|
384
|
+
debugCatch(e, `callLLMWithModelAsync:${mode}:${resolvedModel}`);
|
|
385
|
+
}
|
|
386
|
+
if (primary) return primary;
|
|
387
|
+
|
|
388
|
+
debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to async claude CLI (${resolvedModel})`);
|
|
389
|
+
return callModelCLIAsync(prompt, resolvedModel, { timeout });
|
|
390
|
+
}
|
|
391
|
+
|
|
331
392
|
/**
|
|
332
393
|
* Call LLM with model selection and parse JSON response.
|
|
333
394
|
* @param {string} prompt
|
|
@@ -628,6 +689,20 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
|
|
|
628
689
|
child.on('error', (e) => { debugCatch(e, `${model}-cli-async`); done({ result: null, stderr: '', stdout: '', code: null }); });
|
|
629
690
|
child.on('close', (code) => {
|
|
630
691
|
const t = stdout.trim();
|
|
692
|
+
// Parity with callModelCLI: execFileSync THROWS on a non-zero exit, so the
|
|
693
|
+
// sync leg only ever returns such output when parseJsonFromLLM accepts it
|
|
694
|
+
// (its catch-salvage). Without the same gate, a CLI that prints a
|
|
695
|
+
// diagnostic to stdout and dies — auth failure, overload banner, wrapper
|
|
696
|
+
// error — has that diagnostic returned as the model's ANSWER. rerank is the
|
|
697
|
+
// first caller to consume the raw {text}: extractRanked's last resort
|
|
698
|
+
// matches any bracketed number list in prose, so a `[1]` inside a stack
|
|
699
|
+
// frame becomes a ranking and silently reorders search results. The
|
|
700
|
+
// flag-compat probe below reads stderr/stdout/code directly, not `result`,
|
|
701
|
+
// so nulling here does not cost it its retry.
|
|
702
|
+
if (t && typeof code === 'number' && code !== 0 && parseJsonFromLLM(t) === null) {
|
|
703
|
+
done({ result: null, stderr, stdout, code });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
631
706
|
done({ result: t ? { text: t } : null, stderr, stdout, code });
|
|
632
707
|
});
|
|
633
708
|
// EPIPE guard: the child may exit before we finish writing stdin.
|
package/hook-optimize.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity, clampImportance, cjkBigrams,
|
|
14
14
|
notLowSignalTitleClause, scrubSecrets,
|
|
15
15
|
} from './utils.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { callModelJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
17
17
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
18
18
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
19
19
|
import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
|
|
@@ -175,7 +175,7 @@ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
|
|
|
175
175
|
|
|
176
176
|
JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
|
|
177
177
|
Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
|
|
178
|
-
const parsed = await
|
|
178
|
+
const parsed = await callModelJSONAsync(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
|
|
179
179
|
const aliasArr = parsed && Array.isArray(parsed.search_aliases)
|
|
180
180
|
? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
|
|
181
181
|
: [];
|
|
@@ -204,7 +204,7 @@ importance: 0=no value, 1=routine, 2=notable non-obvious insight, 3=critical. De
|
|
|
204
204
|
lesson_learned: State what was learned. If routine, write "none".
|
|
205
205
|
search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
206
206
|
|
|
207
|
-
const parsed = await
|
|
207
|
+
const parsed = await callModelJSONAsync(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
|
|
208
208
|
if (!parsed || !parsed.title) { skipped++; continue; }
|
|
209
209
|
|
|
210
210
|
// Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
|
|
@@ -356,7 +356,7 @@ Rules:
|
|
|
356
356
|
- Include CJK ↔ English equivalents if present
|
|
357
357
|
- Skip terms that have no synonyms in the list`;
|
|
358
358
|
|
|
359
|
-
const parsed = await
|
|
359
|
+
const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
360
360
|
if (!parsed?.groups || !Array.isArray(parsed.groups)) return [];
|
|
361
361
|
return parsed.groups.filter(g => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
|
|
362
362
|
} catch (e) {
|
|
@@ -524,7 +524,7 @@ Return ONLY valid JSON:
|
|
|
524
524
|
- If they should NOT be merged: {"should_merge":false}
|
|
525
525
|
- If they SHOULD be merged: {"should_merge":true,"merged_title":"≤120 char comprehensive title","merged_narrative":"comprehensive ≤800 char summary preserving all key details","merged_concepts":["kw1","kw2"],"merged_facts":["specific fact 1"],"merged_lesson":"synthesized non-obvious lesson or null","importance":2}`;
|
|
526
526
|
|
|
527
|
-
const parsed = await
|
|
527
|
+
const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
528
528
|
if (!parsed || !parsed.should_merge) return { merged: false };
|
|
529
529
|
|
|
530
530
|
// Keeper = highest importance, then highest access_count. Previously access_count
|
|
@@ -758,7 +758,7 @@ ${obsDescriptions}
|
|
|
758
758
|
|
|
759
759
|
JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
|
|
760
760
|
|
|
761
|
-
const parsed = await
|
|
761
|
+
const parsed = await callModelJSONAsync(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
762
762
|
if (!parsed || !parsed.title) return { compressed: false };
|
|
763
763
|
|
|
764
764
|
// Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
|
package/hook-semaphore.mjs
CHANGED
|
@@ -4,15 +4,62 @@
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { readFileSync, unlinkSync, readdirSync, openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
|
|
6
6
|
import { RUNTIME_DIR } from './hook-shared.mjs';
|
|
7
|
+
import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
7
8
|
|
|
8
9
|
export const LLM_SEM_MAX = 2;
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
// D#134 MEDIUM-2 — both budgets are DERIVED from the longest a slot can
|
|
12
|
+
// legitimately be held, not hand-set. They used to be the literals 30000 and
|
|
13
|
+
// 60000, sized for the ~15-20s LLM calls of the time; v3.66.0 raised the
|
|
14
|
+
// background call budget to 45s and neither literal followed, leaving two
|
|
15
|
+
// silent failures:
|
|
16
|
+
//
|
|
17
|
+
// • wait budget < hold: with both slots busy the third worker gave up after
|
|
18
|
+
// 30s while a holder was still legitimately working, and its caller fell
|
|
19
|
+
// through to degraded storage — the observation is SAVED but never
|
|
20
|
+
// enriched. Nothing errors; the row just quietly lacks aliases/lesson.
|
|
21
|
+
// • stale threshold barely above hold: a 45s holder had 15s of margin, so a
|
|
22
|
+
// slow SIGTERM, GC pause, or loaded machine let a PEER delete the live
|
|
23
|
+
// holder's file. That drops it out of `active`, and the peer then sees
|
|
24
|
+
// room that does not exist — more than LLM_SEM_MAX concurrent calls.
|
|
25
|
+
//
|
|
26
|
+
// Wait one full hold plus a wait-cycle of slack: the worst honest case is
|
|
27
|
+
// arriving just as a 45s call started.
|
|
28
|
+
export const LLM_SEM_TIMEOUT = BG_LLM_TIMEOUT_MS + 15000; // 60s max wait
|
|
29
|
+
// Reaping is the PID-REUSE backstop, not the liveness test (that is
|
|
30
|
+
// process.kill(pid, 0) below). At 2x the hold plus slack it cannot fire on a
|
|
31
|
+
// working holder, which is why the ts written at acquire never needs
|
|
32
|
+
// refreshing — a heartbeat would buy nothing this margin doesn't.
|
|
33
|
+
export const LLM_SEM_STALE_MS = BG_LLM_TIMEOUT_MS * 2 + 30000; // 120s
|
|
10
34
|
|
|
11
35
|
export const sleepMs = (ms) => new Promise(r => setTimeout(r, ms));
|
|
12
36
|
|
|
37
|
+
// Does THIS process currently hold the (single, pid-named) slot?
|
|
38
|
+
//
|
|
39
|
+
// The slot file is one per process, and until the MCP LLM legs went async in
|
|
40
|
+
// v3.68.0 two acquires could not overlap inside one: execFileSync held the event
|
|
41
|
+
// loop, so a second tools/call was not even read from stdio while the first was
|
|
42
|
+
// in its LLM call. The EEXIST branch below encodes that era's assumption — "we
|
|
43
|
+
// are inside acquire and therefore do NOT hold a slot, so it is always stale" —
|
|
44
|
+
// and unlinks unconditionally. With two concurrent mem_optimize handlers that
|
|
45
|
+
// unlink would delete a LIVE sibling's slot: the cross-process count stops
|
|
46
|
+
// seeing it (so more than LLM_SEM_MAX `claude -p` children run at once), and the
|
|
47
|
+
// first holder's release then unlinks the second holder's file. This bookkeeping
|
|
48
|
+
// makes the EEXIST branch's claim true again by keeping same-process acquires out
|
|
49
|
+
// of it — they wait for the holder instead.
|
|
50
|
+
//
|
|
51
|
+
// A TIMESTAMP, not a boolean, and the difference is load-bearing: a boolean has no
|
|
52
|
+
// self-heal, so one caller that acquired and never released would deadlock every
|
|
53
|
+
// later LLM call in a long-lived MCP server for the life of the process — strictly
|
|
54
|
+
// worse than the race it fixes. Past LLM_SEM_STALE_MS the local record is treated
|
|
55
|
+
// as broken bookkeeping and we fall through to the file-side logic, which is the
|
|
56
|
+
// same age escape hatch the reaper applies to other processes' slots. 0 = not held.
|
|
57
|
+
let localHeldAt = 0;
|
|
58
|
+
|
|
13
59
|
/**
|
|
14
60
|
* Acquire a file-based semaphore slot for LLM calls.
|
|
15
61
|
* Uses acquire-then-verify: atomically creates a slot file, then checks total count.
|
|
62
|
+
* At most one slot per process; a concurrent same-process caller queues behind it.
|
|
16
63
|
* @returns {Promise<boolean>} true if slot acquired, false on timeout
|
|
17
64
|
*/
|
|
18
65
|
export async function acquireLLMSlot() {
|
|
@@ -20,6 +67,12 @@ export async function acquireLLMSlot() {
|
|
|
20
67
|
const slotFile = join(RUNTIME_DIR, `llm-sem-${process.pid}`);
|
|
21
68
|
|
|
22
69
|
while (Date.now() < deadline) {
|
|
70
|
+
// A sibling call in this process holds the slot — queue, do not race. The
|
|
71
|
+
// file cannot be re-created without destroying the holder's.
|
|
72
|
+
if (localHeldAt && Date.now() - localHeldAt < LLM_SEM_STALE_MS) {
|
|
73
|
+
await sleepMs(200 + Math.random() * 800);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
23
76
|
// Acquire-then-verify: atomically create our slot first, then check total count
|
|
24
77
|
let created;
|
|
25
78
|
try {
|
|
@@ -35,8 +88,9 @@ export async function acquireLLMSlot() {
|
|
|
35
88
|
} catch {
|
|
36
89
|
// Our own pid-named slot file already exists: a leftover from a prior acquire
|
|
37
90
|
// that never released (crash between acquire and releaseLLMSlot, or PID reuse).
|
|
38
|
-
//
|
|
39
|
-
//
|
|
91
|
+
// `localHeldAt` was checked above, so this process either holds nothing right
|
|
92
|
+
// now or its record is past the stale threshold — either way the file is not
|
|
93
|
+
// a live sibling's. Remove it and retry. The await is essential: a bare `continue`
|
|
40
94
|
// here re-hits the same EEXIST every iteration, a synchronous tight loop that
|
|
41
95
|
// pins a core until the 30s deadline (the age-based cleanup below is unreachable
|
|
42
96
|
// on this path — it only runs after a successful create).
|
|
@@ -57,18 +111,24 @@ export async function acquireLLMSlot() {
|
|
|
57
111
|
const raw = readFileSync(fp, 'utf8');
|
|
58
112
|
const info = JSON.parse(raw);
|
|
59
113
|
const age = Date.now() - (info.ts || 0);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
114
|
+
// Liveness FIRST, age second. The pre-D#134 order reaped on age alone,
|
|
115
|
+
// which evicted holders that were alive and mid-call (see the budget
|
|
116
|
+
// note at the top of this file). A dead holder is reaped at any age;
|
|
117
|
+
// a live one only once its age is implausible as a real hold, which is
|
|
118
|
+
// the pid-reuse case the age check exists for.
|
|
64
119
|
if (info.pid) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
120
|
+
let alive;
|
|
121
|
+
try { process.kill(info.pid, 0); alive = true; } catch (killErr) {
|
|
122
|
+
// EPERM = process exists but belongs to another user → alive.
|
|
123
|
+
alive = killErr.code !== 'ESRCH';
|
|
68
124
|
}
|
|
69
|
-
|
|
70
|
-
|
|
125
|
+
if (!alive) { try { unlinkSync(fp); } catch {} continue; }
|
|
126
|
+
}
|
|
127
|
+
if (age > LLM_SEM_STALE_MS) {
|
|
128
|
+
try { unlinkSync(fp); } catch {}
|
|
129
|
+
continue;
|
|
71
130
|
}
|
|
131
|
+
active++;
|
|
72
132
|
} catch {
|
|
73
133
|
// Corrupt/unreadable semaphore file — treat as stale and remove
|
|
74
134
|
try { unlinkSync(fp); } catch {}
|
|
@@ -76,7 +136,7 @@ export async function acquireLLMSlot() {
|
|
|
76
136
|
}
|
|
77
137
|
} catch {}
|
|
78
138
|
|
|
79
|
-
if (active <= LLM_SEM_MAX) return true; // Slot acquired
|
|
139
|
+
if (active <= LLM_SEM_MAX) { localHeldAt = Date.now(); return true; } // Slot acquired
|
|
80
140
|
|
|
81
141
|
// Too many concurrent — release our slot and back off
|
|
82
142
|
try { unlinkSync(slotFile); } catch {}
|
|
@@ -89,5 +149,6 @@ export async function acquireLLMSlot() {
|
|
|
89
149
|
* Release the file-based semaphore slot for the current process.
|
|
90
150
|
*/
|
|
91
151
|
export function releaseLLMSlot() {
|
|
152
|
+
localHeldAt = 0;
|
|
92
153
|
try { unlinkSync(join(RUNTIME_DIR, `llm-sem-${process.pid}`)); } catch {}
|
|
93
154
|
}
|
package/hook.mjs
CHANGED
|
@@ -53,12 +53,14 @@ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, h
|
|
|
53
53
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
54
54
|
import {
|
|
55
55
|
extractCitationsFromTranscript,
|
|
56
|
-
|
|
56
|
+
extractInjectedBySurface,
|
|
57
|
+
unionSurfaces,
|
|
57
58
|
extractInjectedFromKeyContext,
|
|
58
59
|
bumpCitationAccess,
|
|
59
60
|
computeCiteRecall,
|
|
60
61
|
applyCitationDecay,
|
|
61
62
|
recordCitationFunnel,
|
|
63
|
+
recordCitationSurfaces,
|
|
62
64
|
hasMainThreadAssistantText,
|
|
63
65
|
} from './lib/citation-tracker.mjs';
|
|
64
66
|
import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
|
|
@@ -741,7 +743,14 @@ async function handleStop() {
|
|
|
741
743
|
// filter as citedMain (the numerator, below) — an obs injected only
|
|
742
744
|
// inside a subagent (sidechain) would otherwise enter the denominator
|
|
743
745
|
// but never the numerator and streak-demote despite being used there.
|
|
744
|
-
|
|
746
|
+
// v45: take the per-FACE breakdown and union it, instead of asking
|
|
747
|
+
// for the union directly. Same ids (extractAllInjected IS this union
|
|
748
|
+
// — see unionSurfaces), same single transcript walk, but the split
|
|
749
|
+
// survives to citation_surface_log below so "which face earns its
|
|
750
|
+
// budget" becomes answerable. Before this, every face was merged
|
|
751
|
+
// before anything was recorded and no lever had a target.
|
|
752
|
+
const injectedBySurface = extractInjectedBySurface(transcriptPath, { mainOnly: true });
|
|
753
|
+
const injected = unionSurfaces(injectedBySurface);
|
|
745
754
|
// P5 ①: cite-back signals — observations whose warned file the agent
|
|
746
755
|
// edited this session. Union into injected so they're resolved (they
|
|
747
756
|
// were injected via pre-tool-recall) and, below, into cited so the
|
|
@@ -789,6 +798,19 @@ async function handleStop() {
|
|
|
789
798
|
// obs resolved this run (denominator), promoted = obs cited this run
|
|
790
799
|
// (numerator). Idempotent (touched is 0 on re-fire) + best-effort.
|
|
791
800
|
recordCitationFunnel(db, project, sessionId, r.touched, r.promoted);
|
|
801
|
+
// v45: the same funnel split by injection FACE. Keyed on
|
|
802
|
+
// ccSessionId — the SAME D#60 reasoning as applyCitationDecay
|
|
803
|
+
// above, and load-bearing here for a second reason: this table
|
|
804
|
+
// OVERWRITES rather than accumulates, and the memory sessionId
|
|
805
|
+
// is one file per PROJECT, so two concurrent CC sessions in one
|
|
806
|
+
// project would share a row and the later Stop would erase the
|
|
807
|
+
// earlier session's counts outright. citation_log survives the
|
|
808
|
+
// shared key only because it adds deltas.
|
|
809
|
+
// keyctx rides along for VISIBILITY only — it is a separate
|
|
810
|
+
// telemetry table, so recording it here cannot widen the decay
|
|
811
|
+
// denominator the way v3.66.0's union did.
|
|
812
|
+
recordCitationSurfaces(db, project, ccSessionId || sessionId,
|
|
813
|
+
{ ...injectedBySurface, keyctx: keyCtxIds }, citedMain);
|
|
792
814
|
// P1 (D#78): per-edge attribution. The session cooldown file
|
|
793
815
|
// (keyed by CC session id) records which FILE each obs was
|
|
794
816
|
// injected for; resolve those (obs,file) edges as hit/miss with
|
|
@@ -1727,12 +1749,20 @@ async function handleUserPrompt() {
|
|
|
1727
1749
|
}
|
|
1728
1750
|
} catch { /* file may not exist — that's fine */ }
|
|
1729
1751
|
|
|
1730
|
-
// Phase-2 task-imperative (default OFF — CLAUDE_MEM_TASK_IMPERATIVE):
|
|
1731
|
-
// highest-value lesson relevant to THIS prompt, delivered at the prompt
|
|
1732
|
-
// an imperative template. Excluded from the <memory-context> list so it
|
|
1733
|
-
// injected twice. Channel-isolation measure (efficacy arm U, 2026-06-29):
|
|
1734
|
-
// 6-8/8 vs PreToolUse hook 0/8.
|
|
1735
|
-
//
|
|
1752
|
+
// Phase-2 task-imperative (EXPERIMENTAL, default OFF — CLAUDE_MEM_TASK_IMPERATIVE):
|
|
1753
|
+
// the single highest-value lesson relevant to THIS prompt, delivered at the prompt
|
|
1754
|
+
// position under an imperative template. Excluded from the <memory-context> list so it
|
|
1755
|
+
// is never injected twice. Channel-isolation measure (efficacy arm U, 2026-06-29):
|
|
1756
|
+
// task-prompt 6-8/8 vs PreToolUse hook 0/8.
|
|
1757
|
+
//
|
|
1758
|
+
// The default flip is ABANDONED (D#137, 2026-08-16). rankImperativeCandidates requires
|
|
1759
|
+
// identifier overlap between the prompt and the lesson body/title, and over the last 400
|
|
1760
|
+
// real prompts that gate opened 76 times = 19.0% (CJK prompts 57/352 = 16.2%, ASCII
|
|
1761
|
+
// 19/48 = 39.6%). With 88% of prompts on this install in Chinese, the emitter fires
|
|
1762
|
+
// roughly once every six prompts — the canary can never accumulate n, because the
|
|
1763
|
+
// ceiling is the gate's DESIGN (precision-first symbol anchoring), not a defect.
|
|
1764
|
+
// Reviving the flip needs a CJK-viable anchor proven in A/B without a precision loss;
|
|
1765
|
+
// until then this stays experimental and off.
|
|
1736
1766
|
const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
|
|
1737
1767
|
|| process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
|
|
1738
1768
|
// Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -223,28 +223,6 @@ function eachHookAttachment(transcriptPath, fn, opts = {}) {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
/**
|
|
227
|
-
* Extract observation IDs injected by pre-tool-recall hook in this transcript.
|
|
228
|
-
*
|
|
229
|
-
* Tighter than `computeCiteRecall`'s over-inclusive "any #NN in non-assistant
|
|
230
|
-
* text" — only counts IDs the agent actually saw from us, not user-pasted
|
|
231
|
-
* references or unrelated #NN tokens in tool output.
|
|
232
|
-
*
|
|
233
|
-
* @param {string|null|undefined} transcriptPath
|
|
234
|
-
* @returns {Set<number>} unique injected IDs (empty set on missing path/file)
|
|
235
|
-
*/
|
|
236
|
-
export function extractInjectedFromPreToolUse(transcriptPath, opts = {}) {
|
|
237
|
-
const ids = new Set();
|
|
238
|
-
eachHookAttachment(transcriptPath, ({ command, text }) => {
|
|
239
|
-
if (!command.includes('pre-tool-recall')) return;
|
|
240
|
-
for (const line of text.split('\n')) {
|
|
241
|
-
const m = INJECTED_ROW_RE.exec(line);
|
|
242
|
-
if (m) addObsId(ids, m[1]);
|
|
243
|
-
}
|
|
244
|
-
}, opts);
|
|
245
|
-
return ids;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
226
|
// v34.x: UserPromptSubmit injection extractor. hook.mjs handleUserPrompt emits
|
|
249
227
|
// formatMemoryLine `- [type] title | Lesson: X (#NN)[ [verify-before-use]]`,
|
|
250
228
|
// which INJECTED_RE (anchored on `#NN [type]`) never matched — leaving this
|
|
@@ -260,88 +238,169 @@ const UPS_ID_RE = /\(#(\d{1,7})\)/g;
|
|
|
260
238
|
// `node "/abs/hook.mjs" user-prompt` → normalized to `node /abs/hook.mjs user-prompt`.
|
|
261
239
|
const UPS_COMMAND_SUFFIX = 'hook.mjs user-prompt';
|
|
262
240
|
|
|
241
|
+
// user-prompt-search.js formatResults emits `[mem] FYI — Related memories ...`
|
|
242
|
+
// then one `#NN <icon> title` row per obs (raw stdout, line-leading id). Distinct
|
|
243
|
+
// from the `<memory-context>` block (hook.mjs) — the two UPS injectors dedup obs
|
|
244
|
+
// by id at inject time, so they carry DISJOINT obs sets; both must be extracted
|
|
245
|
+
// or the FYI-carried (highest-importance keyContext) obs never reach decay.
|
|
246
|
+
const FYI_HEADER = '[mem] FYI — Related memories';
|
|
247
|
+
// Anchored at line start so `P#NN` past-question rows (user_prompts, different id
|
|
248
|
+
// space) and any `#NN` inside lesson text are NOT matched.
|
|
249
|
+
const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
|
|
250
|
+
|
|
263
251
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
252
|
+
* The injection FACES memory can reach the model through, as stored in
|
|
253
|
+
* `citation_surface_log.surface` (schema v45). The first four are
|
|
254
|
+
* query-conditioned — a row appears there because it MATCHED something — and
|
|
255
|
+
* are the ones that feed the citation-decay denominator via extractAllInjected.
|
|
256
|
+
* `keyctx` is the odd one out: an unconditional SessionStart render, recorded
|
|
257
|
+
* for VISIBILITY only and promotion-only in the decay loop (see
|
|
258
|
+
* extractInjectedFromKeyContext).
|
|
259
|
+
* @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'keyctx'>}
|
|
260
|
+
*/
|
|
261
|
+
export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'keyctx'];
|
|
262
|
+
|
|
263
|
+
// Single source of truth for "which attachment belongs to which face, and how
|
|
264
|
+
// its ids are read off". Both the per-face extractors below AND the one-pass
|
|
265
|
+
// extractInjectedBySurface dispatch through this table, so a face can never be
|
|
266
|
+
// taught to one path and forgotten on the other — the shape of miss that let
|
|
267
|
+
// UserPromptSubmit go unmetered for a whole minor version (v34.x) and that
|
|
268
|
+
// #10379 records as the repeat offender.
|
|
269
|
+
const SURFACE_MATCHERS = {
|
|
270
|
+
pretool: {
|
|
271
|
+
// Tighter than `computeCiteRecall`'s over-inclusive "any #NN in
|
|
272
|
+
// non-assistant text" — only counts IDs the agent actually saw from us,
|
|
273
|
+
// not user-pasted references or unrelated #NN tokens in tool output.
|
|
274
|
+
accepts: ({ command }) => command.includes('pre-tool-recall'),
|
|
275
|
+
collect: (text, add) => {
|
|
276
|
+
for (const line of text.split('\n')) {
|
|
277
|
+
const m = INJECTED_ROW_RE.exec(line);
|
|
278
|
+
if (m) add(m[1]);
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
ups: {
|
|
283
|
+
// The `<memory-context>` block emitted by hook.mjs handleUserPrompt.
|
|
284
|
+
// Disjoint from pre-tool-recall by construction: PTR has `[type]` AFTER
|
|
285
|
+
// `#NN`, UPS has `(#NN)` at end-of-line.
|
|
286
|
+
accepts: ({ command, text }) =>
|
|
287
|
+
command.includes(UPS_COMMAND_SUFFIX) && text.includes('<memory-context'),
|
|
288
|
+
collect: (text, add) => {
|
|
289
|
+
for (const memLine of text.split('\n')) {
|
|
290
|
+
if (!memLine.startsWith(UPS_LINE_PREFIX)) continue;
|
|
291
|
+
// Take the LAST (#NN) on the line — formatMemoryLine puts the obs id
|
|
292
|
+
// in trailing parens, possibly followed by ` [verify-before-use]`. Any
|
|
293
|
+
// earlier (#NN) refs are inside title/lesson text.
|
|
294
|
+
const matches = [...memLine.matchAll(UPS_ID_RE)];
|
|
295
|
+
if (matches.length === 0) continue;
|
|
296
|
+
add(matches[matches.length - 1][1]);
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
error_recall: {
|
|
301
|
+
// hook.mjs triggerErrorRecall → `[claude-mem-lite] Related memories found
|
|
302
|
+
// for this error:` followed by ` #NN [type] title` lines, delivered via
|
|
303
|
+
// post-tool-use.sh. High-volume surface that NO extractor matched before
|
|
304
|
+
// v3.47 — error-recall'd obs accrued injection_count but never reached
|
|
305
|
+
// applyCitationDecay, so they could neither promote nor demote.
|
|
306
|
+
accepts: ({ command, text }) =>
|
|
307
|
+
command.includes('post-tool-use') && text.includes('Related memories found for this error'),
|
|
308
|
+
collect: (text, add) => {
|
|
309
|
+
// Per-line anchored: match only a row that STARTS with `#NN [type]` (after its
|
|
310
|
+
// indent), NOT every such token in the block. The inlined lesson body (v3.16.x)
|
|
311
|
+
// can quote another obs id, which must not enter the injected set; the trailing
|
|
312
|
+
// `Use mem_get(ids=[...])` line (bare numbers) is excluded too.
|
|
313
|
+
for (const line of text.split('\n')) {
|
|
314
|
+
const m = INJECTED_ROW_RE.exec(line);
|
|
315
|
+
if (m) add(m[1]);
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
fyi: {
|
|
320
|
+
accepts: ({ command, text }) =>
|
|
321
|
+
command.includes('user-prompt-search') && text.includes(FYI_HEADER),
|
|
322
|
+
collect: (text, add) => {
|
|
323
|
+
for (const fyiLine of text.split('\n')) {
|
|
324
|
+
const m = FYI_LINE_ID_RE.exec(fyiLine);
|
|
325
|
+
if (m) add(m[1]);
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
// The query-conditioned faces, in citation_surface_log label order. keyctx is
|
|
332
|
+
// absent on purpose: it has no hook attachment to walk.
|
|
333
|
+
const ATTACHMENT_SURFACES = Object.keys(SURFACE_MATCHERS);
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Split a transcript's injections by FACE in ONE walk.
|
|
337
|
+
*
|
|
338
|
+
* This is the primitive; `extractAllInjected` is its union. Pre-v45 each face
|
|
339
|
+
* re-read and re-parsed the whole transcript (4 walks per Stop) AND the union
|
|
340
|
+
* was a separate list that had to be kept in sync by hand — this collapses both
|
|
341
|
+
* problems into the SURFACE_MATCHERS table.
|
|
267
342
|
*
|
|
268
343
|
* @param {string|null|undefined} transcriptPath
|
|
269
|
-
* @
|
|
344
|
+
* @param {{mainOnly?: boolean}} [opts]
|
|
345
|
+
* @returns {{pretool: Set<number>, ups: Set<number>, error_recall: Set<number>, fyi: Set<number>}}
|
|
346
|
+
* Always all four keys, always Sets (empty on missing/unreadable transcript).
|
|
270
347
|
*/
|
|
271
|
-
export function
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (!
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// earlier (#NN) refs are inside title/lesson text.
|
|
281
|
-
const matches = [...memLine.matchAll(UPS_ID_RE)];
|
|
282
|
-
if (matches.length === 0) continue;
|
|
283
|
-
addObsId(ids, matches[matches.length - 1][1]);
|
|
348
|
+
export function extractInjectedBySurface(transcriptPath, opts = {}) {
|
|
349
|
+
const out = {};
|
|
350
|
+
for (const face of ATTACHMENT_SURFACES) out[face] = new Set();
|
|
351
|
+
eachHookAttachment(transcriptPath, (ctx) => {
|
|
352
|
+
for (const face of ATTACHMENT_SURFACES) {
|
|
353
|
+
const matcher = SURFACE_MATCHERS[face];
|
|
354
|
+
if (!matcher.accepts(ctx)) continue;
|
|
355
|
+
const target = out[face];
|
|
356
|
+
matcher.collect(ctx.text, (raw) => addObsId(target, raw));
|
|
284
357
|
}
|
|
285
358
|
}, opts);
|
|
286
|
-
return
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Per-face extractors: thin wrappers over the shared table, kept as named
|
|
363
|
+
// exports because callers and tests address individual faces.
|
|
364
|
+
function extractOneSurface(face, transcriptPath, opts) {
|
|
365
|
+
return extractInjectedBySurface(transcriptPath, opts)[face];
|
|
287
366
|
}
|
|
288
367
|
|
|
289
368
|
/**
|
|
290
|
-
* Extract observation IDs injected by
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
369
|
+
* Extract observation IDs injected by pre-tool-recall hook in this transcript.
|
|
370
|
+
* @param {string|null|undefined} transcriptPath
|
|
371
|
+
* @returns {Set<number>} unique injected IDs (empty set on missing path/file)
|
|
372
|
+
*/
|
|
373
|
+
export function extractInjectedFromPreToolUse(transcriptPath, opts = {}) {
|
|
374
|
+
return extractOneSurface('pretool', transcriptPath, opts);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Extract observation IDs injected by the UserPromptSubmit `<memory-context>`
|
|
379
|
+
* block (hook.mjs handleUserPrompt).
|
|
297
380
|
* @param {string|null|undefined} transcriptPath
|
|
298
381
|
* @returns {Set<number>}
|
|
299
382
|
*/
|
|
300
|
-
export function
|
|
301
|
-
|
|
302
|
-
eachHookAttachment(transcriptPath, ({ command, text }) => {
|
|
303
|
-
if (!command.includes('post-tool-use')) return;
|
|
304
|
-
if (!text.includes('Related memories found for this error')) return;
|
|
305
|
-
// Per-line anchored: match only a row that STARTS with `#NN [type]` (after its
|
|
306
|
-
// indent), NOT every such token in the block. The inlined lesson body (v3.16.x)
|
|
307
|
-
// can quote another obs id, which must not enter the injected set; the trailing
|
|
308
|
-
// `Use mem_get(ids=[...])` line (bare numbers) is excluded too.
|
|
309
|
-
for (const line of text.split('\n')) {
|
|
310
|
-
const m = INJECTED_ROW_RE.exec(line);
|
|
311
|
-
if (m) addObsId(ids, m[1]);
|
|
312
|
-
}
|
|
313
|
-
}, opts);
|
|
314
|
-
return ids;
|
|
383
|
+
export function extractInjectedFromUserPromptSubmit(transcriptPath, opts = {}) {
|
|
384
|
+
return extractOneSurface('ups', transcriptPath, opts);
|
|
315
385
|
}
|
|
316
386
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
|
|
387
|
+
/**
|
|
388
|
+
* Extract observation IDs injected by the PostToolUse error-recall hint.
|
|
389
|
+
* @param {string|null|undefined} transcriptPath
|
|
390
|
+
* @returns {Set<number>}
|
|
391
|
+
*/
|
|
392
|
+
export function extractInjectedFromErrorRecall(transcriptPath, opts = {}) {
|
|
393
|
+
return extractOneSurface('error_recall', transcriptPath, opts);
|
|
394
|
+
}
|
|
326
395
|
|
|
327
396
|
/**
|
|
328
397
|
* Extract observation IDs injected by the user-prompt-search.js `[mem] FYI —
|
|
329
398
|
* Related memories` block.
|
|
330
|
-
*
|
|
331
399
|
* @param {string|null|undefined} transcriptPath
|
|
332
400
|
* @returns {Set<number>}
|
|
333
401
|
*/
|
|
334
402
|
export function extractInjectedFromFyi(transcriptPath, opts = {}) {
|
|
335
|
-
|
|
336
|
-
eachHookAttachment(transcriptPath, ({ command, text }) => {
|
|
337
|
-
if (!command.includes('user-prompt-search')) return;
|
|
338
|
-
if (!text.includes(FYI_HEADER)) return;
|
|
339
|
-
for (const fyiLine of text.split('\n')) {
|
|
340
|
-
const m = FYI_LINE_ID_RE.exec(fyiLine);
|
|
341
|
-
if (m) addObsId(ids, m[1]);
|
|
342
|
-
}
|
|
343
|
-
}, opts);
|
|
344
|
-
return ids;
|
|
403
|
+
return extractOneSurface('fyi', transcriptPath, opts);
|
|
345
404
|
}
|
|
346
405
|
|
|
347
406
|
/**
|
|
@@ -409,12 +468,25 @@ export function extractInjectedFromKeyContext({ runtimeDir, project, sessionId =
|
|
|
409
468
|
* @returns {Set<number>}
|
|
410
469
|
*/
|
|
411
470
|
export function extractAllInjected(transcriptPath, opts = {}) {
|
|
412
|
-
return
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
471
|
+
return unionSurfaces(extractInjectedBySurface(transcriptPath, opts));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Flatten a per-face breakdown into the single injected set the decay loop
|
|
476
|
+
* takes. Derived — NOT a second hand-maintained face list — so adding a face to
|
|
477
|
+
* SURFACE_MATCHERS automatically widens the denominator (v45; the pre-v45 union
|
|
478
|
+
* enumerated the faces a second time and that is exactly how a face goes
|
|
479
|
+
* unmetered).
|
|
480
|
+
*
|
|
481
|
+
* @param {Record<string, Set<number>>} bySurface
|
|
482
|
+
* @returns {Set<number>}
|
|
483
|
+
*/
|
|
484
|
+
export function unionSurfaces(bySurface) {
|
|
485
|
+
const out = new Set();
|
|
486
|
+
for (const face of ATTACHMENT_SURFACES) {
|
|
487
|
+
for (const id of bySurface?.[face] || []) out.add(id);
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
418
490
|
}
|
|
419
491
|
|
|
420
492
|
/**
|
|
@@ -617,6 +689,47 @@ export function computeCitationAdoption(db, project) {
|
|
|
617
689
|
} catch (e) { debugCatch(e, 'computeCitationAdoption'); return empty; }
|
|
618
690
|
}
|
|
619
691
|
|
|
692
|
+
/**
|
|
693
|
+
* D#61: a lesson injected live and then superseded mid-session (auto-dedup /
|
|
694
|
+
* `supersedes=` save) leaves its citation crediting NOBODY — every consumer
|
|
695
|
+
* excludes superseded rows by design, so the keeper that now carries the lesson
|
|
696
|
+
* goes uncredited. Redirect such ids to their NUMERIC superseded_by keeper (one
|
|
697
|
+
* hop; superseded_by is polymorphic — the typeof guard mirrors timeline-core).
|
|
698
|
+
*
|
|
699
|
+
* Returns a COPY: callers own their input sets. Shared by the per-obs decay loop
|
|
700
|
+
* and the per-surface funnel so the two can't disagree about who gets credit —
|
|
701
|
+
* the superseded invariant has been reopened once per surface that forgot it.
|
|
702
|
+
*
|
|
703
|
+
* @param {import('better-sqlite3').Database} db
|
|
704
|
+
* @param {string} project
|
|
705
|
+
* @param {Set<number>|Iterable<number>} ids
|
|
706
|
+
* @returns {Set<number>}
|
|
707
|
+
*/
|
|
708
|
+
export function redirectSupersededIds(db, project, ids) {
|
|
709
|
+
const src = ids instanceof Set ? ids : new Set(ids || []);
|
|
710
|
+
const out = new Set();
|
|
711
|
+
// Both bail-outs copy: returning `src` would hand back the CALLER'S own Set
|
|
712
|
+
// on the very paths that skip the redirect, quietly breaking the contract one
|
|
713
|
+
// line below this and making a future caller's mutation action-at-a-distance.
|
|
714
|
+
if (!db || !project) return new Set(src);
|
|
715
|
+
let stmt;
|
|
716
|
+
try {
|
|
717
|
+
stmt = db.prepare(
|
|
718
|
+
'SELECT superseded_by FROM observations WHERE id = ? AND project = ? AND superseded_at IS NOT NULL'
|
|
719
|
+
);
|
|
720
|
+
} catch (e) { debugCatch(e, 'redirectSupersededIds-prepare'); return new Set(src); }
|
|
721
|
+
for (const id of src) {
|
|
722
|
+
const r = stmt.get(id, project);
|
|
723
|
+
if (r && typeof r.superseded_by === 'number' && Number.isInteger(r.superseded_by)
|
|
724
|
+
&& r.superseded_by > 0 && r.superseded_by !== id) {
|
|
725
|
+
out.add(r.superseded_by);
|
|
726
|
+
} else {
|
|
727
|
+
out.add(id);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
620
733
|
/**
|
|
621
734
|
* Apply the citation-feedback loop for one session: for each injected obs id,
|
|
622
735
|
* decide cited vs uncited and mutate importance/streak/cited_count per spec.
|
|
@@ -657,31 +770,8 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
657
770
|
if (injected.size === 0) return empty;
|
|
658
771
|
let cited = citedIds instanceof Set ? citedIds : new Set(citedIds || []);
|
|
659
772
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
// excludes superseded rows by design (defense-in-depth parity), so the keeper
|
|
663
|
-
// that now carries the lesson goes uncredited. Redirect such ids to their
|
|
664
|
-
// NUMERIC superseded_by keeper (one hop; superseded_by is polymorphic — the
|
|
665
|
-
// typeof guard mirrors timeline-core). Copies, not mutation: callers own the
|
|
666
|
-
// input sets.
|
|
667
|
-
const redirectStmt = db.prepare(
|
|
668
|
-
'SELECT superseded_by FROM observations WHERE id = ? AND project = ? AND superseded_at IS NOT NULL'
|
|
669
|
-
);
|
|
670
|
-
const redirectSet = (set) => {
|
|
671
|
-
const out = new Set();
|
|
672
|
-
for (const id of set) {
|
|
673
|
-
const r = redirectStmt.get(id, project);
|
|
674
|
-
if (r && typeof r.superseded_by === 'number' && Number.isInteger(r.superseded_by)
|
|
675
|
-
&& r.superseded_by > 0 && r.superseded_by !== id) {
|
|
676
|
-
out.add(r.superseded_by);
|
|
677
|
-
} else {
|
|
678
|
-
out.add(id);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
return out;
|
|
682
|
-
};
|
|
683
|
-
injected = redirectSet(injected);
|
|
684
|
-
cited = redirectSet(cited);
|
|
773
|
+
injected = redirectSupersededIds(db, project, injected);
|
|
774
|
+
cited = redirectSupersededIds(db, project, cited);
|
|
685
775
|
|
|
686
776
|
// Adoption gate (snapshot taken before any mutation this run). Suppress only
|
|
687
777
|
// demotion; promotion always proceeds. Threshold overridable via env.
|
|
@@ -840,6 +930,132 @@ export function recordCitationFunnel(db, project, sessionId, injectedDelta, cite
|
|
|
840
930
|
} catch (e) { debugCatch(e, 'recordCitationFunnel'); }
|
|
841
931
|
}
|
|
842
932
|
|
|
933
|
+
/**
|
|
934
|
+
* v45 — persist this session's invocation→cite funnel split by INJECTION FACE.
|
|
935
|
+
*
|
|
936
|
+
* The aggregate twin (recordCitationFunnel) accumulates deltas because its
|
|
937
|
+
* source is applyCitationDecay's per-run return. This one OVERWRITES, because
|
|
938
|
+
* its source is the transcript, which only ever grows: recomputing after a Stop
|
|
939
|
+
* re-fire yields the same-or-larger sets, so overwrite is idempotent by
|
|
940
|
+
* construction AND lets a cross-turn late citation raise cited_n without
|
|
941
|
+
* double-counting injected_n. No per-obs state, no idempotency key needed.
|
|
942
|
+
*
|
|
943
|
+
* NOT A PARTITION, and NOT comparable to citation_log in either direction.
|
|
944
|
+
* Upward: an obs carried by two faces is counted in BOTH rows. Downward: the
|
|
945
|
+
* Stop handler unions cite-back signals into the aggregate denominator AFTER
|
|
946
|
+
* taking this breakdown, and those ids belong to no face (and skip the mainOnly
|
|
947
|
+
* filter), so citation_log can exceed the surface sum too. A per-face view
|
|
948
|
+
* answers "which face earns its budget", not "how was the budget divided".
|
|
949
|
+
*
|
|
950
|
+
* Ids are filtered to observations that actually exist in this project and are
|
|
951
|
+
* not superseded (redirected to their keeper first), mirroring the decay loop's
|
|
952
|
+
* SELECT — so a cross-project id, a deleted row, or an events-table id can't
|
|
953
|
+
* inflate a face's denominator.
|
|
954
|
+
*
|
|
955
|
+
* Telemetry only: every write is wrapped, and a failure here can never break the
|
|
956
|
+
* Stop handler.
|
|
957
|
+
*
|
|
958
|
+
* @param {import('better-sqlite3').Database} db
|
|
959
|
+
* @param {string} project
|
|
960
|
+
* @param {string} sessionId — the CLAUDE CODE session id, NOT the memory
|
|
961
|
+
* session id citation_log uses. Overwrite semantics make the key choice
|
|
962
|
+
* load-bearing: the memory session id is one file per PROJECT, so two
|
|
963
|
+
* concurrent CC sessions in one project share it and the second Stop would
|
|
964
|
+
* erase the first's counts. citation_log survives that only because it
|
|
965
|
+
* accumulates. Same reasoning as D#60 for applyCitationDecay.
|
|
966
|
+
* @param {Record<string, Set<number>|Iterable<number>>} surfaceSets — keys must
|
|
967
|
+
* be CITATION_SURFACES members; unknown labels are dropped, not written.
|
|
968
|
+
* @param {Set<number>|Iterable<number>} citedIds — this session's cited set
|
|
969
|
+
* (same one the decay loop uses)
|
|
970
|
+
* @returns {Record<string, {injected: number, cited: number}>} what was written
|
|
971
|
+
*/
|
|
972
|
+
export function recordCitationSurfaces(db, project, sessionId, surfaceSets, citedIds) {
|
|
973
|
+
const written = {};
|
|
974
|
+
if (!db || !project || !sessionId || !surfaceSets || typeof surfaceSets !== 'object') return written;
|
|
975
|
+
try {
|
|
976
|
+
const cited = redirectSupersededIds(db, project, citedIds instanceof Set ? citedIds : new Set(citedIds || []));
|
|
977
|
+
const liveStmt = db.prepare(
|
|
978
|
+
'SELECT 1 AS ok FROM observations WHERE id = ? AND project = ? AND superseded_at IS NULL'
|
|
979
|
+
);
|
|
980
|
+
const upsert = db.prepare(`
|
|
981
|
+
INSERT INTO citation_surface_log (project, session_id, surface, resolved_at, injected_n, cited_n)
|
|
982
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
983
|
+
ON CONFLICT(project, session_id, surface) DO UPDATE SET
|
|
984
|
+
injected_n = excluded.injected_n,
|
|
985
|
+
cited_n = excluded.cited_n,
|
|
986
|
+
resolved_at = excluded.resolved_at
|
|
987
|
+
`);
|
|
988
|
+
const now = Date.now();
|
|
989
|
+
const rows = [];
|
|
990
|
+
for (const [surface, rawIds] of Object.entries(surfaceSets)) {
|
|
991
|
+
if (!CITATION_SURFACES.includes(surface)) continue; // unknown label → unqueryable row
|
|
992
|
+
const ids = redirectSupersededIds(db, project, rawIds instanceof Set ? rawIds : new Set(rawIds || []));
|
|
993
|
+
let injected = 0, citedN = 0;
|
|
994
|
+
for (const id of ids) {
|
|
995
|
+
if (!liveStmt.get(id, project)) continue;
|
|
996
|
+
injected++;
|
|
997
|
+
if (cited.has(id)) citedN++;
|
|
998
|
+
}
|
|
999
|
+
if (injected === 0) continue; // empty face → no telemetry noise
|
|
1000
|
+
rows.push([surface, injected, citedN]);
|
|
1001
|
+
written[surface] = { injected, cited: citedN };
|
|
1002
|
+
}
|
|
1003
|
+
if (rows.length === 0) return written;
|
|
1004
|
+
const txn = db.transaction(() => {
|
|
1005
|
+
for (const [surface, injected, citedN] of rows) {
|
|
1006
|
+
upsert.run(project, sessionId, surface, now, injected, citedN);
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
txn();
|
|
1010
|
+
} catch (e) { debugCatch(e, 'recordCitationSurfaces'); }
|
|
1011
|
+
return written;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* v45 — read citation_surface_log back as a per-face cite-rate leaderboard for
|
|
1016
|
+
* the window, highest injection volume first (the face spending the most budget
|
|
1017
|
+
* is the one worth aiming a lever at).
|
|
1018
|
+
*
|
|
1019
|
+
* `unavailable` is set — and ONLY set — when the read could not run (no handle,
|
|
1020
|
+
* missing table, unreadable DB). An empty window leaves it undefined. Without
|
|
1021
|
+
* this split both render as `surfaces: []`, which is exactly how a table that was
|
|
1022
|
+
* never created reads as "no data yet" for as long as the surface stays unmetered
|
|
1023
|
+
* (#10650): the reader swallows `no such table` into the debug log, and the only
|
|
1024
|
+
* caller-visible signal is a shape identical to the benign case.
|
|
1025
|
+
*
|
|
1026
|
+
* @param {import('better-sqlite3').Database} db
|
|
1027
|
+
* @param {{days?: number, project?: string|null}} [opts]
|
|
1028
|
+
* @returns {{window_days: number, surfaces: Array<{surface: string, injected: number, cited: number, rate: number, sessions: number}>, unavailable?: string}}
|
|
1029
|
+
*/
|
|
1030
|
+
export function computeSurfaceFunnel(db, { days = 7, project = null } = {}) {
|
|
1031
|
+
const empty = { window_days: days, surfaces: [] };
|
|
1032
|
+
if (!db) return { ...empty, unavailable: 'no database handle' };
|
|
1033
|
+
try {
|
|
1034
|
+
const windowStart = Date.now() - days * DAY_MS;
|
|
1035
|
+
const params = project ? [windowStart, project] : [windowStart];
|
|
1036
|
+
const rows = db.prepare(`
|
|
1037
|
+
SELECT surface,
|
|
1038
|
+
COALESCE(SUM(injected_n), 0) AS injected,
|
|
1039
|
+
COALESCE(SUM(cited_n), 0) AS cited,
|
|
1040
|
+
-- DISTINCT, not COUNT(*): rows are keyed (project, session,
|
|
1041
|
+
-- surface), so an unfiltered COUNT(*) counts project-sessions and
|
|
1042
|
+
-- over-reports "over N sessions" whenever a session spans projects.
|
|
1043
|
+
COUNT(DISTINCT session_id) AS sessions
|
|
1044
|
+
FROM citation_surface_log
|
|
1045
|
+
WHERE resolved_at >= ? ${project ? 'AND project = ?' : ''}
|
|
1046
|
+
GROUP BY surface
|
|
1047
|
+
ORDER BY injected DESC, surface ASC
|
|
1048
|
+
`).all(...params);
|
|
1049
|
+
return {
|
|
1050
|
+
window_days: days,
|
|
1051
|
+
surfaces: rows.map(r => ({ ...r, rate: r.injected > 0 ? r.cited / r.injected : 0 })),
|
|
1052
|
+
};
|
|
1053
|
+
} catch (e) {
|
|
1054
|
+
debugCatch(e, 'computeSurfaceFunnel');
|
|
1055
|
+
return { ...empty, unavailable: e?.message || 'query failed' };
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
843
1059
|
/**
|
|
844
1060
|
* R1 — read the per-session invocation→cite funnel as a windowed trend.
|
|
845
1061
|
* `window` aggregates [now-days, now]; `prior` aggregates [now-2*days, now-days)
|
package/mem-cli.mjs
CHANGED
|
@@ -55,7 +55,18 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
|
|
|
55
55
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
56
56
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
57
57
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
58
|
-
import { computeCitationFunnelTrend } from './lib/citation-tracker.mjs';
|
|
58
|
+
import { computeCitationFunnelTrend, computeSurfaceFunnel } from './lib/citation-tracker.mjs';
|
|
59
|
+
|
|
60
|
+
// Human labels for citation_surface_log.surface. Padded to a common width so
|
|
61
|
+
// the citation-stats face table lines up; the enum itself lives in
|
|
62
|
+
// lib/citation-tracker.mjs (CITATION_SURFACES).
|
|
63
|
+
const SURFACE_LABELS = {
|
|
64
|
+
pretool: 'PreToolUse recall ',
|
|
65
|
+
ups: 'UserPromptSubmit ',
|
|
66
|
+
error_recall: 'error-recall ',
|
|
67
|
+
fyi: 'FYI (prompt-search)',
|
|
68
|
+
keyctx: 'Key Context ',
|
|
69
|
+
};
|
|
59
70
|
import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
|
|
60
71
|
import {
|
|
61
72
|
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
@@ -2524,6 +2535,8 @@ function cmdCitationStats(db, args) {
|
|
|
2524
2535
|
// R1: per-session invocation→cite funnel trend (citation_log). Same `days` window
|
|
2525
2536
|
// as the per-project cite rate above; funnel.prior/delta_pt show the direction.
|
|
2526
2537
|
const funnel = computeCitationFunnelTrend(db, { days });
|
|
2538
|
+
// v45: per-injection-face split of the same funnel (citation_surface_log).
|
|
2539
|
+
const surfaceFunnel = computeSurfaceFunnel(db, { days });
|
|
2527
2540
|
|
|
2528
2541
|
// Survivorship-honesty: the per-project rate (cited_count/decay_seen_count over
|
|
2529
2542
|
// SURVIVING in-window obs) is doubly biased — GC drops uncited obs from the
|
|
@@ -2542,7 +2555,7 @@ function cmdCitationStats(db, args) {
|
|
|
2542
2555
|
}
|
|
2543
2556
|
|
|
2544
2557
|
if (json) {
|
|
2545
|
-
out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted, data_pollution_note: dataPollutionNote, funnel }, null, 2));
|
|
2558
|
+
out(JSON.stringify({ window_days: days, per_project: perProject, decay_queue: decayQueue, promoted, demoted, data_pollution_note: dataPollutionNote, funnel, surface_funnel: surfaceFunnel }, null, 2));
|
|
2546
2559
|
return;
|
|
2547
2560
|
}
|
|
2548
2561
|
|
|
@@ -2577,6 +2590,28 @@ function cmdCitationStats(db, args) {
|
|
|
2577
2590
|
}
|
|
2578
2591
|
out(trendLine);
|
|
2579
2592
|
out('');
|
|
2593
|
+
|
|
2594
|
+
// v45: the same funnel split by INJECTION FACE. The aggregate above says
|
|
2595
|
+
// whether effectiveness is rising; this says WHICH face to aim a lever at.
|
|
2596
|
+
out(`Cite rate by injection face (last ${days}d):`);
|
|
2597
|
+
out(' a per-face VIEW, not a partition — do NOT reconcile against the funnel above: faces overlap (an obs carried by two counts in both) and the funnel also counts cite-back signals that belong to no face:');
|
|
2598
|
+
if (surfaceFunnel.unavailable) {
|
|
2599
|
+
// The read FAILED — a missing/unreadable citation_surface_log. Pre-b4 this
|
|
2600
|
+
// rendered identically to an empty window, so the #10650 shape (table never
|
|
2601
|
+
// created, `no such table` swallowed into the debug log) read as "no data
|
|
2602
|
+
// yet" for as long as the surface stayed unmetered.
|
|
2603
|
+
out(` (UNAVAILABLE — the per-face table could not be read: ${surfaceFunnel.unavailable})`);
|
|
2604
|
+
out(' this is a failure, not an empty window: run `claude-mem-lite fts-check` to repair the schema');
|
|
2605
|
+
} else if (surfaceFunnel.surfaces.length === 0) {
|
|
2606
|
+
out(' (no rows in this window yet — rows accrue at Stop, one per injection face per session)');
|
|
2607
|
+
} else {
|
|
2608
|
+
for (const s of surfaceFunnel.surfaces) {
|
|
2609
|
+
const pct = (s.rate * 100).toFixed(1) + '%';
|
|
2610
|
+
const note = s.surface === 'keyctx' ? ' (promotion-only: never demotes)' : '';
|
|
2611
|
+
out(` ${SURFACE_LABELS[s.surface] || s.surface} inj ${String(s.injected).padStart(4)} cited ${String(s.cited).padStart(4)} ${pct.padStart(6)} over ${s.sessions} session(s)${note}`);
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
out('');
|
|
2580
2615
|
out('Active decay queue (uncited_streak >= 2, next miss → demote):');
|
|
2581
2616
|
if (decayQueue.length === 0) out(' (none)');
|
|
2582
2617
|
for (const r of decayQueue) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.68.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.68.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.68.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/registry-enricher.mjs
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// Sends resource content to Haiku for semantic metadata generation
|
|
3
3
|
// Graceful degradation: failure preserves existing data
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Async dispatcher: enrichResource is reached from the MCP handlers for
|
|
6
|
+
// mem_registry `enrich` and `import_url`, where a blocking CLI leg would freeze
|
|
7
|
+
// the server event loop for the whole BG_LLM_TIMEOUT_MS budget (D#138 MEDIUM-3).
|
|
8
|
+
import { callHaikuJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
6
9
|
import { truncate, debugCatch } from './utils.mjs';
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -84,7 +87,7 @@ export async function enrichResource(db, name, type, content) {
|
|
|
84
87
|
|
|
85
88
|
try {
|
|
86
89
|
const prompt = buildEnrichPrompt(name, content, existing);
|
|
87
|
-
const result = await
|
|
90
|
+
const result = await callHaikuJSONAsync(prompt, { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
|
|
88
91
|
|
|
89
92
|
if (!result || !result.capability_summary) {
|
|
90
93
|
db.prepare("UPDATE resources SET enrichment_status = 'failed' WHERE name = ? AND type = ?").run(name, type);
|
package/rerank.mjs
CHANGED
|
@@ -69,10 +69,13 @@ export async function llmRerankOrder(query, cand /* [{sid,text}] */, llm) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Default provider — lazy import so stub-injected callers never load the client.
|
|
72
|
-
// Uses
|
|
72
|
+
// Uses the {text}-envelope dispatcher rather than callModelJSONAsync (which
|
|
73
73
|
// JSON-parses internally and nulls on any non-{...} output) so extractRanked can
|
|
74
|
-
// recover bare-array answers the strict JSON parse drops.
|
|
74
|
+
// recover bare-array answers the strict JSON parse drops. The Async variant is
|
|
75
|
+
// load-bearing: rerank runs inside the mem_search MCP handler (deep + rerank), so
|
|
76
|
+
// the blocking callLLMWithModel froze the server event loop whenever a keyed
|
|
77
|
+
// provider was down and the call degraded to the CLI (D#138 MEDIUM-3).
|
|
75
78
|
export async function defaultRerankLLM(prompt) {
|
|
76
|
-
const {
|
|
77
|
-
return
|
|
79
|
+
const { callLLMWithModelAsync } = await import('./haiku-client.mjs');
|
|
80
|
+
return callLLMWithModelAsync(prompt, 'haiku', { timeout: 20000, maxTokens: 300 });
|
|
78
81
|
}
|
package/schema.mjs
CHANGED
|
@@ -129,7 +129,30 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
|
|
|
129
129
|
// 2026-07-14 on this machine's own DB). One version per migration batch keeps
|
|
130
130
|
// the version number itself the detector. LATEST_MIGRATION_COLUMN advances to
|
|
131
131
|
// observations.scope.
|
|
132
|
-
|
|
132
|
+
// v45 (per-surface funnel): citation_surface_log — the same invocation→cite
|
|
133
|
+
// funnel as citation_log (v38) but split by INJECTION FACE. citation_log answers
|
|
134
|
+
// "is effectiveness rising or falling" for a project; it cannot answer "which
|
|
135
|
+
// face is burning the budget", because hook.mjs unions all four
|
|
136
|
+
// query-conditioned faces (pre-tool-recall / UserPromptSubmit <memory-context> /
|
|
137
|
+
// PostToolUse error-recall / user-prompt-search FYI) before anything is
|
|
138
|
+
// recorded. Without per-face cite-rate there is no evidence to aim any
|
|
139
|
+
// precision lever at, which is what gated D#44 and D#129's remaining legs.
|
|
140
|
+
// The two tables are NOT comparable in either direction and the readers say so
|
|
141
|
+
// out loud: an obs carried by two faces is counted in both rows (pushes the
|
|
142
|
+
// surface sum UP), while cite-back signals join citation_log's denominator
|
|
143
|
+
// without belonging to any face and without the mainOnly filter (pushes the
|
|
144
|
+
// aggregate UP). Neither is a partition of the other.
|
|
145
|
+
// Keyed on the CC session id, NOT the memory session id — see the DDL comment.
|
|
146
|
+
// The column was renamed memory_session_id -> session_id BEFORE v45 ever
|
|
147
|
+
// shipped (pre-tag review), so no released database carries the old shape and
|
|
148
|
+
// no rename migration exists; the sentinel stays on `surface`, which is a
|
|
149
|
+
// table-presence check either way.
|
|
150
|
+
// New TABLE (not a column) reached via CORE_SCHEMA's CREATE TABLE IF NOT EXISTS
|
|
151
|
+
// on the forced migration pass. UNLIKE v38/v39 this DOES register a sentinel
|
|
152
|
+
// (citation_surface_log.surface) in LATEST_MIGRATION_COLUMNS: a table that only
|
|
153
|
+
// the forced pass can create is unreachable forever once the version row says
|
|
154
|
+
// "done", which is not a hypothetical — see the note there.
|
|
155
|
+
export const CURRENT_SCHEMA_VERSION = 45;
|
|
133
156
|
|
|
134
157
|
// Sentinel columns for the LATEST migration set(s). The fast-path uses these
|
|
135
158
|
// to self-heal half-migrated DBs — schema_version bumped but column ALTERs
|
|
@@ -139,7 +162,18 @@ export const CURRENT_SCHEMA_VERSION = 44;
|
|
|
139
162
|
// table's pre-migration shape while the version row and the other table stay
|
|
140
163
|
// current — a single sentinel can't see that hole, so every recent batch
|
|
141
164
|
// keeps a representative column here until it is ancient enough to retire.
|
|
165
|
+
// A new TABLE needs an entry here just as much as a new COLUMN does, and v38/v39
|
|
166
|
+
// not having one is a latent hole, not a precedent: CORE_SCHEMA is reached ONLY
|
|
167
|
+
// on the forced pass, so if anything stamps the version without running it (a
|
|
168
|
+
// half-applied dev tree, an interrupted migration, a peer on a newer build), the
|
|
169
|
+
// fast-path returns forever and the table can never appear. Observed live during
|
|
170
|
+
// v45 development — the version bump and the CREATE landed in two edits, a hook
|
|
171
|
+
// fired between them, and the DB sat at v45 with no citation_surface_log while
|
|
172
|
+
// every reader silently swallowed "no such table" as "no data yet".
|
|
173
|
+
// pragma_table_info on a missing table returns zero rows (it does not throw), so
|
|
174
|
+
// naming any column of the new table is a table-presence check.
|
|
142
175
|
const LATEST_MIGRATION_COLUMNS = [
|
|
176
|
+
{ table: 'citation_surface_log', column: 'surface' }, // v45
|
|
143
177
|
{ table: 'observations', column: 'scope' }, // v44
|
|
144
178
|
{ table: 'observation_files', column: 'last_cited_session_id' }, // v43
|
|
145
179
|
];
|
|
@@ -243,6 +277,34 @@ const CORE_SCHEMA = `
|
|
|
243
277
|
PRIMARY KEY (project, memory_session_id)
|
|
244
278
|
);
|
|
245
279
|
|
|
280
|
+
-- v45: per-INJECTION-FACE twin of citation_log. One row per
|
|
281
|
+
-- (project, session, surface); the surface column is one of the
|
|
282
|
+
-- CITATION_SURFACES enum in lib/citation-tracker.mjs
|
|
283
|
+
-- (pretool | ups | error_recall | fyi | keyctx).
|
|
284
|
+
--
|
|
285
|
+
-- session_id is the CLAUDE CODE session id, NOT the memory session id that
|
|
286
|
+
-- keys citation_log. The two tables therefore do NOT join, on purpose. The
|
|
287
|
+
-- memory session id lives in one file per PROJECT (hook-shared session-<project>,
|
|
288
|
+
-- 12h), so two concurrent CC sessions in one project share it -- which is
|
|
289
|
+
-- survivable for citation_log because that table ACCUMULATES deltas, and
|
|
290
|
+
-- destructive here because this one OVERWRITES: the second session's Stop
|
|
291
|
+
-- would erase the first's counts. Same reasoning that moved applyCitationDecay
|
|
292
|
+
-- onto the CC session id in D#60.
|
|
293
|
+
--
|
|
294
|
+
-- Overwrite (not accumulate) is correct for this table because its source --
|
|
295
|
+
-- ONE CC session's transcript -- only ever grows, so a Stop re-fire recomputes
|
|
296
|
+
-- the same-or-larger sets: idempotent by construction, and a cross-turn late
|
|
297
|
+
-- citation raises cited_n without touching injected_n.
|
|
298
|
+
CREATE TABLE IF NOT EXISTS citation_surface_log (
|
|
299
|
+
project TEXT NOT NULL,
|
|
300
|
+
session_id TEXT NOT NULL,
|
|
301
|
+
surface TEXT NOT NULL,
|
|
302
|
+
resolved_at INTEGER,
|
|
303
|
+
injected_n INTEGER NOT NULL DEFAULT 0,
|
|
304
|
+
cited_n INTEGER NOT NULL DEFAULT 0,
|
|
305
|
+
PRIMARY KEY (project, session_id, surface)
|
|
306
|
+
);
|
|
307
|
+
|
|
246
308
|
CREATE TABLE IF NOT EXISTS migration_cleanups (
|
|
247
309
|
name TEXT PRIMARY KEY,
|
|
248
310
|
done_at_epoch INTEGER NOT NULL
|
|
@@ -904,11 +966,13 @@ const DEFERRED_CLEANUPS = [
|
|
|
904
966
|
// Rename the short project to canonical on EVERY project-scoped table.
|
|
905
967
|
// Originally only the first three were rewritten, so a short-named
|
|
906
968
|
// project's deferred TODOs (deferred_work), activity (events), citation
|
|
907
|
-
// history (citation_log), and /clear-/exit
|
|
908
|
-
// were stranded on the old name — invisible to
|
|
909
|
-
// after normalization. All
|
|
969
|
+
// history (citation_log + v45 citation_surface_log), and /clear-/exit
|
|
970
|
+
// handoffs (session_handoffs) were stranded on the old name — invisible to
|
|
971
|
+
// every project-scoped query after normalization. All eight carry a
|
|
972
|
+
// `project` column (verified).
|
|
910
973
|
for (const table of ['observations', 'sdk_sessions', 'session_summaries',
|
|
911
|
-
'session_handoffs', 'citation_log', '
|
|
974
|
+
'session_handoffs', 'citation_log', 'citation_surface_log',
|
|
975
|
+
'events', 'deferred_work']) {
|
|
912
976
|
db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
|
|
913
977
|
}
|
|
914
978
|
}
|