claude-mem-lite 3.51.0 → 3.53.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook.mjs +52 -3
- package/lib/citation-tracker.mjs +28 -2
- package/lib/cite-back-hint.mjs +6 -0
- package/lib/persist-reminder.mjs +86 -0
- package/lib/save-enrich.mjs +163 -0
- package/mem-cli.mjs +6 -1
- package/package.json +3 -1
- package/server.mjs +6 -1
- package/source-files.mjs +2 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.53.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.
|
|
3
|
+
"version": "3.53.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
|
-
|
|
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
|
|
@@ -662,7 +668,13 @@ async function handleStop() {
|
|
|
662
668
|
} else {
|
|
663
669
|
const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
664
670
|
for (const id of citeBackIds) citedMain.add(id);
|
|
665
|
-
|
|
671
|
+
// D#60: the idempotency key must be the CC session UUID, NOT the
|
|
672
|
+
// project-scoped memory sessionId — concurrent same-project CC
|
|
673
|
+
// sessions share the latter, so the second session's decay pass
|
|
674
|
+
// read "already decided" and silently undercounted decay_seen /
|
|
675
|
+
// streaks / adoption denominators. Fallback keeps legacy
|
|
676
|
+
// stdin-less invocations on the old key.
|
|
677
|
+
const r = applyCitationDecay(db, project, injected, citedMain, ccSessionId || sessionId);
|
|
666
678
|
debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
|
|
667
679
|
// R1: persist this session's invocation→cite funnel row. touched =
|
|
668
680
|
// obs resolved this run (denominator), promoted = obs cited this run
|
|
@@ -711,7 +723,21 @@ async function handleStop() {
|
|
|
711
723
|
let priorStreak = 0;
|
|
712
724
|
try { priorStreak = JSON.parse(readFileSync(dest, 'utf8')).lowStreak || 0; } catch {}
|
|
713
725
|
const lowStreak = nextCiteLowStreak(priorStreak, stats);
|
|
714
|
-
|
|
726
|
+
// G3: finalized-in-conversation + zero deliberate persistence →
|
|
727
|
+
// decisionSignal rides the payload; next SessionStart reminds once.
|
|
728
|
+
let decisionSignal = null;
|
|
729
|
+
try {
|
|
730
|
+
const promptRows = db.prepare(`
|
|
731
|
+
SELECT prompt_text FROM user_prompts
|
|
732
|
+
WHERE content_session_id = ? ORDER BY prompt_number ASC LIMIT 200
|
|
733
|
+
`).all(sessionId);
|
|
734
|
+
const d = detectUnpersistedDecision({
|
|
735
|
+
prompts: promptRows.map((r) => r.prompt_text),
|
|
736
|
+
transcriptPath,
|
|
737
|
+
});
|
|
738
|
+
if (d.fire) decisionSignal = d.signal;
|
|
739
|
+
} catch (e) { debugCatch(e, 'handleStop-persist-reminder'); }
|
|
740
|
+
const payload = { ...stats, ...bugfixStats, lowStreak, decisionSignal, project, savedAt: Date.now() };
|
|
715
741
|
writeFileSync(dest, JSON.stringify(payload), { mode: 0o600 });
|
|
716
742
|
} catch (e) { debugCatch(e, 'handleStop-cite-recall-persist'); }
|
|
717
743
|
}
|
|
@@ -1587,6 +1613,28 @@ async function handleUserPrompt() {
|
|
|
1587
1613
|
}
|
|
1588
1614
|
}
|
|
1589
1615
|
|
|
1616
|
+
// ─── Save-Enrich (Background Worker, G1+G2) ─────────────────────────────────
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Detached worker spawned by the save surfaces (lib/save-enrich.mjs
|
|
1620
|
+
* queueSaveEnrich): one Haiku call backfills lesson (obligated types) +
|
|
1621
|
+
* search_aliases, fill-only-empty. Silent on any failure.
|
|
1622
|
+
*/
|
|
1623
|
+
async function handleEnrichSave(rawId) {
|
|
1624
|
+
const id = parseInt(rawId, 10);
|
|
1625
|
+
if (!Number.isInteger(id) || id <= 0) return;
|
|
1626
|
+
const db = openDb();
|
|
1627
|
+
if (!db) return;
|
|
1628
|
+
try {
|
|
1629
|
+
const { executeSaveEnrich } = await import('./lib/save-enrich.mjs');
|
|
1630
|
+
await executeSaveEnrich(db, id);
|
|
1631
|
+
} catch (e) {
|
|
1632
|
+
debugCatch(e, 'enrich-save');
|
|
1633
|
+
} finally {
|
|
1634
|
+
try { db.close(); } catch {}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1590
1638
|
// ─── Auto-Compress (Background Worker) ───────────────────────────────────────
|
|
1591
1639
|
|
|
1592
1640
|
/**
|
|
@@ -1694,6 +1742,7 @@ try {
|
|
|
1694
1742
|
case 'llm-episode': await handleLLMEpisode(); break;
|
|
1695
1743
|
case 'llm-summary': await handleLLMSummary(); break;
|
|
1696
1744
|
case 'auto-compress': handleAutoCompress(); break;
|
|
1745
|
+
case 'enrich-save': await handleEnrichSave(process.argv[3]); break;
|
|
1697
1746
|
case 'auto-maintain': handleAutoMaintain(); break;
|
|
1698
1747
|
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
1699
1748
|
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -599,9 +599,35 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
599
599
|
const empty = { promoted: 0, demoted: 0, touched: 0 };
|
|
600
600
|
if (process.env.MEM_DISABLE_CITATION_DECAY === '1') return empty;
|
|
601
601
|
if (!db || !project || !sessionId) return empty;
|
|
602
|
-
|
|
602
|
+
let injected = injectedIds instanceof Set ? injectedIds : new Set(injectedIds || []);
|
|
603
603
|
if (injected.size === 0) return empty;
|
|
604
|
-
|
|
604
|
+
let cited = citedIds instanceof Set ? citedIds : new Set(citedIds || []);
|
|
605
|
+
|
|
606
|
+
// D#61: a lesson injected live and then superseded mid-session (auto-dedup /
|
|
607
|
+
// supersedes= save) leaves its citation crediting NOBODY — selectStmt below
|
|
608
|
+
// excludes superseded rows by design (defense-in-depth parity), so the keeper
|
|
609
|
+
// that now carries the lesson goes uncredited. Redirect such ids to their
|
|
610
|
+
// NUMERIC superseded_by keeper (one hop; superseded_by is polymorphic — the
|
|
611
|
+
// typeof guard mirrors timeline-core). Copies, not mutation: callers own the
|
|
612
|
+
// input sets.
|
|
613
|
+
const redirectStmt = db.prepare(
|
|
614
|
+
'SELECT superseded_by FROM observations WHERE id = ? AND project = ? AND superseded_at IS NOT NULL'
|
|
615
|
+
);
|
|
616
|
+
const redirectSet = (set) => {
|
|
617
|
+
const out = new Set();
|
|
618
|
+
for (const id of set) {
|
|
619
|
+
const r = redirectStmt.get(id, project);
|
|
620
|
+
if (r && typeof r.superseded_by === 'number' && Number.isInteger(r.superseded_by)
|
|
621
|
+
&& r.superseded_by > 0 && r.superseded_by !== id) {
|
|
622
|
+
out.add(r.superseded_by);
|
|
623
|
+
} else {
|
|
624
|
+
out.add(id);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
};
|
|
629
|
+
injected = redirectSet(injected);
|
|
630
|
+
cited = redirectSet(cited);
|
|
605
631
|
|
|
606
632
|
// Adoption gate (snapshot taken before any mutation this run). Suppress only
|
|
607
633
|
// demotion; promotion always proceeds. Threshold overridable via env.
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "3.53.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
|
-
|
|
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;
|