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
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.64.0",
13
+ "version": "3.66.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.64.0",
3
+ "version": "3.66.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/deep-search.mjs CHANGED
@@ -36,6 +36,7 @@ import { sanitizeFtsQuery } from './utils.mjs';
36
36
  import { RRF_K } from './tfidf.mjs';
37
37
  import { rrfAccumulate } from './lib/rrf.mjs';
38
38
  import { llmRerankOrder, defaultRerankLLM } from './rerank.mjs';
39
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
39
40
 
40
41
  // original + up to 3 rewrites (keyword / concept-expansion / HyDE).
41
42
  export const MAX_VARIANTS = 4;
@@ -68,7 +69,7 @@ export const AUTO_DEEP_MIN_CORPUS = 10;
68
69
  */
69
70
  export function hasEscalatableCorpus(db, project, min = AUTO_DEEP_MIN_CORPUS) {
70
71
  try {
71
- const where = ['superseded_at IS NULL', 'COALESCE(compressed_into, 0) = 0'];
72
+ const where = [liveObsFilterSql('')];
72
73
  const params = [];
73
74
  if (project) { where.push('project = ?'); params.push(project); }
74
75
  const row = db.prepare(`SELECT COUNT(*) AS c FROM observations WHERE ${where.join(' AND ')}`).get(...params);
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
@@ -12,10 +12,12 @@ import {
12
12
  debugLog, debugCatch, neutralizeContextDelimiters,
13
13
  DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
14
14
  } from './utils.mjs';
15
- import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks } from './hook-shared.mjs';
15
+ import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks, KEY_CONTEXT_LIMIT } from './hook-shared.mjs';
16
16
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
17
17
  import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
18
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
18
19
 
20
+ import { DAY_MS } from './lib/time-constants.mjs';
19
21
  /**
20
22
  * Infer the project directory from environment variables or cwd.
21
23
  * @returns {string} Absolute path to the project directory
@@ -42,7 +44,7 @@ function mdCell(s) {
42
44
  }
43
45
 
44
46
  export function computeAdaptiveWindows(db, project) {
45
- const sevenDaysAgo = Date.now() - 7 * 86400000;
47
+ const sevenDaysAgo = Date.now() - 7 * DAY_MS;
46
48
  const row = db.prepare(`
47
49
  SELECT COUNT(*) as c FROM observations
48
50
  WHERE project = ? AND created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0
@@ -51,13 +53,13 @@ export function computeAdaptiveWindows(db, project) {
51
53
 
52
54
  if (velocity > 10) {
53
55
  // High velocity: tighter windows, focus on very recent
54
- 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 };
55
57
  } else if (velocity >= 3) {
56
58
  // Medium velocity: default windows
57
- 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 };
58
60
  } else {
59
61
  // Low velocity: wider windows, older data still relevant
60
- 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 };
61
63
  }
62
64
  }
63
65
 
@@ -83,12 +85,7 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
83
85
  const obsPool = db.prepare(`
84
86
  SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
85
87
  FROM observations
86
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
87
- -- superseded invisibility: auto-dedup (hook.mjs) sets superseded_at but leaves
88
- -- compressed_into=0, so the compressed filter alone lets the hidden near-duplicate
89
- -- resurface in the most-visible surface (injected every SessionStart). Sibling
90
- -- keyObs already filters this; obsPool + fallbackObs must match.
91
- AND superseded_at IS NULL
88
+ WHERE project = ? AND ${liveObsFilterSql('')}
92
89
  AND ${notLowSignalTitleClause('')}
93
90
  AND (
94
91
  (created_at_epoch > ? AND importance >= 1)
@@ -293,9 +290,15 @@ export function cleanupClaudeMdLegacyBlock() {
293
290
  * @param {string|null} [currentCcSessionId=null] Claude Code session id — when provided,
294
291
  * the "Working State (from /clear)" block is filtered to handoffs owned by this
295
292
  * session, preventing parallel-session bleed (see docs/bug.txt).
293
+ * @param {object|null} [collector=null] Optional out-param: when given, its
294
+ * `keyContextIds` property is set to the obs ids ACTUALLY rendered into the
295
+ * File Lessons / Key Context sections ([] under quiet/adopted or when the
296
+ * sections are empty). handleUserPrompt persists this as its exclude-set
297
+ * (D#123: the exclude-set must mirror real injections, not the keyObs query).
296
298
  * @returns {string} Joined markdown lines (without <claude-mem-context> wrappers)
297
299
  */
298
- export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null) {
300
+ export function buildSessionContextLines(db, project, now = new Date(), currentCcSessionId = null, collector = null) {
301
+ if (collector) collector.keyContextIds = [];
299
302
  // 1. Token-budgeted observation selection
300
303
  const selected = selectWithTokenBudget(db, project, 2000);
301
304
  const observations = selected.observations;
@@ -308,8 +311,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
308
311
  fallbackObs = db.prepare(`
309
312
  SELECT id, type, title, project, created_at
310
313
  FROM observations
311
- WHERE COALESCE(compressed_into, 0) = 0
312
- AND superseded_at IS NULL
314
+ WHERE ${liveObsFilterSql('')}
313
315
  AND ${notLowSignalTitleClause('')}
314
316
  AND (
315
317
  (created_at_epoch > ? AND importance >= 1)
@@ -335,10 +337,9 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
335
337
  // and Key Context (informational). Pushed into summaryLines.
336
338
  const keyObs = db.prepare(`
337
339
  SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
338
- WHERE o.project = ? AND COALESCE(o.compressed_into, 0) = 0
339
- AND o.superseded_at IS NULL
340
+ WHERE o.project = ? AND ${liveObsFilterSql('o')}
340
341
  AND COALESCE(o.importance, 1) >= 2
341
- ORDER BY o.created_at_epoch DESC LIMIT 10
342
+ ORDER BY o.created_at_epoch DESC LIMIT ${KEY_CONTEXT_LIMIT}
342
343
  `).all(project);
343
344
 
344
345
  if (keyObs.length > 0) {
@@ -357,30 +358,35 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
357
358
  const files = JSON.parse(o.files_modified);
358
359
  const fname = basename(Array.isArray(files) && files.length > 0 ? files[0] : '');
359
360
  if (fname) {
360
- fileLessons.push(`- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})`);
361
+ fileLessons.push({ id: o.id, line: `- ${fname}: ${truncate(o.lesson_learned, 100)} (#${o.id})` });
361
362
  continue;
362
363
  }
363
364
  } catch { /* fall through to keyContext */ }
364
365
  }
365
366
  const lesson = hasLesson ? ` — ${truncate(o.lesson_learned, 60)}` : '';
366
- keyContext.push(`- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}`);
367
+ keyContext.push({ id: o.id, line: `- [${o.type || 'discovery'}] ${truncate(clean, 80)} (#${o.id})${lesson}` });
367
368
  }
368
369
 
369
370
  // Phase A (QUIET_HOOKS) + Phase D (adopted sentinel): drop descriptive
370
371
  // File Lessons / Key Context sections when the user has opted into low-noise
371
372
  // hooks OR adopted invited-memory (MEMORY.md sentinel carries the triggers
372
373
  // at higher system-prompt authority). The Recent table still fires so #IDs
373
- // remain reachable via mem_get.
374
+ // remain reachable via mem_get. The collector sees only rows that survive
375
+ // BOTH the quiet gate and the per-section slice — rendered rows, nothing else.
374
376
  const quiet = effectiveQuiet();
375
377
  if (fileLessons.length > 0 && !quiet) {
378
+ const shown = fileLessons.slice(0, 5);
376
379
  summaryLines.push('### File Lessons');
377
- summaryLines.push(...fileLessons.slice(0, 5));
380
+ summaryLines.push(...shown.map((e) => e.line));
378
381
  summaryLines.push('');
382
+ if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
379
383
  }
380
384
  if (keyContext.length > 0 && !quiet) {
385
+ const shown = keyContext.slice(0, 5);
381
386
  summaryLines.push('### Key Context');
382
- summaryLines.push(...keyContext.slice(0, 5));
387
+ summaryLines.push(...shown.map((e) => e.line));
383
388
  summaryLines.push('');
389
+ if (collector) collector.keyContextIds.push(...shown.map((e) => e.id));
384
390
  }
385
391
  } else if (!latestSummary && !effectiveQuiet()) {
386
392
  // Fallback: no summary AND no key observations — show recent activity.
package/hook-handoff.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  // immutable in ESM and cannot be mocked after the fact.
14
14
  import * as gitStateModule from './lib/git-state.mjs';
15
15
  import * as taskReaderModule from './lib/task-reader.mjs';
16
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
16
17
 
17
18
  /**
18
19
  * Build and save a handoff snapshot to session_handoffs table.
@@ -76,8 +77,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
76
77
  if (subjectPrompts.length === 0) {
77
78
  const fallback = db.prepare(`
78
79
  SELECT title FROM observations
79
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
80
- AND superseded_at IS NULL
80
+ WHERE project = ? AND ${liveObsFilterSql('')}
81
81
  AND COALESCE(importance, 1) >= 3
82
82
  AND ${notLowSignalTitleClause('')}
83
83
  ORDER BY created_at_epoch DESC LIMIT 1
@@ -185,8 +185,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
185
185
  const decisions = db.prepare(`
186
186
  SELECT title FROM observations
187
187
  WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
188
- AND COALESCE(compressed_into, 0) = 0
189
- AND superseded_at IS NULL ${obsWindowClause}
188
+ AND ${liveObsFilterSql('')} ${obsWindowClause}
190
189
  ORDER BY created_at_epoch DESC LIMIT 10
191
190
  `).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
192
191
 
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,14 +13,16 @@ 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';
20
20
  import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
21
21
  import { DB_DIR } from './schema.mjs';
22
22
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
23
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
23
24
 
25
+ import { DAY_MS } from './lib/time-constants.mjs';
24
26
  const RUNTIME_DIR = join(DB_DIR, 'runtime');
25
27
 
26
28
  // ─── Budget ─────────────────────────────────────────────────────────────────
@@ -104,8 +106,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
104
106
  const stmt = db.prepare(`
105
107
  SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, importance, project
106
108
  FROM observations
107
- WHERE COALESCE(compressed_into, 0) = 0
108
- AND superseded_at IS NULL
109
+ WHERE ${liveObsFilterSql('')}
109
110
  AND (search_aliases IS NULL OR search_aliases = '')
110
111
  AND LENGTH(COALESCE(narrative, '')) > 100
111
112
  AND ${notLowSignalTitleClause('')}
@@ -119,8 +120,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
119
120
  const stmt = db.prepare(`
120
121
  SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
121
122
  FROM observations
122
- WHERE COALESCE(compressed_into, 0) = 0
123
- AND superseded_at IS NULL
123
+ WHERE ${liveObsFilterSql('')}
124
124
  AND optimized_at IS NULL
125
125
  AND type IN ('bugfix','refactor','feature','decision')
126
126
  AND (lesson_learned IS NULL OR lesson_learned = '')
@@ -175,7 +175,7 @@ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
175
175
 
176
176
  JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
177
177
  Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
178
- const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout: 15000, maxTokens: 300 });
178
+ const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
179
179
  const aliasArr = parsed && Array.isArray(parsed.search_aliases)
180
180
  ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
181
181
  : [];
@@ -204,7 +204,7 @@ importance: 0=no value, 1=routine, 2=notable non-obvious insight, 3=critical. De
204
204
  lesson_learned: State what was learned. If routine, write "none".
205
205
  search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
206
206
 
207
- const parsed = await callModelJSON(prompt, 'haiku', { timeout: 15000, maxTokens: 500 });
207
+ const parsed = await callModelJSON(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
208
208
  if (!parsed || !parsed.title) { skipped++; continue; }
209
209
 
210
210
  // Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
@@ -289,7 +289,7 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
289
289
  // ─── Task 2: Normalize ─────────────────────────────────────────────────────
290
290
 
291
291
  const NORMALIZE_GATE_FILE = join(RUNTIME_DIR, 'last-normalize.json');
292
- const NORMALIZE_INTERVAL_MS = 7 * 86400000; // 7 days
292
+ const NORMALIZE_INTERVAL_MS = 7 * DAY_MS; // 7 days
293
293
 
294
294
  // Pure gate decision (no IO) — exported for testing. Fail-OPEN on a
295
295
  // malformed-but-valid-JSON gate: a missing/non-numeric `epoch` makes
@@ -356,7 +356,7 @@ Rules:
356
356
  - Include CJK ↔ English equivalents if present
357
357
  - Skip terms that have no synonyms in the list`;
358
358
 
359
- const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
359
+ const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
360
360
  if (!parsed?.groups || !Array.isArray(parsed.groups)) return [];
361
361
  return parsed.groups.filter(g => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
362
362
  } catch (e) {
@@ -448,7 +448,7 @@ export async function executeNormalize(db, force = false, { project } = {}) {
448
448
 
449
449
  // ─── Task 3: Cluster-merge ─────────────────────────────────────────────────
450
450
 
451
- const MERGE_TIME_WINDOW_MS = 30 * 86400000;
451
+ const MERGE_TIME_WINDOW_MS = 30 * DAY_MS;
452
452
  // Merge-review band [MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD): titles in this
453
453
  // Jaccard range are LLM-reviewed for merge; at/above AUTO_MERGE_THRESHOLD they'd
454
454
  // already auto-merge elsewhere, below MERGE_JACCARD_LOW they're too dissimilar.
@@ -459,8 +459,7 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
459
459
  const stmt = db.prepare(`
460
460
  SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned, concepts, facts
461
461
  FROM observations
462
- WHERE COALESCE(compressed_into, 0) = 0
463
- AND superseded_at IS NULL
462
+ WHERE ${liveObsFilterSql('')}
464
463
  AND optimized_at IS NULL
465
464
  AND title IS NOT NULL AND title != ''
466
465
  AND created_at_epoch > ?
@@ -525,7 +524,7 @@ Return ONLY valid JSON:
525
524
  - If they should NOT be merged: {"should_merge":false}
526
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}`;
527
526
 
528
- const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
527
+ const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
529
528
  if (!parsed || !parsed.should_merge) return { merged: false };
530
529
 
531
530
  // Keeper = highest importance, then highest access_count. Previously access_count
@@ -643,11 +642,11 @@ export async function executeClusterMerge(db, maxClusters = 5, { project } = {})
643
642
 
644
643
  // ─── Task 4: Smart-compress ────────────────────────────────────────────────
645
644
 
646
- const COMPRESS_TIME_SPLIT_MS = 14 * 86400000;
645
+ const COMPRESS_TIME_SPLIT_MS = 14 * DAY_MS;
647
646
  const COMPRESS_COSINE_THRESHOLD = 0.3;
648
647
 
649
648
  export function findSmartCompressCandidates(db, ageDays = 30, { project } = {}) {
650
- const cutoff = Date.now() - ageDays * 86400000;
649
+ const cutoff = Date.now() - ageDays * DAY_MS;
651
650
  const projectClause = project ? 'AND project = ?' : '';
652
651
  const stmt = db.prepare(`
653
652
  SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
@@ -759,7 +758,7 @@ ${obsDescriptions}
759
758
 
760
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"]}`;
761
760
 
762
- const parsed = await callModelJSON(prompt, 'sonnet', { timeout: 20000, maxTokens: 1000 });
761
+ const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
763
762
  if (!parsed || !parsed.title) return { compressed: false };
764
763
 
765
764
  // Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
@@ -7,9 +7,13 @@
7
7
 
8
8
  import { buildSessionContextLines } from './hook-context.mjs';
9
9
  import { inferProject, debugCatch, debugLog } from './utils.mjs';
10
+ import { RUNTIME_DIR } from './hook-shared.mjs';
11
+ import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
10
12
 
11
13
  /**
12
- * Build + emit the memory context block on stdout. Pure read; no DB writes.
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).
13
17
  *
14
18
  * @param {object} ctx
15
19
  * @param {import('better-sqlite3').Database} ctx.db
@@ -19,9 +23,18 @@ import { inferProject, debugCatch, debugLog } from './utils.mjs';
19
23
  */
20
24
  export function handlePreCompact({ db, project, sessionId }) {
21
25
  try {
22
- const body = buildSessionContextLines(db, project, new Date(), sessionId || null);
26
+ const collector = {};
27
+ const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
23
28
  if (!body || String(body).trim() === '') return;
24
29
  process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
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
+ });
25
38
  } catch (e) {
26
39
  debugCatch(e, 'handlePreCompact');
27
40
  }