claude-mem-lite 3.51.0 → 3.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.51.0",
13
+ "version": "3.52.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.51.0",
3
+ "version": "3.52.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/hook.mjs CHANGED
@@ -72,6 +72,7 @@ import { handleLLMOptimize } from './hook-optimize.mjs';
72
72
  import { silentAutoAdopt } from './adopt-cli.mjs';
73
73
  import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
74
74
  import { loadCiteBackForEpisode, extractCiteBackSignals, buildUnsavedBugfixHint, countUnsavedBugfixShape, buildCiteRecallNudge as libBuildCiteRecallNudge, nextCiteLowStreak } from './lib/cite-back-hint.mjs';
75
+ import { detectUnpersistedDecision } from './lib/persist-reminder.mjs';
75
76
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
76
77
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
77
78
  // crash on static import. Degrade gracefully to no-op when the module is absent.
@@ -88,7 +89,12 @@ import { getVocabulary } from './tfidf.mjs';
88
89
  // Prevent recursive hooks from background claude -p calls
89
90
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
90
91
  const event = process.argv[2];
91
- const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain']);
92
+ // Events allowed to run under CLAUDE_MEM_HOOK_RUNNING=1 (the recursion guard at
93
+ // the dispatch below exits everything else). EVERY spawnBackground/queue* event
94
+ // MUST be listed here — a missing entry makes the detached worker exit(0)
95
+ // silently, which looks identical to "worker ran and found nothing" from the
96
+ // outside (live-probe catch, 2026-07-18: enrich-save no-oped on this line).
97
+ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain', 'enrich-save']);
92
98
 
93
99
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
94
100
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
@@ -711,7 +717,21 @@ async function handleStop() {
711
717
  let priorStreak = 0;
712
718
  try { priorStreak = JSON.parse(readFileSync(dest, 'utf8')).lowStreak || 0; } catch {}
713
719
  const lowStreak = nextCiteLowStreak(priorStreak, stats);
714
- const payload = { ...stats, ...bugfixStats, lowStreak, project, savedAt: Date.now() };
720
+ // G3: finalized-in-conversation + zero deliberate persistence
721
+ // decisionSignal rides the payload; next SessionStart reminds once.
722
+ let decisionSignal = null;
723
+ try {
724
+ const promptRows = db.prepare(`
725
+ SELECT prompt_text FROM user_prompts
726
+ WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 200
727
+ `).all(sessionId);
728
+ const d = detectUnpersistedDecision({
729
+ prompts: promptRows.map((r) => r.prompt_text),
730
+ transcriptPath,
731
+ });
732
+ if (d.fire) decisionSignal = d.signal;
733
+ } catch (e) { debugCatch(e, 'handleStop-persist-reminder'); }
734
+ const payload = { ...stats, ...bugfixStats, lowStreak, decisionSignal, project, savedAt: Date.now() };
715
735
  writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
716
736
  } catch (e) { debugCatch(e, 'handleStop-cite-recall-persist'); }
717
737
  }
@@ -1587,6 +1607,28 @@ async function handleUserPrompt() {
1587
1607
  }
1588
1608
  }
1589
1609
 
1610
+ // ─── Save-Enrich (Background Worker, G1+G2) ─────────────────────────────────
1611
+
1612
+ /**
1613
+ * Detached worker spawned by the save surfaces (lib/save-enrich.mjs
1614
+ * queueSaveEnrich): one Haiku call backfills lesson (obligated types) +
1615
+ * search_aliases, fill-only-empty. Silent on any failure.
1616
+ */
1617
+ async function handleEnrichSave(rawId) {
1618
+ const id = parseInt(rawId, 10);
1619
+ if (!Number.isInteger(id) || id <= 0) return;
1620
+ const db = openDb();
1621
+ if (!db) return;
1622
+ try {
1623
+ const { executeSaveEnrich } = await import('./lib/save-enrich.mjs');
1624
+ await executeSaveEnrich(db, id);
1625
+ } catch (e) {
1626
+ debugCatch(e, 'enrich-save');
1627
+ } finally {
1628
+ try { db.close(); } catch {}
1629
+ }
1630
+ }
1631
+
1590
1632
  // ─── Auto-Compress (Background Worker) ───────────────────────────────────────
1591
1633
 
1592
1634
  /**
@@ -1694,6 +1736,7 @@ try {
1694
1736
  case 'llm-episode': await handleLLMEpisode(); break;
1695
1737
  case 'llm-summary': await handleLLMSummary(); break;
1696
1738
  case 'auto-compress': handleAutoCompress(); break;
1739
+ case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
1697
1740
  case 'auto-maintain': handleAutoMaintain(); break;
1698
1741
  case 'llm-optimize': await handleLLMOptimize(); break;
1699
1742
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
@@ -247,6 +247,12 @@ export function buildCiteRecallNudge(project, runtimeDir, env = process.env) {
247
247
  if (typeof data.unsaved === 'number' && data.unsaved > 0) {
248
248
  lines.push(`[mem] Last session: ${data.unsaved} unsaved bugfix-shape edit(s) — if any was a real fix, save now: /lesson --file <path> "<root cause + fix>"`);
249
249
  }
250
+ // G3 third gate: the prior session finalized a decision in conversation
251
+ // ("${data.decisionSignal}") but made no mem_save/mem_defer write — surface
252
+ // once while the handoff context still makes recovery actionable.
253
+ if (typeof data.decisionSignal === 'string' && data.decisionSignal.length > 0) {
254
+ lines.push(`[mem] ⚠ Last session mentioned a finalized decision ("${data.decisionSignal}") but persisted nothing — if it should survive /clear, capture it now: mem_save(type="decision", …) or mem_defer.`);
255
+ }
250
256
  return lines.join('\n');
251
257
  } catch { return ''; }
252
258
  }
@@ -0,0 +1,86 @@
1
+ // Unpersisted-decision reminder — G3 (roadmap 2026-07-18).
2
+ //
3
+ // The write-side other half of the D#92 incident: that deferred item was
4
+ // recoverable only because the originating session VOLUNTARILY wrote a defer
5
+ // detail. When a session finalizes something in conversation (定稿/拍板/
6
+ // approved/…) and makes no deliberate persistence call, /clear loses it —
7
+ // tasks/ and docs/ are gitignored local files with no cross-session search
8
+ // face. This module detects the shape at Stop; the payload rides
9
+ // cite-recall-<project>.json and the NEXT SessionStart surfaces ONE reminder
10
+ // line (that is the moment recovery is actionable — the handoff still carries
11
+ // the context). Remind-only by design: false positives cost one line, so the
12
+ // signal list stays conservative and never auto-writes anything.
13
+
14
+ import { readFileSync, existsSync } from 'fs';
15
+
16
+ // Distinctive finalization word forms (CJK + EN). Deliberately NOT included:
17
+ // "方案 A 定" and bare "定" (too FP-prone), bare "ok/好" (noise). The list is
18
+ // remind-only, so precision beats recall here.
19
+ const FINALIZATION_FORMS = [
20
+ '定稿', '拍板', '敲定', '批准', '采纳',
21
+ ];
22
+ const FINALIZATION_EN_RE = /\bapproved?\b|\bsign(?:ed)?[\s-]?off\b|\bfinali[sz]ed?\b|writing-plans/i;
23
+
24
+ /**
25
+ * Scan user prompts for a finalization signal.
26
+ * @param {string[]} prompts
27
+ * @returns {string|null} the matched form (for quoting in the reminder), or null
28
+ */
29
+ export function detectFinalization(prompts) {
30
+ for (const p of prompts || []) {
31
+ if (typeof p !== 'string' || !p) continue;
32
+ for (const form of FINALIZATION_FORMS) {
33
+ if (p.includes(form)) return form;
34
+ }
35
+ const m = p.match(FINALIZATION_EN_RE);
36
+ if (m) return m[0];
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // Deliberate-persistence calls, transcript-side. Mirrors the tool-name/idiom
42
+ // sets in lib/cite-back-hint.mjs (countUnsavedBugfixShape) but counts ANY
43
+ // mem_save/mem_defer — a decision can legitimately land as any type.
44
+ const PERSIST_TOOL_NAMES = new Set([
45
+ 'mem_save', 'mem_defer',
46
+ 'mcp__claude_mem_lite__mem_save', 'mcp__claude_mem_lite__mem_defer',
47
+ 'mcp__plugin_claude-mem-lite_mem-lite__mem_save',
48
+ 'mcp__plugin_claude-mem-lite_mem-lite__mem_defer',
49
+ ]);
50
+ const PERSIST_CLI_RE = /(?:cli\.mjs|claude-mem-lite)['"]?\s+(?:save\b|defer\s+add\b)/;
51
+
52
+ /**
53
+ * Count deliberate persistence calls (mem_save / mem_defer tool_use, CLI
54
+ * `save` / `defer add`) in the session transcript. 0 on missing/unreadable.
55
+ */
56
+ export function countDeliberatePersistence(transcriptPath) {
57
+ if (!transcriptPath || !existsSync(transcriptPath)) return 0;
58
+ let raw;
59
+ try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return 0; }
60
+ let count = 0;
61
+ for (const line of raw.split('\n')) {
62
+ if (!line.trim()) continue;
63
+ let entry;
64
+ try { entry = JSON.parse(line); } catch { continue; }
65
+ if (entry.type !== 'assistant' && entry.message?.role !== 'assistant') continue;
66
+ const content = entry.message?.content;
67
+ if (!Array.isArray(content)) continue;
68
+ for (const block of content) {
69
+ if (block.type !== 'tool_use') continue;
70
+ if (PERSIST_TOOL_NAMES.has(block.name)) { count++; continue; }
71
+ if (block.name === 'Bash' && PERSIST_CLI_RE.test(block.input?.command || '')) count++;
72
+ }
73
+ }
74
+ return count;
75
+ }
76
+
77
+ /**
78
+ * The G3 gate: finalization signal present AND zero deliberate persistence.
79
+ * @returns {{fire: boolean, signal: string|null}}
80
+ */
81
+ export function detectUnpersistedDecision({ prompts, transcriptPath }) {
82
+ const signal = detectFinalization(prompts);
83
+ if (!signal) return { fire: false, signal: null };
84
+ if (countDeliberatePersistence(transcriptPath) > 0) return { fire: false, signal };
85
+ return { fire: true, signal };
86
+ }
@@ -0,0 +1,163 @@
1
+ // Save-time background enrichment — G1+G2 (roadmap 2026-07-18).
2
+ //
3
+ // The v3.49 save-nudge REMINDS the caller to write a lesson; nothing backfills
4
+ // when the nudge is ignored (14d live: 20.7% of bugfix/decision writes are
5
+ // lessonless, worsening), and manual saves NEVER carry search_aliases at
6
+ // creation — the paraphrase-recall gap stays open until the next daily
7
+ // llm-optimize pass ("saved yesterday, need it today" is the highest-value
8
+ // window). This module closes both at the source with ONE Haiku call: distill
9
+ // lesson_learned (obligated types only) + search_aliases (every manual save),
10
+ // executed by a detached worker so the save path itself stays zero-latency.
11
+ //
12
+ // Contract (empty-overwrite is the historical audit main-class — R1/R4):
13
+ // • FILL-ONLY-EMPTY: never replaces a caller-written lesson or aliases filled
14
+ // by a concurrent optimize pass (re-checked inside a BEGIN IMMEDIATE txn).
15
+ // • Touches ONLY lesson_learned / search_aliases / text (alias append) —
16
+ // title/narrative/type/importance/concepts/facts stay byte-identical.
17
+ // • Never sets optimized_at: the daily wide re-enrich stays the safety net.
18
+ // • Silent degradation: no Haiku, bad JSON, ineligible row → row unchanged.
19
+ //
20
+ // Kill switch: CLAUDE_MEM_SKIP_SAVE_ENRICH=1 (naming mirrors SKIP_COMPRESS /
21
+ // SKIP_OPTIMIZE). VITEST is gated out: e2e suites run hundreds of saves and a
22
+ // dev machine with a logged-in claude CLI would fire that many REAL LLM calls.
23
+
24
+ import { spawn } from 'child_process';
25
+ import { join, dirname } from 'path';
26
+ import { fileURLToPath } from 'url';
27
+ import { scrubSecrets, cjkBigrams, truncate, debugCatch } from '../utils.mjs';
28
+ import { scrubRecord } from './scrub-record.mjs';
29
+
30
+ export const ENRICH_OBLIGATED_TYPES = new Set(['bugfix', 'decision']);
31
+
32
+ const HOOK_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'hook.mjs');
33
+
34
+ /**
35
+ * Trigger predicate for the save surfaces (CLI cmdSave / MCP mem_save).
36
+ * Every genuinely-new manual save qualifies (aliases are always missing at
37
+ * creation); env gates and dedup hits opt out.
38
+ */
39
+ export function shouldQueueSaveEnrich(saveResult, env = process.env) {
40
+ if (!saveResult || saveResult.kind !== 'saved') return false;
41
+ if (env.CLAUDE_MEM_SKIP_SAVE_ENRICH === '1') return false;
42
+ if (env.VITEST) return false;
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * Spawn the detached enrichment worker (`node hook.mjs enrich-save <id>`).
48
+ * Fire-and-forget; never throws into the save path.
49
+ */
50
+ export function queueSaveEnrich(id) {
51
+ try {
52
+ const child = spawn(process.execPath, [HOOK_PATH, 'enrich-save', String(id)], {
53
+ detached: true,
54
+ stdio: 'ignore',
55
+ env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
56
+ });
57
+ child.on('error', (err) => { debugCatch(err, 'queueSaveEnrich'); });
58
+ child.unref();
59
+ return true;
60
+ } catch (err) {
61
+ debugCatch(err, 'queueSaveEnrich');
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Worker body: one Haiku call → fill-only-empty backfill.
68
+ * @param {import('better-sqlite3').Database} db
69
+ * @param {number} id observation id
70
+ * @param {{callJson?: Function}} [opts] injectable LLM for tests
71
+ * @returns {Promise<{enriched: boolean, reason?: string}>}
72
+ */
73
+ export async function executeSaveEnrich(db, id, { callJson } = {}) {
74
+ const row = db.prepare(`
75
+ SELECT id, type, title, narrative, text, lesson_learned, search_aliases,
76
+ superseded_at, compressed_into
77
+ FROM observations WHERE id = ?
78
+ `).get(id);
79
+ if (!row || row.superseded_at || row.compressed_into) {
80
+ return { enriched: false, reason: 'ineligible' };
81
+ }
82
+ const wantLesson = ENRICH_OBLIGATED_TYPES.has(row.type)
83
+ && !(row.lesson_learned && row.lesson_learned.trim());
84
+ const wantAliases = !(row.search_aliases && row.search_aliases.trim());
85
+ if (!wantLesson && !wantAliases) return { enriched: false, reason: 'complete' };
86
+
87
+ const call = callJson || (await import('../haiku-client.mjs')).callModelJSON;
88
+ const prompt = `A coding memory was just saved. Enrich its retrievability. Return ONLY valid JSON, no markdown fences.
89
+
90
+ Title: ${truncate(row.title || '(untitled)', 200)}
91
+ Context: ${truncate(row.narrative || row.text || '(none)', 500)}
92
+ Type: ${row.type || 'change'}
93
+
94
+ JSON: {"lesson_learned":"the transferable insight (root cause + fix, or constraint + tradeoff) in 1-2 sentences — or 'none' if routine","search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if a key domain word has one"]}
95
+ search_aliases: 3-6 terms a user might search for the SAME memory that are NOT already in the title.`;
96
+
97
+ let parsed = null;
98
+ try {
99
+ parsed = await call(prompt, 'haiku', { timeout: 15000, maxTokens: 400 });
100
+ } catch (e) { debugCatch(e, 'save-enrich-llm'); }
101
+ if (!parsed || typeof parsed !== 'object') return { enriched: false, reason: 'llm-null' };
102
+
103
+ const lesson = wantLesson
104
+ && typeof parsed.lesson_learned === 'string'
105
+ && parsed.lesson_learned.trim().length > 0
106
+ && parsed.lesson_learned.trim().toLowerCase() !== 'none'
107
+ ? scrubSecrets(parsed.lesson_learned).slice(0, 500) : null;
108
+ const aliasArr = wantAliases && Array.isArray(parsed.search_aliases)
109
+ ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0).slice(0, 6)
110
+ : [];
111
+ if (!lesson && aliasArr.length === 0) return { enriched: false, reason: 'nothing-usable' };
112
+
113
+ // Fill-only-empty under BEGIN IMMEDIATE: a concurrent daily-optimize pass (or
114
+ // a caller's mem_update) may have filled either field between spawn and now —
115
+ // read-then-modify without the immediate lock is exactly sqlite gotcha #5.
116
+ let enriched = false;
117
+ const txn = db.transaction(() => {
118
+ const fresh = db.prepare(`
119
+ SELECT lesson_learned, search_aliases, text, superseded_at, compressed_into
120
+ FROM observations WHERE id = ?
121
+ `).get(id);
122
+ if (!fresh || fresh.superseded_at || fresh.compressed_into) return;
123
+ const sets = [];
124
+ const vals = [];
125
+ if (lesson && !(fresh.lesson_learned && fresh.lesson_learned.trim())) {
126
+ const safe = scrubRecord('observations', { lesson_learned: lesson });
127
+ sets.push('lesson_learned = ?');
128
+ vals.push(safe.lesson_learned);
129
+ }
130
+ if (aliasArr.length && !(fresh.search_aliases && fresh.search_aliases.trim())) {
131
+ // APPEND aliases (+ CJK bigrams) to the existing FTS text — never rebuild
132
+ // it (the alias-backfill branch invariant: rebuilding from concepts/facts
133
+ // would drop the original narrative terms on manual saves).
134
+ const searchAliases = aliasArr.join(' ');
135
+ const appendedText = [fresh.text || '', searchAliases, cjkBigrams(searchAliases)]
136
+ .filter(Boolean).join(' ');
137
+ const safe = scrubRecord('observations', { search_aliases: searchAliases, text: appendedText });
138
+ sets.push('search_aliases = ?', 'text = ?');
139
+ vals.push(safe.search_aliases, safe.text);
140
+ }
141
+ if (sets.length === 0) return;
142
+ db.prepare(`UPDATE observations SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
143
+ enriched = true;
144
+ });
145
+ try {
146
+ txn.immediate();
147
+ } catch (e) {
148
+ debugCatch(e, 'save-enrich-txn');
149
+ return { enriched: false, reason: 'txn-failed' };
150
+ }
151
+
152
+ if (enriched) {
153
+ // Refresh the TF-IDF vector so backfilled lesson/aliases reach the vector
154
+ // arm too (single source: hook-optimize's rebuildVector; lazy import keeps
155
+ // the save surfaces from loading the optimize stack for the predicate).
156
+ try {
157
+ const { rebuildVector } = await import('../hook-optimize.mjs');
158
+ const full = db.prepare('SELECT * FROM observations WHERE id = ?').get(id);
159
+ if (full) rebuildVector(db, id, full);
160
+ } catch (e) { debugCatch(e, 'save-enrich-vector'); }
161
+ }
162
+ return { enriched };
163
+ }
package/mem-cli.mjs CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  searchDeferredWork, formatDeferredSearchTrailer,
59
59
  formatDeferListRow, countStaleOpen, formatDeferStaleHint,
60
60
  } from './lib/deferred-work.mjs';
61
+ import { shouldQueueSaveEnrich, queueSaveEnrich } from './lib/save-enrich.mjs';
61
62
 
62
63
  // ─── Commands ────────────────────────────────────────────────────────────────
63
64
 
@@ -960,7 +961,11 @@ function cmdSave(db, args) {
960
961
  const supersededNote = result.supersededIds && result.supersededIds.length > 0
961
962
  ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
962
963
  : '';
963
- out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
964
+ // G1+G2: detached backfill worker (lesson for obligated types + aliases for
965
+ // every save) — fill-only-empty, so an agent acting on the nudge still wins.
966
+ const enrichNote = shouldQueueSaveEnrich(result) && queueSaveEnrich(result.id)
967
+ ? ' (background enrichment queued)' : '';
968
+ out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${enrichNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
964
969
  }
965
970
 
966
971
  // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.51.0",
3
+ "version": "3.52.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -95,6 +95,8 @@
95
95
  "lib/stats-core.mjs",
96
96
  "lib/obs-types.mjs",
97
97
  "lib/save-nudge.mjs",
98
+ "lib/save-enrich.mjs",
99
+ "lib/persist-reminder.mjs",
98
100
  "lib/maintain-core.mjs",
99
101
  "lib/dedup-constants.mjs",
100
102
  "lib/deferred-work.mjs",
package/server.mjs CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  searchDeferredWork, formatDeferredSearchTrailer,
57
57
  formatDeferListRow, countStaleOpen, formatDeferStaleHint,
58
58
  } from './lib/deferred-work.mjs';
59
+ import { shouldQueueSaveEnrich, queueSaveEnrich } from './lib/save-enrich.mjs';
59
60
  import { _resetVocabCache } from './tfidf.mjs';
60
61
  import { createRequire } from 'module';
61
62
 
@@ -799,7 +800,11 @@ server.registerTool(
799
800
  ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
800
801
  : '';
801
802
  const nudge = buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'mcp' });
802
- return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}${nudge}` }] };
803
+ // G1+G2: detached backfill worker (lesson for obligated types + aliases for
804
+ // every save) — fill-only-empty, so an agent acting on the nudge still wins.
805
+ const enrichNote = shouldQueueSaveEnrich(result) && queueSaveEnrich(result.id)
806
+ ? ' (background enrichment queued)' : '';
807
+ return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}${enrichNote}${nudge}` }] };
803
808
  })
804
809
  );
805
810
 
package/source-files.mjs CHANGED
@@ -175,6 +175,8 @@ export const SOURCE_FILES = [
175
175
  // (mem_save) and mem-cli.mjs (cmdSave). Missing it breaks both save surfaces on
176
176
  // auto-update.
177
177
  'lib/save-nudge.mjs',
178
+ 'lib/save-enrich.mjs',
179
+ 'lib/persist-reminder.mjs',
178
180
  // P10 dedup/merge threshold constants — single source of truth for the Jaccard
179
181
  // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
180
182
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;