claude-mem-lite 3.64.0 → 3.66.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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/deep-search.mjs +2 -1
  4. package/format-utils.mjs +2 -1
  5. package/haiku-client.mjs +39 -6
  6. package/hook-context.mjs +28 -22
  7. package/hook-handoff.mjs +3 -4
  8. package/hook-llm.mjs +7 -5
  9. package/hook-memory.mjs +4 -3
  10. package/hook-optimize.mjs +15 -16
  11. package/hook-precompact.mjs +15 -2
  12. package/hook-shared.mjs +97 -5
  13. package/hook.mjs +59 -20
  14. package/lib/citation-tracker.mjs +47 -3
  15. package/lib/db-backup.mjs +2 -1
  16. package/lib/deferred-work.mjs +1 -1
  17. package/lib/err-sampler.mjs +1 -1
  18. package/lib/hook-telemetry.mjs +1 -1
  19. package/lib/inject-search-core.mjs +16 -13
  20. package/lib/injected-ids.mjs +20 -0
  21. package/lib/keyctx-marker.mjs +71 -0
  22. package/lib/maintain-core.mjs +5 -4
  23. package/lib/metrics.mjs +1 -1
  24. package/lib/recall-core.mjs +2 -2
  25. package/lib/recent-core.mjs +3 -1
  26. package/lib/save-enrich.mjs +3 -2
  27. package/lib/search-core.mjs +6 -4
  28. package/lib/stats-core.mjs +6 -4
  29. package/lib/stats-quality.mjs +2 -1
  30. package/lib/time-constants.mjs +21 -0
  31. package/lib/timeline-core.mjs +7 -6
  32. package/mem-cli.mjs +19 -19
  33. package/npm-shrinkwrap.json +2 -2
  34. package/package.json +3 -1
  35. package/registry-enricher.mjs +2 -2
  36. package/registry-recommend.mjs +3 -2
  37. package/scoring-sql.mjs +8 -7
  38. package/scripts/pre-tool-recall.js +2 -1
  39. package/scripts/user-prompt-search.js +2 -1
  40. package/search-scoring.mjs +4 -3
  41. package/server.mjs +10 -4
  42. package/source-files.mjs +6 -0
  43. package/tfidf.mjs +3 -3
  44. package/tier.mjs +4 -3
package/hook-shared.mjs CHANGED
@@ -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 } from './haiku-client.mjs';
13
+ import { getClaudePath as getClaudePathShared, 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.
@@ -18,6 +18,7 @@ import { memdirPath as _memdirPath, isAdopted as _isAdoptedMemdir } from './memd
18
18
  import { isAdopted as _isAdoptedClaudeMd } from './claudemd.mjs';
19
19
  import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
20
20
 
21
+ import { DAY_MS } from './lib/time-constants.mjs';
21
22
  // ─── Constants ────────────────────────────────────────────────────────────────
22
23
 
23
24
  export const RUNTIME_DIR = join(DB_DIR, 'runtime');
@@ -30,8 +31,14 @@ export const SESSION_EXPIRY_MS = 12 * 60 * 60 * 1000; // 12h
30
31
  export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
31
32
  export const STALE_LOCK_MS = 30000; // 30s
32
33
  export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
33
- export const RELATED_OBS_WINDOW_MS = 7 * 86400000; // 7 days
34
+ export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
34
35
  export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
36
+ // Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
37
+ // keyObs; each of the two sections then renders at most 5). The user-prompt
38
+ // exclude-set does NOT mirror this query — it reads the ids actually rendered
39
+ // from the keyctx marker (D#123 review C-1: query-mirroring suppressed
40
+ // <memory-context> injection on quiet/adopted projects where nothing renders).
41
+ export const KEY_CONTEXT_LIMIT = 10;
35
42
 
36
43
  // Phase A (v2.31.3+): MEM_QUIET_HOOKS=1 drops descriptive hook/MCP-instruction
37
44
  // bodies (File Lessons / Key Context headers, MCP WHEN-TO-USE & decision rules,
@@ -117,6 +124,89 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
117
124
  return count;
118
125
  }
119
126
 
127
+ // ─── Per-project marker GC (P2-15) ───────────────────────────────────────────
128
+ // RUNTIME_DIR had three sweeps and a hole. Per-SESSION files age out at 24h
129
+ // (hook.mjs) and orphaned episode/read trackers at 1h/24h (above), but the
130
+ // per-PROJECT markers — one file per project, written once, never revisited —
131
+ // had no reclamation path at all. A live install on 2026-08-16 held 253 files,
132
+ // 152 of them past 30 days, including entire families for test sandboxes
133
+ // deleted months earlier (session-tmp--sdscc-e2e-*, cite-recall-scratchpad--
134
+ // fixture-*) and a .skill-reco-cooldown-* family that nothing had ever swept.
135
+ //
136
+ // Deliberately a NAMED list rather than a wildcard: these markers share a shape
137
+ // but not a meaning. The GC-able ones are caches — delete them and the next
138
+ // session re-derives the state (or, for a cooldown, merely allows a suggestion
139
+ // sooner). The preserved ones are records of a side effect already performed;
140
+ // removing them re-arms it (.auto-adopt-* re-attempts a write into the user's
141
+ // project CLAUDE.md, the migration sentinels re-run their one-time work), which
142
+ // is a bad trade for the 13-45 bytes each occupies.
143
+ export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
144
+
145
+ // Regenerated on demand; safe to lose at any time.
146
+ export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
147
+ 'session-', // project → memory-session-id pointer
148
+ 'cite-recall-', // last session's cite-recall snapshot (nudge input)
149
+ '.skill-cooldown-', // suggestion throttle timestamp
150
+ '.skill-reco-cooldown-', // recommendation throttle timestamp
151
+ // These two have NO writer and NO reader left in the tree (verified by grep,
152
+ // 2026-08-16) — they are version-keyed one-time markers from retired code
153
+ // paths (live dir holds `.mcp-dedup-v2.10`, `.residue-warned-v2.55`). Nothing
154
+ // recreates them, so sweeping them is a one-shot cleanup, not a policy.
155
+ '.mcp-dedup-',
156
+ '.residue-warned-',
157
+ ]);
158
+
159
+ // Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
160
+ // `pending-`/`reads-` are absent from BOTH lists on purpose: the first holds
161
+ // unflushed observations (data, not cache) and the rest already belong to
162
+ // sweepOrphanEpisodeFiles on tighter cutoffs.
163
+ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
164
+ '.auto-adopt-',
165
+ '.deferred-block-migrated-',
166
+ '.legacy-claude-md-cleaned-',
167
+ ]);
168
+
169
+ /**
170
+ * Sweep per-project runtime markers older than `ageMs`. fs-only, best-effort,
171
+ * never throws. Returns the number of files removed.
172
+ *
173
+ * The two prefix lists are injectable ONLY so the precedence rule below can be
174
+ * exercised: with the shipped lists they are disjoint, which makes the
175
+ * preserved check redundant today and load-bearing the moment a future family
176
+ * nests inside a GC-able one. Production callers pass neither.
177
+ *
178
+ * @param {string} runtimeDir
179
+ * @param {{ageMs?: number, now?: number, gcPrefixes?: string[], preservedPrefixes?: string[]}} [opts]
180
+ * @returns {number}
181
+ */
182
+ export function sweepStaleProjectMarkers(runtimeDir, {
183
+ ageMs = STALE_PROJECT_MARKER_AGE_MS,
184
+ now = Date.now(),
185
+ gcPrefixes = GC_PROJECT_MARKER_PREFIXES,
186
+ preservedPrefixes = GC_PRESERVED_MARKER_PREFIXES,
187
+ env = process.env,
188
+ } = {}) {
189
+ // Kill switch (naming mirrors SKIP_COMPRESS / SKIP_OPTIMIZE / SKIP_SAVE_ENRICH):
190
+ // this is the only sweep that deletes files a user might want to inspect, so a
191
+ // released default that reclaims state needs a documented way back out.
192
+ if (env.CLAUDE_MEM_SKIP_MARKER_GC === '1') return 0;
193
+ let entries;
194
+ try { entries = readdirSync(runtimeDir); } catch { return 0; }
195
+ const cutoff = now - ageMs;
196
+ let count = 0;
197
+ for (const f of entries) {
198
+ // Preserved wins on any overlap, so a future prefix added to both lists
199
+ // fails safe (kept) instead of deleting a side-effect record.
200
+ if (preservedPrefixes.some((p) => f.startsWith(p))) continue;
201
+ if (!gcPrefixes.some((p) => f.startsWith(p))) continue;
202
+ const full = join(runtimeDir, f);
203
+ try {
204
+ if (statSync(full).mtimeMs < cutoff) { unlinkSync(full); count++; }
205
+ } catch { /* concurrent unlink / permission / directory — ignore */ }
206
+ }
207
+ return count;
208
+ }
209
+
120
210
  // Ensure runtime directory exists AND is owner-only (0700), matching the DB dir
121
211
  // (schema.mjs). Runtime aux files carry captured file paths + scrubbed activity; on a
122
212
  // shared host a 0755 dir would let another local user read them. hardenRuntimeFiles()
@@ -189,7 +279,7 @@ export function openDb() {
189
279
  // response string (callers run parseJsonFromLLM themselves) or null.
190
280
  // maxTokens is sized for session-summary / episode JSON (larger than the
191
281
  // registry/optimize callers' budgets).
192
- export async function callLLM(prompt, timeoutMs = 15000) {
282
+ export async function callLLM(prompt, timeoutMs = BG_LLM_TIMEOUT_MS) {
193
283
  if (detectLLMMode() !== 'cli') {
194
284
  const result = await callHaiku(prompt, { timeout: timeoutMs, maxTokens: 2000 });
195
285
  return result?.text ?? null;
@@ -197,11 +287,13 @@ export async function callLLM(prompt, timeoutMs = 15000) {
197
287
 
198
288
  const { cli: modelName } = resolveModelShared();
199
289
  try {
200
- const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName], {
290
+ // Same headless-tax flags as haiku-client.mjs#callModelCLI (rationale
291
+ // there): no transcript persistence, no claudemd hook fan-out.
292
+ const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName, '--no-session-persistence'], {
201
293
  input: _flattenForCLI(prompt),
202
294
  timeout: timeoutMs,
203
295
  encoding: 'utf8',
204
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
296
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
205
297
  stdio: ['pipe', 'pipe', 'pipe'],
206
298
  cwd: '/tmp', // Prevent ghost sessions in user's /resume list
207
299
  });
package/hook.mjs CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
43
43
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
44
44
  sessionFile, getSessionId, createSessionId, openDb,
45
- spawnBackground, sweepOrphanEpisodeFiles,
45
+ spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
46
46
  } from './hook-shared.mjs';
47
47
  import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
48
48
  import { scrubRecord } from './lib/scrub-record.mjs';
@@ -68,7 +68,8 @@ import { formatTaskImperative } from './lib/task-imperative.mjs';
68
68
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
69
69
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
70
70
  import { detectMemOverride } from './lib/mem-override.mjs';
71
- import { injectedIdsFileName } from './lib/injected-ids.mjs';
71
+ import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
72
+ import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
72
73
  import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
73
74
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
74
75
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
@@ -90,6 +91,7 @@ async function loadCacheGuard() {
90
91
  import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
91
92
  import { getVocabulary } from './tfidf.mjs';
92
93
 
94
+ import { DAY_MS } from './lib/time-constants.mjs';
93
95
  // Prevent recursive hooks from background claude -p calls
94
96
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
95
97
  const event = process.argv[2];
@@ -738,7 +740,12 @@ async function handleStop() {
738
740
  // filter as citedMain (the numerator, below) — an obs injected only
739
741
  // inside a subagent (sidechain) would otherwise enter the denominator
740
742
  // but never the numerator and streak-demote despite being used there.
741
- const injected = extractAllInjected(transcriptPath, { mainOnly: true });
743
+ // runtimeDir + project enable the 5th (Key Context) face — see
744
+ // extractInjectedFromKeyContext: it is marker-derived, because the
745
+ // SessionStart block leaves no hook attachment to parse.
746
+ const injected = extractAllInjected(transcriptPath, {
747
+ mainOnly: true, runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
748
+ });
742
749
  // P5 ①: cite-back signals — observations whose warned file the agent
743
750
  // edited this session. Union into injected so they're resolved (they
744
751
  // were injected via pre-tool-recall) and, below, into cited so the
@@ -869,7 +876,8 @@ function gcStalePreRecallCooldowns() {
869
876
  // D#120: the injected-ids marker is also per-session now — same growth
870
877
  // shape as the cooldown files, same 24h GC (dedup window is 5 min).
871
878
  const isCooldown = name.startsWith('pre-recall-cooldown-') && name.endsWith('.json');
872
- const isInjectedMarker = name.startsWith('.claude-mem-injected-');
879
+ const isInjectedMarker = name.startsWith('.claude-mem-injected-')
880
+ || name.startsWith('.claude-mem-keyctx-'); // D#123 Key Context marker — same per-session growth, same 24h GC
873
881
  if (!isCooldown && !isInjectedMarker) continue;
874
882
  try {
875
883
  const p = join(RUNTIME_DIR, name);
@@ -889,7 +897,7 @@ function gcStalePreRecallCooldowns() {
889
897
  function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
890
898
  // ── DB mutations in a transaction (crash-safe consistency) ──
891
899
  const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
892
- const autoCompressAge = Date.now() - 30 * 86400000; // 30 days (accelerated from 90)
900
+ const autoCompressAge = Date.now() - 30 * DAY_MS; // 30 days (accelerated from 90)
893
901
 
894
902
  db.transaction(() => {
895
903
  // Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
@@ -944,7 +952,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
944
952
  // imp=1 on these already; this just shrinks the GC latency so the
945
953
  // projected 32.5% corpus reduction materializes within a week on live
946
954
  // DBs instead of bleeding into the 30-day tier.
947
- const noiseCompressAge = Date.now() - 7 * 86400000;
955
+ const noiseCompressAge = Date.now() - 7 * DAY_MS;
948
956
  const noiseCompressed = db.prepare(`
949
957
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
950
958
  WHERE COALESCE(compressed_into, 0) = 0
@@ -974,7 +982,7 @@ function runSessionStartAutoMaintain(db) {
974
982
  } catch {}
975
983
  if (shouldMaintain) {
976
984
  try {
977
- const STALE_AGE = Date.now() - 30 * 86400000;
985
+ const STALE_AGE = Date.now() - 30 * DAY_MS;
978
986
  const OP_CAP = 500;
979
987
  // Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
980
988
  // op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
@@ -994,7 +1002,7 @@ function runSessionStartAutoMaintain(db) {
994
1002
  // children (compressed_into dangling at a deleted id). purgeStale recovers them
995
1003
  // first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
996
1004
  // created_at_epoch: 30d marking gate + 7d grace = 37d.
997
- const purged = purgeStale(db, mctx, Date.now() - 37 * 86400000);
1005
+ const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
998
1006
  if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
999
1007
 
1000
1008
  const cleaned = cleanupBroken(db, mctx);
@@ -1108,7 +1116,7 @@ function runSessionStartAutoMaintain(db) {
1108
1116
  DELETE FROM session_handoffs
1109
1117
  WHERE (type = 'clear' AND created_at_epoch < ?)
1110
1118
  OR (type != 'clear' AND created_at_epoch < ?)
1111
- `).run(Date.now() - HANDOFF_EXPIRY_CLEAR - 86400000, Date.now() - HANDOFF_EXPIRY_EXIT - 86400000);
1119
+ `).run(Date.now() - HANDOFF_EXPIRY_CLEAR - DAY_MS, Date.now() - HANDOFF_EXPIRY_EXIT - DAY_MS);
1112
1120
  if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
1113
1121
  } catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
1114
1122
 
@@ -1348,6 +1356,10 @@ async function handleSessionStart() {
1348
1356
  // GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
1349
1357
  // moved here from pre-tool-recall.js's hot path.
1350
1358
  gcStalePreRecallCooldowns();
1359
+ // P2-15: the per-PROJECT half of the same problem — markers written once per
1360
+ // project and never revisited (session-/cite-recall-/skill cooldowns). Same
1361
+ // SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
1362
+ try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
1351
1363
  // Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
1352
1364
  try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1353
1365
  // Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
@@ -1471,13 +1483,31 @@ async function handleSessionStart() {
1471
1483
  // token-budgeted observation pool directly from the DB.
1472
1484
  // Pass CC session id so the Working State block is scoped to this session,
1473
1485
  // preventing parallel sessions from seeing each other's /clear handoff.
1474
- const fullContext = buildSessionContextLines(db, project, now, ccSessionId);
1486
+ const contextCollector = {};
1487
+ const fullContext = buildSessionContextLines(db, project, now, ccSessionId, contextCollector);
1475
1488
 
1476
1489
  // Stdout is the sole context-delivery channel. The SessionStart hook output
1477
1490
  // is injected as a <system-reminder> at session start, giving Claude the
1478
1491
  // full summary + handoff state + observations table fresh from the DB.
1479
1492
  process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
1480
1493
 
1494
+ // D#123 (review C-1): persist the Key Context ids ACTUALLY rendered above so
1495
+ // handleUserPrompt can exclude exactly those from <memory-context> — and
1496
+ // nothing else. Under quiet/adopted the sections don't render, the id list is
1497
+ // empty, and prompt-time injection is NOT suppressed (the old query-mirroring
1498
+ // exclude-set blanked the same-project leg on adopted projects). Written
1499
+ // unconditionally (even when empty) so a resumed session can't act on a
1500
+ // previous session's stale marker semantics; 24h GC below.
1501
+ // D#124: the same call also bumps injection_count on the rendered rows —
1502
+ // Key Context was a shown-but-uncounted surface, so its rows could never
1503
+ // reach applyCitationDecay's denominator. One recorder, both writers.
1504
+ recordKeyContextInjection(db, {
1505
+ runtimeDir: RUNTIME_DIR,
1506
+ project,
1507
+ sessionId: ccSessionId,
1508
+ ids: contextCollector.keyContextIds || [],
1509
+ });
1510
+
1481
1511
  // One-time migration: remove any stale <claude-mem-context> block left in
1482
1512
  // CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
1483
1513
  cleanupClaudeMdLegacyBlock();
@@ -1651,13 +1681,21 @@ async function handleUserPrompt() {
1651
1681
  // (mirrors CC built-in memoryTypes.ts:215). Skip both Key Context lookup
1652
1682
  // and the <memory-context> emission for this turn.
1653
1683
  if (!detectMemOverride(promptText)) try {
1654
- const keyObs = db.prepare(`
1655
- SELECT id FROM observations
1656
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
1657
- AND COALESCE(importance, 1) >= 2
1658
- ORDER BY created_at_epoch DESC LIMIT 5
1659
- `).all(project);
1660
- const keyContextIds = keyObs.map(o => o.id);
1684
+ // D#123 (review C-1): the exclude-set is the Key Context ids ACTUALLY
1685
+ // rendered at SessionStart — read from the marker handleSessionStart wrote,
1686
+ // not re-derived from a query. The old query-mirroring set excluded rows
1687
+ // that were never shown (quiet/adopted projects render no Key Context at
1688
+ // all), blanking the same-project <memory-context> leg outright. Missing
1689
+ // or other-session marker → empty set: unknown injections must fail open
1690
+ // (inject, maybe duplicate) rather than fail closed (suppress).
1691
+ const keyContextIds = [];
1692
+ try {
1693
+ const raw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
1694
+ const { ids, session } = JSON.parse(raw);
1695
+ if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
1696
+ keyContextIds.push(...ids);
1697
+ }
1698
+ } catch { /* no marker — nothing was injected, exclude nothing */ }
1661
1699
  const pathAInjectedIds = [];
1662
1700
 
1663
1701
  // Read IDs already injected by user-prompt-search.js to avoid duplicate injection
@@ -1686,8 +1724,9 @@ async function handleUserPrompt() {
1686
1724
  const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
1687
1725
  || process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
1688
1726
  // Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
1689
- // key-context top-5, which overlaps the high-value lesson pool and would suppress
1690
- // the pick. The chosen id is excluded from the <memory-context> block below instead.
1727
+ // SessionStart Key Context set, which overlaps the high-value lesson pool and
1728
+ // would suppress the pick. The chosen id is excluded from the <memory-context>
1729
+ // block below instead.
1691
1730
  const imperativePick = taskImperativeOn
1692
1731
  ? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
1693
1732
  : null;
@@ -1772,7 +1811,7 @@ function handleAutoCompress() {
1772
1811
  if (!db) return;
1773
1812
 
1774
1813
  try {
1775
- const compressCutoff = Date.now() - 60 * 86400000; // 60 days
1814
+ const compressCutoff = Date.now() - 60 * DAY_MS; // 60 days
1776
1815
  const compressCandidates = selectCompressionCandidates(db, { cutoff: compressCutoff, includeAutoMarked: true });
1777
1816
  if (compressCandidates.length < 3) return;
1778
1817
 
@@ -13,7 +13,9 @@
13
13
  import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
14
14
  import { join } from 'path';
15
15
  import { debugCatch } from '../utils.mjs';
16
+ import { keyContextIdsFileName } from './injected-ids.mjs';
16
17
 
18
+ import { DAY_MS } from './time-constants.mjs';
17
19
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
18
20
  // Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
19
21
  const CITATION_RE = /#(\d{1,7})\b/g;
@@ -342,16 +344,55 @@ export function extractInjectedFromFyi(transcriptPath, opts = {}) {
342
344
  return ids;
343
345
  }
344
346
 
347
+ /**
348
+ * Extract observation IDs rendered into the SessionStart / PreCompact
349
+ * `<claude-mem-context>` File Lessons + Key Context sections (D#124).
350
+ *
351
+ * The odd one out among the extractors: this surface leaves no hook attachment
352
+ * in the transcript — the block is written straight to stdout at SessionStart —
353
+ * so there is nothing to parse. It reads the per-session marker instead, the
354
+ * same file handleUserPrompt uses as its exclude-set, which by construction
355
+ * lists what was ACTUALLY rendered (empty on quiet/adopted projects where the
356
+ * sections never appear).
357
+ *
358
+ * Session-gated: a marker whose recorded session differs from the caller's is
359
+ * another window's render and must not enter this session's denominator. No
360
+ * coordinates → empty set, so transcript-only callers keep their old behaviour.
361
+ *
362
+ * @param {object} [ctx]
363
+ * @param {string} [ctx.runtimeDir]
364
+ * @param {string} [ctx.project]
365
+ * @param {string|null} [ctx.sessionId]
366
+ * @returns {Set<number>}
367
+ */
368
+ export function extractInjectedFromKeyContext({ runtimeDir, project, sessionId = null } = {}) {
369
+ const ids = new Set();
370
+ if (!runtimeDir || !project) return ids;
371
+ try {
372
+ const raw = readFileSync(join(runtimeDir, keyContextIdsFileName(project, sessionId)), 'utf8');
373
+ const parsed = JSON.parse(raw);
374
+ if (sessionId && parsed?.session && parsed.session !== sessionId) return ids;
375
+ for (const id of Array.isArray(parsed?.ids) ? parsed.ids : []) addObsId(ids, id);
376
+ } catch { /* no marker / torn write → nothing was rendered, fail open */ }
377
+ return ids;
378
+ }
379
+
345
380
  /**
346
381
  * Union of every injection surface's IDs for a transcript: pre-tool-recall +
347
382
  * UserPromptSubmit `<memory-context>` + PostToolUse error-recall + the
348
- * user-prompt-search FYI block. Single integration point the Stop handler calls.
383
+ * user-prompt-search FYI block + the SessionStart Key Context block. Single
384
+ * integration point the Stop handler calls.
349
385
  *
350
386
  * @param {string|null|undefined} transcriptPath
351
387
  * @param {object} [opts]
352
388
  * @param {boolean} [opts.mainOnly=false] Skip sidechain-injected IDs. The
353
389
  * citation-decay caller passes true so the injected denominator matches the
354
390
  * mainOnly cited numerator; the P4 access-bump caller omits it (broader).
391
+ * @param {string} [opts.runtimeDir] With `project`, enables the Key Context
392
+ * face. Omitted by callers that only have a transcript path (computeCiteRecall
393
+ * over an arbitrary file), which then see the four transcript-derived faces.
394
+ * @param {string} [opts.project]
395
+ * @param {string|null} [opts.sessionId]
355
396
  * @returns {Set<number>}
356
397
  */
357
398
  export function extractAllInjected(transcriptPath, opts = {}) {
@@ -360,6 +401,9 @@ export function extractAllInjected(transcriptPath, opts = {}) {
360
401
  ...extractInjectedFromUserPromptSubmit(transcriptPath, opts),
361
402
  ...extractInjectedFromErrorRecall(transcriptPath, opts),
362
403
  ...extractInjectedFromFyi(transcriptPath, opts),
404
+ // Marker-derived, not transcript-derived: no-op unless the caller passes
405
+ // runtimeDir + project (see extractInjectedFromKeyContext).
406
+ ...extractInjectedFromKeyContext(opts),
363
407
  ]);
364
408
  }
365
409
 
@@ -808,8 +852,8 @@ export function computeCitationFunnelTrend(db, { days = 7, limit = 10, project =
808
852
  if (!db) return empty;
809
853
  try {
810
854
  const now = Date.now();
811
- const windowStart = now - days * 86400000;
812
- const priorStart = now - 2 * days * 86400000;
855
+ const windowStart = now - days * DAY_MS;
856
+ const priorStart = now - 2 * days * DAY_MS;
813
857
  const projClause = project ? 'AND project = ?' : '';
814
858
 
815
859
  const sessions = db.prepare(`
package/lib/db-backup.mjs CHANGED
@@ -11,6 +11,7 @@ import { readdirSync, unlinkSync, statSync } from 'fs';
11
11
  import { dirname, basename, join } from 'path';
12
12
  import { debugLog } from '../utils.mjs';
13
13
 
14
+ import { DAY_MS } from './time-constants.mjs';
14
15
  // M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
15
16
  // each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
16
17
  // siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
@@ -35,7 +36,7 @@ export function backupBudgetBytes() {
35
36
  // a per-tag exemption would make every one-shot tag's only snapshot immortal —
36
37
  // exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
37
38
  // convention (7d) used by stale-observation retention.
38
- export const BACKUP_EVICTION_GRACE_MS = 7 * 86400000;
39
+ export const BACKUP_EVICTION_GRACE_MS = 7 * DAY_MS;
39
40
 
40
41
  // Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
41
42
  // snapshotDb writes). Budget eviction deletes ONLY names matching this — a
@@ -1,3 +1,4 @@
1
+ import { DAY_MS } from './time-constants.mjs';
1
2
  // claude-mem-lite — deferred_work data layer
2
3
  // Pure-data CRUD + ordinal resolver + transactional closure helper.
3
4
  // Decoupled from observations table: different lifecycle, different scoring.
@@ -60,7 +61,6 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
60
61
 
61
62
  // ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
62
63
 
63
- const DAY_MS = 86_400_000;
64
64
  // Internal const (was exported at v3.51.0 birth with zero external importers —
65
65
  // knip-baseline discipline: un-export rather than grow the unused-exports list).
66
66
  const DEFER_STALE_DAYS = 30;
@@ -19,8 +19,8 @@ import { appendFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSyn
19
19
  import { join } from 'path';
20
20
  import { scrubSecrets } from '../secret-scrub.mjs';
21
21
 
22
- const DAY_MS = 86400000;
23
22
 
23
+ import { DAY_MS } from './time-constants.mjs';
24
24
  function today() {
25
25
  // UTC date string; sharding daily keeps files bounded even at high sample rates.
26
26
  return new Date(Date.now()).toISOString().slice(0, 10);
@@ -26,7 +26,7 @@ import { join } from 'path';
26
26
  import { isNativeBindingError } from './binding-probe.mjs';
27
27
  import { recordNativeBindingBreakage } from './native-binding-hint.mjs';
28
28
 
29
- const DAY_MS = 86400000;
29
+ import { DAY_MS } from './time-constants.mjs';
30
30
  const RETENTION_MS = 14 * DAY_MS;
31
31
  const HOOK_ERRORS_SUBDIR = 'hook-errors';
32
32
 
@@ -1,10 +1,13 @@
1
- // lib/inject-search-core.mjs — the injection-side shared core (P2-11, audit
2
- // 2026-08-14). Shared home for the three SQL atoms that kept drifting across
3
- // hand-copied twins on the INJECTION-side retrieval surfaces (the five consumer
4
- // files the ledger test enforces — NOT yet the whole read surface: recall-core /
5
- // recent-core / timeline-core / hook-context / hook-handoff / hook-optimize /
6
- // deep-search / stats and the sessions/events decay arms in lib/search-core
7
- // still inline their own copies; extending them is the deferred second cut):
1
+ // lib/inject-search-core.mjs — the retrieval-side shared core (P2-11, audit
2
+ // 2026-08-14; second cut D#123, 2026-08-16). Shared home for the three SQL
3
+ // atoms that kept drifting across hand-copied twins — first on the five
4
+ // injection-side surfaces, then extended across the remaining read surfaces
5
+ // (hook-context / hook-handoff / hook-optimize / mem-cli / search-scoring /
6
+ // tfidf / deep-search / recall-core / recent-core / timeline-core / stats-core /
7
+ // search-core incl. its sessions+events decay arms / maintain-core; the ledger
8
+ // test enforces the full list). Deliberate compressed-only singles (maintain
9
+ // UPDATE guards, stats noise-gauge counts, export tombstone toggles, session-own
10
+ // history) stay inline — see the ledger test's non-member notes:
8
11
  //
9
12
  // * live-row filter — the compressed+superseded pair whose omission was
10
13
  // the superseded-invariant's recurring reopening
@@ -17,12 +20,12 @@
17
20
  // behavior factors (audit M-3: wired on every auto
18
21
  // surface, missing from the explicit-surface score)
19
22
  //
20
- // Consumers: scripts/user-prompt-search.js, scripts/pre-tool-recall.js,
21
- // hook-memory.mjs, hook.mjs (error-recall), search-engine.mjs. Each surface keeps
22
- // its own deliberate pipeline composition (BM25-sort + JS scoring vs SQL full
23
- // chain vs file-keyed sort — see #8786: per-surface asymmetries stay explicit);
24
- // only the ATOMS are shared. tests/inject-search-core.test.mjs holds the ledger:
25
- // the five consumer files must compose these builders, never re-inline copies.
23
+ // Each surface keeps its own deliberate pipeline composition (BM25-sort + JS
24
+ // scoring vs SQL full chain vs file-keyed sort — see #8786: per-surface
25
+ // asymmetries stay explicit); only the ATOMS are shared.
26
+ // tests/inject-search-core.test.mjs holds the ledger: consumer files must
27
+ // compose these builders, never re-inline copies (and the decay shape may not
28
+ // be hand-rolled anywhere, benchmark included).
26
29
  //
27
30
  // Lives under lib/ (not scripts/) so hook.mjs can statically import it without
28
31
  // colliding with the installExtractedRelease scripts-dir rename (same constraint
@@ -27,3 +27,23 @@ export function injectedIdsFileName(project, sessionId) {
27
27
  const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
28
28
  return `${base}-${safe}`;
29
29
  }
30
+
31
+ /**
32
+ * Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
33
+ * ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
34
+ * sections (empty under quiet/adopted). handleUserPrompt reads it as its
35
+ * exclude-set — D#123 review C-1: excluding the injector's QUERY result instead
36
+ * of what was really shown suppressed <memory-context> injection outright on
37
+ * quiet/adopted projects, where Key Context never renders at all.
38
+ * Session-lifetime validity (no time window): the SessionStart block stays in
39
+ * context for the whole session. Swept with the same 24h GC as the marker above.
40
+ * @param {string} project - inferProject() value (already filename-safe)
41
+ * @param {string} [sessionId] - CC session id
42
+ * @returns {string}
43
+ */
44
+ export function keyContextIdsFileName(project, sessionId) {
45
+ const base = `.claude-mem-keyctx-${project}`;
46
+ if (!sessionId) return base;
47
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
48
+ return `${base}-${safe}`;
49
+ }
@@ -0,0 +1,71 @@
1
+ // lib/keyctx-marker.mjs — the one place the SessionStart Key Context render is
2
+ // recorded. Two things happen together because they must describe the SAME set:
3
+ //
4
+ // ① the per-session marker file (D#123 review C-1): the obs ids ACTUALLY
5
+ // rendered into <claude-mem-context>, which handleUserPrompt reads as its
6
+ // exclude-set. Written even when empty, so a resumed session can never act
7
+ // on a previous session's stale semantics.
8
+ // ② injection_count / last_injected_at on those same rows (D#124): before
9
+ // this, Key Context was a shown-but-uncounted surface — up to 10 rows per
10
+ // session that could accrue no denominator and therefore never promote or
11
+ // demote through applyCitationDecay.
12
+ //
13
+ // handleSessionStart (hook.mjs) and handlePreCompact (hook-precompact.mjs) both
14
+ // render the block, so both call this. Keeping the pair in one function is the
15
+ // point: the CLI/MCP and SessionStart/PreCompact twin pairs in this repo have
16
+ // drifted often enough that "two callers, one body" is the standing rule.
17
+ //
18
+ // Never throws: a marker-write failure must not break context delivery, and it
19
+ // must not cost the metering either — the bump runs first.
20
+
21
+ import { writeFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import { debugCatch } from '../utils.mjs';
24
+ import { keyContextIdsFileName } from './injected-ids.mjs';
25
+
26
+ /**
27
+ * Record one Key Context render: bump the rendered rows, then persist the id
28
+ * list for the prompt-time exclude-set and the citation extractor.
29
+ *
30
+ * @param {import('better-sqlite3').Database} db
31
+ * @param {object} ctx
32
+ * @param {string} ctx.runtimeDir RUNTIME_DIR of the caller
33
+ * @param {string} ctx.project inferProject() value (filename-safe)
34
+ * @param {string|null} [ctx.sessionId] CC session id
35
+ * @param {number[]} [ctx.ids] obs ids ACTUALLY rendered (empty on quiet projects)
36
+ * @returns {{bumped: number, written: boolean}}
37
+ */
38
+ export function recordKeyContextInjection(db, { runtimeDir, project, sessionId = null, ids = [] } = {}) {
39
+ const clean = [];
40
+ for (const raw of Array.isArray(ids) ? ids : []) {
41
+ const id = Number(raw);
42
+ if (Number.isInteger(id) && id > 0 && id < 1e7) clean.push(id);
43
+ }
44
+
45
+ let bumped = 0;
46
+ if (db && clean.length > 0) {
47
+ try {
48
+ const now = Date.now();
49
+ // Mirrors hook-memory.mjs's UPS bump verbatim so the two surfaces feed the
50
+ // same counter with the same semantics. Per-row try/catch for FTS trigger
51
+ // safety (project_non_obvious.md).
52
+ const stmt = db.prepare(
53
+ 'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
54
+ );
55
+ for (const id of clean) {
56
+ try { stmt.run(now, id); bumped++; } catch { /* single-row failure must not drop the rest */ }
57
+ }
58
+ } catch (e) { debugCatch(e, 'keyctx-bump'); }
59
+ }
60
+
61
+ let written = false;
62
+ try {
63
+ writeFileSync(
64
+ join(runtimeDir, keyContextIdsFileName(project, sessionId)),
65
+ JSON.stringify({ ids: clean, ts: Date.now(), session: sessionId || null }),
66
+ );
67
+ written = true;
68
+ } catch (e) { debugCatch(e, 'keyctx-marker-write'); }
69
+
70
+ return { bumped, written };
71
+ }