claude-mem-lite 3.20.0 → 3.22.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/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-memory.mjs +5 -2
- package/hook-update.mjs +58 -19
- package/hook.mjs +44 -18
- package/lib/compress-core.mjs +5 -2
- package/lib/db-backup.mjs +55 -0
- package/lib/maintain-core.mjs +42 -9
- package/lib/search-core.mjs +1 -1
- package/mem-cli.mjs +77 -41
- package/package.json +2 -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 +28 -8
- package/source-files.mjs +4 -0
- 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.22.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.22.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/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-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-update.mjs
CHANGED
|
@@ -489,6 +489,51 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
|
|
|
489
489
|
// below skips the 'node_modules' switchable path (existsSync guard), leaving
|
|
490
490
|
// the target's node_modules untouched. Dependency bumps still flow through the
|
|
491
491
|
// GitHub-tarball path (downloadAndInstall), which keeps skipNpmInstall=false.
|
|
492
|
+
// Undo a (partial or complete) file swap: delete the freshly-installed files, then
|
|
493
|
+
// rename each backup back into place. Shared by the error path and the MED-5
|
|
494
|
+
// post-install smoke gate so there is ONE rollback implementation.
|
|
495
|
+
function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
496
|
+
for (const relPath of installed.reverse()) {
|
|
497
|
+
try { rmSync(join(targetDir, relPath), { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
498
|
+
}
|
|
499
|
+
for (const relPath of backedUp.reverse()) {
|
|
500
|
+
const backupPath = join(backupDir, relPath);
|
|
501
|
+
const targetPath = join(targetDir, relPath);
|
|
502
|
+
try {
|
|
503
|
+
if (existsSync(backupPath)) {
|
|
504
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
505
|
+
renameSync(backupPath, targetPath);
|
|
506
|
+
}
|
|
507
|
+
} catch (restoreErr) {
|
|
508
|
+
debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// MED-5 post-install health gate: load-test the freshly-switched code in a SEPARATE
|
|
514
|
+
// process before the backup is discarded. A clean file swap can still yield an
|
|
515
|
+
// install that won't boot — a syntax error, an unresolved import from a half-applied
|
|
516
|
+
// mixed-version swap (renameSync loop hard-killed mid-way), or a native ABI mismatch.
|
|
517
|
+
// `cli.mjs help` boots the CLI entry, which dynamically imports the mem-cli module
|
|
518
|
+
// graph (maintain-core / search-engine / scoring / db-backup / schema / …) and exits
|
|
519
|
+
// 0 without opening the DB or a server. hook.mjs and server.mjs auto-execute on
|
|
520
|
+
// import (so they can't be import-smoked) — they get a `node --check` syntax pass.
|
|
521
|
+
// execSync (not execFileSync) so the unit-test child_process mock intercepts it.
|
|
522
|
+
function smokeInstalledRelease(targetDir) {
|
|
523
|
+
const q = (s) => JSON.stringify(s);
|
|
524
|
+
try {
|
|
525
|
+
execSync(`${q(process.execPath)} ${q(join(targetDir, 'cli.mjs'))} help`, { timeout: 20000, stdio: 'ignore' });
|
|
526
|
+
for (const entry of ['hook.mjs', 'server.mjs']) {
|
|
527
|
+
const p = join(targetDir, entry);
|
|
528
|
+
if (existsSync(p)) execSync(`${q(process.execPath)} --check ${q(p)}`, { timeout: 10000, stdio: 'ignore' });
|
|
529
|
+
}
|
|
530
|
+
return true;
|
|
531
|
+
} catch (e) {
|
|
532
|
+
debugLog('WARN', 'hook-update', `post-install smoke failed (rolling back): ${e.message}`);
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
492
537
|
export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR, opts = {}) {
|
|
493
538
|
// Cross-process lock: concurrent SessionStart self-heals / auto-updates must
|
|
494
539
|
// not interleave the rename loop below (→ mixed-version install). A live peer
|
|
@@ -541,6 +586,16 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
541
586
|
installed.push(relPath);
|
|
542
587
|
}
|
|
543
588
|
|
|
589
|
+
// MED-5: before discarding the rollback backup, prove the switched code boots.
|
|
590
|
+
// If it can't, restore the backup and report failure — the running (old) version
|
|
591
|
+
// keeps working rather than leaving a broken install with no way back.
|
|
592
|
+
if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
|
|
593
|
+
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
594
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
595
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
|
|
544
599
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
545
600
|
rmSync(backupDir, { recursive: true, force: true });
|
|
546
601
|
|
|
@@ -580,25 +635,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
580
635
|
return true;
|
|
581
636
|
} catch (err) {
|
|
582
637
|
debugCatch(err, 'installExtractedRelease');
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
for (const relPath of backedUp.reverse()) {
|
|
588
|
-
const backupPath = join(backupDir, relPath);
|
|
589
|
-
const targetPath = join(targetDir, relPath);
|
|
590
|
-
try {
|
|
591
|
-
if (existsSync(backupPath)) {
|
|
592
|
-
mkdirSync(dirname(targetPath), { recursive: true });
|
|
593
|
-
renameSync(backupPath, targetPath);
|
|
594
|
-
}
|
|
595
|
-
} catch (restoreErr) {
|
|
596
|
-
debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
|
|
601
|
-
try { rmSync(backupDir, { recursive: true, force: true }); } catch {}
|
|
638
|
+
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
639
|
+
try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
640
|
+
try { rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
602
641
|
return false;
|
|
603
642
|
} finally {
|
|
604
643
|
release();
|
package/hook.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
truncate, inferProject, detectBashSignificance,
|
|
27
27
|
extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
|
|
28
28
|
makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
|
|
29
|
-
COMPRESSED_AUTO,
|
|
29
|
+
COMPRESSED_AUTO, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
|
|
30
30
|
} from './utils.mjs';
|
|
31
31
|
import {
|
|
32
32
|
readEpisodeRaw, episodeFile,
|
|
@@ -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,8 @@ 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 } from './lib/maintain-core.mjs';
|
|
50
|
+
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale } from './lib/maintain-core.mjs';
|
|
51
|
+
import { snapshotDb } from './lib/db-backup.mjs';
|
|
50
52
|
import {
|
|
51
53
|
extractCitationsFromTranscript,
|
|
52
54
|
extractAllInjected,
|
|
@@ -695,15 +697,19 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
695
697
|
WHERE status = 'active' AND started_at_epoch < ?
|
|
696
698
|
`).run(staleSessionCutoff);
|
|
697
699
|
|
|
698
|
-
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance
|
|
700
|
+
// Auto-compress: mark old low-importance observations as compressed (30+ days, importance<=1)
|
|
699
701
|
// Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
|
|
700
702
|
// v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
|
|
701
703
|
// via hook-memory injection, even if user never explicitly fetched). Same
|
|
702
704
|
// protection applied symmetrically in auto-maintain decay/mark-idle below.
|
|
705
|
+
// `<= 1` (was `= 1`): citation-decay floors importance at 0 (added v2.73.2, after this
|
|
706
|
+
// predicate was written) and the LLM low-signal filter saves at imp=0 — those rows are
|
|
707
|
+
// STRICTLY lower value than imp=1 yet escaped GC, accumulating to ~40% of a mature DB
|
|
708
|
+
// (immortal: hidden from injection by the imp>=1 floor, but visible as explicit-search noise).
|
|
703
709
|
const compressed = db.prepare(`
|
|
704
710
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
705
711
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
706
|
-
AND importance
|
|
712
|
+
AND COALESCE(importance, 1) <= 1
|
|
707
713
|
AND COALESCE(injection_count, 0) = 0
|
|
708
714
|
AND created_at_epoch < ?
|
|
709
715
|
AND project = ?
|
|
@@ -721,7 +727,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
721
727
|
const noiseCompressed = db.prepare(`
|
|
722
728
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
723
729
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
724
|
-
AND importance
|
|
730
|
+
AND COALESCE(importance, 1) <= 1
|
|
725
731
|
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
726
732
|
AND (facts IS NULL OR facts = '' OR facts = '[]')
|
|
727
733
|
AND (
|
|
@@ -749,21 +755,27 @@ function runSessionStartAutoMaintain(db) {
|
|
|
749
755
|
try {
|
|
750
756
|
const STALE_AGE = Date.now() - 30 * 86400000;
|
|
751
757
|
const OP_CAP = 500;
|
|
752
|
-
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
// Older cutoffs (e.g. 7d) were always redundant with the 30d marking filter and
|
|
756
|
-
// made purge effectively immediate on the next maintenance cycle — fix for T4-P1-A.
|
|
757
|
-
const purged = db.prepare(`
|
|
758
|
-
DELETE FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
759
|
-
AND created_at_epoch < ?
|
|
760
|
-
`).run(Date.now() - 37 * 86400000);
|
|
761
|
-
if (purged.changes > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged.changes} stale observations`);
|
|
762
|
-
|
|
763
|
-
// cleanup / decay+mark-idle / boost via maintain-core (shared with CLI + MCP).
|
|
764
|
-
// injection_count>0 protection lives in decayAndMarkIdle. Whole-DB, cap 500.
|
|
758
|
+
// Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
|
|
759
|
+
// op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
|
|
760
|
+
// decayAndMarkIdle.
|
|
765
761
|
const mctx = { projectFilter: '', baseParams: [], staleAge: STALE_AGE, opCap: OP_CAP };
|
|
766
762
|
|
|
763
|
+
// MED-2: snapshot the DB before the irreversible purge/cleanup hard-deletes
|
|
764
|
+
// below, but only when rows will actually be removed (cheap COUNT). Must run
|
|
765
|
+
// here, outside any transaction — VACUUM cannot run inside one. Best-effort:
|
|
766
|
+
// snapshotDb never throws, so a backup failure cannot block auto-maintain.
|
|
767
|
+
if (hardDeleteCandidateCount(db, mctx, { cleanup: true, purge: true }) > 0) {
|
|
768
|
+
snapshotDb(db, { tag: 'pre-maintain' });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Purge FIRST via the SHARED purgeStale — was an inline DELETE that skipped
|
|
772
|
+
// recoverChildrenOf, so purging a keeper that had absorbed dups orphaned its
|
|
773
|
+
// children (compressed_into dangling at a deleted id). purgeStale recovers them
|
|
774
|
+
// first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
|
|
775
|
+
// created_at_epoch: 30d marking gate + 7d grace = 37d.
|
|
776
|
+
const purged = purgeStale(db, mctx, Date.now() - 37 * 86400000);
|
|
777
|
+
if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
|
|
778
|
+
|
|
767
779
|
const cleaned = cleanupBroken(db, mctx);
|
|
768
780
|
if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
|
|
769
781
|
|
|
@@ -841,6 +853,20 @@ function runSessionStartAutoMaintain(db) {
|
|
|
841
853
|
if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
|
|
842
854
|
} catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
|
|
843
855
|
|
|
856
|
+
// GC expired session_handoffs: the consume-DELETE (handleSessionStart) only removes
|
|
857
|
+
// the single handoff a continuation reads back; an 'exit'/'compact' that is never
|
|
858
|
+
// resumed (and every superseded 'clear') lingers forever — read paths filter by
|
|
859
|
+
// expiry but nothing reaped the rows. Delete past-expiry rows with a +1d margin so a
|
|
860
|
+
// still-readable handoff is never raced away. 'clear' 6h+1d, 'exit'/other 7d+1d.
|
|
861
|
+
try {
|
|
862
|
+
const gc = db.prepare(`
|
|
863
|
+
DELETE FROM session_handoffs
|
|
864
|
+
WHERE (type = 'clear' AND created_at_epoch < ?)
|
|
865
|
+
OR (type != 'clear' AND created_at_epoch < ?)
|
|
866
|
+
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR - 86400000, Date.now() - HANDOFF_EXPIRY_EXIT - 86400000);
|
|
867
|
+
if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
|
|
868
|
+
} catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
|
|
869
|
+
|
|
844
870
|
// Mark maintenance as done (24h gate) — even though compression runs in background
|
|
845
871
|
writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
|
|
846
872
|
// Weekly summary grouping runs in background to avoid blocking SessionStart
|
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,7 +38,7 @@ 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
|
|
40
43
|
AND created_at_epoch < ?
|
|
41
44
|
${compressedFilter}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// lib/db-backup.mjs — point-in-time DB snapshot before irreversible maintenance.
|
|
2
|
+
//
|
|
3
|
+
// VACUUM INTO produces a consistent, compact copy of the database (WAL-safe —
|
|
4
|
+
// unlike copyFileSync, which would miss un-checkpointed WAL frames). Best-effort:
|
|
5
|
+
// a failure logs a WARN and returns null so it NEVER blocks the maintenance it
|
|
6
|
+
// guards (a backup that aborts a disk-full purge would be worse than no backup).
|
|
7
|
+
//
|
|
8
|
+
// MUST be called OUTSIDE any transaction — VACUUM cannot run inside one, which is
|
|
9
|
+
// why the maintenance entry points snapshot before opening their db.transaction().
|
|
10
|
+
import { readdirSync, unlinkSync } from 'fs';
|
|
11
|
+
import { dirname, basename, join } from 'path';
|
|
12
|
+
import { debugLog } from '../utils.mjs';
|
|
13
|
+
|
|
14
|
+
// Monotonic per-process suffix so two snapshots in the same millisecond (same pid)
|
|
15
|
+
// still get unique filenames (VACUUM INTO fails if the target already exists).
|
|
16
|
+
let _seq = 0;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Snapshot `db` to `<db-path>.<tag>-<ts>.bak` via VACUUM INTO, then prune to the
|
|
20
|
+
* newest `retain` snapshots. No-op (returns null) for :memory: DBs (tests) and on
|
|
21
|
+
* any error (logged). Returns the snapshot path on success.
|
|
22
|
+
* @returns {string|null}
|
|
23
|
+
*/
|
|
24
|
+
export function snapshotDb(db, { tag = 'pre-maintain', retain = 3 } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
const dbPath = db && db.name;
|
|
27
|
+
if (!dbPath || dbPath === ':memory:') return null; // in-memory — nothing to snapshot
|
|
28
|
+
const stamp = `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${_seq++}`;
|
|
29
|
+
const out = `${dbPath}.${tag}-${stamp}.bak`;
|
|
30
|
+
// Path is internal (db.name from our own config), but escape single quotes
|
|
31
|
+
// defensively since VACUUM INTO takes a string literal, not a bound param.
|
|
32
|
+
db.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`);
|
|
33
|
+
pruneSnapshots(dbPath, tag, retain);
|
|
34
|
+
return out;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
try { debugLog('WARN', 'db-backup', `snapshot skipped (proceeding without): ${e.message}`); } catch { /* ignore */ }
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Keep the newest `retain` `<base>.<tag>-*.bak` files, unlink the rest. The ISO
|
|
42
|
+
// timestamp embedded in the name makes a lexical sort chronological.
|
|
43
|
+
function pruneSnapshots(dbPath, tag, retain) {
|
|
44
|
+
try {
|
|
45
|
+
const dir = dirname(dbPath);
|
|
46
|
+
const prefix = `${basename(dbPath)}.${tag}-`;
|
|
47
|
+
const names = readdirSync(dir)
|
|
48
|
+
.filter(n => n.startsWith(prefix) && n.endsWith('.bak'))
|
|
49
|
+
.sort()
|
|
50
|
+
.reverse(); // newest first
|
|
51
|
+
for (const n of names.slice(retain)) {
|
|
52
|
+
try { unlinkSync(join(dir, n)); } catch { /* per-entry best-effort */ }
|
|
53
|
+
}
|
|
54
|
+
} catch { /* best-effort — dir may be unreadable */ }
|
|
55
|
+
}
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -112,17 +112,25 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* Decay importance of old, never-accessed, NEVER-INJECTED observations
|
|
116
|
-
*
|
|
117
|
-
*
|
|
115
|
+
* Decay importance of old, never-accessed, NEVER-INJECTED observations and mark the
|
|
116
|
+
* importance-1 idle ones as pending-purge. injection_count>0 is protected as first-class
|
|
117
|
+
* engagement alongside access_count (unified across all three paths).
|
|
118
|
+
*
|
|
119
|
+
* MARK-IDLE RUNS BEFORE DECAY (audit MED-1): if decay ran first, an imp-2 row would be
|
|
120
|
+
* decayed 2→1 and then re-selected by the same call's mark-idle pass → hidden as
|
|
121
|
+
* pending-purge in ONE pass, collapsing the per-tier grace cycle and over-marking vs what
|
|
122
|
+
* `maintain scan` (stale = imp-1 only) forecasts. Marking first means each call only marks
|
|
123
|
+
* rows that were ALREADY imp-1; a freshly-decayed imp-2→1 row waits for the next call,
|
|
124
|
+
* so importance tiers each buy a grace cycle (imp3→2→1→pending across runs) and the scan
|
|
125
|
+
* forecast matches what decay actually marks.
|
|
118
126
|
*/
|
|
119
127
|
export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCap = OP_CAP }) {
|
|
120
|
-
const
|
|
121
|
-
UPDATE observations SET
|
|
128
|
+
const idleMarked = db.prepare(`
|
|
129
|
+
UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
|
|
122
130
|
WHERE id IN (
|
|
123
131
|
SELECT id FROM observations
|
|
124
132
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
125
|
-
AND COALESCE(importance, 1)
|
|
133
|
+
AND COALESCE(importance, 1) = 1
|
|
126
134
|
AND COALESCE(access_count, 0) = 0
|
|
127
135
|
AND COALESCE(injection_count, 0) = 0
|
|
128
136
|
AND created_at_epoch < ?
|
|
@@ -130,12 +138,12 @@ export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCa
|
|
|
130
138
|
)
|
|
131
139
|
`).run(staleAge, ...baseParams).changes;
|
|
132
140
|
|
|
133
|
-
const
|
|
134
|
-
UPDATE observations SET
|
|
141
|
+
const decayed = db.prepare(`
|
|
142
|
+
UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
|
|
135
143
|
WHERE id IN (
|
|
136
144
|
SELECT id FROM observations
|
|
137
145
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
138
|
-
AND COALESCE(importance, 1)
|
|
146
|
+
AND COALESCE(importance, 1) > 1
|
|
139
147
|
AND COALESCE(access_count, 0) = 0
|
|
140
148
|
AND COALESCE(injection_count, 0) = 0
|
|
141
149
|
AND created_at_epoch < ?
|
|
@@ -238,6 +246,31 @@ export function mergeDuplicates(db, groups) {
|
|
|
238
246
|
return merged;
|
|
239
247
|
}
|
|
240
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Count rows a destructive maintenance run would hard-DELETE: pending-purge rows
|
|
251
|
+
* (any age — a cheap proxy for "purge has something to remove", deliberately not
|
|
252
|
+
* age-filtered so the guard never under-counts) and/or broken empty-content rows
|
|
253
|
+
* (cleanupBroken's doomed set). Used by the maintenance entry points to decide
|
|
254
|
+
* whether to VACUUM-snapshot the DB first (audit MED-2) — over-counting only costs
|
|
255
|
+
* one extra bounded backup; under-counting would skip the safety net.
|
|
256
|
+
*/
|
|
257
|
+
export function hardDeleteCandidateCount(db, { projectFilter, baseParams }, { cleanup = false, purge = false } = {}) {
|
|
258
|
+
let n = 0;
|
|
259
|
+
if (purge) {
|
|
260
|
+
n += db.prepare(
|
|
261
|
+
`SELECT COUNT(*) AS c FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} ${projectFilter}`
|
|
262
|
+
).get(...baseParams).c;
|
|
263
|
+
}
|
|
264
|
+
if (cleanup) {
|
|
265
|
+
n += db.prepare(
|
|
266
|
+
`SELECT COUNT(*) AS c FROM observations
|
|
267
|
+
WHERE COALESCE(compressed_into, 0) = 0
|
|
268
|
+
AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '') ${projectFilter}`
|
|
269
|
+
).get(...baseParams).c;
|
|
270
|
+
}
|
|
271
|
+
return n;
|
|
272
|
+
}
|
|
273
|
+
|
|
241
274
|
/** Preview pending-purge candidates older than the retain cutoff (no deletion). */
|
|
242
275
|
export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutoff) {
|
|
243
276
|
return db.prepare(`
|
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
|
@@ -19,9 +19,10 @@ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
|
19
19
|
import {
|
|
20
20
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
21
21
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
22
|
-
recoverChildrenOf,
|
|
22
|
+
recoverChildrenOf, hardDeleteCandidateCount,
|
|
23
23
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
24
24
|
} from './lib/maintain-core.mjs';
|
|
25
|
+
import { snapshotDb } from './lib/db-backup.mjs';
|
|
25
26
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
26
27
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
27
28
|
import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
@@ -323,7 +324,10 @@ function cmdRecent(db, args) {
|
|
|
323
324
|
// accepted silently; the positional path must reject garbage like the --limit flag does.
|
|
324
325
|
const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
|
|
325
326
|
if (rawArg !== undefined && !isValid) {
|
|
326
|
-
|
|
327
|
+
// Name the ACTUAL fallback: a present --limit overrides the positional below, so
|
|
328
|
+
// claiming "default 10" when `recent abc --limit 5` returns 5 misled the user.
|
|
329
|
+
const fallbackLabel = flags.limit !== undefined ? '--limit' : 'default 10';
|
|
330
|
+
process.stderr.write(`[mem] Invalid count "${rawArg}" (must be an integer between 1 and ${RECENT_MAX}); using ${fallbackLabel}\n`);
|
|
327
331
|
}
|
|
328
332
|
// Positional [N] wins for backward-compat; --limit is sibling-parity alias
|
|
329
333
|
// (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
|
|
@@ -1422,6 +1426,12 @@ function cmdDelete(db, args) {
|
|
|
1422
1426
|
return;
|
|
1423
1427
|
}
|
|
1424
1428
|
|
|
1429
|
+
// Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
|
|
1430
|
+
// matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
|
|
1431
|
+
// (never throws, skips :memory:); rows here are confirmed + non-empty, so the delete
|
|
1432
|
+
// always removes something worth backing up. Must run OUTSIDE the transaction (VACUUM).
|
|
1433
|
+
snapshotDb(db, { tag: 'pre-delete' });
|
|
1434
|
+
|
|
1425
1435
|
// Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
|
|
1426
1436
|
const deletedIds = new Set(ids);
|
|
1427
1437
|
const deleteTx = db.transaction(() => {
|
|
@@ -1551,6 +1561,12 @@ function cmdUpdate(db, args) {
|
|
|
1551
1561
|
|
|
1552
1562
|
function cmdExport(db, args) {
|
|
1553
1563
|
const { flags } = parseArgs(args);
|
|
1564
|
+
// Guard value-less string flags. Bare `--to` parsed to boolean `true`, and
|
|
1565
|
+
// `new Date(true).getTime()` is 1 (NOT NaN), so the isNaN guard below missed it and
|
|
1566
|
+
// the filter became `created_at_epoch <= 1` → an EMPTY export with exit 0. A backup
|
|
1567
|
+
// script (`export --to "$END" > backup.json`) with an unset `$END` would silently
|
|
1568
|
+
// write an empty backup and report success. Reject like cmdSearch does.
|
|
1569
|
+
if (rejectBareStringFlags(flags, ['project', 'type', 'from', 'to'])) return;
|
|
1554
1570
|
const wheres = [];
|
|
1555
1571
|
const params = [];
|
|
1556
1572
|
// --include-compressed: include compressed observations (aligned with MCP mem_export)
|
|
@@ -1742,8 +1758,10 @@ function cmdCompress(db, args) {
|
|
|
1742
1758
|
// got the 30-day cutoff without knowing their input was discarded.
|
|
1743
1759
|
let ageDays = 30;
|
|
1744
1760
|
if (flags['age-days'] !== undefined) {
|
|
1745
|
-
|
|
1746
|
-
|
|
1761
|
+
// isNumericToken (not bare parseInt) so "1e5"→1 and "30x"→30 are rejected rather than
|
|
1762
|
+
// silently mis-parsed into a far-too-broad cutoff — parity with recent/search/maintain.
|
|
1763
|
+
const parsed = Number(flags['age-days']);
|
|
1764
|
+
if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 1) {
|
|
1747
1765
|
fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
|
|
1748
1766
|
return;
|
|
1749
1767
|
}
|
|
@@ -1799,6 +1817,11 @@ function cmdMaintain(db, args) {
|
|
|
1799
1817
|
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.");
|
|
1800
1818
|
return;
|
|
1801
1819
|
}
|
|
1820
|
+
// Guard value-less string flags before any `.split()` / resolveProject runs. A bare
|
|
1821
|
+
// `--merge-ids` parsed to boolean `true`, and `true.split(',')` (line ~1911) crashed
|
|
1822
|
+
// with a raw stack trace — the one string-flag path that lacked this #8470 guard, and
|
|
1823
|
+
// the exact form the `scan` output suggests copy-pasting (`--merge-ids <pairs>`).
|
|
1824
|
+
if (rejectBareStringFlags(flags, ['ops', 'project', 'merge-ids', 'retain-days'])) return;
|
|
1802
1825
|
|
|
1803
1826
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
1804
1827
|
const projectFilter = project ? 'AND project = ?' : '';
|
|
@@ -1867,7 +1890,57 @@ function cmdMaintain(db, args) {
|
|
|
1867
1890
|
// T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
|
|
1868
1891
|
const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
|
|
1869
1892
|
|
|
1893
|
+
// Parse + validate --retain-days BEFORE the transaction so an invalid value rejects the
|
|
1894
|
+
// whole command atomically. The old code validated inside db.transaction() with a bare
|
|
1895
|
+
// `return`, so an earlier op (cleanup hard-delete / decay / boost) had already mutated and
|
|
1896
|
+
// the transaction COMMITTED despite the exit-1 error (audit MED-2 atomicity).
|
|
1897
|
+
let retainDays = 30;
|
|
1898
|
+
if (ops.includes('purge_stale') && flags['retain-days'] !== undefined) {
|
|
1899
|
+
// Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than silently
|
|
1900
|
+
// truncated — parity with the mem_maintain zod .int().min(7).max(365) bound.
|
|
1901
|
+
const parsed = Number(flags['retain-days']);
|
|
1902
|
+
if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
|
|
1903
|
+
fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
retainDays = parsed;
|
|
1907
|
+
}
|
|
1908
|
+
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1909
|
+
// purge_stale is the only DELETE here — require --confirm so a mis-typed run can't wipe rows.
|
|
1910
|
+
const confirmed = flags.confirm === true || flags.confirm === 'true';
|
|
1911
|
+
|
|
1912
|
+
// Snapshot the DB before the irreversible cleanup/purge hard-deletes — only when rows will
|
|
1913
|
+
// actually be removed, and OUTSIDE the transaction below (VACUUM cannot run inside one).
|
|
1914
|
+
// Best-effort; snapshotDb never throws.
|
|
1915
|
+
const willPurge = ops.includes('purge_stale') && confirmed;
|
|
1916
|
+
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: willPurge }) > 0) {
|
|
1917
|
+
snapshotDb(db, { tag: 'pre-maintain' });
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1870
1920
|
db.transaction(() => {
|
|
1921
|
+
// PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766). Running decay BEFORE
|
|
1922
|
+
// purge in one transaction marked a stale row pending-purge AND deleted it in the SAME call
|
|
1923
|
+
// (zero grace), and the pre-txn snapshot guard counts only PRE-EXISTING pending rows so it
|
|
1924
|
+
// skipped the backup → permanent, unrecoverable loss of notable imp-2/3 memories (audit
|
|
1925
|
+
// HIGH-1). Purging first deletes only rows a PRIOR run marked (which the guard saw + backed
|
|
1926
|
+
// up); rows decay marks below wait for the next maintain run, regaining the grace cycle.
|
|
1927
|
+
if (ops.includes('purge_stale')) {
|
|
1928
|
+
if (!confirmed) {
|
|
1929
|
+
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1930
|
+
const pushLines = [`purge_stale preview (no --confirm):`,
|
|
1931
|
+
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
|
|
1932
|
+
if (previewRow.candidates > 0) {
|
|
1933
|
+
pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1934
|
+
pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1935
|
+
}
|
|
1936
|
+
pushLines.push(` To delete, re-run with --confirm.`);
|
|
1937
|
+
results.push(pushLines.join('\n'));
|
|
1938
|
+
} else {
|
|
1939
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1940
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1871
1944
|
if (ops.includes('cleanup')) {
|
|
1872
1945
|
const deleted = cleanupBroken(db, mctx);
|
|
1873
1946
|
results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
|
|
@@ -1917,43 +1990,6 @@ function cmdMaintain(db, args) {
|
|
|
1917
1990
|
results.push('Warning: --merge-ids provided but "dedup" not in operations — merge-ids ignored');
|
|
1918
1991
|
}
|
|
1919
1992
|
|
|
1920
|
-
if (ops.includes('purge_stale')) {
|
|
1921
|
-
// --retain-days: default 30 when absent; reject negative / 0 / NaN / out-of-range.
|
|
1922
|
-
// A negative value made retainCutoff a FUTURE timestamp → purged the entire
|
|
1923
|
-
// pending-purge backlog regardless of age; 0/garbage silently became 30 and
|
|
1924
|
-
// masked typos. Parity with the mem_maintain MCP zod bound [7, 365].
|
|
1925
|
-
let retainDays = 30;
|
|
1926
|
-
if (flags['retain-days'] !== undefined) {
|
|
1927
|
-
// Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than
|
|
1928
|
-
// silently truncated to 7/30 — parity with the mem_maintain zod .int() bound.
|
|
1929
|
-
const parsed = Number(flags['retain-days']);
|
|
1930
|
-
if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
|
|
1931
|
-
// fail() only sets exitCode + writes stderr; it does NOT throw, so we
|
|
1932
|
-
// MUST return or execution falls through to the DELETE with the bad value.
|
|
1933
|
-
fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
|
|
1934
|
-
return;
|
|
1935
|
-
}
|
|
1936
|
-
retainDays = parsed;
|
|
1937
|
-
}
|
|
1938
|
-
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1939
|
-
// T2-P0-A (CLI parity): purge_stale is the only DELETE in this code path — require
|
|
1940
|
-
// --confirm so a mis-typed `maintain execute --ops purge_stale` can't wipe rows silently.
|
|
1941
|
-
const confirmed = flags.confirm === true || flags.confirm === 'true';
|
|
1942
|
-
if (!confirmed) {
|
|
1943
|
-
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1944
|
-
const pushLines = [`purge_stale preview (no --confirm):`,
|
|
1945
|
-
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
|
|
1946
|
-
if (previewRow.candidates > 0) {
|
|
1947
|
-
pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1948
|
-
pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1949
|
-
}
|
|
1950
|
-
pushLines.push(` To delete, re-run with --confirm.`);
|
|
1951
|
-
results.push(pushLines.join('\n'));
|
|
1952
|
-
} else {
|
|
1953
|
-
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1954
|
-
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
1993
|
})();
|
|
1958
1994
|
|
|
1959
1995
|
// FTS optimize
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.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",
|
|
@@ -84,6 +84,7 @@
|
|
|
84
84
|
"lib/search-core.mjs",
|
|
85
85
|
"lib/rrf.mjs",
|
|
86
86
|
"lib/compress-core.mjs",
|
|
87
|
+
"lib/db-backup.mjs",
|
|
87
88
|
"lib/maintain-core.mjs",
|
|
88
89
|
"lib/dedup-constants.mjs",
|
|
89
90
|
"lib/deferred-work.mjs",
|
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
|
@@ -18,9 +18,10 @@ import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './l
|
|
|
18
18
|
import {
|
|
19
19
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
20
20
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
21
|
-
recoverChildrenOf,
|
|
21
|
+
recoverChildrenOf, hardDeleteCandidateCount,
|
|
22
22
|
OP_CAP, STALE_AGE_MS,
|
|
23
23
|
} from './lib/maintain-core.mjs';
|
|
24
|
+
import { snapshotDb } from './lib/db-backup.mjs';
|
|
24
25
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
25
26
|
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
26
27
|
import { formatObsFieldValue } from './cli/common.mjs';
|
|
@@ -616,6 +617,11 @@ server.registerTool(
|
|
|
616
617
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
617
618
|
}
|
|
618
619
|
|
|
620
|
+
// Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
|
|
621
|
+
// matching the CLI delete + maintain purge/cleanup paths (audit MED-2). Best-effort
|
|
622
|
+
// (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM).
|
|
623
|
+
snapshotDb(db, { tag: 'pre-delete' });
|
|
624
|
+
|
|
619
625
|
// Wrap cleanup + deletion in a transaction for consistency
|
|
620
626
|
const deletedIds = new Set(args.ids);
|
|
621
627
|
const deleteTx = db.transaction(() => {
|
|
@@ -1078,7 +1084,28 @@ server.registerTool(
|
|
|
1078
1084
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1079
1085
|
}
|
|
1080
1086
|
|
|
1087
|
+
// MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
|
|
1088
|
+
// only when rows will actually be removed, and OUTSIDE the transaction below
|
|
1089
|
+
// (VACUUM cannot run inside one). purge_stale is already confirmed by here (the
|
|
1090
|
+
// preview branch returned above otherwise). Best-effort; snapshotDb never throws.
|
|
1091
|
+
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
|
|
1092
|
+
snapshotDb(db, { tag: 'pre-maintain' });
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1081
1095
|
db.transaction(() => {
|
|
1096
|
+
// PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766) and the CLI
|
|
1097
|
+
// cmdMaintain. Running decay BEFORE purge in one transaction marked a stale row
|
|
1098
|
+
// pending-purge AND deleted it in the SAME call (zero grace), while the pre-txn
|
|
1099
|
+
// snapshot guard counts only PRE-EXISTING pending rows so it skipped the backup →
|
|
1100
|
+
// permanent loss of notable imp-2/3 memories (audit HIGH-1). Purging first deletes
|
|
1101
|
+
// only rows a PRIOR run marked (backed up); rows decay marks below wait one cycle.
|
|
1102
|
+
if (ops.includes('purge_stale')) {
|
|
1103
|
+
const retainDays = args.retain_days ?? 30;
|
|
1104
|
+
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1105
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1106
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1082
1109
|
if (ops.includes('cleanup')) {
|
|
1083
1110
|
const deleted = cleanupBroken(db, mctx);
|
|
1084
1111
|
results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
@@ -1109,13 +1136,6 @@ server.registerTool(
|
|
|
1109
1136
|
if (!ops.includes('dedup') && args.merge_ids) {
|
|
1110
1137
|
results.push('Warning: merge_ids provided but "dedup" not in operations — merge_ids ignored');
|
|
1111
1138
|
}
|
|
1112
|
-
|
|
1113
|
-
if (ops.includes('purge_stale')) {
|
|
1114
|
-
const retainDays = args.retain_days ?? 30;
|
|
1115
|
-
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1116
|
-
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1117
|
-
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1118
|
-
}
|
|
1119
1139
|
})();
|
|
1120
1140
|
|
|
1121
1141
|
// FTS5 optimize (outside transaction)
|
package/source-files.mjs
CHANGED
|
@@ -131,6 +131,10 @@ export const SOURCE_FILES = [
|
|
|
131
131
|
// Statically imported by mem-cli.mjs (cmdMaintain), server.mjs (mem_maintain),
|
|
132
132
|
// and hook.mjs (handleAutoMaintain) — missing it would break maintain on auto-update.
|
|
133
133
|
'lib/maintain-core.mjs',
|
|
134
|
+
// Pre-maintenance VACUUM INTO snapshot (MED-2). Statically imported by mem-cli.mjs,
|
|
135
|
+
// server.mjs, and hook.mjs before their destructive purge/cleanup — missing it
|
|
136
|
+
// would crash maintain on auto-update with an unresolved import.
|
|
137
|
+
'lib/db-backup.mjs',
|
|
134
138
|
// P10 dedup/merge threshold constants — single source of truth for the Jaccard
|
|
135
139
|
// dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
|
|
136
140
|
// hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
|
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
|
|