claude-mem-lite 3.47.0 → 3.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-context.mjs +20 -1
- package/hook.mjs +54 -3
- package/lib/citation-tracker.mjs +7 -1
- package/lib/delete-core.mjs +72 -0
- package/lib/events-injection.mjs +91 -0
- package/lib/search-core.mjs +20 -4
- package/mem-cli.mjs +8 -37
- package/package.json +3 -1
- package/scripts/pre-tool-recall.js +12 -4
- package/server.mjs +8 -42
- package/source-files.mjs +10 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.48.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.48.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/hook-context.mjs
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
debugLog, debugCatch, neutralizeContextDelimiters,
|
|
9
9
|
DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
|
|
10
10
|
} from './utils.mjs';
|
|
11
|
-
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet } from './hook-shared.mjs';
|
|
11
|
+
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks } from './hook-shared.mjs';
|
|
12
12
|
import { extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
13
|
+
import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Infer the project directory from environment variables or cwd.
|
|
@@ -390,6 +391,24 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
390
391
|
}
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
// HIGH-1 (full audit 2026-07-16): surface recent high-importance events — the
|
|
395
|
+
// canonical store for promoted bugfix/decision/lesson memories that
|
|
396
|
+
// persistHaikuSummary upgrade-deletes out of observations. Without this section
|
|
397
|
+
// SessionStart never shows them. E# prefix keeps citation extractors (bare-`#`
|
|
398
|
+
// anchored) from reading an event id as an observation id. Gated on isQuietHooks()
|
|
399
|
+
// ONLY (explicit low-noise opt-out), NOT effectiveQuiet: unlike Key Context, events
|
|
400
|
+
// never appear in the obs-only Recent table and are absent from the MEMORY.md
|
|
401
|
+
// sentinel, so an adopted project (the default) would otherwise have zero
|
|
402
|
+
// SessionStart surface for them. Never throws (recentInjectableEvents catches).
|
|
403
|
+
if (!isQuietHooks()) {
|
|
404
|
+
const keyEvents = recentInjectableEvents(db, { project, limit: 5 });
|
|
405
|
+
if (keyEvents.length > 0) {
|
|
406
|
+
summaryLines.push('### Key Events');
|
|
407
|
+
for (const e of keyEvents) summaryLines.push(`- ${renderInjectableEvent(e)}`);
|
|
408
|
+
summaryLines.push('');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
393
412
|
// 5. Working state from latest /clear handoff.
|
|
394
413
|
// Session scoping: when currentCcSessionId is provided, restrict to this session's
|
|
395
414
|
// own clear handoff so parallel sessions don't see each other's Working State block.
|
package/hook.mjs
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
|
|
62
62
|
import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
|
|
63
63
|
import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
|
|
64
|
+
import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
64
65
|
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
65
66
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
66
67
|
import { gcOldMetricShards } from './lib/metrics.mjs';
|
|
@@ -87,7 +88,7 @@ import { getVocabulary } from './tfidf.mjs';
|
|
|
87
88
|
// Prevent recursive hooks from background claude -p calls
|
|
88
89
|
// Background workers (llm-episode, llm-summary) are exempt — they're ours
|
|
89
90
|
const event = process.argv[2];
|
|
90
|
-
const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize']);
|
|
91
|
+
const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain']);
|
|
91
92
|
|
|
92
93
|
// Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
|
|
93
94
|
// install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
|
|
@@ -420,7 +421,18 @@ function triggerErrorRecall(db, toolInput, response) {
|
|
|
420
421
|
`).all(ftsQuery, project, nowR);
|
|
421
422
|
|
|
422
423
|
const out = formatErrorRecallHints(rows);
|
|
423
|
-
if (out)
|
|
424
|
+
if (out) {
|
|
425
|
+
// MED-3 (full audit 2026-07-16): emit via the JSON envelope (trailing '\n'),
|
|
426
|
+
// NOT raw stdout. A raw multi-line write corrupts a co-emitted episode-flush
|
|
427
|
+
// receipt (both write to PostToolUse stdout in the same call → `<text>{json}`)
|
|
428
|
+
// and is silently dropped on CC variants that ignore plain-text PostToolUse
|
|
429
|
+
// stdout. This is the same suppressOutput+additionalContext channel
|
|
430
|
+
// flushEpisode uses; two separate JSON lines each parse independently.
|
|
431
|
+
process.stdout.write(JSON.stringify({
|
|
432
|
+
suppressOutput: true,
|
|
433
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: out },
|
|
434
|
+
}) + '\n');
|
|
435
|
+
}
|
|
424
436
|
} catch (e) { debugCatch(e, 'triggerErrorRecall'); }
|
|
425
437
|
}
|
|
426
438
|
|
|
@@ -975,6 +987,29 @@ function runSessionStartAutoMaintain(db) {
|
|
|
975
987
|
}
|
|
976
988
|
}
|
|
977
989
|
|
|
990
|
+
// SessionStart boot path (MED-4): cheap 24h gate pre-check only. When maintenance is
|
|
991
|
+
// due, the heavy pass (VACUUM-INTO snapshot + purge/cleanup/decay/dedup) is handed to
|
|
992
|
+
// a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
|
|
993
|
+
// session start. The worker re-checks the same gate (idempotent) before doing the work.
|
|
994
|
+
function scheduleSessionStartAutoMaintain() {
|
|
995
|
+
const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
|
|
996
|
+
try {
|
|
997
|
+
const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
|
|
998
|
+
if (Date.now() - last.epoch < 24 * 3600000) return; // not due — no spawn
|
|
999
|
+
} catch { /* no gate file → due */ }
|
|
1000
|
+
if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
|
|
1004
|
+
// pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
|
|
1005
|
+
// gate + the compress/optimize spawns at its tail.
|
|
1006
|
+
function handleAutoMaintain() {
|
|
1007
|
+
const db = openDb();
|
|
1008
|
+
if (!db) return;
|
|
1009
|
+
try { runSessionStartAutoMaintain(db); }
|
|
1010
|
+
finally { try { db.close(); } catch { /* ignore */ } }
|
|
1011
|
+
}
|
|
1012
|
+
|
|
978
1013
|
function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
|
|
979
1014
|
// Shared clear handoff reference — queried once, used by fast summary + working state
|
|
980
1015
|
let prevClearHandoff = null;
|
|
@@ -1285,7 +1320,7 @@ async function handleSessionStart() {
|
|
|
1285
1320
|
|
|
1286
1321
|
runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
|
|
1287
1322
|
|
|
1288
|
-
|
|
1323
|
+
scheduleSessionStartAutoMaintain();
|
|
1289
1324
|
|
|
1290
1325
|
// ── Non-transactional operations (side effects, background work) ──
|
|
1291
1326
|
|
|
@@ -1520,6 +1555,21 @@ async function handleUserPrompt() {
|
|
|
1520
1555
|
lines.push('</memory-context>');
|
|
1521
1556
|
process.stdout.write(lines.join('\n') + '\n');
|
|
1522
1557
|
}
|
|
1558
|
+
// HIGH-1 (full audit 2026-07-16): surface FTS-matched events — the canonical
|
|
1559
|
+
// store for promoted bugfix/decision/lesson memories that persistHaikuSummary
|
|
1560
|
+
// upgrade-deletes out of observations. Without this leg they are unreachable at
|
|
1561
|
+
// prompt time. Separate E#-tagged block so it doesn't perturb observation
|
|
1562
|
+
// ranking and citation extractors (bare-`#` anchored) never read an event id as
|
|
1563
|
+
// an obs id. Nested try so an events failure can't suppress the imperative pick.
|
|
1564
|
+
try {
|
|
1565
|
+
const events = searchInjectableEvents(db, { prompt: promptText, project });
|
|
1566
|
+
if (events.length > 0) {
|
|
1567
|
+
const elines = ['<memory-context relevance="events">'];
|
|
1568
|
+
for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
|
|
1569
|
+
elines.push('</memory-context>');
|
|
1570
|
+
process.stdout.write(elines.join('\n') + '\n');
|
|
1571
|
+
}
|
|
1572
|
+
} catch (e) { debugCatch(e, 'handleUserPrompt-events'); }
|
|
1523
1573
|
if (imperativePick) {
|
|
1524
1574
|
// Guard the write on a non-empty return — formatTaskImperative yields '' for a
|
|
1525
1575
|
// lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
|
|
@@ -1639,6 +1689,7 @@ try {
|
|
|
1639
1689
|
case 'llm-episode': await handleLLMEpisode(); break;
|
|
1640
1690
|
case 'llm-summary': await handleLLMSummary(); break;
|
|
1641
1691
|
case 'auto-compress': handleAutoCompress(); break;
|
|
1692
|
+
case 'auto-maintain': handleAutoMaintain(); break;
|
|
1642
1693
|
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
1643
1694
|
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
|
1644
1695
|
// GitHub fetch + (non-plugin) install off the SessionStart critical path,
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -707,7 +707,13 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
707
707
|
}
|
|
708
708
|
}
|
|
709
709
|
});
|
|
710
|
-
|
|
710
|
+
// IMMEDIATE (not the default DEFERRED): this txn reads each obs then writes it.
|
|
711
|
+
// Under concurrent same-project Stop hooks a DEFERRED txn pins a read snapshot
|
|
712
|
+
// first, and the second session's write then hits SQLITE_BUSY_SNAPSHOT — a
|
|
713
|
+
// snapshot-upgrade conflict busy_timeout cannot resolve — silently dropping the
|
|
714
|
+
// whole decay pass (promotes/demotes/streak + the paired funnel deltas). Taking
|
|
715
|
+
// the write lock up front lets busy_timeout actually serialize the two sessions.
|
|
716
|
+
try { txn.immediate(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
|
|
711
717
|
return { promoted, demoted, touched };
|
|
712
718
|
}
|
|
713
719
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// lib/delete-core.mjs — shared hard-delete orchestration for the CLI `delete`
|
|
2
|
+
// command (mem-cli.mjs cmdDelete) and the MCP `mem_delete` tool (server.mjs).
|
|
3
|
+
// Both surfaces previously inlined a byte-for-byte-equivalent copy of this logic,
|
|
4
|
+
// kept in sync by "aligned with MCP mem_delete" / "matching the CLI delete" parity
|
|
5
|
+
// comments — the project's documented #1 drift risk. Consolidated here so there is
|
|
6
|
+
// one implementation: pre-delete snapshot, stale related_ids cleanup,
|
|
7
|
+
// merged/compressed-child recovery, and the delete transaction.
|
|
8
|
+
import { snapshotDb } from './db-backup.mjs';
|
|
9
|
+
import { recoverChildrenOf } from './maintain-core.mjs';
|
|
10
|
+
import { debugCatch } from '../utils.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hard-delete the given observation ids with full orchestration. The CALLER owns
|
|
14
|
+
* the confirm/preview gating and fetches whatever rows it needs to render (existence
|
|
15
|
+
* check, preview list, missing-id note); this performs only the irreversible mutation
|
|
16
|
+
* once the caller has decided to proceed.
|
|
17
|
+
*
|
|
18
|
+
* Order (preserved from the pre-extraction inline paths):
|
|
19
|
+
* 1. snapshotDb OUTSIDE any transaction (VACUUM INTO cannot run inside one). Best-
|
|
20
|
+
* effort: no-op on :memory:, never throws.
|
|
21
|
+
* 2. In ONE transaction: strip the deleted ids out of other rows' related_ids
|
|
22
|
+
* (coarse LIKE pre-filter → JSON-parse precise filter), recover children
|
|
23
|
+
* merged/compressed INTO the doomed rows (compressed_into has no FK), then DELETE.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('better-sqlite3').Database} db
|
|
26
|
+
* @param {number[]} ids observation ids to delete
|
|
27
|
+
* @param {{ snapshotTag?: string }} [opts]
|
|
28
|
+
* @returns {{ deleted: number, recoveredChildren: number, snapshotPath: string|null }}
|
|
29
|
+
*/
|
|
30
|
+
export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {}) {
|
|
31
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
32
|
+
|
|
33
|
+
// Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
|
|
34
|
+
// matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
|
|
35
|
+
// (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM INTO).
|
|
36
|
+
const snapshotPath = snapshotDb(db, { tag: snapshotTag });
|
|
37
|
+
|
|
38
|
+
const deletedIds = new Set(ids);
|
|
39
|
+
const deleteTx = db.transaction(() => {
|
|
40
|
+
// Clean up stale references in other observations' related_ids.
|
|
41
|
+
// LIKE %id% is a coarse pre-filter (false positives: %1% matches [10], [21]) — it only
|
|
42
|
+
// narrows which rows to fetch; the JSON parse + Set.has below is the precise filter.
|
|
43
|
+
// Acceptable because observation count per user is typically <10K.
|
|
44
|
+
const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
|
|
45
|
+
const likeParams = ids.map(id => `%${id}%`);
|
|
46
|
+
const referencing = db.prepare(`
|
|
47
|
+
SELECT id, related_ids FROM observations
|
|
48
|
+
WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
|
|
49
|
+
`).all(...likeParams);
|
|
50
|
+
for (const r of referencing) {
|
|
51
|
+
let refIds;
|
|
52
|
+
try { refIds = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
|
|
53
|
+
// Only rewrite a well-formed integer array; a malformed related_ids value is left
|
|
54
|
+
// untouched rather than reshaped by the filter (the stricter of the two originals).
|
|
55
|
+
if (!Array.isArray(refIds) || !refIds.every(id => Number.isInteger(id))) continue;
|
|
56
|
+
const filtered = refIds.filter(id => !deletedIds.has(id));
|
|
57
|
+
if (filtered.length !== refIds.length) {
|
|
58
|
+
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Resurface rows merged/compressed INTO the doomed keepers before deleting, else they
|
|
62
|
+
// dangle behind a now-missing parent (compressed_into has no FK) — invisible to every
|
|
63
|
+
// COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the maintain
|
|
64
|
+
// hard-delete paths use (recoverChildrenOf).
|
|
65
|
+
const recovered = recoverChildrenOf(db, ids);
|
|
66
|
+
// Execute deletion (FTS5 cleanup handled by the observations_ad trigger).
|
|
67
|
+
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
|
|
68
|
+
return { changes: deleted.changes, recovered };
|
|
69
|
+
});
|
|
70
|
+
const result = deleteTx();
|
|
71
|
+
return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
|
|
72
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// lib/events-injection.mjs — surface `events` into the passive injection surfaces.
|
|
2
|
+
//
|
|
3
|
+
// HIGH-1 (full audit 2026-07-16): persistHaikuSummary upgrade-deletes every
|
|
4
|
+
// event-typed memory (bugfix/decision/lesson/discovery/…) out of `observations`
|
|
5
|
+
// and into the `events` table, so after Haiku enrichment the high-value history
|
|
6
|
+
// lives ONLY in events. v3.44 wired events into mem_search + PreToolUse recall,
|
|
7
|
+
// but the three PASSIVE injection surfaces (SessionStart context, UserPromptSubmit
|
|
8
|
+
// path A = user-prompt-search.js, path B = hook.mjs memory-context) still read only
|
|
9
|
+
// observations — so the promoted memories were unreachable at prompt/session time.
|
|
10
|
+
//
|
|
11
|
+
// id-space discipline: events share the numeric id space with observations but are a
|
|
12
|
+
// separate table, so injected events are rendered with an `E#` prefix. The
|
|
13
|
+
// citation-tracker injected-id extractors all anchor on a BARE `#`
|
|
14
|
+
// (FYI `/^#\d/`, memory-context `/\(#\d/`, error-recall row-anchored), so an `E#`
|
|
15
|
+
// row is never mis-attributed to an observation id (which would let citation decay
|
|
16
|
+
// mutate an unrelated observation sharing that id). Events carry no citation columns,
|
|
17
|
+
// so they inject as reference-only — reachability, not decay bookkeeping.
|
|
18
|
+
|
|
19
|
+
import { searchEventsFts } from './search-core.mjs';
|
|
20
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
21
|
+
import { sanitizeFtsQuery } from '../utils.mjs';
|
|
22
|
+
|
|
23
|
+
const DEFAULT_LIMIT = 3;
|
|
24
|
+
const DEFAULT_MIN_IMPORTANCE = 2;
|
|
25
|
+
const TITLE_MAX = 80;
|
|
26
|
+
const LESSON_MAX = 160;
|
|
27
|
+
|
|
28
|
+
function normalizeRow(r) {
|
|
29
|
+
return {
|
|
30
|
+
id: r.id,
|
|
31
|
+
type: r.event_type || r.type || 'event',
|
|
32
|
+
title: r.title || '',
|
|
33
|
+
lesson_learned: (r.body ?? r.lesson_learned) || null,
|
|
34
|
+
importance: r.importance,
|
|
35
|
+
created_at_epoch: r.created_at_epoch,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* FTS-matched events for a prompt (UserPromptSubmit surfaces). Superseded events are
|
|
41
|
+
* excluded by searchEventsFts; importance floor drops low-value rows. Never throws.
|
|
42
|
+
* @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
|
|
43
|
+
*/
|
|
44
|
+
export function searchInjectableEvents(db, { prompt, ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
|
|
45
|
+
if (!db || !project) return [];
|
|
46
|
+
const q = ftsQuery || (prompt ? sanitizeFtsQuery(prompt) : null);
|
|
47
|
+
if (!q) return [];
|
|
48
|
+
try {
|
|
49
|
+
const rows = searchEventsFts(db, {
|
|
50
|
+
ftsQuery: q, project, projectBoost: project,
|
|
51
|
+
importance: minImportance, perSourceLimit: limit,
|
|
52
|
+
});
|
|
53
|
+
return rows.map(normalizeRow);
|
|
54
|
+
} catch { return []; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recency + importance events (SessionStart context — no FTS query available).
|
|
59
|
+
* Excludes superseded events. Never throws.
|
|
60
|
+
* @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
|
|
61
|
+
*/
|
|
62
|
+
export function recentInjectableEvents(db, { project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
|
|
63
|
+
if (!db || !project) return [];
|
|
64
|
+
try {
|
|
65
|
+
const rows = db.prepare(`
|
|
66
|
+
SELECT id, event_type, title, body, importance, created_at_epoch
|
|
67
|
+
FROM events
|
|
68
|
+
WHERE project = ?
|
|
69
|
+
AND superseded_at_epoch IS NULL
|
|
70
|
+
AND COALESCE(importance, 1) >= ?
|
|
71
|
+
ORDER BY created_at_epoch DESC
|
|
72
|
+
LIMIT ?
|
|
73
|
+
`).all(project, minImportance, limit);
|
|
74
|
+
return rows.map(normalizeRow);
|
|
75
|
+
} catch { return []; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render one injectable event as a defanged `E#<id> [<type>] <title> — <lesson>` line.
|
|
80
|
+
* The E# prefix keeps citation-tracker extractors (which anchor on a bare `#`) from
|
|
81
|
+
* mis-reading the event id as an observation id.
|
|
82
|
+
*/
|
|
83
|
+
export function renderInjectableEvent(row) {
|
|
84
|
+
const title = neutralizeContextDelimiters((row.title || '').slice(0, TITLE_MAX));
|
|
85
|
+
const head = `E#${row.id} [${row.type}] ${title}`;
|
|
86
|
+
if (row.lesson_learned) {
|
|
87
|
+
const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
|
|
88
|
+
if (lesson) return `${head} — ${lesson}`;
|
|
89
|
+
}
|
|
90
|
+
return head;
|
|
91
|
+
}
|
package/lib/search-core.mjs
CHANGED
|
@@ -245,17 +245,33 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
|
|
|
245
245
|
`).all(...params);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// A lone match has no within-source spread to normalize against. Clamp it to
|
|
249
|
+
// the neutral middle of the [-1, 0] band (MED-5): leaving its raw BM25 magnitude
|
|
250
|
+
// (a title hit can reach -8..-40) let one incidental single-source match — and
|
|
251
|
+
// `event` is now a common low-cardinality source — outrank an entire page of
|
|
252
|
+
// strongly-matched, normalized rows from another source. -0.5 is magnitude-
|
|
253
|
+
// independent, so it fixes both the dominating strong lone match and does not
|
|
254
|
+
// inflate a weak one to -1.0 (the reason single-row sources were previously
|
|
255
|
+
// skipped). Global-max normalization would NOT fix a lone match that IS the
|
|
256
|
+
// global max, so a fixed neutral value is used instead.
|
|
257
|
+
const NEUTRAL_SINGLE_MATCH_SCORE = -0.5;
|
|
258
|
+
|
|
248
259
|
/**
|
|
249
260
|
* Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
|
|
250
261
|
* Prevents observations (BM25 can reach -40) from systematically outranking
|
|
251
|
-
* sessions (-6) and prompts (-1) regardless of relevance.
|
|
252
|
-
*
|
|
253
|
-
* -
|
|
262
|
+
* sessions (-6) and prompts (-1) regardless of relevance. A source with a single
|
|
263
|
+
* scored row is clamped to the neutral mid (see NEUTRAL_SINGLE_MATCH_SCORE) —
|
|
264
|
+
* it has no within-source spread to normalize against. Mutates `results` in
|
|
265
|
+
* place; callers re-sort afterwards.
|
|
254
266
|
*/
|
|
255
267
|
export function normalizeCrossSourceScores(results, sourceKey) {
|
|
256
268
|
for (const src of ['obs', 'session', 'prompt', 'event']) {
|
|
257
269
|
const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined);
|
|
258
|
-
if (srcResults.length
|
|
270
|
+
if (srcResults.length === 0) continue;
|
|
271
|
+
if (srcResults.length === 1) {
|
|
272
|
+
srcResults[0].score = NEUTRAL_SINGLE_MATCH_SCORE;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
259
275
|
const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
|
|
260
276
|
if (maxAbs > 0) {
|
|
261
277
|
for (const r of srcResults) r.score = r.score / maxAbs;
|
package/mem-cli.mjs
CHANGED
|
@@ -20,10 +20,11 @@ import {
|
|
|
20
20
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
21
21
|
recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
|
|
22
22
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
23
|
-
|
|
23
|
+
hardDeleteCandidateCount,
|
|
24
24
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
25
25
|
} from './lib/maintain-core.mjs';
|
|
26
26
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
27
|
+
import { deleteObservations } from './lib/delete-core.mjs';
|
|
27
28
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
28
29
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
29
30
|
import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
@@ -1508,43 +1509,13 @@ function cmdDelete(db, args) {
|
|
|
1508
1509
|
return;
|
|
1509
1510
|
}
|
|
1510
1511
|
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
// (
|
|
1514
|
-
|
|
1515
|
-
snapshotDb(db, { tag: 'pre-delete' });
|
|
1516
|
-
|
|
1517
|
-
// Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
|
|
1518
|
-
const deletedIds = new Set(ids);
|
|
1519
|
-
const deleteTx = db.transaction(() => {
|
|
1520
|
-
const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
|
|
1521
|
-
const likeParams = ids.map(id => `%${id}%`);
|
|
1522
|
-
const referencing = db.prepare(`
|
|
1523
|
-
SELECT id, related_ids FROM observations
|
|
1524
|
-
WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
|
|
1525
|
-
`).all(...likeParams);
|
|
1526
|
-
for (const r of referencing) {
|
|
1527
|
-
let refIds;
|
|
1528
|
-
try { refIds = JSON.parse(r.related_ids); } catch { continue; }
|
|
1529
|
-
if (!Array.isArray(refIds)) continue;
|
|
1530
|
-
const filtered = refIds.filter(id => !deletedIds.has(id));
|
|
1531
|
-
if (filtered.length !== refIds.length) {
|
|
1532
|
-
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
// Resurface any rows merged/compressed INTO the doomed keepers before deleting,
|
|
1536
|
-
// else they dangle behind a missing parent (compressed_into has no FK) — invisible
|
|
1537
|
-
// to every COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the
|
|
1538
|
-
// maintain hard-delete paths use (recoverChildrenOf); the interactive delete path
|
|
1539
|
-
// was missing it. Returned in the result so the user sees the recovery count.
|
|
1540
|
-
const recovered = recoverChildrenOf(db, ids);
|
|
1541
|
-
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
|
|
1542
|
-
return { changes: deleted.changes, recovered };
|
|
1543
|
-
});
|
|
1544
|
-
const result = deleteTx();
|
|
1512
|
+
// Full delete orchestration (snapshot + related_ids cleanup + child recovery + delete
|
|
1513
|
+
// transaction) lives in lib/delete-core.mjs — single source of truth shared with the MCP
|
|
1514
|
+
// mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
|
|
1515
|
+
const result = deleteObservations(db, ids);
|
|
1545
1516
|
const missing = ids.filter(id => !rows.some(r => r.id === id));
|
|
1546
|
-
const recoveredNote = result.
|
|
1547
|
-
out(`[mem] Deleted ${result.
|
|
1517
|
+
const recoveredNote = result.recoveredChildren > 0 ? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.` : '';
|
|
1518
|
+
out(`[mem] Deleted ${result.deleted} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
|
|
1548
1519
|
}
|
|
1549
1520
|
|
|
1550
1521
|
// ─── Update ──────────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.48.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",
|
|
@@ -90,6 +90,8 @@
|
|
|
90
90
|
"lib/rrf.mjs",
|
|
91
91
|
"lib/compress-core.mjs",
|
|
92
92
|
"lib/db-backup.mjs",
|
|
93
|
+
"lib/delete-core.mjs",
|
|
94
|
+
"lib/events-injection.mjs",
|
|
93
95
|
"lib/maintain-core.mjs",
|
|
94
96
|
"lib/dedup-constants.mjs",
|
|
95
97
|
"lib/deferred-work.mjs",
|
|
@@ -15,6 +15,7 @@ import { fileIntelFor } from '../lib/file-intel.mjs';
|
|
|
15
15
|
import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
|
|
16
16
|
import { recordMetric } from '../lib/metrics.mjs';
|
|
17
17
|
import { presentIdents } from '../lib/lesson-idents.mjs';
|
|
18
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
18
19
|
|
|
19
20
|
// CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
|
|
20
21
|
// whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
|
|
@@ -519,7 +520,14 @@ try {
|
|
|
519
520
|
// when the model misreads passive lesson context as a closing note.
|
|
520
521
|
lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
|
|
521
522
|
}
|
|
522
|
-
|
|
523
|
+
// MED-1 (full audit 2026-07-16): defang the injection-block delimiters in
|
|
524
|
+
// all DB/file-derived text before it enters additionalContext (which CC wraps
|
|
525
|
+
// in <system-reminder>). Stored text is raw — a lesson/title/summary carrying
|
|
526
|
+
// a literal </system-reminder> or forged <invoke ...> would break the wrapper
|
|
527
|
+
// and inject a privileged instruction. Mirrors formatErrorRecallHints, which
|
|
528
|
+
// already applies the same guard on the parallel error-recall surface. The
|
|
529
|
+
// `#id [type]` prefix is agent-generated (numeric id + type enum) — no defang.
|
|
530
|
+
if (fileIntelLine) lines.push(neutralizeContextDelimiters(fileIntelLine));
|
|
523
531
|
if (hasLessons) {
|
|
524
532
|
lines.push(`[mem] Lessons for ${fname}:`);
|
|
525
533
|
for (const r of allRows) {
|
|
@@ -527,12 +535,12 @@ try {
|
|
|
527
535
|
const lesson = r.lesson_learned.length > LESSON_MAX
|
|
528
536
|
? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
|
|
529
537
|
: r.lesson_learned;
|
|
530
|
-
lines.push(` #${r.id} [${r.type}] ${lesson}`);
|
|
538
|
+
lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
|
|
531
539
|
} else {
|
|
532
540
|
const title = (r.title || '').length > LESSON_MAX
|
|
533
541
|
? r.title.slice(0, LESSON_MAX - 3) + '...'
|
|
534
542
|
: (r.title || '');
|
|
535
|
-
lines.push(` #${r.id} [${r.type}] ${title}`);
|
|
543
|
+
lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(title)}`);
|
|
536
544
|
}
|
|
537
545
|
}
|
|
538
546
|
// v2.98 salience: Edit/Write is the action point — close the block with an
|
|
@@ -544,7 +552,7 @@ try {
|
|
|
544
552
|
const changeText = [toolInput?.old_string, toolInput?.new_string, toolInput?.content]
|
|
545
553
|
.filter(Boolean).join('\n');
|
|
546
554
|
const bridged = await bridgeTopLesson(allRows, changeText);
|
|
547
|
-
if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${bridged.check}. Confirm your new code satisfies it.`);
|
|
555
|
+
if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`);
|
|
548
556
|
else lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
|
|
549
557
|
}
|
|
550
558
|
} else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
|
package/server.mjs
CHANGED
|
@@ -19,10 +19,11 @@ import {
|
|
|
19
19
|
cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
|
|
20
20
|
recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
|
|
21
21
|
purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
|
|
22
|
-
|
|
22
|
+
hardDeleteCandidateCount,
|
|
23
23
|
OP_CAP, STALE_AGE_MS,
|
|
24
24
|
} from './lib/maintain-core.mjs';
|
|
25
25
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
26
|
+
import { deleteObservations } from './lib/delete-core.mjs';
|
|
26
27
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
27
28
|
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
28
29
|
import { formatObsFieldValue } from './cli/common.mjs';
|
|
@@ -680,49 +681,14 @@ server.registerTool(
|
|
|
680
681
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
681
682
|
}
|
|
682
683
|
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
// Wrap cleanup + deletion in a transaction for consistency
|
|
689
|
-
const deletedIds = new Set(args.ids);
|
|
690
|
-
const deleteTx = db.transaction(() => {
|
|
691
|
-
// Clean up stale references in other observations' related_ids
|
|
692
|
-
// Use LIKE filter to avoid O(N) full-table scan — only fetch rows that may reference deleted IDs.
|
|
693
|
-
// NOTE: LIKE %id% has false positives (e.g. %1% matches [10], [21]). This is intentional —
|
|
694
|
-
// the LIKE is a coarse pre-filter; the JSON parse + Set.has below is the precise filter.
|
|
695
|
-
// Acceptable because observation count per user is typically <10K.
|
|
696
|
-
const likeConditions = args.ids.map(() => `related_ids LIKE ?`).join(' OR ');
|
|
697
|
-
const likeParams = args.ids.map(id => `%${id}%`);
|
|
698
|
-
const referencing = db.prepare(`
|
|
699
|
-
SELECT id, related_ids FROM observations
|
|
700
|
-
WHERE related_ids IS NOT NULL AND related_ids != '[]'
|
|
701
|
-
AND (${likeConditions})
|
|
702
|
-
`).all(...likeParams);
|
|
703
|
-
for (const r of referencing) {
|
|
704
|
-
let ids;
|
|
705
|
-
try { ids = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
|
|
706
|
-
if (!Array.isArray(ids) || !ids.every(id => Number.isInteger(id))) continue;
|
|
707
|
-
const filtered = ids.filter(id => !deletedIds.has(id));
|
|
708
|
-
if (filtered.length !== ids.length) {
|
|
709
|
-
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
// Resurface rows merged/compressed INTO the doomed keepers before deleting, else
|
|
713
|
-
// they dangle behind a now-missing parent (compressed_into has no FK) — invisible
|
|
714
|
-
// to every COALESCE(compressed_into,0)=0 view and unrecoverable. Mirrors the CLI
|
|
715
|
-
// delete path + the maintain hard-delete guard (recoverChildrenOf).
|
|
716
|
-
const recovered = recoverChildrenOf(db, args.ids);
|
|
717
|
-
// Execute deletion (FTS5 cleanup handled by observations_ad trigger)
|
|
718
|
-
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...args.ids);
|
|
719
|
-
return { changes: deleted.changes, recovered };
|
|
720
|
-
});
|
|
721
|
-
const result = deleteTx();
|
|
684
|
+
// Full delete orchestration (snapshot + related_ids cleanup + child recovery +
|
|
685
|
+
// delete transaction) lives in lib/delete-core.mjs — single source of truth shared
|
|
686
|
+
// with the CLI `delete` path (was inlined + kept in sync by parity comments).
|
|
687
|
+
const result = deleteObservations(db, args.ids);
|
|
722
688
|
|
|
723
689
|
const missing = args.ids.filter(id => !rows.some(r => r.id === id));
|
|
724
|
-
const msg = [`Deleted ${result.
|
|
725
|
-
if (result.
|
|
690
|
+
const msg = [`Deleted ${result.deleted} observation(s).`];
|
|
691
|
+
if (result.recoveredChildren > 0) msg.push(`Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`);
|
|
726
692
|
if (missing.length > 0) msg.push(`Note: ID(s) ${missing.join(', ')} not found.`);
|
|
727
693
|
return { content: [{ type: 'text', text: msg.join(' ') }] };
|
|
728
694
|
})
|
package/source-files.mjs
CHANGED
|
@@ -152,6 +152,16 @@ export const SOURCE_FILES = [
|
|
|
152
152
|
// server.mjs, and hook.mjs before their destructive purge/cleanup — missing it
|
|
153
153
|
// would crash maintain on auto-update with an unresolved import.
|
|
154
154
|
'lib/db-backup.mjs',
|
|
155
|
+
// HIGH-1 events-injection: surfaces the `events` canonical store into the passive
|
|
156
|
+
// injection surfaces. Statically imported by hook.mjs (UserPromptSubmit) and
|
|
157
|
+
// hook-context.mjs (SessionStart) — missing it from the manifest would break both
|
|
158
|
+
// hooks on auto-update.
|
|
159
|
+
'lib/events-injection.mjs',
|
|
160
|
+
// Shared delete orchestration (snapshot + related_ids cleanup + child recovery
|
|
161
|
+
// + delete txn). Statically imported by server.mjs (mem_delete) and mem-cli.mjs
|
|
162
|
+
// (cmdDelete) — extracted to kill the byte-duplicated twin. Missing it from the
|
|
163
|
+
// manifest would leave both delete surfaces unsigned/broken on auto-update.
|
|
164
|
+
'lib/delete-core.mjs',
|
|
155
165
|
// P10 dedup/merge threshold constants — single source of truth for the Jaccard
|
|
156
166
|
// dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
|
|
157
167
|
// hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
|