claude-mem-lite 3.65.0 → 3.66.1

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.65.0",
13
+ "version": "3.66.1",
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.65.0",
3
+ "version": "3.66.1",
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/format-utils.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { DAY_MS } from './lib/time-constants.mjs';
1
2
  // claude-mem-lite: String formatting and display utilities
2
3
  // Extracted from utils.mjs for focused responsibility
3
4
 
@@ -231,7 +232,7 @@ export function isoWeekKey(epochMs) {
231
232
  const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
232
233
  tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
233
234
  const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
234
- const weekNum = Math.ceil(((tmp - yearStart) / 86400000 + 1) / 7);
235
+ const weekNum = Math.ceil(((tmp - yearStart) / DAY_MS + 1) / 7);
235
236
  const isoYear = tmp.getUTCFullYear();
236
237
  return `${isoYear}-W${String(weekNum).padStart(2, '0')}`;
237
238
  }
package/haiku-client.mjs CHANGED
@@ -100,6 +100,28 @@ const MODEL_MAP = {
100
100
  // A call that genuinely needs sampling can pass opts.temperature to override.
101
101
  const DEFAULT_LLM_TEMPERATURE = 0;
102
102
 
103
+ /**
104
+ * Timeout budget for BACKGROUND LLM work (detached enrich/optimize/summary
105
+ * workers, registry indexing) — the calls with no latency budget at all.
106
+ *
107
+ * Every dispatcher below degrades to `claude -p` when the keyed provider fails,
108
+ * and the CLI leg pays a full Claude Code boot before inference: measured
109
+ * 8.1s / 9.2s / 11.7s / 13.4s on an idle machine for a 400-token JSON reply
110
+ * (2026-08-16), against API-leg latencies under 2s. Callers that sized their
111
+ * timeout for the API leg (15–20s) were therefore killing the fallback
112
+ * mid-flight — save-enrich's 15s budget left 1.6s of headroom over the worst
113
+ * sample, which is how 6/57 (10.5%) of instrumented runs landed on
114
+ * reason:'llm-null' and why manual saves stopped getting search_aliases.
115
+ *
116
+ * Deliberately NOT applied as a floor inside callModelCLI / callHaikuCLI /
117
+ * callModelCLIAsync: those are also reached from latency-bound callers — the
118
+ * lesson bridge's 2.5s fail-open budget on the PreToolUse hook, and deep-search
119
+ * rerank on the MCP request path — where failing fast beats blocking a user for
120
+ * 45s. The allowance is caller-side policy, not a clamp.
121
+ * Pinned both ways by `tests/llm-timeout-budget.test.mjs`.
122
+ */
123
+ export const BG_LLM_TIMEOUT_MS = 45000;
124
+
103
125
  /**
104
126
  * Resolve the LLM model to use for background calls.
105
127
  * Reads CLAUDE_MEM_MODEL env var, defaults to 'haiku'.
@@ -409,11 +431,20 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
409
431
  function callModelCLI(prompt, model, { timeout }) {
410
432
  const modelName = MODEL_MAP[model] ? model : 'haiku';
411
433
  try {
412
- const result = execFileSync(getClaudePath(), ['-p', '--model', modelName], {
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'], {
413
444
  input: flattenForCLI(prompt),
414
445
  timeout,
415
446
  encoding: 'utf8',
416
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
447
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
417
448
  stdio: ['pipe', 'pipe', 'pipe'],
418
449
  cwd: '/tmp',
419
450
  });
@@ -453,8 +484,9 @@ export function callModelCLIAsync(prompt, model, { timeout }) {
453
484
  const modelName = MODEL_MAP[model] ? model : 'haiku';
454
485
  let child;
455
486
  try {
456
- child = spawn(getClaudePath(), ['-p', '--model', modelName], {
457
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
487
+ // Same headless-tax flags as callModelCLI (rationale there).
488
+ child = spawn(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
489
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
458
490
  cwd: '/tmp',
459
491
  stdio: ['pipe', 'pipe', 'pipe'],
460
492
  });
@@ -603,11 +635,12 @@ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature
603
635
  function callHaikuCLI(prompt, { timeout }) {
604
636
  const { cli: modelName } = resolveModel();
605
637
  try {
606
- const result = execFileSync(getClaudePath(), ['-p', '--model', modelName], {
638
+ // Same headless-tax flags as callModelCLI (rationale there).
639
+ const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
607
640
  input: flattenForCLI(prompt),
608
641
  timeout,
609
642
  encoding: 'utf8',
610
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
643
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
611
644
  stdio: ['pipe', 'pipe', 'pipe'],
612
645
  cwd: '/tmp', // Prevent ghost sessions in user's /resume list
613
646
  });
package/hook-context.mjs CHANGED
@@ -17,6 +17,7 @@ import { extractUnfinishedSummary } from './hook-handoff.mjs';
17
17
  import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
18
18
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
19
19
 
20
+ import { DAY_MS } from './lib/time-constants.mjs';
20
21
  /**
21
22
  * Infer the project directory from environment variables or cwd.
22
23
  * @returns {string} Absolute path to the project directory
@@ -43,7 +44,7 @@ function mdCell(s) {
43
44
  }
44
45
 
45
46
  export function computeAdaptiveWindows(db, project) {
46
- const sevenDaysAgo = Date.now() - 7 * 86400000;
47
+ const sevenDaysAgo = Date.now() - 7 * DAY_MS;
47
48
  const row = db.prepare(`
48
49
  SELECT COUNT(*) as c FROM observations
49
50
  WHERE project = ? AND created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0
@@ -52,13 +53,13 @@ export function computeAdaptiveWindows(db, project) {
52
53
 
53
54
  if (velocity > 10) {
54
55
  // High velocity: tighter windows, focus on very recent
55
- return { tier1: 12 * 3600000, tier2: 3 * 86400000, tier3: 14 * 86400000, sessWindow: 3 * 86400000 };
56
+ return { tier1: 12 * 3600000, tier2: 3 * DAY_MS, tier3: 14 * DAY_MS, sessWindow: 3 * DAY_MS };
56
57
  } else if (velocity >= 3) {
57
58
  // Medium velocity: default windows
58
- return { tier1: 24 * 3600000, tier2: 7 * 86400000, tier3: 30 * 86400000, sessWindow: 7 * 86400000 };
59
+ return { tier1: 24 * 3600000, tier2: 7 * DAY_MS, tier3: 30 * DAY_MS, sessWindow: 7 * DAY_MS };
59
60
  } else {
60
61
  // Low velocity: wider windows, older data still relevant
61
- return { tier1: 48 * 3600000, tier2: 14 * 86400000, tier3: 60 * 86400000, sessWindow: 14 * 86400000 };
62
+ return { tier1: 48 * 3600000, tier2: 14 * DAY_MS, tier3: 60 * DAY_MS, sessWindow: 14 * DAY_MS };
62
63
  }
63
64
  }
64
65
 
package/hook-llm.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getCurrentBranch, notLowSignalTitleClause,
11
11
  } from './utils.mjs';
12
12
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
+ import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
13
14
  import { scrubRecord } from './lib/scrub-record.mjs';
14
15
  import { getVocabulary, computeVector, vecTextForRow } from './tfidf.mjs';
15
16
  import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope } from './lib/observation-write.mjs';
@@ -23,6 +24,7 @@ import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './l
23
24
  import { episodeHasSignificantContent } from './hook-episode.mjs';
24
25
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
25
26
 
27
+ import { DAY_MS } from './lib/time-constants.mjs';
26
28
  // T9: memdir-incompatible types live in the `events` table, not `observations`.
27
29
  // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
28
30
  const EVENT_TYPE_SET = new Set(EVENT_TYPES);
@@ -84,7 +86,7 @@ export function recordRetryAttempt(db, recovered, bucket = dateBucketUtc()) {
84
86
  * YYYY-MM-DD lexicographic order).
85
87
  */
86
88
  export function readRetryStats(db, days = 30) {
87
- const cutoff = new Date(Date.now() - days * 86400000);
89
+ const cutoff = new Date(Date.now() - days * DAY_MS);
88
90
  return db.prepare(
89
91
  `SELECT date_bucket, attempts, recovered FROM lesson_retry_stats
90
92
  WHERE date_bucket >= ? ORDER BY date_bucket DESC`
@@ -205,8 +207,8 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
205
207
  // 3-day Jaccard catches near-duplicates without blocking legitimately new observations
206
208
  const LOW_SIGNAL = LOW_SIGNAL_TITLE;
207
209
  if (obs.title && LOW_SIGNAL.test(obs.title)) {
208
- const sevenDaysAgo = now.getTime() - 7 * 86400000;
209
- const threeDaysAgo = now.getTime() - 3 * 86400000;
210
+ const sevenDaysAgo = now.getTime() - 7 * DAY_MS;
211
+ const threeDaysAgo = now.getTime() - 3 * DAY_MS;
210
212
  // Phase 1: exact title match within 7 days
211
213
  const exactDup = db.prepare(`
212
214
  SELECT 1 FROM observations
@@ -856,7 +858,7 @@ ${actionList}`;
856
858
  const retrySlot = await acquireLLMSlot();
857
859
  try {
858
860
  const retryPrompt = buildLessonRetryPrompt(episode, parsed);
859
- const retryRaw = retrySlot ? await callLLM(retryPrompt, 10000) : null;
861
+ const retryRaw = retrySlot ? await callLLM(retryPrompt, BG_LLM_TIMEOUT_MS) : null;
860
862
  if (retryRaw) {
861
863
  const retry = parseJsonFromLLM(retryRaw);
862
864
  const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
@@ -1135,7 +1137,7 @@ ${obsList}`;
1135
1137
 
1136
1138
  let raw, llmParsed;
1137
1139
  try {
1138
- raw = await callLLM(prompt, 20000);
1140
+ raw = await callLLM(prompt, BG_LLM_TIMEOUT_MS);
1139
1141
  llmParsed = parseJsonFromLLM(raw);
1140
1142
  } finally {
1141
1143
  releaseLLMSlot();
package/hook-memory.mjs CHANGED
@@ -9,8 +9,9 @@ import { DB_DIR } from './schema.mjs';
9
9
  import { extractIdents } from './lib/lesson-idents.mjs';
10
10
  import { formatSubagentContext } from './lib/task-imperative.mjs';
11
11
 
12
+ import { DAY_MS } from './lib/time-constants.mjs';
12
13
  const MAX_MEMORY_INJECTIONS = 3;
13
- const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
14
+ const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
14
15
  // Aligned with TYPE_QUALITY_CASE in scoring-sql.mjs (R2 rebalance).
15
16
  // Weights calibrated to empirical avg access_count:
16
17
  // decision 6.05, discovery 3.32, bugfix 2.24, feature 2.04, change 0.93, refactor 0.54.
@@ -98,7 +99,7 @@ function candidateCoverage(row, queryTerms) {
98
99
  return hits / queryTerms.length;
99
100
  }
100
101
 
101
- const FILE_RECALL_LOOKBACK_MS = 60 * 86400000; // 60 days
102
+ const FILE_RECALL_LOOKBACK_MS = 60 * DAY_MS; // 60 days
102
103
  const MAX_FILE_RECALL = 2;
103
104
 
104
105
  // P1: stale-obs verify-before-use threshold. An injected obs older than this
@@ -107,7 +108,7 @@ const MAX_FILE_RECALL = 2;
107
108
  // renamed since capture. Pure-decision/architecture obs (no file_paths)
108
109
  // don't get the hint: their drift is text-only and Claude already verifies
109
110
  // at consumption time per the project mem-usage contract.
110
- const STALE_OBS_THRESHOLD_MS = 30 * 86400000;
111
+ const STALE_OBS_THRESHOLD_MS = 30 * DAY_MS;
111
112
 
112
113
  /**
113
114
  * Format a single line for the <memory-context> block emitted by
package/hook-optimize.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity, clampImportance, cjkBigrams,
14
14
  notLowSignalTitleClause, scrubSecrets,
15
15
  } from './utils.mjs';
16
- import { callModelJSON } from './haiku-client.mjs';
16
+ import { callModelJSON, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
17
17
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
18
18
  import { scrubRecord } from './lib/scrub-record.mjs';
19
19
  import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
@@ -22,6 +22,7 @@ import { DB_DIR } from './schema.mjs';
22
22
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
23
23
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
24
24
 
25
+ import { DAY_MS } from './lib/time-constants.mjs';
25
26
  const RUNTIME_DIR = join(DB_DIR, 'runtime');
26
27
 
27
28
  // ─── Budget ─────────────────────────────────────────────────────────────────
@@ -174,7 +175,7 @@ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
174
175
 
175
176
  JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
176
177
  Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
177
- const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout: 15000, maxTokens: 300 });
178
+ const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
178
179
  const aliasArr = parsed && Array.isArray(parsed.search_aliases)
179
180
  ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
180
181
  : [];
@@ -203,7 +204,7 @@ importance: 0=no value, 1=routine, 2=notable non-obvious insight, 3=critical. De
203
204
  lesson_learned: State what was learned. If routine, write "none".
204
205
  search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
205
206
 
206
- const parsed = await callModelJSON(prompt, 'haiku', { timeout: 15000, maxTokens: 500 });
207
+ const parsed = await callModelJSON(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
207
208
  if (!parsed || !parsed.title) { skipped++; continue; }
208
209
 
209
210
  // Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
@@ -288,7 +289,7 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
288
289
  // ─── Task 2: Normalize ─────────────────────────────────────────────────────
289
290
 
290
291
  const NORMALIZE_GATE_FILE = join(RUNTIME_DIR, 'last-normalize.json');
291
- const NORMALIZE_INTERVAL_MS = 7 * 86400000; // 7 days
292
+ const NORMALIZE_INTERVAL_MS = 7 * DAY_MS; // 7 days
292
293
 
293
294
  // Pure gate decision (no IO) — exported for testing. Fail-OPEN on a
294
295
  // malformed-but-valid-JSON gate: a missing/non-numeric `epoch` makes
@@ -355,7 +356,7 @@ Rules:
355
356
  - Include CJK ↔ English equivalents if present
356
357
  - Skip terms that have no synonyms in the list`;
357
358
 
358
- const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
359
+ const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
359
360
  if (!parsed?.groups || !Array.isArray(parsed.groups)) return [];
360
361
  return parsed.groups.filter(g => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
361
362
  } catch (e) {
@@ -447,7 +448,7 @@ export async function executeNormalize(db, force = false, { project } = {}) {
447
448
 
448
449
  // ─── Task 3: Cluster-merge ─────────────────────────────────────────────────
449
450
 
450
- const MERGE_TIME_WINDOW_MS = 30 * 86400000;
451
+ const MERGE_TIME_WINDOW_MS = 30 * DAY_MS;
451
452
  // Merge-review band [MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD): titles in this
452
453
  // Jaccard range are LLM-reviewed for merge; at/above AUTO_MERGE_THRESHOLD they'd
453
454
  // already auto-merge elsewhere, below MERGE_JACCARD_LOW they're too dissimilar.
@@ -523,7 +524,7 @@ Return ONLY valid JSON:
523
524
  - If they should NOT be merged: {"should_merge":false}
524
525
  - If they SHOULD be merged: {"should_merge":true,"merged_title":"≤120 char comprehensive title","merged_narrative":"comprehensive ≤800 char summary preserving all key details","merged_concepts":["kw1","kw2"],"merged_facts":["specific fact 1"],"merged_lesson":"synthesized non-obvious lesson or null","importance":2}`;
525
526
 
526
- const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
527
+ const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
527
528
  if (!parsed || !parsed.should_merge) return { merged: false };
528
529
 
529
530
  // Keeper = highest importance, then highest access_count. Previously access_count
@@ -641,11 +642,11 @@ export async function executeClusterMerge(db, maxClusters = 5, { project } = {})
641
642
 
642
643
  // ─── Task 4: Smart-compress ────────────────────────────────────────────────
643
644
 
644
- const COMPRESS_TIME_SPLIT_MS = 14 * 86400000;
645
+ const COMPRESS_TIME_SPLIT_MS = 14 * DAY_MS;
645
646
  const COMPRESS_COSINE_THRESHOLD = 0.3;
646
647
 
647
648
  export function findSmartCompressCandidates(db, ageDays = 30, { project } = {}) {
648
- const cutoff = Date.now() - ageDays * 86400000;
649
+ const cutoff = Date.now() - ageDays * DAY_MS;
649
650
  const projectClause = project ? 'AND project = ?' : '';
650
651
  const stmt = db.prepare(`
651
652
  SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
@@ -757,7 +758,7 @@ ${obsDescriptions}
757
758
 
758
759
  JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
759
760
 
760
- const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
761
+ const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
761
762
  if (!parsed || !parsed.title) return { compressed: false };
762
763
 
763
764
  // Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
@@ -5,17 +5,15 @@
5
5
  // Differs from SessionStart-on-compact (which fires AFTER compaction):
6
6
  // PreCompact ensures memory survives the compaction step itself.
7
7
 
8
- import { writeFileSync } from 'fs';
9
- import { join } from 'path';
10
8
  import { buildSessionContextLines } from './hook-context.mjs';
11
9
  import { inferProject, debugCatch, debugLog } from './utils.mjs';
12
10
  import { RUNTIME_DIR } from './hook-shared.mjs';
13
- import { keyContextIdsFileName } from './lib/injected-ids.mjs';
11
+ import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
14
12
 
15
13
  /**
16
- * Build + emit the memory context block on stdout. Pure read; no DB writes
17
- * (one runtime marker file: the Key Context ids the re-emitted block renders,
18
- * refreshing handleUserPrompt's exclude-set — see D#123 in hook.mjs).
14
+ * Build + emit the memory context block on stdout. Writes the Key Context ids
15
+ * the re-emitted block renders (refreshing handleUserPrompt's exclude-set see
16
+ * D#123 in hook.mjs) and bumps injection_count on those rows (D#124).
19
17
  *
20
18
  * @param {object} ctx
21
19
  * @param {import('better-sqlite3').Database} ctx.db
@@ -29,12 +27,14 @@ export function handlePreCompact({ db, project, sessionId }) {
29
27
  const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
30
28
  if (!body || String(body).trim() === '') return;
31
29
  process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
32
- try {
33
- writeFileSync(
34
- join(RUNTIME_DIR, keyContextIdsFileName(project, sessionId || null)),
35
- JSON.stringify({ ids: collector.keyContextIds || [], ts: Date.now(), session: sessionId || null }),
36
- );
37
- } catch (e) { debugCatch(e, 'pre-compact-keyctx-marker'); }
30
+ // Same recorder as handleSessionStart: marker + injection_count bump (D#124).
31
+ // A re-render into a compacted context is a fresh injection of those rows.
32
+ recordKeyContextInjection(db, {
33
+ runtimeDir: RUNTIME_DIR,
34
+ project,
35
+ sessionId: sessionId || null,
36
+ ids: collector.keyContextIds || [],
37
+ });
38
38
  } catch (e) {
39
39
  debugCatch(e, 'handlePreCompact');
40
40
  }
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,7 +31,7 @@ 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
35
36
  // Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
36
37
  // keyObs; each of the two sections then renders at most 5). The user-prompt
@@ -123,6 +124,120 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
123
124
  return count;
124
125
  }
125
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
+ ]);
152
+
153
+ // Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
154
+ // `pending-`/`reads-` are absent from BOTH lists on purpose: the first holds
155
+ // unflushed observations (data, not cache) and the rest already belong to
156
+ // sweepOrphanEpisodeFiles on tighter cutoffs.
157
+ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
158
+ '.auto-adopt-',
159
+ '.deferred-block-migrated-',
160
+ '.legacy-claude-md-cleaned-',
161
+ // v3.66.1: these two shipped in the GC list for one release and had to come
162
+ // out. Both are version-keyed one-shot migration sentinels written by
163
+ // scripts/setup.sh, and their gate is `! -f <marker>` — deleting one re-runs
164
+ // its migration. `.mcp-dedup-v2.78` gates a block that removes
165
+ // mcpServers.mem / mcpServers["mem-lite"] from the user's ~/.claude.json with
166
+ // a raw writeFileSync (no tmp+rename, no backup), which the repo's own test
167
+ // documents as intentionally one-shot: "If a user later runs `claude mcp add
168
+ // mem ...` themselves, the gate intentionally lets it stand." A 30-day sweep
169
+ // turned that into a recurring purge of a config file we do not own.
170
+ // The mtime never refreshes (the gate skips the block once the file exists),
171
+ // so every install older than 30 days would have lost it on the first
172
+ // SessionStart after upgrading.
173
+ //
174
+ // Why it was missed: the search for writers used `grep --include=*.mjs
175
+ // --include=*.js`, and the writer is a SHELL script. `sentinelPrefixesFromShell`
176
+ // below now derives this class from scripts/*.sh instead of from memory.
177
+ '.mcp-dedup-',
178
+ '.residue-warned-',
179
+ ]);
180
+
181
+ /**
182
+ * Marker-name prefixes that scripts/*.sh treats as one-shot sentinels, derived
183
+ * from the shell source rather than restated here. `tests/runtime-marker-gc`
184
+ * asserts none of them is GC-able: a shell-written sentinel is invisible to a
185
+ * JS-only grep, which is exactly how `.mcp-dedup-` reached the GC list.
186
+ *
187
+ * @param {string} shellSource concatenated contents of scripts/*.sh
188
+ * @returns {string[]} prefixes like `.mcp-dedup-`
189
+ */
190
+ export function sentinelPrefixesFromShell(shellSource) {
191
+ const out = new Set();
192
+ // Matches `"$DATA_DIR/runtime/.mcp-dedup-v2.78"` and friends: a dotfile under
193
+ // runtime/ whose name carries a version-ish suffix.
194
+ for (const m of String(shellSource || '').matchAll(/runtime\/(\.[a-z0-9-]*?-)v?[0-9][0-9.]*/gi)) {
195
+ out.add(m[1]);
196
+ }
197
+ return [...out];
198
+ }
199
+
200
+ /**
201
+ * Sweep per-project runtime markers older than `ageMs`. fs-only, best-effort,
202
+ * never throws. Returns the number of files removed.
203
+ *
204
+ * The two prefix lists are injectable ONLY so the precedence rule below can be
205
+ * exercised: with the shipped lists they are disjoint, which makes the
206
+ * preserved check redundant today and load-bearing the moment a future family
207
+ * nests inside a GC-able one. Production callers pass neither.
208
+ *
209
+ * @param {string} runtimeDir
210
+ * @param {{ageMs?: number, now?: number, gcPrefixes?: string[], preservedPrefixes?: string[]}} [opts]
211
+ * @returns {number}
212
+ */
213
+ export function sweepStaleProjectMarkers(runtimeDir, {
214
+ ageMs = STALE_PROJECT_MARKER_AGE_MS,
215
+ now = Date.now(),
216
+ gcPrefixes = GC_PROJECT_MARKER_PREFIXES,
217
+ preservedPrefixes = GC_PRESERVED_MARKER_PREFIXES,
218
+ env = process.env,
219
+ } = {}) {
220
+ // Kill switch (naming mirrors SKIP_COMPRESS / SKIP_OPTIMIZE / SKIP_SAVE_ENRICH):
221
+ // this is the only sweep that deletes files a user might want to inspect, so a
222
+ // released default that reclaims state needs a documented way back out.
223
+ if (env.CLAUDE_MEM_SKIP_MARKER_GC === '1') return 0;
224
+ let entries;
225
+ try { entries = readdirSync(runtimeDir); } catch { return 0; }
226
+ const cutoff = now - ageMs;
227
+ let count = 0;
228
+ for (const f of entries) {
229
+ // Preserved wins on any overlap, so a future prefix added to both lists
230
+ // fails safe (kept) instead of deleting a side-effect record.
231
+ if (preservedPrefixes.some((p) => f.startsWith(p))) continue;
232
+ if (!gcPrefixes.some((p) => f.startsWith(p))) continue;
233
+ const full = join(runtimeDir, f);
234
+ try {
235
+ if (statSync(full).mtimeMs < cutoff) { unlinkSync(full); count++; }
236
+ } catch { /* concurrent unlink / permission / directory — ignore */ }
237
+ }
238
+ return count;
239
+ }
240
+
126
241
  // Ensure runtime directory exists AND is owner-only (0700), matching the DB dir
127
242
  // (schema.mjs). Runtime aux files carry captured file paths + scrubbed activity; on a
128
243
  // shared host a 0755 dir would let another local user read them. hardenRuntimeFiles()
@@ -195,7 +310,7 @@ export function openDb() {
195
310
  // response string (callers run parseJsonFromLLM themselves) or null.
196
311
  // maxTokens is sized for session-summary / episode JSON (larger than the
197
312
  // registry/optimize callers' budgets).
198
- export async function callLLM(prompt, timeoutMs = 15000) {
313
+ export async function callLLM(prompt, timeoutMs = BG_LLM_TIMEOUT_MS) {
199
314
  if (detectLLMMode() !== 'cli') {
200
315
  const result = await callHaiku(prompt, { timeout: timeoutMs, maxTokens: 2000 });
201
316
  return result?.text ?? null;
@@ -203,11 +318,13 @@ export async function callLLM(prompt, timeoutMs = 15000) {
203
318
 
204
319
  const { cli: modelName } = resolveModelShared();
205
320
  try {
206
- const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName], {
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'], {
207
324
  input: _flattenForCLI(prompt),
208
325
  timeout: timeoutMs,
209
326
  encoding: 'utf8',
210
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
327
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
211
328
  stdio: ['pipe', 'pipe', 'pipe'],
212
329
  cwd: '/tmp', // Prevent ghost sessions in user's /resume list
213
330
  });