claude-mem-lite 3.66.1 → 3.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.66.1",
13
+ "version": "3.67.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.66.1",
3
+ "version": "3.67.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
@@ -428,26 +428,122 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
428
428
  }
429
429
  }
430
430
 
431
+ // ─── Headless CLI flag compatibility ─────────────────────────────────────────
432
+ //
433
+ // --no-session-persistence + DISABLE_CLAUDEMD_HOOKS (2026-08-16): these headless
434
+ // calls were paying the full interactive-session tax — 1,004 transcripts piled up
435
+ // in ~/.claude/projects/-tmp/, and every spawn ran the claudemd plugin's whole
436
+ // hook fan-out (its SessionStart banner alone logged 682 rows in 3 days, drowning
437
+ // that project's telemetry). The persistence flag is OAuth-safe (probed);
438
+ // `--bare`/CLAUDE_CODE_SIMPLE are NOT (they hard-require ANTHROPIC_API_KEY —
439
+ // "Not logged in" on OAuth machines). The user's global CLAUDE.md injection has
440
+ // no OAuth-safe opt-out; accepted (haiku + prompt caching keeps it cheap).
441
+ //
442
+ // The flag is an unguarded dependency on a recent Claude Code CLI: package.json
443
+ // declares only node>=20, no Claude Code floor. On an older binary the spawn dies
444
+ // in argument parsing, the catch swallows it, and every CLI-leg LLM call returns
445
+ // null — no retry, no telemetry. That leg is what the keyed providers degrade to,
446
+ // so such a user loses enrichment, summarization and optimize all at once. So:
447
+ // detect the arg-parse rejection, retry once without the flag, and cache the
448
+ // negative only AFTER the retry actually succeeded. Caching on the failure
449
+ // instead would let one transient non-zero exit that happens to mention the flag
450
+ // push a healthy CLI back onto the session tax for the rest of the process.
451
+ // Hooks are short-lived, so an old binary pays one extra fail-fast spawn per
452
+ // process; the long-lived MCP server pays it once per run.
453
+ const HEADLESS_FLAG = '--no-session-persistence';
454
+ let _headlessFlagOk = true;
455
+
456
+ /** @internal test hook — module-level compat state must not leak across cases. */
457
+ export function _resetHeadlessFlag() { _headlessFlagOk = true; }
458
+
459
+ function claudeArgs(modelName) {
460
+ return _headlessFlagOk
461
+ ? ['-p', '--model', modelName, HEADLESS_FLAG]
462
+ : ['-p', '--model', modelName];
463
+ }
464
+
465
+ // A retry is only ever worth it when the diagnostic NAMES the token it rejected —
466
+ // every argv parser does, and requiring it is what keeps this from firing on
467
+ // Claude Code's own config diagnostics. The installed CLI carries strings like
468
+ // `Skill X has invalid effort 'y'. Valid options: …` and `Input validation error:
469
+ // Invalid arguments for tool`, which an unanchored unknown-word/option-word regex
470
+ // matches outright. Those are emitted for a malformed agent/skill file — a
471
+ // *persistent* condition — so an unanchored match would fire on the next transient
472
+ // 529, permanently revert v3.66.0's session-tax fix on a perfectly healthy CLI,
473
+ // and log a WARN blaming a flag that was never the problem (pre-tag review, HIGH).
474
+ // Deliberately NOT keyed on exit code alone either: a non-zero exit is also the
475
+ // normal shape of an overload/auth failure.
476
+ const FLAG_TOKEN = /no-session-persistence/;
477
+ const PARSE_REJECTION = /(unknown|unrecognized|unsupported|invalid|unexpected)[^\n]{0,40}(option|argument|flag|switch)/i;
478
+
479
+ // Below this many ms left, a retry can only spawn a process and immediately kill
480
+ // it — worse than returning the original failure.
481
+ const RETRY_MIN_BUDGET_MS = 500;
482
+
483
+ export function _isUnknownFlagError(diagnostic) {
484
+ if (!diagnostic) return false;
485
+ return FLAG_TOKEN.test(diagnostic) && (PARSE_REJECTION.test(diagnostic) || /usage:/i.test(diagnostic));
486
+ }
487
+
488
+ // stdout as well as stderr: a parser that prints its rejection (or usage banner)
489
+ // on stdout is otherwise invisible here, and FLAG_TOKEN keeps the widened input
490
+ // from loosening the match.
491
+ function cliDiagnostic(e) {
492
+ const err = e?.stderr?.toString?.() || e?.output?.[2]?.toString?.() || '';
493
+ const out = e?.stdout?.toString?.() || e?.output?.[1]?.toString?.() || '';
494
+ return `${err}\n${out}`;
495
+ }
496
+
497
+ /**
498
+ * Shared blocking `claude -p` runner for every sync CLI leg (callModelCLI,
499
+ * callHaikuCLI, hook-shared#callLLM). Throws exactly what execFileSync throws so
500
+ * each caller keeps its own partial-output salvage; the only added behaviour is
501
+ * the one-shot flag-compat retry described above.
502
+ * @param {string} modelName CLI model name ('haiku'|'sonnet')
503
+ * @param {{input:string, timeout:number}} opts
504
+ * @returns {string} raw stdout
505
+ */
506
+ export function execClaudeCliSync(modelName, { input, timeout }) {
507
+ const opts = {
508
+ input,
509
+ timeout,
510
+ encoding: 'utf8',
511
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
512
+ stdio: ['pipe', 'pipe', 'pipe'],
513
+ cwd: '/tmp', // Prevent ghost sessions in the user's /resume list
514
+ };
515
+ const args = claudeArgs(modelName);
516
+ const started = Date.now();
517
+ try {
518
+ return execFileSync(getClaudePath(), args, opts);
519
+ } catch (e) {
520
+ // A timeout is NOT a parse rejection. execFileSync kills the child and throws
521
+ // with its partial buffers attached (callModelCLI's salvage depends on exactly
522
+ // that), so without this guard a slow call whose output merely looked
523
+ // parse-shaped would be retried on the FULL original budget — doubling a
524
+ // latency-bound ceiling. lesson-bridge runs this leg at 2500ms on PreToolUse,
525
+ // where the CLI is measured at 8–13s and therefore times out routinely.
526
+ if (e?.killed || e?.signal) throw e;
527
+ // `args`, not the live flag: what matters is whether THIS attempt carried it.
528
+ // Symmetry with the async leg, where the distinction is load-bearing (a
529
+ // sibling can flip the flag across an await). Here execFileSync blocks the
530
+ // event loop for the whole child, so no other JS can interleave and the two
531
+ // readings are behaviourally identical — the substitution is deliberately
532
+ // mutation-silent, kept so the two legs cannot drift apart in meaning.
533
+ if (!args.includes(HEADLESS_FLAG) || !_isUnknownFlagError(cliDiagnostic(e))) throw e;
534
+ const remaining = timeout - (Date.now() - started);
535
+ if (remaining < RETRY_MIN_BUDGET_MS) throw e;
536
+ const out = execFileSync(getClaudePath(), ['-p', '--model', modelName], { ...opts, timeout: remaining });
537
+ _headlessFlagOk = false;
538
+ debugLog('WARN', 'cli-compat', `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`);
539
+ return out;
540
+ }
541
+ }
542
+
431
543
  function callModelCLI(prompt, model, { timeout }) {
432
544
  const modelName = MODEL_MAP[model] ? model : 'haiku';
433
545
  try {
434
- // --no-session-persistence + DISABLE_CLAUDEMD_HOOKS (2026-08-16): these
435
- // headless calls were paying the full interactive-session tax — 1,004
436
- // transcripts piled up in ~/.claude/projects/-tmp/, and every spawn ran
437
- // the claudemd plugin's whole hook fan-out (its SessionStart banner alone
438
- // logged 682 rows in 3 days, drowning that project's telemetry). The
439
- // persistence flag is OAuth-safe (probed); `--bare`/CLAUDE_CODE_SIMPLE are
440
- // NOT (they hard-require ANTHROPIC_API_KEY — "Not logged in" on OAuth
441
- // machines). The user's global CLAUDE.md injection has no OAuth-safe
442
- // opt-out; accepted (haiku + prompt caching keeps it cheap).
443
- const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
444
- input: flattenForCLI(prompt),
445
- timeout,
446
- encoding: 'utf8',
447
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
448
- stdio: ['pipe', 'pipe', 'pipe'],
449
- cwd: '/tmp',
450
- });
546
+ const result = execClaudeCliSync(modelName, { input: flattenForCLI(prompt), timeout });
451
547
  const text = result.trim();
452
548
  return text ? { text } : null;
453
549
  } catch (e) {
@@ -479,23 +575,31 @@ function callModelCLI(prompt, model, { timeout }) {
479
575
  * @param {{timeout:number}} opts SIGKILL after `timeout` ms; no retry.
480
576
  * @returns {Promise<{text:string}|null>}
481
577
  */
482
- export function callModelCLIAsync(prompt, model, { timeout }) {
483
- return new Promise((resolve) => {
484
- const modelName = MODEL_MAP[model] ? model : 'haiku';
578
+ export async function callModelCLIAsync(prompt, model, { timeout }) {
579
+ const modelName = MODEL_MAP[model] ? model : 'haiku';
580
+ const payload = flattenForCLI(prompt);
581
+ const started = Date.now();
582
+
583
+ // One spawn. Resolves {result, stderr, stdout, code}, never rejects. `code` is
584
+ // a number ONLY when the child exited on its own; a timeout/SIGKILL or a spawn
585
+ // error reports null, which is what keeps either from being mistaken for an
586
+ // argument-parse rejection and costing a second full-budget spawn.
587
+ const attempt = (args, budget) => new Promise((resolve) => {
485
588
  let child;
486
589
  try {
487
- // Same headless-tax flags as callModelCLI (rationale there).
488
- child = spawn(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
590
+ // Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
591
+ child = spawn(getClaudePath(), args, {
489
592
  env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
490
593
  cwd: '/tmp',
491
594
  stdio: ['pipe', 'pipe', 'pipe'],
492
595
  });
493
596
  } catch (e) {
494
597
  debugCatch(e, `${model}-cli-async`);
495
- resolve(null);
598
+ resolve({ result: null, stderr: '', stdout: '', code: null });
496
599
  return;
497
600
  }
498
601
  let stdout = '';
602
+ let stderr = '';
499
603
  let settled = false;
500
604
  const done = (val) => {
501
605
  if (settled) return;
@@ -510,26 +614,59 @@ export function callModelCLIAsync(prompt, model, { timeout }) {
510
614
  // brace check would discard a complete-but-```json-fenced payload (#8605);
511
615
  // parseJsonFromLLM strips fences before validating, and the caller re-parses
512
616
  // the returned text the same way.
513
- if (t && parseJsonFromLLM(t) !== null) { done({ text: t }); return; }
514
- done(null);
515
- }, timeout);
617
+ if (t && parseJsonFromLLM(t) !== null) { done({ result: { text: t }, stderr, stdout, code: null }); return; }
618
+ done({ result: null, stderr, stdout, code: null });
619
+ }, budget);
516
620
  child.stdout?.setEncoding('utf8'); // decode multi-byte UTF-8 (CJK) across chunk boundaries
517
621
  child.stdout?.on('data', (d) => { stdout += d; });
518
- child.stderr?.on('data', () => {}); // drain stderr so a chatty child can't block on a full pipe
519
- child.on('error', (e) => { debugCatch(e, `${model}-cli-async`); done(null); });
520
- child.on('close', () => {
622
+ // Keep draining stderr so a chatty child can't block on a full pipe, but keep
623
+ // a bounded head of it — the flag-compat probe needs the parser's complaint.
624
+ // Slice AFTER appending: checking the length first lets one arbitrarily large
625
+ // chunk through whole, which is the shape a single big stderr write takes.
626
+ child.stderr?.setEncoding?.('utf8');
627
+ child.stderr?.on('data', (d) => { stderr = (stderr + d).slice(0, 4096); });
628
+ child.on('error', (e) => { debugCatch(e, `${model}-cli-async`); done({ result: null, stderr: '', stdout: '', code: null }); });
629
+ child.on('close', (code) => {
521
630
  const t = stdout.trim();
522
- done(t ? { text: t } : null);
631
+ done({ result: t ? { text: t } : null, stderr, stdout, code });
523
632
  });
524
633
  // EPIPE guard: the child may exit before we finish writing stdin.
525
634
  child.stdin?.on('error', () => {});
526
635
  try {
527
- child.stdin?.write(flattenForCLI(prompt));
636
+ child.stdin?.write(payload);
528
637
  child.stdin?.end();
529
638
  } catch (e) {
530
639
  debugCatch(e, `${model}-cli-async:stdin`);
531
640
  }
532
641
  });
642
+
643
+ const firstArgs = claudeArgs(modelName);
644
+ const first = await attempt(firstArgs, timeout);
645
+ // Judged on `firstArgs`, not the live flag: a concurrent sibling may have
646
+ // flipped it between our spawn and our resume, and reading the global there
647
+ // would silently deny THIS call the retry it earned (MCP server, concurrent
648
+ // deep-search escalations). Gating on the exit code before `first.result` also
649
+ // covers a CLI that prints its usage banner to stdout and exits non-zero —
650
+ // otherwise that banner is returned as the model's answer and nothing retries.
651
+ const rejected = firstArgs.includes(HEADLESS_FLAG)
652
+ && typeof first.code === 'number' && first.code !== 0
653
+ && _isUnknownFlagError(`${first.stderr}\n${first.stdout.slice(0, 4096)}`);
654
+ if (!rejected) return first.result;
655
+ // The rejection is instantaneous (the child dies in argv parsing), so the retry
656
+ // normally gets nearly the whole budget; spend only what is left of it.
657
+ const remaining = timeout - (Date.now() - started);
658
+ if (remaining < RETRY_MIN_BUDGET_MS) return first.result;
659
+ const second = await attempt(['-p', '--model', modelName], remaining);
660
+ // Cache on the retry's EXIT, not its payload. Empty output is a designed
661
+ // outcome here (emit-nothing prompts, an `N/A` that trims away), so keying on
662
+ // text left the long-lived MCP server re-probing — two spawns per call, for the
663
+ // life of the process — on exactly the old CLI this exists to rescue. The sync
664
+ // twin caches on any non-throwing run; this now means the same thing.
665
+ if (second.code === 0) {
666
+ _headlessFlagOk = false;
667
+ debugLog('WARN', `${model}-cli-async`, `claude CLI rejected ${HEADLESS_FLAG}; dropped for this process (the headless session tax returns — upgrade Claude Code to avoid it)`);
668
+ }
669
+ return second.result;
533
670
  }
534
671
 
535
672
  // ─── API Mode ────────────────────────────────────────────────────────────────
@@ -635,15 +772,8 @@ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature
635
772
  function callHaikuCLI(prompt, { timeout }) {
636
773
  const { cli: modelName } = resolveModel();
637
774
  try {
638
- // Same headless-tax flags as callModelCLI (rationale there).
639
- const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
640
- input: flattenForCLI(prompt),
641
- timeout,
642
- encoding: 'utf8',
643
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
644
- stdio: ['pipe', 'pipe', 'pipe'],
645
- cwd: '/tmp', // Prevent ghost sessions in user's /resume list
646
- });
775
+ // Same headless-tax flags + flag-compat retry as callModelCLI (rationale there).
776
+ const result = execClaudeCliSync(modelName, { input: flattenForCLI(prompt), timeout });
647
777
  const text = result.trim();
648
778
  return text ? { text } : null;
649
779
  } catch (e) {
@@ -4,9 +4,33 @@
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
- export const LLM_SEM_TIMEOUT = 30000; // 30s max wait
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
 
@@ -57,18 +81,24 @@ export async function acquireLLMSlot() {
57
81
  const raw = readFileSync(fp, 'utf8');
58
82
  const info = JSON.parse(raw);
59
83
  const age = Date.now() - (info.ts || 0);
60
- if (age > 60000) {
61
- try { unlinkSync(fp); } catch {}
62
- continue;
63
- }
84
+ // Liveness FIRST, age second. The pre-D#134 order reaped on age alone,
85
+ // which evicted holders that were alive and mid-call (see the budget
86
+ // note at the top of this file). A dead holder is reaped at any age;
87
+ // a live one only once its age is implausible as a real hold, which is
88
+ // the pid-reuse case the age check exists for.
64
89
  if (info.pid) {
65
- try { process.kill(info.pid, 0); active++; } catch (killErr) {
66
- if (killErr.code === 'ESRCH') { try { unlinkSync(fp); } catch {} }
67
- else { active++; } // EPERM = process exists but different user
90
+ let alive;
91
+ try { process.kill(info.pid, 0); alive = true; } catch (killErr) {
92
+ // EPERM = process exists but belongs to another user → alive.
93
+ alive = killErr.code !== 'ESRCH';
68
94
  }
69
- } else {
70
- active++;
95
+ if (!alive) { try { unlinkSync(fp); } catch {} continue; }
96
+ }
97
+ if (age > LLM_SEM_STALE_MS) {
98
+ try { unlinkSync(fp); } catch {}
99
+ continue;
71
100
  }
101
+ active++;
72
102
  } catch {
73
103
  // Corrupt/unreadable semaphore file — treat as stale and remove
74
104
  try { unlinkSync(fp); } catch {}
package/hook-shared.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // claude-mem-lite: Shared infrastructure for hook.mjs and hook-llm.mjs
2
2
  // Constants, session management, DB access, LLM calls, process utilities
3
3
 
4
- import { execFileSync, spawn } from 'child_process';
4
+ import { spawn } from 'child_process';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
@@ -10,7 +10,7 @@ import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
10
10
  // Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
11
11
  // neither imports this file) — no cycle.
12
12
  import { recordHookError } from './lib/hook-telemetry.mjs';
13
- import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
13
+ import { execClaudeCliSync, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
14
14
  // Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
15
15
  // fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
16
16
  // neither imports hook-shared.
@@ -318,16 +318,10 @@ export async function callLLM(prompt, timeoutMs = BG_LLM_TIMEOUT_MS) {
318
318
 
319
319
  const { cli: modelName } = resolveModelShared();
320
320
  try {
321
- // Same headless-tax flags as haiku-client.mjs#callModelCLI (rationale
322
- // there): no transcript persistence, no claudemd hook fan-out.
323
- const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName, '--no-session-persistence'], {
324
- input: _flattenForCLI(prompt),
325
- timeout: timeoutMs,
326
- encoding: 'utf8',
327
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
328
- stdio: ['pipe', 'pipe', 'pipe'],
329
- cwd: '/tmp', // Prevent ghost sessions in user's /resume list
330
- });
321
+ // Shared runner with haiku-client.mjs#callModelCLI (rationale there): no
322
+ // transcript persistence, no claudemd hook fan-out, and the one-shot
323
+ // retry-without-flag that keeps this leg alive on an older Claude Code CLI.
324
+ const result = execClaudeCliSync(modelName, { input: _flattenForCLI(prompt), timeout: timeoutMs });
331
325
  return result.trim();
332
326
  } catch (e) {
333
327
  const out = _extractResponseFromError(e);
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
- extractAllInjected,
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
- const injected = extractAllInjected(transcriptPath, { mainOnly: true });
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
@@ -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
- * Extract observation IDs injected by the UserPromptSubmit `<memory-context>`
265
- * block (hook.mjs handleUserPrompt). Disjoint from pre-tool-recall extraction —
266
- * the Stop handler unions all surfaces via extractAllInjected.
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
- * @returns {Set<number>}
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 extractInjectedFromUserPromptSubmit(transcriptPath, opts = {}) {
272
- const ids = new Set();
273
- eachHookAttachment(transcriptPath, ({ command, text }) => {
274
- if (!command.includes(UPS_COMMAND_SUFFIX)) return;
275
- if (!text.includes('<memory-context')) return;
276
- for (const memLine of text.split('\n')) {
277
- if (!memLine.startsWith(UPS_LINE_PREFIX)) continue;
278
- // Take the LAST (#NN) on the line — formatMemoryLine puts the obs id
279
- // in trailing parens, possibly followed by ` [verify-before-use]`. Any
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 ids;
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 the PostToolUse error-recall hint
291
- * (hook.mjs triggerErrorRecall → `[claude-mem-lite] Related memories found for
292
- * this error:` followed by ` #NN [type] title` lines, delivered via
293
- * post-tool-use.sh). This is a high-volume surface that NO extractor matched
294
- * before error-recall'd obs accrued injection_count but never reached
295
- * applyCitationDecay, so they could neither promote nor demote.
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 extractInjectedFromErrorRecall(transcriptPath, opts = {}) {
301
- const ids = new Set();
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
- // user-prompt-search.js formatResults emits `[mem] FYI — Related memories ...`
318
- // then one `#NN <icon> title` row per obs (raw stdout, line-leading id). Distinct
319
- // from the `<memory-context>` block (hook.mjs) — the two UPS injectors dedup obs
320
- // by id at inject time, so they carry DISJOINT obs sets; both must be extracted
321
- // or the FYI-carried (highest-importance keyContext) obs never reach decay.
322
- const FYI_HEADER = '[mem] FYI Related memories';
323
- // Anchored at line start so `P#NN` past-question rows (user_prompts, different id
324
- // space) and any `#NN` inside lesson text are NOT matched.
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
- const ids = new Set();
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 new Set([
413
- ...extractInjectedFromPreToolUse(transcriptPath, opts),
414
- ...extractInjectedFromUserPromptSubmit(transcriptPath, opts),
415
- ...extractInjectedFromErrorRecall(transcriptPath, opts),
416
- ...extractInjectedFromFyi(transcriptPath, opts),
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
- // D#61: a lesson injected live and then superseded mid-session (auto-dedup /
661
- // supersedes= save) leaves its citation crediting NOBODY — selectStmt below
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,122 @@ 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
+ * @param {import('better-sqlite3').Database} db
1020
+ * @param {{days?: number, project?: string|null}} [opts]
1021
+ * @returns {{window_days: number, surfaces: Array<{surface: string, injected: number, cited: number, rate: number, sessions: number}>}}
1022
+ */
1023
+ export function computeSurfaceFunnel(db, { days = 7, project = null } = {}) {
1024
+ const empty = { window_days: days, surfaces: [] };
1025
+ if (!db) return empty;
1026
+ try {
1027
+ const windowStart = Date.now() - days * DAY_MS;
1028
+ const params = project ? [windowStart, project] : [windowStart];
1029
+ const rows = db.prepare(`
1030
+ SELECT surface,
1031
+ COALESCE(SUM(injected_n), 0) AS injected,
1032
+ COALESCE(SUM(cited_n), 0) AS cited,
1033
+ -- DISTINCT, not COUNT(*): rows are keyed (project, session,
1034
+ -- surface), so an unfiltered COUNT(*) counts project-sessions and
1035
+ -- over-reports "over N sessions" whenever a session spans projects.
1036
+ COUNT(DISTINCT session_id) AS sessions
1037
+ FROM citation_surface_log
1038
+ WHERE resolved_at >= ? ${project ? 'AND project = ?' : ''}
1039
+ GROUP BY surface
1040
+ ORDER BY injected DESC, surface ASC
1041
+ `).all(...params);
1042
+ return {
1043
+ window_days: days,
1044
+ surfaces: rows.map(r => ({ ...r, rate: r.injected > 0 ? r.cited / r.injected : 0 })),
1045
+ };
1046
+ } catch (e) { debugCatch(e, 'computeSurfaceFunnel'); return empty; }
1047
+ }
1048
+
843
1049
  /**
844
1050
  * R1 — read the per-session invocation→cite funnel as a windowed trend.
845
1051
  * `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,25 @@ 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.surfaces.length === 0) {
2599
+ // Deliberately does NOT claim "no data yet": the reader swallows a query
2600
+ // error, so an absent or unreadable citation_surface_log renders exactly
2601
+ // like an empty one. Say what is true (nothing came back) and name the
2602
+ // check, rather than assert the benign cause (pre-tag review b4).
2603
+ out(' (nothing returned for this window — rows accrue at Stop; if this stays empty after a few sessions, check the table exists: claude-mem-lite fts-check)');
2604
+ } else {
2605
+ for (const s of surfaceFunnel.surfaces) {
2606
+ const pct = (s.rate * 100).toFixed(1) + '%';
2607
+ const note = s.surface === 'keyctx' ? ' (promotion-only: never demotes)' : '';
2608
+ 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}`);
2609
+ }
2610
+ }
2611
+ out('');
2580
2612
  out('Active decay queue (uncited_streak >= 2, next miss → demote):');
2581
2613
  if (decayQueue.length === 0) out(' (none)');
2582
2614
  for (const r of decayQueue) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.66.1",
3
+ "version": "3.67.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.66.1",
9
+ "version": "3.67.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.66.1",
3
+ "version": "3.67.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
@@ -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
- export const CURRENT_SCHEMA_VERSION = 44;
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 handoffs (session_handoffs)
908
- // were stranded on the old name — invisible to every project-scoped query
909
- // after normalization. All seven carry a `project` column (verified).
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', 'events', 'deferred_work']) {
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
  }