claude-mem-lite 3.21.0 → 3.23.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/bash-utils.mjs +15 -1
- package/cli/common.mjs +13 -1
- package/format-utils.mjs +18 -0
- package/hook-context.mjs +6 -2
- package/hook-handoff.mjs +9 -5
- package/hook-llm.mjs +8 -1
- package/hook-memory.mjs +5 -2
- package/hook.mjs +38 -4
- package/lib/cite-back-hint.mjs +8 -2
- package/lib/compress-core.mjs +9 -2
- package/lib/maintain-core.mjs +41 -9
- package/lib/search-core.mjs +1 -1
- package/mem-cli.mjs +86 -46
- package/package.json +1 -1
- package/scripts/post-tool-use.sh +5 -1
- package/scripts/user-prompt-search.js +10 -1
- package/search-engine.mjs +8 -3
- package/server.mjs +30 -9
- package/utils.mjs +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.23.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.23.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/bash-utils.mjs
CHANGED
|
@@ -13,6 +13,16 @@ const CMD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'time', 'command', 'nice',
|
|
|
13
13
|
// git read subcommands whose output contains commit/log/match text, not failures.
|
|
14
14
|
const GIT_READ_SUBCMDS = new Set(['grep', 'log', 'show', 'diff', 'blame', 'ls-files', 'cat-file', 'whatchanged', 'shortlog', 'reflog', 'status']);
|
|
15
15
|
|
|
16
|
+
// Hard failure fingerprints — a real crash / thrown exception / non-zero-exit marker,
|
|
17
|
+
// as opposed to output that merely CONTAINS the word "error" (search results, log
|
|
18
|
+
// scans, prose). Deliberately strong/narrow: a JS stack frame (`\n at fn (…)`),
|
|
19
|
+
// panic/traceback/segfault, ENOENT/command-not-found, AssertionError, or a *named*
|
|
20
|
+
// error class (TypeError:/ReferenceError:/…). Generic `Error:`/`exception` are
|
|
21
|
+
// intentionally excluded — they appear too often in benign search/log output. Gates
|
|
22
|
+
// the bugfix-shape save-nudge (lib/cite-back-hint.mjs) so `node cli.mjs search "error"`
|
|
23
|
+
// + an edit in the same episode no longer looks like an unsaved fix.
|
|
24
|
+
const HARD_ERROR_RE = /\bERR!|\bpanic\b|traceback|segfault|core dumped|\benoent\b|command not found|assertion\s?error|\n\s+at\s+\S|(?:type|reference|range|syntax|eval|uri)error:/i;
|
|
25
|
+
|
|
16
26
|
// True when the command's PRIMARY operation (left of the first pipe, past any
|
|
17
27
|
// env-assignments / wrapper like `sudo`/`env`/`time`) is a read/search — including
|
|
18
28
|
// `git grep`/`git log`. Anchoring on the primary command (not "search verb appears
|
|
@@ -69,6 +79,10 @@ export function detectBashSignificance(input, response) {
|
|
|
69
79
|
const hasHardErrorSignal = hasGreenTestSummary
|
|
70
80
|
&& /\bERR!|panic|traceback|enoent|command not found|exception|AssertionError|TypeError:|SyntaxError:/i.test(response);
|
|
71
81
|
const isError = looksLikeError && !(hasGreenTestSummary && !hasHardErrorSignal);
|
|
82
|
+
// Strict subset of isError: a genuine failure fingerprint, not just the word "error"
|
|
83
|
+
// in benign output. Consumers that must avoid false positives (the bugfix-shape
|
|
84
|
+
// save-nudge) gate on this instead of isError.
|
|
85
|
+
const isHardError = isError && HARD_ERROR_RE.test(response);
|
|
72
86
|
// Match actual test runner invocations, not commands that merely reference "test" as a keyword
|
|
73
87
|
const isTest = /\b(npm\s+test|npm\s+run\s+test|yarn\s+test|pnpm\s+test|pnpm\s+run\s+test|bun\s+test|go\s+test|cargo\s+test)\b/i.test(cmd)
|
|
74
88
|
|| /\b(jest|pytest|vitest|mocha|cypress|playwright)\b/i.test(cmd);
|
|
@@ -85,7 +99,7 @@ export function detectBashSignificance(input, response) {
|
|
|
85
99
|
|| /\bgh\s+release\s+(?:create|edit|upload|delete)\b/i.test(cmd)
|
|
86
100
|
|| /\btwine\s+upload\b/i.test(cmd);
|
|
87
101
|
return {
|
|
88
|
-
isError, isTest, isBuild, isGit, isDeploy,
|
|
102
|
+
isError, isHardError, isTest, isBuild, isGit, isDeploy,
|
|
89
103
|
isSignificant: isError || isTest || isBuild || isGit || isDeploy,
|
|
90
104
|
};
|
|
91
105
|
}
|
package/cli/common.mjs
CHANGED
|
@@ -21,7 +21,19 @@ export function parseArgs(argv) {
|
|
|
21
21
|
while (i < argv.length) {
|
|
22
22
|
const arg = argv[i];
|
|
23
23
|
if (arg.startsWith('--')) {
|
|
24
|
-
const
|
|
24
|
+
const body = arg.slice(2);
|
|
25
|
+
// `--key=value` (GNU long-option form). Split on the FIRST '=' so values that
|
|
26
|
+
// themselves contain '=' (e.g. `--from=2026-01-01`, a token with '=') stay intact.
|
|
27
|
+
// Without this, `--type=feature` parsed as a boolean flag literally named
|
|
28
|
+
// "type=feature"; the real `--type` stayed undefined and the default silently
|
|
29
|
+
// applied — a save landed in the wrong project / type with no error.
|
|
30
|
+
const eq = body.indexOf('=');
|
|
31
|
+
if (eq >= 0) {
|
|
32
|
+
flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const key = body;
|
|
25
37
|
const next = argv[i + 1];
|
|
26
38
|
if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
|
|
27
39
|
flags[key] = next;
|
package/format-utils.mjs
CHANGED
|
@@ -24,6 +24,24 @@ export function truncate(str, max = 80) {
|
|
|
24
24
|
return str.slice(0, end) + '\u2026';
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The block delimiters claude-mem-lite wraps injected context in. Any user-derived text
|
|
28
|
+
// (observation title / lesson, handoff body) that contains one of these LITERALLY would
|
|
29
|
+
// prematurely open or close the block it lands in, and the model then reads the rest as
|
|
30
|
+
// undelimited context. Reachable by editing files that contain these tokens \u2014 e.g.
|
|
31
|
+
// developing claude-mem-lite itself, where source/observations carry the delimiter names.
|
|
32
|
+
const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff)>/gi;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Defang the literal context-block delimiter tags in user-derived text. Strips just the
|
|
36
|
+
* angle brackets, so `</claude-mem-context>` renders as `/claude-mem-context` \u2014 still
|
|
37
|
+
* readable, but no longer a structural delimiter. Complements `mdCell`'s pipe-escaping.
|
|
38
|
+
* @param {string} s Input string (any type; coerced)
|
|
39
|
+
* @returns {string} Text with delimiter tags defanged
|
|
40
|
+
*/
|
|
41
|
+
export function neutralizeContextDelimiters(s) {
|
|
42
|
+
return String(s ?? '').replace(CONTEXT_DELIMITER_RE, (m) => m.slice(1, -1));
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
/**
|
|
28
46
|
* Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
|
|
29
47
|
* The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
|
package/hook-context.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, join } from 'path';
|
|
|
5
5
|
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
6
6
|
import {
|
|
7
7
|
estimateTokens, truncate, typeIcon, fmtTime, inferProject,
|
|
8
|
-
debugLog, debugCatch,
|
|
8
|
+
debugLog, debugCatch, neutralizeContextDelimiters,
|
|
9
9
|
DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
|
|
10
10
|
} from './utils.mjs';
|
|
11
11
|
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet } from './hook-shared.mjs';
|
|
@@ -462,7 +462,11 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
|
|
465
|
+
// Defang any literal block-delimiter tag carried in a title/lesson/summary so a row
|
|
466
|
+
// can't prematurely close the <claude-mem-context> block it's wrapped in (mdCell does
|
|
467
|
+
// the same for `|`). One source of truth: both the SessionStart hook and the CLI
|
|
468
|
+
// `context` command consume this return.
|
|
469
|
+
return neutralizeContextDelimiters([...summaryLines, ...handoffLines, ...deferredLines, ...obsLines].join('\n'));
|
|
466
470
|
}
|
|
467
471
|
|
|
468
472
|
/**
|
package/hook-handoff.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Extracted for testability — hook.mjs has module-level side effects
|
|
3
3
|
|
|
4
4
|
import { basename } from 'path';
|
|
5
|
-
import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, scrubSecrets, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause } from './utils.mjs';
|
|
5
|
+
import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, scrubSecrets, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause, neutralizeContextDelimiters } from './utils.mjs';
|
|
6
6
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
7
7
|
import {
|
|
8
8
|
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_ANCHOR_MAX_AGE,
|
|
@@ -418,11 +418,15 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
418
418
|
`<session-handoff source="${handoff.type}" age="${ageStr}" origin="hook-injected">`,
|
|
419
419
|
];
|
|
420
420
|
|
|
421
|
+
// Defang delimiter tags in the free-text fields ONLY — never the structural
|
|
422
|
+
// <session-handoff> tags in `lines`, or the block would lose its own framing. A
|
|
423
|
+
// user prompt or edit snippet carrying a literal </session-handoff> would otherwise
|
|
424
|
+
// close the block early and the rest would read as a real user message.
|
|
421
425
|
if (handoff.working_on) {
|
|
422
|
-
lines.push('## Working On', handoff.working_on, '');
|
|
426
|
+
lines.push('## Working On', neutralizeContextDelimiters(handoff.working_on), '');
|
|
423
427
|
}
|
|
424
428
|
if (handoff.completed) {
|
|
425
|
-
lines.push('## Completed', ...handoff.completed.split('\n').map(l => `- ${l}`), '');
|
|
429
|
+
lines.push('## Completed', ...neutralizeContextDelimiters(handoff.completed).split('\n').map(l => `- ${l}`), '');
|
|
426
430
|
}
|
|
427
431
|
if (handoff.unfinished) {
|
|
428
432
|
// Extract only the pending-work portion (before narrative history separator).
|
|
@@ -431,7 +435,7 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
431
435
|
// completeness claim the episode buffer can't support.
|
|
432
436
|
const pending = extractUnfinishedSummary(handoff.unfinished);
|
|
433
437
|
if (pending) {
|
|
434
|
-
lines.push('## Recent activity', ...pending.split('; ').map(l => `- ${l}`), '');
|
|
438
|
+
lines.push('## Recent activity', ...neutralizeContextDelimiters(pending).split('; ').map(l => `- ${l}`), '');
|
|
435
439
|
}
|
|
436
440
|
}
|
|
437
441
|
if (handoff.key_files) {
|
|
@@ -441,7 +445,7 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
441
445
|
} catch {}
|
|
442
446
|
}
|
|
443
447
|
if (handoff.key_decisions) {
|
|
444
|
-
lines.push('## Key Decisions', ...handoff.key_decisions.split('\n').map(l => `- ${l}`), '');
|
|
448
|
+
lines.push('## Key Decisions', ...neutralizeContextDelimiters(handoff.key_decisions).split('\n').map(l => `- ${l}`), '');
|
|
445
449
|
}
|
|
446
450
|
|
|
447
451
|
lines.push('</session-handoff>');
|
package/hook-llm.mjs
CHANGED
|
@@ -803,9 +803,16 @@ ${actionList}`;
|
|
|
803
803
|
// retry's entire purpose (a recovered bugfix lesson would silently drop
|
|
804
804
|
// out of --importance 2 searches and the working tier). Gate the cap on
|
|
805
805
|
// the *effective* low-signal state, not the pre-retry flag.
|
|
806
|
+
// v3.23: cap the FILE-PATH heuristic's contribution at 2. computeRuleImportance
|
|
807
|
+
// returns 3 for any entry touching schema./migration/prisma/.env/.key paths; via the
|
|
808
|
+
// Math.max below that force-promoted ordinary has-lesson episodes to "critical" imp=3
|
|
809
|
+
// even when Haiku judged them 1-2 (audit: auto imp=3 = 34.8%, e.g. a thin-lesson edit
|
|
810
|
+
// to schema.mjs). Haiku's OWN importance can still reach 3 (genuine judgment); only the
|
|
811
|
+
// path heuristic is capped. The isLessonLowSignal branch still floors no-lesson
|
|
812
|
+
// non-decision autos at ≤1; manual mem_save uses a different path and is unaffected.
|
|
806
813
|
importance: isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
|
|
807
814
|
? Math.min(ruleImportance, 1)
|
|
808
|
-
: Math.max(ruleImportance, clampImportance(parsed.importance)),
|
|
815
|
+
: Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
|
|
809
816
|
lessonLearned,
|
|
810
817
|
searchAliases,
|
|
811
818
|
};
|
package/hook-memory.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// claude-mem-lite — Semantic Memory Injection
|
|
2
2
|
// Search past observations for relevant memories to inject as context at user-prompt time.
|
|
3
3
|
|
|
4
|
-
import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords } from './utils.mjs';
|
|
4
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters } from './utils.mjs';
|
|
5
5
|
import { citeFactorJs } from './scoring-sql.mjs';
|
|
6
6
|
import { recordMetric } from './lib/metrics.mjs';
|
|
7
7
|
import { DB_DIR } from './schema.mjs';
|
|
@@ -123,7 +123,10 @@ export function formatMemoryLine(obs) {
|
|
|
123
123
|
&& hasFilePaths(obs.files_modified)) {
|
|
124
124
|
staleHint = ' [verify-before-use]';
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
// Defang any literal block-delimiter tag in title/lesson so it can't prematurely close
|
|
127
|
+
// the <memory-context> block this line is injected into (parity with hook-context's
|
|
128
|
+
// <claude-mem-context> defense).
|
|
129
|
+
return neutralizeContextDelimiters(`- [${obs.type}] ${truncate(obs.title, 80)}${lessonTag} (#${obs.id})${staleHint}`);
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
function hasFilePaths(filesModified) {
|
package/hook.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { entry as preCompactEntry } from './hook-precompact.mjs';
|
|
|
39
39
|
import {
|
|
40
40
|
RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
|
|
41
41
|
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
|
|
42
|
+
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
|
|
42
43
|
sessionFile, getSessionId, createSessionId, openDb,
|
|
43
44
|
spawnBackground, sweepOrphanEpisodeFiles,
|
|
44
45
|
} from './hook-shared.mjs';
|
|
@@ -46,7 +47,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
|
|
|
46
47
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
47
48
|
import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
48
49
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
49
|
-
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale } from './lib/maintain-core.mjs';
|
|
50
|
+
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren } from './lib/maintain-core.mjs';
|
|
50
51
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
51
52
|
import {
|
|
52
53
|
extractCitationsFromTranscript,
|
|
@@ -285,6 +286,9 @@ async function handlePostToolUse() {
|
|
|
285
286
|
files,
|
|
286
287
|
ts: Date.now(),
|
|
287
288
|
isError: bashSig?.isError || false,
|
|
289
|
+
// isHardError gates the bugfix-shape save-nudge (lib/cite-back-hint.mjs): a real
|
|
290
|
+
// failure fingerprint, not just "error" appearing in search/log output.
|
|
291
|
+
isHardError: bashSig?.isHardError || false,
|
|
288
292
|
isSignificant: EDIT_TOOLS.has(tool_name) ||
|
|
289
293
|
bashSig?.isSignificant || false,
|
|
290
294
|
bashSig: bashSig || null,
|
|
@@ -696,16 +700,26 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
696
700
|
WHERE status = 'active' AND started_at_epoch < ?
|
|
697
701
|
`).run(staleSessionCutoff);
|
|
698
702
|
|
|
699
|
-
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance
|
|
703
|
+
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance<=1)
|
|
700
704
|
// Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
|
|
701
705
|
// v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
|
|
702
706
|
// via hook-memory injection, even if user never explicitly fetched). Same
|
|
703
707
|
// protection applied symmetrically in auto-maintain decay/mark-idle below.
|
|
708
|
+
// `<= 1` (was `= 1`): citation-decay floors importance at 0 (added v2.73.2, after this
|
|
709
|
+
// predicate was written) and the LLM low-signal filter saves at imp=0 — those rows are
|
|
710
|
+
// STRICTLY lower value than imp=1 yet escaped GC, accumulating to ~40% of a mature DB
|
|
711
|
+
// (immortal: hidden from injection by the imp>=1 floor, but visible as explicit-search noise).
|
|
704
712
|
const compressed = db.prepare(`
|
|
705
713
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
706
714
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
707
|
-
AND importance
|
|
715
|
+
AND COALESCE(importance, 1) <= 1
|
|
708
716
|
AND COALESCE(injection_count, 0) = 0
|
|
717
|
+
-- v3.23: never auto-hide a row that carries a real lesson. compression folds
|
|
718
|
+
-- sources into a title-only summary (lesson lost), and COMPRESSED_AUTO hides the
|
|
719
|
+
-- row from search entirely. A low-importance obs whose lesson_learned is the
|
|
720
|
+
-- distilled value must stay findable (audit: 62 lessons buried this way). The 7d
|
|
721
|
+
-- noise block below already excludes lessons; this 30d block had drifted.
|
|
722
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
709
723
|
AND created_at_epoch < ?
|
|
710
724
|
AND project = ?
|
|
711
725
|
`).run(autoCompressAge, project);
|
|
@@ -722,7 +736,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
722
736
|
const noiseCompressed = db.prepare(`
|
|
723
737
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
724
738
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
725
|
-
AND importance
|
|
739
|
+
AND COALESCE(importance, 1) <= 1
|
|
726
740
|
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
727
741
|
AND (facts IS NULL OR facts = '' OR facts = '[]')
|
|
728
742
|
AND (
|
|
@@ -774,6 +788,12 @@ function runSessionStartAutoMaintain(db) {
|
|
|
774
788
|
const cleaned = cleanupBroken(db, mctx);
|
|
775
789
|
if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
|
|
776
790
|
|
|
791
|
+
// Self-heal legacy orphans: children whose compression keeper was hard-deleted
|
|
792
|
+
// before recoverChildrenOf existed are hidden + queue-less (unreachable). Resurface
|
|
793
|
+
// them so normal decay/GC handles them on merit. Non-destructive (un-hide only).
|
|
794
|
+
const orphansRecovered = recoverOrphanedChildren(db, mctx);
|
|
795
|
+
if (orphansRecovered > 0) debugLog('DEBUG', 'auto-maintain', `recovered ${orphansRecovered} orphaned compression children`);
|
|
796
|
+
|
|
777
797
|
const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
|
|
778
798
|
if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
|
|
779
799
|
if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
|
|
@@ -848,6 +868,20 @@ function runSessionStartAutoMaintain(db) {
|
|
|
848
868
|
if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
|
|
849
869
|
} catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
|
|
850
870
|
|
|
871
|
+
// GC expired session_handoffs: the consume-DELETE (handleSessionStart) only removes
|
|
872
|
+
// the single handoff a continuation reads back; an 'exit'/'compact' that is never
|
|
873
|
+
// resumed (and every superseded 'clear') lingers forever — read paths filter by
|
|
874
|
+
// expiry but nothing reaped the rows. Delete past-expiry rows with a +1d margin so a
|
|
875
|
+
// still-readable handoff is never raced away. 'clear' 6h+1d, 'exit'/other 7d+1d.
|
|
876
|
+
try {
|
|
877
|
+
const gc = db.prepare(`
|
|
878
|
+
DELETE FROM session_handoffs
|
|
879
|
+
WHERE (type = 'clear' AND created_at_epoch < ?)
|
|
880
|
+
OR (type != 'clear' AND created_at_epoch < ?)
|
|
881
|
+
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR - 86400000, Date.now() - HANDOFF_EXPIRY_EXIT - 86400000);
|
|
882
|
+
if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
|
|
883
|
+
} catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
|
|
884
|
+
|
|
851
885
|
// Mark maintenance as done (24h gate) — even though compression runs in background
|
|
852
886
|
writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
|
|
853
887
|
// Weekly summary grouping runs in background to avoid blocking SessionStart
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -68,11 +68,17 @@ export function buildCiteBackHint(episode, cooldown) {
|
|
|
68
68
|
// the lib so all save-prompt hints share one home + the same wording rules.
|
|
69
69
|
//
|
|
70
70
|
// Detection (mirrors pre-v2.83 hook.mjs:194 heuristic):
|
|
71
|
-
// • has at least one entry with
|
|
71
|
+
// • has at least one entry with a HARD failure fingerprint (isHardError) — NOT just
|
|
72
|
+
// isError. `node cli.mjs search "error"`, `npx vitest` green output, or any command
|
|
73
|
+
// whose output merely MENTIONS "error"/"failed" sets isError=true but is not a fix;
|
|
74
|
+
// gating on isError nagged on non-fixes (audit: fired on a read-only session + a
|
|
75
|
+
// scratch write). isHardError requires a real crash/exception/stack (bash-utils.mjs).
|
|
72
76
|
// • has at least one entry using an edit tool
|
|
73
77
|
// • entries.length >= 3 (rules out single-typo fixes that don't need a lesson)
|
|
74
78
|
// Returns null when any condition fails or when no edited files are recoverable
|
|
75
79
|
// from the entry list (defensive — episodes flushed mid-tool can have empties).
|
|
80
|
+
// Legacy fallback: entries written before isHardError existed lack the field, so fall
|
|
81
|
+
// back to isError for them rather than silently regress the nudge to never-fire.
|
|
76
82
|
const MIN_BUGFIX_ENTRIES = 3;
|
|
77
83
|
const MAX_DISPLAY_FILES = 3;
|
|
78
84
|
|
|
@@ -86,7 +92,7 @@ export function buildUnsavedBugfixHint(episode) {
|
|
|
86
92
|
const editedFiles = new Set();
|
|
87
93
|
for (const e of entries) {
|
|
88
94
|
if (!e) continue;
|
|
89
|
-
if (e.isError) hasError = true;
|
|
95
|
+
if (e.isHardError !== undefined ? e.isHardError : e.isError) hasError = true;
|
|
90
96
|
if (EDIT_TOOLS.has(e.tool)) {
|
|
91
97
|
hasEdit = true;
|
|
92
98
|
for (const f of e.files || []) editedFiles.add(f);
|
package/lib/compress-core.mjs
CHANGED
|
@@ -22,9 +22,12 @@ import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
|
22
22
|
import { scrubRecord } from './scrub-record.mjs';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Low-value compression candidates: importance
|
|
25
|
+
* Low-value compression candidates: importance<=1, never accessed, older than
|
|
26
26
|
* `cutoff`, not already compressed. `includeAutoMarked` also folds in rows the
|
|
27
27
|
* hook lightweight-marked as COMPRESSED_AUTO (the hook re-summarizes those).
|
|
28
|
+
* `<= 1` (was `= 1`): citation-decay floors importance at 0 and the LLM low-signal
|
|
29
|
+
* filter saves at imp=0; those rows are strictly lower value than imp=1 and must be
|
|
30
|
+
* GC-eligible too, or they accumulate forever (parity with hook.mjs auto-compress).
|
|
28
31
|
*/
|
|
29
32
|
export function selectCompressionCandidates(db, { cutoff, project = null, includeAutoMarked = false }) {
|
|
30
33
|
const compressedFilter = includeAutoMarked
|
|
@@ -35,8 +38,12 @@ export function selectCompressionCandidates(db, { cutoff, project = null, includ
|
|
|
35
38
|
return db.prepare(`
|
|
36
39
|
SELECT id, project, type, title, created_at, created_at_epoch
|
|
37
40
|
FROM observations
|
|
38
|
-
WHERE COALESCE(importance, 1)
|
|
41
|
+
WHERE COALESCE(importance, 1) <= 1
|
|
39
42
|
AND COALESCE(access_count, 0) = 0
|
|
43
|
+
-- v3.23: exclude rows carrying a real lesson — folding them into a title-only
|
|
44
|
+
-- weekly summary discards the lesson (the distilled value of a lessons store).
|
|
45
|
+
-- Mirrors the hook auto-compress lesson guard so neither path buries a lesson.
|
|
46
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
40
47
|
AND created_at_epoch < ?
|
|
41
48
|
${compressedFilter}
|
|
42
49
|
${projectFilter}
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -98,6 +98,25 @@ export function recoverChildrenOf(db, ids) {
|
|
|
98
98
|
).run(...ids, ...ids).changes;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Resurface children orphaned by a keeper hard-deleted BEFORE recoverChildrenOf existed
|
|
102
|
+
// (legacy data). recoverChildrenOf only fires at delete time for the keepers being deleted
|
|
103
|
+
// in that call; rows whose keeper vanished in a past release are missed forever. A child
|
|
104
|
+
// with compressed_into = <positive keeperId> whose keeper row no longer exists is hidden
|
|
105
|
+
// from every COALESCE(compressed_into,0)=0 view AND sits in no maintenance queue (not
|
|
106
|
+
// COMPRESSED_AUTO, not COMPRESSED_PENDING_PURGE), so nothing ever resurfaces or GCs it —
|
|
107
|
+
// it leaks its full narrative out of reach. Setting compressed_into = NULL makes it live
|
|
108
|
+
// again; normal decay/GC then handles it on merit. `compressed_into > 0` excludes the
|
|
109
|
+
// negative sentinels (intentional states, not orphans). NON-DESTRUCTIVE: only un-hides
|
|
110
|
+
// rows, never deletes — safe to run unconditionally, no snapshot needed.
|
|
111
|
+
export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [] } = {}) {
|
|
112
|
+
return db.prepare(`
|
|
113
|
+
UPDATE observations SET compressed_into = NULL
|
|
114
|
+
WHERE compressed_into > 0
|
|
115
|
+
AND NOT EXISTS (SELECT 1 FROM observations k WHERE k.id = observations.compressed_into)
|
|
116
|
+
${projectFilter}
|
|
117
|
+
`).run(...baseParams).changes;
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
|
|
102
121
|
const doomed = db.prepare(`
|
|
103
122
|
SELECT id FROM observations
|
|
@@ -112,30 +131,43 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
/**
|
|
115
|
-
* Decay importance of old, never-accessed, NEVER-INJECTED observations
|
|
116
|
-
*
|
|
117
|
-
*
|
|
134
|
+
* Decay importance of old, never-accessed, NEVER-INJECTED observations and mark the
|
|
135
|
+
* importance-1 idle ones as pending-purge. injection_count>0 is protected as first-class
|
|
136
|
+
* engagement alongside access_count (unified across all three paths).
|
|
137
|
+
*
|
|
138
|
+
* MARK-IDLE RUNS BEFORE DECAY (audit MED-1): if decay ran first, an imp-2 row would be
|
|
139
|
+
* decayed 2→1 and then re-selected by the same call's mark-idle pass → hidden as
|
|
140
|
+
* pending-purge in ONE pass, collapsing the per-tier grace cycle and over-marking vs what
|
|
141
|
+
* `maintain scan` (stale = imp-1 only) forecasts. Marking first means each call only marks
|
|
142
|
+
* rows that were ALREADY imp-1; a freshly-decayed imp-2→1 row waits for the next call,
|
|
143
|
+
* so importance tiers each buy a grace cycle (imp3→2→1→pending across runs) and the scan
|
|
144
|
+
* forecast matches what decay actually marks.
|
|
118
145
|
*/
|
|
119
146
|
export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCap = OP_CAP }) {
|
|
120
|
-
const
|
|
121
|
-
UPDATE observations SET
|
|
147
|
+
const idleMarked = db.prepare(`
|
|
148
|
+
UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
122
149
|
WHERE id IN (
|
|
123
150
|
SELECT id FROM observations
|
|
124
151
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
125
|
-
AND COALESCE(importance, 1)
|
|
152
|
+
AND COALESCE(importance, 1) = 1
|
|
126
153
|
AND COALESCE(access_count, 0) = 0
|
|
127
154
|
AND COALESCE(injection_count, 0) = 0
|
|
155
|
+
-- v3.23: never mark a lesson-bearing row idle→pending-purge. A lesson is the
|
|
156
|
+
-- distilled value of a lessons store; auto-GC must not silently purge it (parity
|
|
157
|
+
-- with the compress lesson guards in hook.mjs + compress-core.mjs). Truly stale
|
|
158
|
+
-- lessons are removed by explicit delete, not background decay.
|
|
159
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
128
160
|
AND created_at_epoch < ?
|
|
129
161
|
${projectFilter} LIMIT ${opCap}
|
|
130
162
|
)
|
|
131
163
|
`).run(staleAge, ...baseParams).changes;
|
|
132
164
|
|
|
133
|
-
const
|
|
134
|
-
UPDATE observations SET
|
|
165
|
+
const decayed = db.prepare(`
|
|
166
|
+
UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
|
|
135
167
|
WHERE id IN (
|
|
136
168
|
SELECT id FROM observations
|
|
137
169
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
138
|
-
AND COALESCE(importance, 1)
|
|
170
|
+
AND COALESCE(importance, 1) > 1
|
|
139
171
|
AND COALESCE(access_count, 0) = 0
|
|
140
172
|
AND COALESCE(injection_count, 0) = 0
|
|
141
173
|
AND created_at_epoch < ?
|
package/lib/search-core.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function searchSessionsFts(db, { ftsQuery, project = null, projectBoost =
|
|
|
102
102
|
return db.prepare(`
|
|
103
103
|
SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
|
|
104
104
|
${SESS_BM25}
|
|
105
|
-
* (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
|
|
105
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
|
|
106
106
|
* (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
|
|
107
107
|
FROM session_summaries_fts
|
|
108
108
|
JOIN session_summaries s ON session_summaries_fts.rowid = s.id
|
package/mem-cli.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
|
|
|
18
18
|
import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
19
19
|
import {
|
|
20
20
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
21
|
+
recoverOrphanedChildren,
|
|
21
22
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
22
23
|
recoverChildrenOf, hardDeleteCandidateCount,
|
|
23
24
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
@@ -324,7 +325,10 @@ function cmdRecent(db, args) {
|
|
|
324
325
|
// accepted silently; the positional path must reject garbage like the --limit flag does.
|
|
325
326
|
const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
|
|
326
327
|
if (rawArg !== undefined && !isValid) {
|
|
327
|
-
|
|
328
|
+
// Name the ACTUAL fallback: a present --limit overrides the positional below, so
|
|
329
|
+
// claiming "default 10" when `recent abc --limit 5` returns 5 misled the user.
|
|
330
|
+
const fallbackLabel = flags.limit !== undefined ? '--limit' : 'default 10';
|
|
331
|
+
process.stderr.write(`[mem] Invalid count "${rawArg}" (must be an integer between 1 and ${RECENT_MAX}); using ${fallbackLabel}\n`);
|
|
328
332
|
}
|
|
329
333
|
// Positional [N] wins for backward-compat; --limit is sibling-parity alias
|
|
330
334
|
// (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
|
|
@@ -1093,9 +1097,17 @@ async function cmdStats(db, args) {
|
|
|
1093
1097
|
`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
|
|
1094
1098
|
).get(...baseParams);
|
|
1095
1099
|
const thirtyDaysAgo = now - 30 * 86400000;
|
|
1100
|
+
// v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
|
|
1101
|
+
// structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
|
|
1102
|
+
// dormant rows to 0, which on a real store is ~half the live corpus. The gauge
|
|
1103
|
+
// therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
|
|
1104
|
+
// imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
|
|
1105
|
+
// injected-but-decayed row (pinned noise, tracked separately) is not miscounted
|
|
1106
|
+
// as "never used".
|
|
1096
1107
|
const lowVal = db.prepare(`
|
|
1097
1108
|
SELECT COUNT(*) as c FROM observations
|
|
1098
|
-
WHERE COALESCE(importance,1)
|
|
1109
|
+
WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
|
|
1110
|
+
AND COALESCE(injection_count,0) = 0
|
|
1099
1111
|
AND COALESCE(compressed_into, 0) = 0
|
|
1100
1112
|
AND created_at_epoch < ? ${projectFilter}
|
|
1101
1113
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
@@ -1194,7 +1206,7 @@ async function cmdStats(db, args) {
|
|
|
1194
1206
|
out('Data Health:');
|
|
1195
1207
|
out(` Est. tokens: ${tokenEst.t ?? 0}`);
|
|
1196
1208
|
out(` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`);
|
|
1197
|
-
out(` Low-value (imp
|
|
1209
|
+
out(` Low-value (imp≤1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
|
|
1198
1210
|
out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
|
|
1199
1211
|
out(` Compressed: ${compressedCount.c}`);
|
|
1200
1212
|
out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
|
|
@@ -1423,6 +1435,12 @@ function cmdDelete(db, args) {
|
|
|
1423
1435
|
return;
|
|
1424
1436
|
}
|
|
1425
1437
|
|
|
1438
|
+
// Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
|
|
1439
|
+
// matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
|
|
1440
|
+
// (never throws, skips :memory:); rows here are confirmed + non-empty, so the delete
|
|
1441
|
+
// always removes something worth backing up. Must run OUTSIDE the transaction (VACUUM).
|
|
1442
|
+
snapshotDb(db, { tag: 'pre-delete' });
|
|
1443
|
+
|
|
1426
1444
|
// Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
|
|
1427
1445
|
const deletedIds = new Set(ids);
|
|
1428
1446
|
const deleteTx = db.transaction(() => {
|
|
@@ -1552,6 +1570,12 @@ function cmdUpdate(db, args) {
|
|
|
1552
1570
|
|
|
1553
1571
|
function cmdExport(db, args) {
|
|
1554
1572
|
const { flags } = parseArgs(args);
|
|
1573
|
+
// Guard value-less string flags. Bare `--to` parsed to boolean `true`, and
|
|
1574
|
+
// `new Date(true).getTime()` is 1 (NOT NaN), so the isNaN guard below missed it and
|
|
1575
|
+
// the filter became `created_at_epoch <= 1` → an EMPTY export with exit 0. A backup
|
|
1576
|
+
// script (`export --to "$END" > backup.json`) with an unset `$END` would silently
|
|
1577
|
+
// write an empty backup and report success. Reject like cmdSearch does.
|
|
1578
|
+
if (rejectBareStringFlags(flags, ['project', 'type', 'from', 'to'])) return;
|
|
1555
1579
|
const wheres = [];
|
|
1556
1580
|
const params = [];
|
|
1557
1581
|
// --include-compressed: include compressed observations (aligned with MCP mem_export)
|
|
@@ -1743,8 +1767,10 @@ function cmdCompress(db, args) {
|
|
|
1743
1767
|
// got the 30-day cutoff without knowing their input was discarded.
|
|
1744
1768
|
let ageDays = 30;
|
|
1745
1769
|
if (flags['age-days'] !== undefined) {
|
|
1746
|
-
|
|
1747
|
-
|
|
1770
|
+
// isNumericToken (not bare parseInt) so "1e5"→1 and "30x"→30 are rejected rather than
|
|
1771
|
+
// silently mis-parsed into a far-too-broad cutoff — parity with recent/search/maintain.
|
|
1772
|
+
const parsed = Number(flags['age-days']);
|
|
1773
|
+
if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 1) {
|
|
1748
1774
|
fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
|
|
1749
1775
|
return;
|
|
1750
1776
|
}
|
|
@@ -1800,6 +1826,11 @@ function cmdMaintain(db, args) {
|
|
|
1800
1826
|
fail("[mem] Usage: claude-mem-lite maintain <scan|execute> [--ops cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum] [--project P] [--retain-days N] [--merge-ids keepId:removeId,...] — 'scan' previews, 'execute' applies.");
|
|
1801
1827
|
return;
|
|
1802
1828
|
}
|
|
1829
|
+
// Guard value-less string flags before any `.split()` / resolveProject runs. A bare
|
|
1830
|
+
// `--merge-ids` parsed to boolean `true`, and `true.split(',')` (line ~1911) crashed
|
|
1831
|
+
// with a raw stack trace — the one string-flag path that lacked this #8470 guard, and
|
|
1832
|
+
// the exact form the `scan` output suggests copy-pasting (`--merge-ids <pairs>`).
|
|
1833
|
+
if (rejectBareStringFlags(flags, ['ops', 'project', 'merge-ids', 'retain-days'])) return;
|
|
1803
1834
|
|
|
1804
1835
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
1805
1836
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
@@ -1868,18 +1899,64 @@ function cmdMaintain(db, args) {
|
|
|
1868
1899
|
// T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
|
|
1869
1900
|
const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
|
|
1870
1901
|
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
1873
|
-
// (
|
|
1874
|
-
|
|
1902
|
+
// Parse + validate --retain-days BEFORE the transaction so an invalid value rejects the
|
|
1903
|
+
// whole command atomically. The old code validated inside db.transaction() with a bare
|
|
1904
|
+
// `return`, so an earlier op (cleanup hard-delete / decay / boost) had already mutated and
|
|
1905
|
+
// the transaction COMMITTED despite the exit-1 error (audit MED-2 atomicity).
|
|
1906
|
+
let retainDays = 30;
|
|
1907
|
+
if (ops.includes('purge_stale') && flags['retain-days'] !== undefined) {
|
|
1908
|
+
// Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than silently
|
|
1909
|
+
// truncated — parity with the mem_maintain zod .int().min(7).max(365) bound.
|
|
1910
|
+
const parsed = Number(flags['retain-days']);
|
|
1911
|
+
if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
|
|
1912
|
+
fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
retainDays = parsed;
|
|
1916
|
+
}
|
|
1917
|
+
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1918
|
+
// purge_stale is the only DELETE here — require --confirm so a mis-typed run can't wipe rows.
|
|
1919
|
+
const confirmed = flags.confirm === true || flags.confirm === 'true';
|
|
1920
|
+
|
|
1921
|
+
// Snapshot the DB before the irreversible cleanup/purge hard-deletes — only when rows will
|
|
1922
|
+
// actually be removed, and OUTSIDE the transaction below (VACUUM cannot run inside one).
|
|
1923
|
+
// Best-effort; snapshotDb never throws.
|
|
1924
|
+
const willPurge = ops.includes('purge_stale') && confirmed;
|
|
1875
1925
|
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: willPurge }) > 0) {
|
|
1876
1926
|
snapshotDb(db, { tag: 'pre-maintain' });
|
|
1877
1927
|
}
|
|
1878
1928
|
|
|
1879
1929
|
db.transaction(() => {
|
|
1930
|
+
// PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766). Running decay BEFORE
|
|
1931
|
+
// purge in one transaction marked a stale row pending-purge AND deleted it in the SAME call
|
|
1932
|
+
// (zero grace), and the pre-txn snapshot guard counts only PRE-EXISTING pending rows so it
|
|
1933
|
+
// skipped the backup → permanent, unrecoverable loss of notable imp-2/3 memories (audit
|
|
1934
|
+
// HIGH-1). Purging first deletes only rows a PRIOR run marked (which the guard saw + backed
|
|
1935
|
+
// up); rows decay marks below wait for the next maintain run, regaining the grace cycle.
|
|
1936
|
+
if (ops.includes('purge_stale')) {
|
|
1937
|
+
if (!confirmed) {
|
|
1938
|
+
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1939
|
+
const pushLines = [`purge_stale preview (no --confirm):`,
|
|
1940
|
+
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
|
|
1941
|
+
if (previewRow.candidates > 0) {
|
|
1942
|
+
pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1943
|
+
pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1944
|
+
}
|
|
1945
|
+
pushLines.push(` To delete, re-run with --confirm.`);
|
|
1946
|
+
results.push(pushLines.join('\n'));
|
|
1947
|
+
} else {
|
|
1948
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1949
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1880
1953
|
if (ops.includes('cleanup')) {
|
|
1881
1954
|
const deleted = cleanupBroken(db, mctx);
|
|
1882
1955
|
results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
|
|
1956
|
+
// Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf): resurface
|
|
1957
|
+
// unreachable children. Non-destructive — un-hide only, no delete.
|
|
1958
|
+
const orphans = recoverOrphanedChildren(db, mctx);
|
|
1959
|
+
if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
|
|
1883
1960
|
}
|
|
1884
1961
|
|
|
1885
1962
|
if (ops.includes('decay')) {
|
|
@@ -1926,43 +2003,6 @@ function cmdMaintain(db, args) {
|
|
|
1926
2003
|
results.push('Warning: --merge-ids provided but "dedup" not in operations — merge-ids ignored');
|
|
1927
2004
|
}
|
|
1928
2005
|
|
|
1929
|
-
if (ops.includes('purge_stale')) {
|
|
1930
|
-
// --retain-days: default 30 when absent; reject negative / 0 / NaN / out-of-range.
|
|
1931
|
-
// A negative value made retainCutoff a FUTURE timestamp → purged the entire
|
|
1932
|
-
// pending-purge backlog regardless of age; 0/garbage silently became 30 and
|
|
1933
|
-
// masked typos. Parity with the mem_maintain MCP zod bound [7, 365].
|
|
1934
|
-
let retainDays = 30;
|
|
1935
|
-
if (flags['retain-days'] !== undefined) {
|
|
1936
|
-
// Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than
|
|
1937
|
-
// silently truncated to 7/30 — parity with the mem_maintain zod .int() bound.
|
|
1938
|
-
const parsed = Number(flags['retain-days']);
|
|
1939
|
-
if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
|
|
1940
|
-
// fail() only sets exitCode + writes stderr; it does NOT throw, so we
|
|
1941
|
-
// MUST return or execution falls through to the DELETE with the bad value.
|
|
1942
|
-
fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
|
|
1943
|
-
return;
|
|
1944
|
-
}
|
|
1945
|
-
retainDays = parsed;
|
|
1946
|
-
}
|
|
1947
|
-
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1948
|
-
// T2-P0-A (CLI parity): purge_stale is the only DELETE in this code path — require
|
|
1949
|
-
// --confirm so a mis-typed `maintain execute --ops purge_stale` can't wipe rows silently.
|
|
1950
|
-
const confirmed = flags.confirm === true || flags.confirm === 'true';
|
|
1951
|
-
if (!confirmed) {
|
|
1952
|
-
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1953
|
-
const pushLines = [`purge_stale preview (no --confirm):`,
|
|
1954
|
-
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
|
|
1955
|
-
if (previewRow.candidates > 0) {
|
|
1956
|
-
pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1957
|
-
pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1958
|
-
}
|
|
1959
|
-
pushLines.push(` To delete, re-run with --confirm.`);
|
|
1960
|
-
results.push(pushLines.join('\n'));
|
|
1961
|
-
} else {
|
|
1962
|
-
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1963
|
-
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
2006
|
})();
|
|
1967
2007
|
|
|
1968
2008
|
// FTS optimize
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.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",
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -32,7 +32,11 @@ if [[ "$tool" == "Read" ]]; then
|
|
|
32
32
|
# Sanitize project name to match utils.mjs inferProject()
|
|
33
33
|
project="${project//[^a-zA-Z0-9_.-]/-}"
|
|
34
34
|
project="${project:-unknown}"
|
|
35
|
-
|
|
35
|
+
# Honor CLAUDE_MEM_DIR relocation (mirrors schema.mjs DB_DIR → hook-shared RUNTIME_DIR).
|
|
36
|
+
# hook.mjs flushEpisode reads reads-<project>.txt from CLAUDE_MEM_DIR/runtime; if this
|
|
37
|
+
# bash fast-path wrote to $HOME unconditionally, a relocated install would drop all
|
|
38
|
+
# Read context from episodes AND grow an uncollected reads file in $HOME forever.
|
|
39
|
+
runtime_dir="${CLAUDE_MEM_DIR:-$HOME/.claude-mem-lite}/runtime"
|
|
36
40
|
mkdir -p "$runtime_dir" 2>/dev/null
|
|
37
41
|
# Use printf to avoid shell interpretation of special characters in file paths
|
|
38
42
|
printf '%s\n' "$file_path" >> "${runtime_dir}/reads-${project}.txt"
|
|
@@ -491,6 +491,11 @@ async function main() {
|
|
|
491
491
|
|
|
492
492
|
let hookData;
|
|
493
493
|
try { hookData = JSON.parse(raw); } catch { return; }
|
|
494
|
+
// JSON.parse('null'/'42'/'"x"') succeeds with a non-object; dereferencing .prompt on
|
|
495
|
+
// it threw a raw TypeError → unhandled rejection → exit 1 (this was the lone hook
|
|
496
|
+
// script without an exit-0 safety net, violating the "hooks never exit non-zero"
|
|
497
|
+
// invariant — exit 2 on UserPromptSubmit would even block the user's prompt).
|
|
498
|
+
if (!hookData || typeof hookData !== 'object') return;
|
|
494
499
|
|
|
495
500
|
const rawPrompt = hookData.prompt || hookData.user_prompt;
|
|
496
501
|
if (!rawPrompt || typeof rawPrompt !== 'string') return;
|
|
@@ -724,4 +729,8 @@ async function main() {
|
|
|
724
729
|
}
|
|
725
730
|
}
|
|
726
731
|
|
|
727
|
-
|
|
732
|
+
// Swallow any rejection so the hook can never surface a non-zero exit (the invariant
|
|
733
|
+
// every sibling hook script upholds). Deliberately NOT `.finally(process.exit(0))` —
|
|
734
|
+
// this hook detaches a background `claude -p` search and a forced exit would kill it;
|
|
735
|
+
// letting the loop drain naturally exits 0 once the detached child is unref'd.
|
|
736
|
+
main().catch(() => {});
|
package/search-engine.mjs
CHANGED
|
@@ -16,8 +16,13 @@ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
|
|
|
16
16
|
|
|
17
17
|
// Scoring expressions — full adds project boost + access bonus; simple is for
|
|
18
18
|
// expansion paths where boost would over-amplify already-loose matches.
|
|
19
|
+
// `MAX(0, now - ts)` clamps the recency age to >= 0: a far-FUTURE created_at/last_accessed
|
|
20
|
+
// (reachable via restore/import-jsonl, which accept arbitrary epochs) otherwise made the
|
|
21
|
+
// exponent large-positive → EXP overflowed to +Infinity → score -Infinity → that row sorted
|
|
22
|
+
// #1 for any match AND JSON.stringify emitted `"score": null` (numeric-contract break). A
|
|
23
|
+
// future row now reads as age 0 = max (finite) recency, not Infinity.
|
|
19
24
|
const FULL_SCORE = `${OBS_BM25}
|
|
20
|
-
* (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
25
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
21
26
|
* ${TYPE_QUALITY_CASE}
|
|
22
27
|
* (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
|
|
23
28
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
@@ -25,7 +30,7 @@ const FULL_SCORE = `${OBS_BM25}
|
|
|
25
30
|
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
|
|
26
31
|
|
|
27
32
|
const SIMPLE_SCORE = `${OBS_BM25}
|
|
28
|
-
* (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
33
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
29
34
|
* ${TYPE_QUALITY_CASE}
|
|
30
35
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
31
36
|
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
|
|
@@ -320,7 +325,7 @@ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfL
|
|
|
320
325
|
AND (? IS NULL OR o.project = ?)
|
|
321
326
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
322
327
|
ORDER BY ${OBS_BM25}
|
|
323
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${halfLifeMs}.0))
|
|
328
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${halfLifeMs}.0))
|
|
324
329
|
LIMIT 1
|
|
325
330
|
`;
|
|
326
331
|
const stmt = db.prepare(sql);
|
package/server.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
|
|
|
17
17
|
import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
18
18
|
import {
|
|
19
19
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
20
|
+
recoverOrphanedChildren,
|
|
20
21
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
21
22
|
recoverChildrenOf, hardDeleteCandidateCount,
|
|
22
23
|
OP_CAP, STALE_AGE_MS,
|
|
@@ -617,6 +618,11 @@ server.registerTool(
|
|
|
617
618
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
618
619
|
}
|
|
619
620
|
|
|
621
|
+
// Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
|
|
622
|
+
// matching the CLI delete + maintain purge/cleanup paths (audit MED-2). Best-effort
|
|
623
|
+
// (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM).
|
|
624
|
+
snapshotDb(db, { tag: 'pre-delete' });
|
|
625
|
+
|
|
620
626
|
// Wrap cleanup + deletion in a transaction for consistency
|
|
621
627
|
const deletedIds = new Set(args.ids);
|
|
622
628
|
const deleteTx = db.transaction(() => {
|
|
@@ -858,9 +864,14 @@ server.registerTool(
|
|
|
858
864
|
`).get(...baseParams);
|
|
859
865
|
|
|
860
866
|
const thirtyDaysAgo = Date.now() - 30 * 86400000;
|
|
867
|
+
// v3.23 noise-gauge de-blinding (mirrors mem-cli cmdStats): `<= 1` makes the
|
|
868
|
+
// imp=0 dormant population visible (decay floor + LLM low-signal filter push
|
|
869
|
+
// ~half the live corpus to 0); injection_count=0 mirrors decay's NEVER-INJECTED
|
|
870
|
+
// guard so injected-but-decayed pinned noise isn't miscounted as "never used".
|
|
861
871
|
const lowVal = db.prepare(`
|
|
862
872
|
SELECT COUNT(*) as c FROM observations
|
|
863
|
-
WHERE COALESCE(importance,1)
|
|
873
|
+
WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
|
|
874
|
+
AND COALESCE(injection_count,0) = 0
|
|
864
875
|
AND COALESCE(compressed_into, 0) = 0
|
|
865
876
|
AND created_at_epoch < ? ${projectFilter}
|
|
866
877
|
`).get(thirtyDaysAgo, ...baseParams);
|
|
@@ -910,7 +921,7 @@ server.registerTool(
|
|
|
910
921
|
'Data Health:',
|
|
911
922
|
` Est. tokens: ${tokenEst.t ?? 0}`,
|
|
912
923
|
` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`,
|
|
913
|
-
` Low-value (imp
|
|
924
|
+
` Low-value (imp≤1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`,
|
|
914
925
|
` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`,
|
|
915
926
|
` Compressed: ${compressedCount.c}`,
|
|
916
927
|
...((noiseRatio > 0.6 || lowSignalRatio > 0.3) ? [' ⚠️ High noise ratio — consider running mem_compress / maintain'] : []),
|
|
@@ -1088,9 +1099,26 @@ server.registerTool(
|
|
|
1088
1099
|
}
|
|
1089
1100
|
|
|
1090
1101
|
db.transaction(() => {
|
|
1102
|
+
// PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766) and the CLI
|
|
1103
|
+
// cmdMaintain. Running decay BEFORE purge in one transaction marked a stale row
|
|
1104
|
+
// pending-purge AND deleted it in the SAME call (zero grace), while the pre-txn
|
|
1105
|
+
// snapshot guard counts only PRE-EXISTING pending rows so it skipped the backup →
|
|
1106
|
+
// permanent loss of notable imp-2/3 memories (audit HIGH-1). Purging first deletes
|
|
1107
|
+
// only rows a PRIOR run marked (backed up); rows decay marks below wait one cycle.
|
|
1108
|
+
if (ops.includes('purge_stale')) {
|
|
1109
|
+
const retainDays = args.retain_days ?? 30;
|
|
1110
|
+
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1111
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1112
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1091
1115
|
if (ops.includes('cleanup')) {
|
|
1092
1116
|
const deleted = cleanupBroken(db, mctx);
|
|
1093
1117
|
results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1118
|
+
// Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf):
|
|
1119
|
+
// resurface unreachable children. Non-destructive — un-hide only, no delete.
|
|
1120
|
+
const orphans = recoverOrphanedChildren(db, mctx);
|
|
1121
|
+
if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
|
|
1094
1122
|
}
|
|
1095
1123
|
|
|
1096
1124
|
if (ops.includes('decay')) {
|
|
@@ -1118,13 +1146,6 @@ server.registerTool(
|
|
|
1118
1146
|
if (!ops.includes('dedup') && args.merge_ids) {
|
|
1119
1147
|
results.push('Warning: merge_ids provided but "dedup" not in operations — merge_ids ignored');
|
|
1120
1148
|
}
|
|
1121
|
-
|
|
1122
|
-
if (ops.includes('purge_stale')) {
|
|
1123
|
-
const retainDays = args.retain_days ?? 30;
|
|
1124
|
-
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1125
|
-
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1126
|
-
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1127
|
-
}
|
|
1128
1149
|
})();
|
|
1129
1150
|
|
|
1130
1151
|
// FTS5 optimize (outside transaction)
|
package/utils.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, extractCjkLike
|
|
|
14
14
|
export { resolveProject, _resetProjectCache } from './project-utils.mjs';
|
|
15
15
|
export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
|
|
16
16
|
export { stripPrivate } from './lib/private-strip.mjs';
|
|
17
|
-
export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey, formatErrorRecallHints } from './format-utils.mjs';
|
|
17
|
+
export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey, formatErrorRecallHints, neutralizeContextDelimiters } from './format-utils.mjs';
|
|
18
18
|
export { computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from './hash-utils.mjs';
|
|
19
19
|
export { detectBashSignificance, extractErrorKeywords, extractFilePaths, stripTestSuffix } from './bash-utils.mjs';
|
|
20
20
|
|