claude-mem-lite 2.54.0 → 2.58.2
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/cli/doctor.mjs +30 -1
- package/cli.mjs +8 -4
- package/haiku-client.mjs +51 -13
- package/hook-llm.mjs +131 -34
- package/hook-shared.mjs +6 -2
- package/hook-update.mjs +70 -11
- package/hook.mjs +29 -7
- package/install.mjs +34 -32
- package/lib/low-signal-patterns.mjs +38 -0
- package/lib/private-strip.mjs +36 -0
- package/mem-cli.mjs +43 -1
- package/package.json +7 -2
- package/schema.mjs +132 -1
- package/scripts/setup.sh +58 -4
- package/scripts/user-prompt-search.js +124 -9
- package/source-files.mjs +21 -0
- package/utils.mjs +1 -0
package/hook.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { homedir } from 'os';
|
|
|
25
25
|
import {
|
|
26
26
|
truncate, inferProject, detectBashSignificance,
|
|
27
27
|
extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
|
|
28
|
-
makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog,
|
|
28
|
+
makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
|
|
29
29
|
COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, isoWeekKey, OBS_BM25,
|
|
30
30
|
computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity,
|
|
31
31
|
} from './utils.mjs';
|
|
@@ -639,10 +639,14 @@ async function handleSessionStart() {
|
|
|
639
639
|
|
|
640
640
|
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
|
|
641
641
|
// Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
|
|
642
|
+
// v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
|
|
643
|
+
// via hook-memory injection, even if user never explicitly fetched). Same
|
|
644
|
+
// protection applied symmetrically in auto-maintain decay/mark-idle below.
|
|
642
645
|
const compressed = db.prepare(`
|
|
643
646
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
644
647
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
645
648
|
AND importance = 1
|
|
649
|
+
AND COALESCE(injection_count, 0) = 0
|
|
646
650
|
AND created_at_epoch < ?
|
|
647
651
|
AND project = ?
|
|
648
652
|
`).run(autoCompressAge, project);
|
|
@@ -708,6 +712,11 @@ async function handleSessionStart() {
|
|
|
708
712
|
if (cleaned.changes > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned.changes} broken observations`);
|
|
709
713
|
|
|
710
714
|
// Decay: reduce importance of old, never-accessed observations
|
|
715
|
+
// v2.56.0 #4: injection_count is a separate engagement signal —
|
|
716
|
+
// hook-memory.mjs bumps it when the obs is auto-injected into Claude's
|
|
717
|
+
// context. Pre-v2.56 only checked access_count, so an obs auto-injected
|
|
718
|
+
// 8x (proven contextually relevant) still got decayed/marked. Adding
|
|
719
|
+
// `injection_count = 0` treats injection as first-class engagement.
|
|
711
720
|
const decayed = db.prepare(`
|
|
712
721
|
UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
|
|
713
722
|
WHERE id IN (
|
|
@@ -715,13 +724,15 @@ async function handleSessionStart() {
|
|
|
715
724
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
716
725
|
AND COALESCE(importance, 1) > 1
|
|
717
726
|
AND COALESCE(access_count, 0) = 0
|
|
727
|
+
AND COALESCE(injection_count, 0) = 0
|
|
718
728
|
AND created_at_epoch < ?
|
|
719
729
|
LIMIT ${OP_CAP}
|
|
720
730
|
)
|
|
721
731
|
`).run(STALE_AGE);
|
|
722
732
|
if (decayed.changes > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed.changes} stale observations`);
|
|
723
733
|
|
|
724
|
-
// Mark idle: importance=1, never-accessed, old → pending-purge
|
|
734
|
+
// Mark idle: importance=1, never-accessed, never-injected, old → pending-purge
|
|
735
|
+
// (will be purged next cycle). v2.56.0 #4: injection_count protects.
|
|
725
736
|
const idleMarked = db.prepare(`
|
|
726
737
|
UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
727
738
|
WHERE id IN (
|
|
@@ -729,6 +740,7 @@ async function handleSessionStart() {
|
|
|
729
740
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
730
741
|
AND COALESCE(importance, 1) = 1
|
|
731
742
|
AND COALESCE(access_count, 0) = 0
|
|
743
|
+
AND COALESCE(injection_count, 0) = 0
|
|
732
744
|
AND created_at_epoch < ?
|
|
733
745
|
LIMIT ${OP_CAP}
|
|
734
746
|
)
|
|
@@ -1020,11 +1032,21 @@ async function handleUserPrompt() {
|
|
|
1020
1032
|
let hookData;
|
|
1021
1033
|
try { hookData = JSON.parse(raw.text); } catch { return; }
|
|
1022
1034
|
|
|
1023
|
-
const
|
|
1024
|
-
if (!
|
|
1025
|
-
|
|
1026
|
-
// Skip internal Claude Code protocol messages — not real user input
|
|
1027
|
-
|
|
1035
|
+
const rawPrompt = hookData.prompt || hookData.user_prompt;
|
|
1036
|
+
if (!rawPrompt || typeof rawPrompt !== 'string') return;
|
|
1037
|
+
|
|
1038
|
+
// Skip internal Claude Code protocol messages — not real user input.
|
|
1039
|
+
// Check on raw text BEFORE stripPrivate (the marker is a literal sentinel,
|
|
1040
|
+
// wrapping it in <private> would never make sense, but order matters: a
|
|
1041
|
+
// future <task-notification> with embedded <private> blocks should still
|
|
1042
|
+
// be classified as protocol first.)
|
|
1043
|
+
if (rawPrompt.startsWith('<task-notification>')) return;
|
|
1044
|
+
|
|
1045
|
+
// Strip user-marked <private>...</private> blocks at the input boundary so
|
|
1046
|
+
// every downstream consumer (user_prompts INSERT, FTS query, continuation
|
|
1047
|
+
// detection, semantic-memory injection) sees the redacted text — single
|
|
1048
|
+
// source of truth for the privacy primitive.
|
|
1049
|
+
const promptText = stripPrivate(rawPrompt);
|
|
1028
1050
|
|
|
1029
1051
|
const sessionId = getSessionId();
|
|
1030
1052
|
const db = openDb();
|
package/install.mjs
CHANGED
|
@@ -29,23 +29,13 @@ import { createRequire } from 'module';
|
|
|
29
29
|
|
|
30
30
|
import { RESOURCE_METADATA } from './install-metadata.mjs';
|
|
31
31
|
import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
|
|
32
|
-
import { SOURCE_FILES } from './source-files.mjs';
|
|
32
|
+
import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* dropped pre-tool-recall.js + pre-skill-bridge.js — every fresh install left
|
|
40
|
-
* settings.json pointing at non-existent files).
|
|
41
|
-
*/
|
|
42
|
-
export const HOOK_SCRIPT_FILES = [
|
|
43
|
-
'post-tool-use.sh',
|
|
44
|
-
'user-prompt-search.js',
|
|
45
|
-
'prompt-search-utils.mjs',
|
|
46
|
-
'pre-tool-recall.js',
|
|
47
|
-
'pre-skill-bridge.js',
|
|
48
|
-
];
|
|
34
|
+
// Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
|
|
35
|
+
// and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
|
|
36
|
+
// The constant itself moved to source-files.mjs in v2.55 so hook-update.mjs
|
|
37
|
+
// can share it without a static cycle.
|
|
38
|
+
export { HOOK_SCRIPT_FILES };
|
|
49
39
|
|
|
50
40
|
export function copyHookScripts(srcDir, destDir) {
|
|
51
41
|
for (const name of HOOK_SCRIPT_FILES) {
|
|
@@ -349,12 +339,10 @@ async function install() {
|
|
|
349
339
|
if (existsSync(join(PROJECT_DIR, 'registry'))) {
|
|
350
340
|
symlinkSync(join(PROJECT_DIR, 'registry'), regLink);
|
|
351
341
|
}
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
symlinkSync(join(PROJECT_DIR, 'commands'), cmdLink);
|
|
357
|
-
}
|
|
342
|
+
// commands/ is intentionally NOT linked: Claude Code reads slash commands
|
|
343
|
+
// from the plugin cache (~/.claude/plugins/cache/<mp>/<plugin>/<ver>/commands/)
|
|
344
|
+
// or user-level ~/.claude/commands/, never from ~/.claude-mem-lite/commands/.
|
|
345
|
+
// Pre-v2.55 maintained a symlink/copy here that had no consumers.
|
|
358
346
|
ok('Symlinks created in ~/.claude-mem-lite/ → dev dir');
|
|
359
347
|
} else {
|
|
360
348
|
log('Installing to ~/.claude-mem-lite/...');
|
|
@@ -375,15 +363,7 @@ async function install() {
|
|
|
375
363
|
copyHookScripts(join(PROJECT_DIR, 'scripts'), scriptsDir);
|
|
376
364
|
// Ensure bash script is executable
|
|
377
365
|
try { execFileSync('chmod', ['+x', join(scriptsDir, 'post-tool-use.sh')], { stdio: 'pipe' }); } catch {}
|
|
378
|
-
//
|
|
379
|
-
const commandsDir = join(DATA_DIR, 'commands');
|
|
380
|
-
if (!existsSync(commandsDir)) mkdirSync(commandsDir, { recursive: true });
|
|
381
|
-
const commandsSrc = join(PROJECT_DIR, 'commands');
|
|
382
|
-
if (existsSync(commandsSrc)) {
|
|
383
|
-
for (const f of readdirSync(commandsSrc).filter(f => f.endsWith('.md'))) {
|
|
384
|
-
copyFileSync(join(commandsSrc, f), join(commandsDir, f));
|
|
385
|
-
}
|
|
386
|
-
}
|
|
366
|
+
// commands/ is intentionally NOT copied — see dev-mode branch above.
|
|
387
367
|
// Copy registry manifest
|
|
388
368
|
const registryDir = join(DATA_DIR, 'registry');
|
|
389
369
|
if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
|
|
@@ -1614,9 +1594,10 @@ function syncVersions() {
|
|
|
1614
1594
|
const marketJson = JSON.parse(readFileSync(marketJsonPath, 'utf8'));
|
|
1615
1595
|
const plugin = marketJson.plugins?.[0];
|
|
1616
1596
|
if (plugin && plugin.version !== version) {
|
|
1597
|
+
const prev = plugin.version;
|
|
1617
1598
|
plugin.version = version;
|
|
1618
1599
|
writeFileSync(marketJsonPath, JSON.stringify(marketJson, null, 2) + '\n');
|
|
1619
|
-
ok(`marketplace.json: ${
|
|
1600
|
+
ok(`marketplace.json: ${prev} → ${version}`);
|
|
1620
1601
|
} else if (plugin) {
|
|
1621
1602
|
ok(`marketplace.json: already ${version}`);
|
|
1622
1603
|
}
|
|
@@ -1624,6 +1605,27 @@ function syncVersions() {
|
|
|
1624
1605
|
warn('marketplace.json not found');
|
|
1625
1606
|
}
|
|
1626
1607
|
|
|
1608
|
+
// Sync CLAUDE.md `**Version**: x.y.z` line — install-e2e asserts this
|
|
1609
|
+
// matches package.json so omitting it here would break CI on every release.
|
|
1610
|
+
const claudeMdPath = join(PROJECT_DIR, 'CLAUDE.md');
|
|
1611
|
+
if (existsSync(claudeMdPath)) {
|
|
1612
|
+
const orig = readFileSync(claudeMdPath, 'utf8');
|
|
1613
|
+
const versionLine = /^- \*\*Version\*\*: .+$/m;
|
|
1614
|
+
if (versionLine.test(orig)) {
|
|
1615
|
+
const patched = orig.replace(versionLine, `- **Version**: ${version}`);
|
|
1616
|
+
if (patched !== orig) {
|
|
1617
|
+
writeFileSync(claudeMdPath, patched);
|
|
1618
|
+
ok(`CLAUDE.md: → ${version}`);
|
|
1619
|
+
} else {
|
|
1620
|
+
ok(`CLAUDE.md: already ${version}`);
|
|
1621
|
+
}
|
|
1622
|
+
} else {
|
|
1623
|
+
warn('CLAUDE.md: `**Version**:` line not found — skipped');
|
|
1624
|
+
}
|
|
1625
|
+
} else {
|
|
1626
|
+
warn('CLAUDE.md not found');
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1627
1629
|
console.log('');
|
|
1628
1630
|
}
|
|
1629
1631
|
|
|
@@ -147,6 +147,44 @@ export function capNoiseImportance(obs) {
|
|
|
147
147
|
return original > 1 ? 1 : original;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* v2.56.0 #1: paired-gate DROP for type=change + null/short lesson + low importance.
|
|
152
|
+
*
|
|
153
|
+
* Pairs with capNoiseImportance (DEMOTE) per #8152's paired-gate model. The
|
|
154
|
+
* existing isNoiseObservation gate is title-pattern keyed (LOW_SIGNAL regex);
|
|
155
|
+
* Haiku-titled `change` obs with substantive-looking titles but no extractable
|
|
156
|
+
* lesson slip through it. This gate is type+lesson keyed and catches them.
|
|
157
|
+
*
|
|
158
|
+
* Empirical baseline (CLAUDE.md, projects--mem): type=change has 16.5% hit-rate
|
|
159
|
+
* vs decision 72.7%. type=change is 67% of recent 30d obs, and Haiku writes
|
|
160
|
+
* lesson_learned=null/'none' for ~70% of curated observations (per
|
|
161
|
+
* hook-llm.mjs:639 lowSignalLesson set). When *all three* hold — change type +
|
|
162
|
+
* no lesson + Haiku didn't flag importance>=2 — the obs is by definition
|
|
163
|
+
* low-yield and adds noise to the corpus.
|
|
164
|
+
*
|
|
165
|
+
* Scope: ONLY type='change'. bugfix/decision get a lesson-retry pass already
|
|
166
|
+
* (hook-llm.mjs:648); feature/refactor/discovery aren't dominated by null
|
|
167
|
+
* lessons in the same way.
|
|
168
|
+
*
|
|
169
|
+
* Opt-out: env `CLAUDE_MEM_KEEP_LOW_SIGNAL=1` disables (parity with
|
|
170
|
+
* isNoiseObservation).
|
|
171
|
+
*
|
|
172
|
+
* @param {object} obs { type, lessonLearned|lesson_learned, importance }
|
|
173
|
+
* @param {object} [env=process.env] Environment (injected for testability)
|
|
174
|
+
* @returns {boolean} true = drop, caller should skip insert
|
|
175
|
+
*/
|
|
176
|
+
export function isLowYieldChangeObs(obs, env = process.env) {
|
|
177
|
+
if (env && env.CLAUDE_MEM_KEEP_LOW_SIGNAL === '1') return false;
|
|
178
|
+
if (!obs || obs.type !== 'change') return false;
|
|
179
|
+
if ((obs.importance ?? 1) >= 2) return false;
|
|
180
|
+
const lesson = obs.lessonLearned ?? obs.lesson_learned;
|
|
181
|
+
const trimmed = (typeof lesson === 'string') ? lesson.trim() : '';
|
|
182
|
+
if (!trimmed) return true; // null / undefined / whitespace
|
|
183
|
+
if (trimmed.toLowerCase() === 'none') return true; // Haiku default
|
|
184
|
+
if (trimmed.length < 12) return true; // "ok" / "fixed it" / "works"
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
150
188
|
export function isNoiseObservation(obs, env = process.env) {
|
|
151
189
|
if (env && env.CLAUDE_MEM_KEEP_LOW_SIGNAL === '1') return false;
|
|
152
190
|
const title = (obs && obs.title) || '';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// claude-mem-lite: Strip <private>...</private> blocks from user-supplied text
|
|
2
|
+
// before any persistence or downstream processing.
|
|
3
|
+
//
|
|
4
|
+
// Use case: user wraps sensitive content (test fixtures, internal IDs, draft
|
|
5
|
+
// secrets that scrubSecrets misses) in <private>X</private> to opt out of
|
|
6
|
+
// memory capture. Replaces each well-formed pair with [redacted] to preserve
|
|
7
|
+
// surrounding grammar and FTS bigram boundaries.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors thedotmack/claude-mem v13's <private> primitive (referenced in
|
|
10
|
+
// observation #8252 follow-up scope) — same syntax for cross-tool familiarity.
|
|
11
|
+
//
|
|
12
|
+
// Intentionally does NOT strip:
|
|
13
|
+
// - Open-without-close (`<private>...` with no `</private>`): user may still
|
|
14
|
+
// be typing; aggressive strip-to-EOL would surprise. Caller can chain a
|
|
15
|
+
// length cap (`promptText.slice(0, 10000)`) after this for safety.
|
|
16
|
+
// - Stray `</private>` with no opener: same reasoning, leave intact.
|
|
17
|
+
// Both gaps are documented for callers to layer additional guards if needed.
|
|
18
|
+
//
|
|
19
|
+
// Case-insensitive on the tag (`<PRIVATE>`, `<Private>` all work) since users
|
|
20
|
+
// type by hand. Non-greedy match handles multiple blocks correctly.
|
|
21
|
+
|
|
22
|
+
const PRIVATE_BLOCK_RE = /<private>([\s\S]*?)<\/private>/gi;
|
|
23
|
+
const REDACTION_MARKER = '[redacted]';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Replace each well-formed <private>...</private> block with [redacted].
|
|
27
|
+
* Returns input unchanged if no closed block is present.
|
|
28
|
+
*
|
|
29
|
+
* @param {unknown} text Input string (non-string passes through)
|
|
30
|
+
* @returns {string|unknown} Stripped text, or input unchanged if not a string
|
|
31
|
+
*/
|
|
32
|
+
export function stripPrivate(text) {
|
|
33
|
+
if (typeof text !== 'string') return text;
|
|
34
|
+
if (!text.includes('<')) return text; // fast path — most prompts have no tags
|
|
35
|
+
return text.replace(PRIVATE_BLOCK_RE, REDACTION_MARKER);
|
|
36
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -905,6 +905,43 @@ async function cmdStats(db, args) {
|
|
|
905
905
|
await renderQualityReport(db, { project, days });
|
|
906
906
|
return;
|
|
907
907
|
}
|
|
908
|
+
// v2.57.x B2: --retry shows the lesson_retry_stats aggregate. Answers
|
|
909
|
+
// "is the bugfix/decision retry path (1 extra Haiku call per attempt)
|
|
910
|
+
// paying off?". If recovered/attempts < 0.10 over a long window, the
|
|
911
|
+
// path is dead weight and should be deleted.
|
|
912
|
+
const retry = flags.retry === true || flags.retry === 'true';
|
|
913
|
+
if (retry) {
|
|
914
|
+
const { readRetryStats } = await import('./hook-llm.mjs');
|
|
915
|
+
const rows = readRetryStats(db, days);
|
|
916
|
+
const totalAttempts = rows.reduce((a, r) => a + r.attempts, 0);
|
|
917
|
+
const totalRecovered = rows.reduce((a, r) => a + r.recovered, 0);
|
|
918
|
+
const recoveryRate = totalAttempts > 0 ? totalRecovered / totalAttempts : 0;
|
|
919
|
+
if (flags.json === true || flags.json === 'true') {
|
|
920
|
+
out(JSON.stringify({
|
|
921
|
+
days, total_attempts: totalAttempts, total_recovered: totalRecovered,
|
|
922
|
+
recovery_rate: Number(recoveryRate.toFixed(4)),
|
|
923
|
+
per_day: rows,
|
|
924
|
+
}, null, 2));
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
out(`[mem] lesson-retry stats — last ${days}d (UTC date buckets)`);
|
|
928
|
+
out(` attempts: ${totalAttempts}`);
|
|
929
|
+
out(` recovered: ${totalRecovered}`);
|
|
930
|
+
out(` rate: ${(recoveryRate * 100).toFixed(1)}% ${totalAttempts === 0 ? '(no data — retry path may be unused this window)' : ''}`);
|
|
931
|
+
if (totalAttempts >= 50 && recoveryRate < 0.10) {
|
|
932
|
+
out(' ⚠ recovery rate <10% over ≥50 attempts — retry path likely dead weight, consider deleting');
|
|
933
|
+
} else if (totalAttempts >= 50 && recoveryRate >= 0.30) {
|
|
934
|
+
out(' ✓ recovery rate ≥30% — retry path actively saving lessons');
|
|
935
|
+
}
|
|
936
|
+
if (rows.length > 0) {
|
|
937
|
+
out('\n date attempts recovered rate');
|
|
938
|
+
for (const r of rows.slice(0, 14)) {
|
|
939
|
+
const rate = r.attempts > 0 ? (r.recovered / r.attempts * 100).toFixed(1) + '%' : '—';
|
|
940
|
+
out(` ${r.date_bucket} ${String(r.attempts).padStart(8)} ${String(r.recovered).padStart(9)} ${rate.padStart(5)}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
908
945
|
|
|
909
946
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
910
947
|
const baseParams = project ? [project] : [];
|
|
@@ -1566,6 +1603,9 @@ function cmdMaintain(db, args) {
|
|
|
1566
1603
|
}
|
|
1567
1604
|
|
|
1568
1605
|
if (ops.includes('decay')) {
|
|
1606
|
+
// v2.56.0 #4: parity with hook.mjs auto-maintain — injection_count > 0
|
|
1607
|
+
// protects from decay/mark-idle, treating hook injection as first-class
|
|
1608
|
+
// engagement alongside access_count.
|
|
1569
1609
|
const decayed = db.prepare(`
|
|
1570
1610
|
UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
|
|
1571
1611
|
WHERE id IN (
|
|
@@ -1573,12 +1613,13 @@ function cmdMaintain(db, args) {
|
|
|
1573
1613
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
1574
1614
|
AND COALESCE(importance, 1) > 1
|
|
1575
1615
|
AND COALESCE(access_count, 0) = 0
|
|
1616
|
+
AND COALESCE(injection_count, 0) = 0
|
|
1576
1617
|
AND created_at_epoch < ?
|
|
1577
1618
|
${projectFilter} LIMIT ${OP_CAP}
|
|
1578
1619
|
)
|
|
1579
1620
|
`).run(staleAge, ...baseParams);
|
|
1580
1621
|
|
|
1581
|
-
// Mark importance=1, never-accessed, old
|
|
1622
|
+
// Mark importance=1, never-accessed, never-injected, old → pending-purge.
|
|
1582
1623
|
const idleMarked = db.prepare(`
|
|
1583
1624
|
UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
1584
1625
|
WHERE id IN (
|
|
@@ -1586,6 +1627,7 @@ function cmdMaintain(db, args) {
|
|
|
1586
1627
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
1587
1628
|
AND COALESCE(importance, 1) = 1
|
|
1588
1629
|
AND COALESCE(access_count, 0) = 0
|
|
1630
|
+
AND COALESCE(injection_count, 0) = 0
|
|
1589
1631
|
AND created_at_epoch < ?
|
|
1590
1632
|
${projectFilter} LIMIT ${OP_CAP}
|
|
1591
1633
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.58.2",
|
|
4
4
|
"description": "Lightweight persistent memory system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"lint": "eslint .",
|
|
18
|
+
"dead-code": "knip",
|
|
18
19
|
"test": "vitest run",
|
|
19
20
|
"test:smoke": "vitest run tests/smoke.test.mjs",
|
|
20
21
|
"test:coverage": "vitest run --coverage",
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"lib/doctor-drift.mjs",
|
|
52
53
|
"lib/stats-quality.mjs",
|
|
53
54
|
"lib/low-signal-patterns.mjs",
|
|
55
|
+
"lib/private-strip.mjs",
|
|
54
56
|
"lib/citation-tracker.mjs",
|
|
55
57
|
"lib/summary-extractor.mjs",
|
|
56
58
|
"lib/id-routing.mjs",
|
|
@@ -117,13 +119,16 @@
|
|
|
117
119
|
"zod": "^4.3.6"
|
|
118
120
|
},
|
|
119
121
|
"overrides": {
|
|
120
|
-
"hono": ">=4.12.
|
|
122
|
+
"hono": ">=4.12.16",
|
|
123
|
+
"fast-uri": ">=3.1.2",
|
|
124
|
+
"ip-address": ">=10.1.1"
|
|
121
125
|
},
|
|
122
126
|
"devDependencies": {
|
|
123
127
|
"@eslint/js": "^10.0.1",
|
|
124
128
|
"@vitest/coverage-v8": "^4.0.18",
|
|
125
129
|
"eslint": "^10.0.0",
|
|
126
130
|
"fast-check": "^4.5.3",
|
|
131
|
+
"knip": "^6.12.1",
|
|
127
132
|
"vitest": "^4.0.18"
|
|
128
133
|
}
|
|
129
134
|
}
|
package/schema.mjs
CHANGED
|
@@ -26,7 +26,21 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
|
|
|
26
26
|
// 2839/6429 (44%) orphaned rows (historic deletes during FK-OFF migrations)
|
|
27
27
|
// and 3282/6429 (51%) stale-vocab rows (rebuildVocabulary never pruned old
|
|
28
28
|
// versions before v2.47). Idempotent one-shot DELETE on ensureDb.
|
|
29
|
-
|
|
29
|
+
//
|
|
30
|
+
// v29 (v2.57.x): (1) sdk_sessions_id_invariant trigger guarding the v2.33.1
|
|
31
|
+
// mix pattern (memory_session_id and content_session_id must not be the same
|
|
32
|
+
// non-null value — they're different ID schemes). (2) lesson_retry_stats
|
|
33
|
+
// aggregate table tracking how often hook-llm.mjs retry path actually
|
|
34
|
+
// recovers a lesson (vs being a wasted Haiku call). Both purely additive.
|
|
35
|
+
//
|
|
36
|
+
// v30 (v2.57.x patch): trigger body fix — UUID-shape gate so test fixtures
|
|
37
|
+
// using short literal IDs ('sess-1') don't trigger. Initial v29 trigger
|
|
38
|
+
// fired on any equal non-null pair, breaking 60+ test scaffolds that write
|
|
39
|
+
// the same literal to both columns by helper convention. v30 forces
|
|
40
|
+
// DROP+CREATE so DBs that picked up the strict v29 trigger get the UUID-
|
|
41
|
+
// gated body. Required because `CREATE TRIGGER IF NOT EXISTS` is a no-op
|
|
42
|
+
// when the trigger already exists, even with a different body.
|
|
43
|
+
export const CURRENT_SCHEMA_VERSION = 30;
|
|
30
44
|
|
|
31
45
|
const CORE_SCHEMA = `
|
|
32
46
|
CREATE TABLE IF NOT EXISTS sdk_sessions (
|
|
@@ -471,6 +485,62 @@ export function initSchema(db) {
|
|
|
471
485
|
}
|
|
472
486
|
} catch { /* non-critical — normalization can retry on next open */ }
|
|
473
487
|
|
|
488
|
+
// ─── v29 (v2.57.x): session-id mix invariant + lesson-retry stats ─────────
|
|
489
|
+
//
|
|
490
|
+
// (B1) sdk_sessions_id_mix_check trigger — guards the v2.33.1 bug pattern
|
|
491
|
+
// where memory_session_id and content_session_id were silently the same
|
|
492
|
+
// value because a caller passed the wrong ID type. The two columns hold
|
|
493
|
+
// *different* ID schemes (mem-internal `hook-<project>-<hash>` vs Claude
|
|
494
|
+
// Code UUID); they should never be equal non-null in production.
|
|
495
|
+
//
|
|
496
|
+
// Trigger fires only when both values look like CC UUIDs (length 36 +
|
|
497
|
+
// hyphenated 8-4-4-4-12 LIKE pattern). This is the v2.33.1 fingerprint —
|
|
498
|
+
// a CC UUID accidentally written into BOTH columns. Test fixtures use
|
|
499
|
+
// short literal strings ('sess-1') for which neither column holds a UUID,
|
|
500
|
+
// so the trigger correctly bypasses them; the audit function below reports
|
|
501
|
+
// any mix regardless for diagnostic completeness.
|
|
502
|
+
//
|
|
503
|
+
// DROP+CREATE pattern (not IF NOT EXISTS) so v29 DBs that captured the
|
|
504
|
+
// initial strict trigger body get the UUID-gated v30 body on next init.
|
|
505
|
+
// Cheap — triggers are metadata-only DDL; this runs once per schema
|
|
506
|
+
// version bump (gated by the fast-path schema_version check above).
|
|
507
|
+
db.exec(`
|
|
508
|
+
DROP TRIGGER IF EXISTS sdk_sessions_id_mix_check_ai;
|
|
509
|
+
DROP TRIGGER IF EXISTS sdk_sessions_id_mix_check_au;
|
|
510
|
+
CREATE TRIGGER sdk_sessions_id_mix_check_ai
|
|
511
|
+
BEFORE INSERT ON sdk_sessions
|
|
512
|
+
WHEN NEW.memory_session_id IS NOT NULL
|
|
513
|
+
AND NEW.memory_session_id = NEW.content_session_id
|
|
514
|
+
AND length(NEW.memory_session_id) = 36
|
|
515
|
+
AND NEW.memory_session_id LIKE '________-____-____-____-____________'
|
|
516
|
+
BEGIN
|
|
517
|
+
SELECT RAISE(ABORT, 'sdk_sessions invariant: memory_session_id and content_session_id must not hold the same UUID value (v2.33.1 mix pattern)');
|
|
518
|
+
END;
|
|
519
|
+
CREATE TRIGGER sdk_sessions_id_mix_check_au
|
|
520
|
+
BEFORE UPDATE ON sdk_sessions
|
|
521
|
+
WHEN NEW.memory_session_id IS NOT NULL
|
|
522
|
+
AND NEW.memory_session_id = NEW.content_session_id
|
|
523
|
+
AND length(NEW.memory_session_id) = 36
|
|
524
|
+
AND NEW.memory_session_id LIKE '________-____-____-____-____________'
|
|
525
|
+
BEGIN
|
|
526
|
+
SELECT RAISE(ABORT, 'sdk_sessions invariant: memory_session_id and content_session_id must not hold the same UUID value (v2.33.1 mix pattern)');
|
|
527
|
+
END;
|
|
528
|
+
`);
|
|
529
|
+
|
|
530
|
+
// (B2) lesson_retry_stats — daily aggregate of hook-llm.mjs retry path
|
|
531
|
+
// outcomes. attempts = times the bugfix/decision retry prompt was issued;
|
|
532
|
+
// recovered = times the retry actually returned a non-low-signal lesson.
|
|
533
|
+
// Used by `claude-mem-lite stats --retry` to answer "is the extra Haiku
|
|
534
|
+
// call paying off?" — if recovered/attempts < 0.1 over a long window,
|
|
535
|
+
// delete the retry path and save one LLM call per bugfix/decision.
|
|
536
|
+
db.exec(`
|
|
537
|
+
CREATE TABLE IF NOT EXISTS lesson_retry_stats (
|
|
538
|
+
date_bucket TEXT PRIMARY KEY,
|
|
539
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
540
|
+
recovered INTEGER NOT NULL DEFAULT 0
|
|
541
|
+
)
|
|
542
|
+
`);
|
|
543
|
+
|
|
474
544
|
// Record schema version for fast-path on subsequent calls
|
|
475
545
|
db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
|
|
476
546
|
db.transaction(() => {
|
|
@@ -481,6 +551,67 @@ export function initSchema(db) {
|
|
|
481
551
|
return db;
|
|
482
552
|
}
|
|
483
553
|
|
|
554
|
+
// ─── Session-consistency audit (B1) ─────────────────────────────────────────
|
|
555
|
+
//
|
|
556
|
+
// Used by `claude-mem-lite doctor --session-audit` to surface dangling state
|
|
557
|
+
// that the schema invariant trigger only catches at insert/update time. The
|
|
558
|
+
// trigger is a forward-protection; this function detects historical drift.
|
|
559
|
+
//
|
|
560
|
+
// Returns shape: {
|
|
561
|
+
// id_mix_uuid_shape: rows where both columns hold the same UUID-shaped value
|
|
562
|
+
// (the v2.33.1 production fingerprint — alarming),
|
|
563
|
+
// id_mix_other: rows where both columns equal but NOT UUID-shaped
|
|
564
|
+
// (typically test-fixture scaffold convention — informational),
|
|
565
|
+
// missing_mem_id: sdk_sessions rows where memory_session_id IS NULL after grace,
|
|
566
|
+
// orphan_obs: observations.memory_session_id values not in sdk_sessions,
|
|
567
|
+
// healthy: true when id_mix_uuid_shape + missing_mem_id + orphan_obs == 0;
|
|
568
|
+
// id_mix_other does NOT drive healthy=false, mirroring the
|
|
569
|
+
// trigger's UUID-shape gate so doctor doesn't misfire on DBs
|
|
570
|
+
// contaminated with test-fixture-style literal IDs.
|
|
571
|
+
// }
|
|
572
|
+
//
|
|
573
|
+
// Post-review fix (Important #5): split id_mix to avoid false-positive doctor
|
|
574
|
+
// failures on DBs that contain test fixtures or any 'sess-1'-style literal
|
|
575
|
+
// equality. The trigger only fires for UUID-shaped equality (the actual bug
|
|
576
|
+
// fingerprint); the audit now mirrors that policy for the exit-code-driving
|
|
577
|
+
// metric while still surfacing the broader count for diagnostic transparency.
|
|
578
|
+
export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
|
|
579
|
+
const cutoff = Date.now() - graceMinutes * 60_000;
|
|
580
|
+
// UUID-shape gate mirrors the v30 trigger — same length=36 + LIKE pattern.
|
|
581
|
+
const UUID_LIKE = '________-____-____-____-____________';
|
|
582
|
+
const idMixUuidShape = db.prepare(`
|
|
583
|
+
SELECT COUNT(*) AS c FROM sdk_sessions
|
|
584
|
+
WHERE memory_session_id IS NOT NULL
|
|
585
|
+
AND memory_session_id = content_session_id
|
|
586
|
+
AND length(memory_session_id) = 36
|
|
587
|
+
AND memory_session_id LIKE ?
|
|
588
|
+
`).get(UUID_LIKE).c;
|
|
589
|
+
const idMixOther = db.prepare(`
|
|
590
|
+
SELECT COUNT(*) AS c FROM sdk_sessions
|
|
591
|
+
WHERE memory_session_id IS NOT NULL
|
|
592
|
+
AND memory_session_id = content_session_id
|
|
593
|
+
AND NOT (length(memory_session_id) = 36 AND memory_session_id LIKE ?)
|
|
594
|
+
`).get(UUID_LIKE).c;
|
|
595
|
+
const missingMemId = db.prepare(`
|
|
596
|
+
SELECT COUNT(*) AS c FROM sdk_sessions
|
|
597
|
+
WHERE memory_session_id IS NULL
|
|
598
|
+
AND started_at_epoch < ?
|
|
599
|
+
`).get(cutoff).c;
|
|
600
|
+
const orphanObs = db.prepare(`
|
|
601
|
+
SELECT COUNT(*) AS c FROM observations o
|
|
602
|
+
WHERE NOT EXISTS (
|
|
603
|
+
SELECT 1 FROM sdk_sessions s WHERE s.memory_session_id = o.memory_session_id
|
|
604
|
+
)
|
|
605
|
+
`).get().c;
|
|
606
|
+
return {
|
|
607
|
+
id_mix_uuid_shape: idMixUuidShape,
|
|
608
|
+
id_mix_other: idMixOther,
|
|
609
|
+
missing_mem_id: missingMemId,
|
|
610
|
+
orphan_obs: orphanObs,
|
|
611
|
+
healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
484
615
|
/**
|
|
485
616
|
* Ensure DB directory, database file, and all tables exist.
|
|
486
617
|
* Safe to call from any process (hook or server). Idempotent.
|
package/scripts/setup.sh
CHANGED
|
@@ -26,6 +26,7 @@ fi
|
|
|
26
26
|
log_ok() { echo -e "${GREEN}✓${NC} $*" >&2; }
|
|
27
27
|
log_info() { echo -e "${BLUE}ℹ${NC} $*" >&2; }
|
|
28
28
|
log_warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
|
|
29
|
+
# shellcheck disable=SC2317 # kept for API symmetry with log_ok/log_info/log_warn
|
|
29
30
|
log_err() { echo -e "${RED}✗${NC} $*" >&2; }
|
|
30
31
|
|
|
31
32
|
# 1. Migrate unhidden dir (~/claude-mem-lite/ → ~/.claude-mem-lite/)
|
|
@@ -71,8 +72,9 @@ mkdir -p "$DATA_DIR/runtime"
|
|
|
71
72
|
if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
|
|
72
73
|
# Fast path: symlink from data dir (instant, no network needed)
|
|
73
74
|
if [[ -d "$DATA_DIR/node_modules/better-sqlite3" ]]; then
|
|
74
|
-
ln -sfn "$DATA_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null
|
|
75
|
-
log_ok "Dependencies linked from $DATA_DIR"
|
|
75
|
+
if ln -sfn "$DATA_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null; then
|
|
76
|
+
log_ok "Dependencies linked from $DATA_DIR"
|
|
77
|
+
fi
|
|
76
78
|
fi
|
|
77
79
|
# Slow path: npm install (first-time only, ~10-20s for native addon)
|
|
78
80
|
if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
|
|
@@ -122,11 +124,15 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
|
|
|
122
124
|
CACHE_DIR="$HOME/.claude/plugins/cache/sdsrss/claude-mem-lite"
|
|
123
125
|
if [[ -d "$CACHE_DIR" ]]; then
|
|
124
126
|
# List version dirs sorted by semver descending, skip top 3
|
|
125
|
-
# Use while-read
|
|
127
|
+
# Use glob + while-read for bash 3.2 (macOS) compatibility (no mapfile, no `ls | grep`)
|
|
126
128
|
OLD_VERS=()
|
|
129
|
+
shopt -s nullglob
|
|
130
|
+
_all_dirs=("$CACHE_DIR"/[0-9]*)
|
|
131
|
+
shopt -u nullglob
|
|
127
132
|
while IFS= read -r ver; do
|
|
128
133
|
[[ -n "$ver" ]] && OLD_VERS+=("$ver")
|
|
129
|
-
done < <(
|
|
134
|
+
done < <(for _d in "${_all_dirs[@]}"; do [[ -d "$_d" ]] && echo "${_d##*/}"; done | sort -t. -k1,1nr -k2,2nr -k3,3nr | tail -n +4)
|
|
135
|
+
unset _all_dirs _d
|
|
130
136
|
if [[ ${#OLD_VERS[@]} -gt 0 ]]; then
|
|
131
137
|
for ver in "${OLD_VERS[@]}"; do
|
|
132
138
|
rm -rf "${CACHE_DIR:?}/$ver" 2>/dev/null || true
|
|
@@ -136,5 +142,53 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
|
|
|
136
142
|
fi
|
|
137
143
|
fi
|
|
138
144
|
|
|
145
|
+
# 9. Residue detection (plugin mode only): warn once if legacy direct-install
|
|
146
|
+
# hooks remain in ~/.claude/settings.json. A user who installed via global
|
|
147
|
+
# `claude-mem-lite install` and later switched to the marketplace plugin
|
|
148
|
+
# will run every hook twice (direct settings.json hooks AND plugin hooks)
|
|
149
|
+
# until they run `claude-mem-lite uninstall` to clear the settings.json
|
|
150
|
+
# entries. /plugin uninstall does not touch settings.json.
|
|
151
|
+
RESIDUE_MARKER="$DATA_DIR/runtime/.residue-warned-v2.55"
|
|
152
|
+
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
|
|
153
|
+
SETTINGS="$HOME/.claude/settings.json"
|
|
154
|
+
if [[ -f "$SETTINGS" ]]; then
|
|
155
|
+
SETTINGS_PATH="$SETTINGS" node -e '
|
|
156
|
+
const fs = require("fs");
|
|
157
|
+
try {
|
|
158
|
+
const raw = fs.readFileSync(process.env.SETTINGS_PATH, "utf8");
|
|
159
|
+
const data = JSON.parse(raw);
|
|
160
|
+
const hooks = data.hooks || {};
|
|
161
|
+
const events = Object.keys(hooks);
|
|
162
|
+
const found = [];
|
|
163
|
+
for (const ev of events) {
|
|
164
|
+
const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
|
|
165
|
+
for (const entry of list) {
|
|
166
|
+
const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
167
|
+
for (const h of inner) {
|
|
168
|
+
const cmd = String(h?.command || "");
|
|
169
|
+
if (cmd.includes(".claude-mem-lite/") || cmd.includes("claude-mem-lite/scripts") || cmd.includes("claude-mem-lite/hook.mjs")) {
|
|
170
|
+
found.push(ev);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (found.length) {
|
|
177
|
+
process.stderr.write("\n");
|
|
178
|
+
process.stderr.write("\x1b[33m⚠\x1b[0m Legacy direct-install hooks detected in " + process.env.SETTINGS_PATH + "\n");
|
|
179
|
+
process.stderr.write(" Events with stale entries: " + [...new Set(found)].join(", ") + "\n");
|
|
180
|
+
process.stderr.write(" These will fire alongside plugin hooks (each tool call runs twice).\n");
|
|
181
|
+
process.stderr.write(" Fix: run \x1b[1mclaude-mem-lite uninstall\x1b[0m to clear settings.json,\n");
|
|
182
|
+
process.stderr.write(" then keep using the plugin install. (One-time warning.)\n\n");
|
|
183
|
+
process.exit(2);
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
' || true
|
|
187
|
+
fi
|
|
188
|
+
# Mark the warning as shown regardless of result — silence is fine if no
|
|
189
|
+
# residue, and the warning above is one-shot per data-dir.
|
|
190
|
+
touch "$RESIDUE_MARKER"
|
|
191
|
+
fi
|
|
192
|
+
|
|
139
193
|
log_ok "claude-mem-lite ready"
|
|
140
194
|
exit 0
|