claude-mem-lite 3.35.1 → 3.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bash-utils.mjs +7 -1
- package/deep-search.mjs +13 -6
- package/format-utils.mjs +6 -2
- package/hook-context.mjs +12 -1
- package/hook-episode.mjs +33 -0
- package/hook-handoff.mjs +9 -3
- package/hook-memory.mjs +8 -2
- package/hook-optimize.mjs +8 -0
- package/hook-shared.mjs +22 -9
- package/hook-update.mjs +5 -2
- package/hook.mjs +112 -72
- package/install.mjs +33 -11
- package/lib/atomic-write.mjs +19 -8
- package/lib/deferred-work.mjs +3 -1
- package/lib/doctor-benchmark.mjs +6 -1
- package/lib/maintain-core.mjs +12 -0
- package/lib/task-imperative.mjs +4 -2
- package/mem-cli.mjs +39 -11
- package/package.json +1 -2
- package/registry-recommend.mjs +55 -14
- package/registry.mjs +19 -7
- package/schema.mjs +8 -1
- package/scripts/post-tool-use.sh +10 -1
- package/search-engine.mjs +5 -1
- package/search-scoring.mjs +10 -0
- package/source-files.mjs +1 -1
- package/tool-schemas.mjs +2 -2
- package/registry-indexer.mjs +0 -175
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.36.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.36.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/bash-utils.mjs
CHANGED
|
@@ -53,7 +53,13 @@ function isExcludedPath(p) {
|
|
|
53
53
|
* @returns {{isError: boolean, isTest: boolean, isBuild: boolean, isGit: boolean, isDeploy: boolean, isSignificant: boolean}}
|
|
54
54
|
*/
|
|
55
55
|
export function detectBashSignificance(input, response) {
|
|
56
|
-
|
|
56
|
+
// Coerce command to a string at the source. A malformed PostToolUse payload can
|
|
57
|
+
// hand us a non-string `command` (object/number); `(input.command||'').toLowerCase()`
|
|
58
|
+
// then threw, and this is called UNGUARDED in the hottest hook path (hook.mjs:309) —
|
|
59
|
+
// the throw propagated to main()'s exit(0), dropping the ENTIRE tool event (no episode
|
|
60
|
+
// entry, not even a pending file). A non-string command has nothing to analyze, so
|
|
61
|
+
// degrading to '' (no significance) is the correct, event-preserving fallback.
|
|
62
|
+
const cmd = (typeof input?.command === 'string' ? input.command : '').toLowerCase();
|
|
57
63
|
// Skip error keyword matching only when the PRIMARY command is a read/search op (its
|
|
58
64
|
// output naturally contains "error"-like keywords that aren't failures). Anchored on the
|
|
59
65
|
// primary command — NOT "search verb appears anywhere" — so `npm run build 2>&1 | tail`
|
package/deep-search.mjs
CHANGED
|
@@ -454,12 +454,19 @@ export async function deepSearch(db, params, { llm, searchFn = defaultSearchFn,
|
|
|
454
454
|
// it does on the single-query baseline path, so "never worse than baseline"
|
|
455
455
|
// holds in the error dimension too — a DB failure must not be silently
|
|
456
456
|
// swallowed into an empty result (F5). Only rewrite variants are best-effort.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
457
|
+
let list;
|
|
458
|
+
if (i === 0) list = searchFn(db, v, params) || [];
|
|
459
|
+
else { try { list = searchFn(db, v, params) || []; } catch { list = []; } }
|
|
460
|
+
// rrfFuseN fuses by array index as rank, so each list MUST already be in
|
|
461
|
+
// composite-score order. searchObservationsHybrid appends downweighted
|
|
462
|
+
// concept(×0.7)/PRF(×0.6) expansion rows to the TAIL unsorted and, on the
|
|
463
|
+
// vectors-disabled path, returns BEFORE the sort its vector arm applies
|
|
464
|
+
// (search-engine.mjs:430) — so a sparse variant (common in deep search, the
|
|
465
|
+
// vocabulary-mismatch path) would hand a tail-ranked expansion row to RRF at a
|
|
466
|
+
// worse rank than its score earns. Sort so index == composite rank, mirroring
|
|
467
|
+
// the in-engine sort that already guards the vector-RRF merge.
|
|
468
|
+
list.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
469
|
+
return list;
|
|
463
470
|
});
|
|
464
471
|
|
|
465
472
|
const fused = rrfFuseN(lists, rrfK);
|
package/format-utils.mjs
CHANGED
|
@@ -74,10 +74,14 @@ export function neutralizeContextDelimiters(s) {
|
|
|
74
74
|
export function formatErrorRecallHints(rows) {
|
|
75
75
|
if (!rows || rows.length === 0) return '';
|
|
76
76
|
const lines = rows.map((r, i) => {
|
|
77
|
-
|
|
77
|
+
// Defang title + lesson: this block is written to PostToolUse stdout \u2192 model context,
|
|
78
|
+
// and observations are stored raw (defense is at the injection boundary, not at save),
|
|
79
|
+
// so a poisoned lesson carrying a forged <system-reminder>/tool tag would inject here
|
|
80
|
+
// un-neutralized. Same guard the handoff render applies to its replayed fields.
|
|
81
|
+
const head = ` #${r.id} [${r.type}] ${neutralizeContextDelimiters(truncate(r.title, 60))}`;
|
|
78
82
|
// Inline the lesson body for the single most-relevant hit only (bounded payload).
|
|
79
83
|
if (i === 0 && typeof r.lesson_learned === 'string' && r.lesson_learned.trim()) {
|
|
80
|
-
return `${head} \u2014 ${truncate(r.lesson_learned.trim(), 200)}`;
|
|
84
|
+
return `${head} \u2014 ${neutralizeContextDelimiters(truncate(r.lesson_learned.trim(), 200))}`;
|
|
81
85
|
}
|
|
82
86
|
return head;
|
|
83
87
|
});
|
package/hook-context.mjs
CHANGED
|
@@ -79,6 +79,11 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
79
79
|
SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
|
|
80
80
|
FROM observations
|
|
81
81
|
WHERE project = ? AND COALESCE(compressed_into, 0) = 0
|
|
82
|
+
-- superseded invisibility: auto-dedup (hook.mjs) sets superseded_at but leaves
|
|
83
|
+
-- compressed_into=0, so the compressed filter alone lets the hidden near-duplicate
|
|
84
|
+
-- resurface in the most-visible surface (injected every SessionStart). Sibling
|
|
85
|
+
-- keyObs already filters this; obsPool + fallbackObs must match.
|
|
86
|
+
AND superseded_at IS NULL
|
|
82
87
|
AND ${notLowSignalTitleClause('')}
|
|
83
88
|
AND (
|
|
84
89
|
(created_at_epoch > ? AND importance >= 1)
|
|
@@ -116,7 +121,12 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
116
121
|
const impBoost = 0.5 + 0.5 * (o.importance || 1);
|
|
117
122
|
const lessonBoost = o.lesson_learned ? 1.3 : 1.0;
|
|
118
123
|
const value = recency * typeQuality * impBoost * lessonBoost;
|
|
119
|
-
|
|
124
|
+
// Cost = ONLY what is injected. The Recent table renders title-only (narrative is neither
|
|
125
|
+
// pushed nor emitted), so charging title+narrative made the budget measure ~5x the real
|
|
126
|
+
// injected size — it filled to ~44% of capacity and, worse, the √cost density term penalized
|
|
127
|
+
// long-narrative rows that cost nothing to inject. Title-only cost fills the budget with the
|
|
128
|
+
// rows actually shown and makes valueDensity = value per injected token.
|
|
129
|
+
const cost = estimateTokens(o.title || '');
|
|
120
130
|
return { ...o, value, cost, valueDensity: cost > 0 ? value / Math.sqrt(cost) : 0 };
|
|
121
131
|
});
|
|
122
132
|
|
|
@@ -294,6 +304,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
294
304
|
SELECT id, type, title, project, created_at
|
|
295
305
|
FROM observations
|
|
296
306
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
307
|
+
AND superseded_at IS NULL
|
|
297
308
|
AND (
|
|
298
309
|
(created_at_epoch > ? AND importance >= 1)
|
|
299
310
|
OR (created_at_epoch > ? AND importance >= 2)
|
package/hook-episode.mjs
CHANGED
|
@@ -136,6 +136,39 @@ export function createEpisode(sessionId, project) {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Split an episode's entries by originating CC session so each concurrent
|
|
141
|
+
* session flushes as its own observation. Common path (single session, or all
|
|
142
|
+
* untagged/legacy entries) returns [episode] BY REFERENCE — behavior identical
|
|
143
|
+
* to pre-grouping. When >=2 sessions interleaved in one buffer, returns one
|
|
144
|
+
* sub-episode per session with its own entries + recomputed file union;
|
|
145
|
+
* filesRead (untagged bash reads-file) is inherited by every sub. Each sub
|
|
146
|
+
* resets savedId so it receives its own from its immediate-save (savedId is
|
|
147
|
+
* load-bearing: llm-episode upgrades the pre-saved obs by it).
|
|
148
|
+
* @param {object} episode
|
|
149
|
+
* @returns {object[]}
|
|
150
|
+
*/
|
|
151
|
+
export function planEpisodeFlush(episode) {
|
|
152
|
+
const groups = new Map();
|
|
153
|
+
for (const e of episode.entries) {
|
|
154
|
+
const key = e.ccSession ?? '__none__';
|
|
155
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
156
|
+
groups.get(key).push(e);
|
|
157
|
+
}
|
|
158
|
+
if (groups.size <= 1) return [episode];
|
|
159
|
+
const subs = [];
|
|
160
|
+
for (const [, entries] of groups) {
|
|
161
|
+
subs.push({
|
|
162
|
+
...episode,
|
|
163
|
+
entries,
|
|
164
|
+
files: [...new Set(entries.flatMap(e => e.files || []))],
|
|
165
|
+
filesRead: episode.filesRead,
|
|
166
|
+
savedId: undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return subs;
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
/**
|
|
140
173
|
* Add file paths to an episode's file tracking set (deduped).
|
|
141
174
|
* @param {object} episode The episode to update
|
package/hook-handoff.mjs
CHANGED
|
@@ -136,10 +136,13 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
136
136
|
try {
|
|
137
137
|
const tasks = taskReaderModule.readProjectTasks({ projectPath: process.cwd() });
|
|
138
138
|
if (tasks.length > 0) {
|
|
139
|
+
// Join with the ENTRY separator ('; '), NOT '\n': extractUnfinishedSummary
|
|
140
|
+
// and renderHandoffFromRow split pending work on UNFINISHED_ENTRY_SEP, so a
|
|
141
|
+
// '\n'-join collapsed the whole task list into one unreadable multi-line bullet.
|
|
139
142
|
unfinished = tasks
|
|
140
143
|
.slice(0, 5)
|
|
141
144
|
.map(t => `[${t.status}] ${t.title}`)
|
|
142
|
-
.join(
|
|
145
|
+
.join(UNFINISHED_ENTRY_SEP);
|
|
143
146
|
}
|
|
144
147
|
} catch { /* task reader is best-effort; never block handoff */ }
|
|
145
148
|
}
|
|
@@ -227,7 +230,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
227
230
|
project, type, storedSessionId,
|
|
228
231
|
truncate(safe.working_on, 1000),
|
|
229
232
|
safe.completed,
|
|
230
|
-
safe.unfinished.length > 3000 ? safe.unfinished.slice(0, 2999) + '…' : safe.unfinished,
|
|
233
|
+
safe.unfinished.length > 3000 ? Array.from(safe.unfinished).slice(0, 2999).join('') + '…' : safe.unfinished,
|
|
231
234
|
safeKeyFiles,
|
|
232
235
|
safe.key_decisions,
|
|
233
236
|
safe.match_keywords,
|
|
@@ -441,7 +444,10 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
441
444
|
if (handoff.key_files) {
|
|
442
445
|
try {
|
|
443
446
|
const files = JSON.parse(handoff.key_files);
|
|
444
|
-
|
|
447
|
+
// Defang basenames too: a filename on disk can contain a literal authority tag
|
|
448
|
+
// (Linux allows almost any char but '/'), and this is the one field in this block
|
|
449
|
+
// that was rendered raw while working_on/unfinished/key_decisions all neutralize.
|
|
450
|
+
if (files.length > 0) lines.push('## Key Files', neutralizeContextDelimiters(files.map(f => basename(f)).join(', ')), '');
|
|
445
451
|
} catch {}
|
|
446
452
|
}
|
|
447
453
|
if (handoff.key_decisions) {
|
package/hook-memory.mjs
CHANGED
|
@@ -118,7 +118,12 @@ const STALE_OBS_THRESHOLD_MS = 30 * 86400000;
|
|
|
118
118
|
* @returns {string} `- [type] title[ | Lesson: X] (#id)[ [verify-before-use]]`
|
|
119
119
|
*/
|
|
120
120
|
export function formatMemoryLine(obs) {
|
|
121
|
-
|
|
121
|
+
// truncate (not raw): caps the per-hit token cost (an unbounded lesson bloated every
|
|
122
|
+
// memory-context line) AND collapses newlines — a multi-line lesson pushed the trailing
|
|
123
|
+
// "(#NN)" onto a later physical line that failed the "- [" prefix gate in
|
|
124
|
+
// citation-tracker's UserPromptSubmit extractor, so the obs never entered the
|
|
125
|
+
// citation-decay denominator (its promote/demote loop was silently dead).
|
|
126
|
+
const lessonTag = obs.lesson_learned ? ` | Lesson: ${truncate(obs.lesson_learned, 200)}` : '';
|
|
122
127
|
let staleHint = '';
|
|
123
128
|
if (typeof obs.created_at_epoch === 'number'
|
|
124
129
|
&& Date.now() - obs.created_at_epoch > STALE_OBS_THRESHOLD_MS
|
|
@@ -310,7 +315,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
310
315
|
// Adaptive threshold: scales with corpus size to filter noise.
|
|
311
316
|
// Each result must individually exceed the threshold (not just the top one).
|
|
312
317
|
const obsCount = db.prepare(
|
|
313
|
-
'SELECT COUNT(*) as c FROM observations WHERE project = ? AND COALESCE(compressed_into, 0) = 0',
|
|
318
|
+
'SELECT COUNT(*) as c FROM observations WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL',
|
|
314
319
|
).get(project)?.c || 0;
|
|
315
320
|
const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
|
|
316
321
|
const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
|
|
@@ -422,6 +427,7 @@ export function selectImperativeLesson(db, userPrompt, project, excludeIds = [])
|
|
|
422
427
|
FROM observations
|
|
423
428
|
WHERE project = ?
|
|
424
429
|
AND COALESCE(compressed_into, 0) = 0
|
|
430
|
+
AND superseded_at IS NULL
|
|
425
431
|
AND COALESCE(importance, 1) >= 2
|
|
426
432
|
AND lesson_learned IS NOT NULL
|
|
427
433
|
AND TRIM(lesson_learned) != ''
|
package/hook-optimize.mjs
CHANGED
|
@@ -539,6 +539,14 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
|
|
|
539
539
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
540
540
|
AND COALESCE(importance, 1) = 1
|
|
541
541
|
AND COALESCE(access_count, 0) = 0
|
|
542
|
+
-- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
|
|
543
|
+
-- on the originals (line ~693), which hides them from every injection/search
|
|
544
|
+
-- surface AND puts them out of recoverBuriedLessons' reach (it only lifts
|
|
545
|
+
-- compressed_into=0). A lesson demoted to imp=1 by citation-decay would be
|
|
546
|
+
-- silently buried by the unattended 24h llm-optimize run. Exact parity with the
|
|
547
|
+
-- canonical compress sibling selectCompressionCandidates (compress-core.mjs) —
|
|
548
|
+
-- "lessons never auto-GC" (also enforced in decayAndMarkIdle, maintain-core.mjs).
|
|
549
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
542
550
|
AND created_at_epoch < ?
|
|
543
551
|
${projectClause}
|
|
544
552
|
ORDER BY project, created_at_epoch
|
package/hook-shared.mjs
CHANGED
|
@@ -75,22 +75,35 @@ export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚
|
|
|
75
75
|
// caller will ever pick up, so sweeping them on SessionStart is safe.
|
|
76
76
|
export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
78
|
+
// `reads-<project>.txt` (bash fast-path Read tracker) is consumed by flushEpisode's
|
|
79
|
+
// rename-collect on the next edit-flush, NOT by a background worker — so a project
|
|
80
|
+
// that reads but never triggers an edit-flush leaves it uncollected and unswept, and
|
|
81
|
+
// it grows without bound (the 1h episode threshold is far too eager: a long read-only
|
|
82
|
+
// investigation legitimately appends to it for hours). A dedicated 24h floor sweeps
|
|
83
|
+
// only genuinely-abandoned trackers (no append AND no flush in a day → its paths are
|
|
84
|
+
// stale to any current episode) while leaving every active session's file untouched.
|
|
85
|
+
export const ORPHAN_READS_AGE_MS = 24 * 60 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
// Sweep stale `ep-flush-*` / `pending-*` (older than `ageMs`, default 1h) and
|
|
88
|
+
// `reads-*.txt` (older than `readsAgeMs`, default 24h) files in `runtimeDir` by
|
|
89
|
+
// mtime. Returns the number of files removed. fs-only — no DB / no network. Used by
|
|
90
|
+
// handleSessionStart auto-maintain to prevent the doctor "Stale temp files" warning
|
|
91
|
+
// from accumulating across crashes; equivalent to the manual path in
|
|
92
|
+
// `node install.mjs cleanup` but age-gated so concurrent in-flight workers / active
|
|
93
|
+
// read sessions are never raced.
|
|
94
|
+
export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, now = Date.now() } = {}) {
|
|
85
95
|
let entries;
|
|
86
96
|
try { entries = readdirSync(runtimeDir); } catch { return 0; }
|
|
87
97
|
const cutoff = now - ageMs;
|
|
98
|
+
const readsCutoff = now - readsAgeMs;
|
|
88
99
|
let count = 0;
|
|
89
100
|
for (const f of entries) {
|
|
90
|
-
|
|
101
|
+
const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
|
|
102
|
+
const isReads = f.startsWith('reads-') && f.endsWith('.txt');
|
|
103
|
+
if (!isEpisode && !isReads) continue;
|
|
91
104
|
const full = join(runtimeDir, f);
|
|
92
105
|
try {
|
|
93
|
-
if (statSync(full).mtimeMs < cutoff) {
|
|
106
|
+
if (statSync(full).mtimeMs < (isReads ? readsCutoff : cutoff)) {
|
|
94
107
|
unlinkSync(full);
|
|
95
108
|
count++;
|
|
96
109
|
}
|
package/hook-update.mjs
CHANGED
|
@@ -187,7 +187,7 @@ function shouldCheck(state) {
|
|
|
187
187
|
|
|
188
188
|
// ── GitHub API ─────────────────────────────────────────────
|
|
189
189
|
// Try releases/latest first, fallback to tags (some repos only use tags)
|
|
190
|
-
async function fetchLatestRelease() {
|
|
190
|
+
export async function fetchLatestRelease() {
|
|
191
191
|
const headers = {
|
|
192
192
|
'Accept': 'application/vnd.github+json',
|
|
193
193
|
'User-Agent': 'claude-mem-lite-updater/1.0',
|
|
@@ -235,7 +235,10 @@ async function fetchWithTimeout(url, headers) {
|
|
|
235
235
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
236
236
|
try {
|
|
237
237
|
const res = await fetch(url, { signal: controller.signal, headers });
|
|
238
|
-
if (res.status === 403) {
|
|
238
|
+
if (res.status === 403 || res.status === 429) {
|
|
239
|
+
// 429 = GitHub secondary rate limit (403 = primary). Both must route to the 6h
|
|
240
|
+
// rate-limit backoff, not the 24h transient-failure path — else a 429 defers the
|
|
241
|
+
// next check a full day instead of retrying on the shorter rate-limit cadence.
|
|
239
242
|
const state = readState();
|
|
240
243
|
saveState({ ...state, rateLimited: true });
|
|
241
244
|
debugLog('DEBUG', 'hook-update', 'GitHub API rate limited; will retry on the 6h rate-limit cadence');
|
package/hook.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
readEpisodeRaw, episodeFile,
|
|
33
33
|
acquireLock, releaseLock, readEpisode, writeEpisode,
|
|
34
|
-
createEpisode, addFileToEpisode,
|
|
34
|
+
createEpisode, addFileToEpisode, planEpisodeFlush,
|
|
35
35
|
writePendingEntry, mergePendingEntries, episodeHasSignificantContent,
|
|
36
36
|
} from './hook-episode.mjs';
|
|
37
37
|
import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
|
|
@@ -118,12 +118,13 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
|
118
118
|
try {
|
|
119
119
|
const ep = readEpisodeRaw();
|
|
120
120
|
if (ep && ep.entries && ep.entries.length > 0) {
|
|
121
|
-
// Persist a rule-based observation synchronously
|
|
122
|
-
//
|
|
123
|
-
//
|
|
121
|
+
// Persist a rule-based observation synchronously — the ONLY thing that
|
|
122
|
+
// salvages the in-flight episode on abnormal termination (audit #6). A
|
|
123
|
+
// detached llm-episode child can't be spawned from a dying process, so no
|
|
124
|
+
// ep-flush-* file is written here: it would have NO consumer AND would make
|
|
125
|
+
// every later handleLLMSummary poll the full CLAUDE_MEM_FLUSH_TIMEOUT (~15s)
|
|
126
|
+
// waiting for a file that only the 24h orphan-sweep ever removes.
|
|
124
127
|
saveEpisodeImmediate(ep);
|
|
125
|
-
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
126
|
-
writeFileSync(flushFile, JSON.stringify(ep));
|
|
127
128
|
try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
|
|
128
129
|
}
|
|
129
130
|
} catch {}
|
|
@@ -162,77 +163,104 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
162
163
|
episode.filesRead = episode.filesRead || [];
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
|
|
166
|
+
// Split by CC session so concurrent same-project sessions flush as separate
|
|
167
|
+
// observations. planEpisodeFlush returns [episode] BY REFERENCE for the common
|
|
168
|
+
// single-session (or all-legacy) case → flushEpisodeGroup(episode) is identical
|
|
169
|
+
// to pre-grouping. Two+ interleaved sessions each get their own sub-episode.
|
|
170
|
+
const subs = planEpisodeFlush(episode);
|
|
171
|
+
let anySignificant = false;
|
|
172
|
+
for (const sub of subs) {
|
|
173
|
+
const r = flushEpisodeGroup(sub);
|
|
174
|
+
if (r === 'writefail') {
|
|
175
|
+
// Single-group: preserve the original early return — buffer left un-unlinked
|
|
176
|
+
// for a later retry, no receipt. Multi-group: skip only the failed group and
|
|
177
|
+
// keep the rest. The asymmetry is safe: each group's immediate obs is persisted
|
|
178
|
+
// BEFORE its flush-file write, so re-flushing the whole buffer would re-emit
|
|
179
|
+
// already-saved groups as duplicate observations.
|
|
180
|
+
if (subs.length === 1) return;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (r === 'significant') anySignificant = true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Aggregate receipt over the whole episode, gated exactly as before
|
|
187
|
+
// (isSignificant → anySignificant). v2.33.4: Stop rejects hookSpecificOutput.
|
|
188
|
+
if (anySignificant && RECEIPT_EVENTS.has(hookEventName)) {
|
|
189
|
+
try {
|
|
190
|
+
const entries = episode.entries || [];
|
|
191
|
+
const toolCounts = {};
|
|
192
|
+
for (const e of entries) toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
|
|
193
|
+
const toolSummary = Object.entries(toolCounts)
|
|
194
|
+
.sort((a, b) => b[1] - a[1])
|
|
195
|
+
.slice(0, 3)
|
|
196
|
+
.map(([t, n]) => `${t}×${n}`)
|
|
197
|
+
.join(', ');
|
|
198
|
+
const lines = [`[mem] episode flushed: ${entries.length} entries (${toolSummary})`];
|
|
199
|
+
// v2.83: error→fix nudge lifted to lib/cite-back-hint.mjs::buildUnsavedBugfixHint
|
|
200
|
+
// so the wording (count + "Save now" verb) stays in sync with cite-back.
|
|
201
|
+
const bugfixHint = buildUnsavedBugfixHint(episode);
|
|
202
|
+
if (bugfixHint) lines.push(bugfixHint);
|
|
203
|
+
// v2.81: cite-back hint — fires when this episode edits a file that
|
|
204
|
+
// PreToolUse:Read/Edit nudged earlier in the same session. Precision
|
|
205
|
+
// signal (we know the file was warned about); orthogonal to the
|
|
206
|
+
// bugfix-shape nudge above and may co-fire.
|
|
207
|
+
const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
|
|
208
|
+
if (citeBack) lines.push(citeBack);
|
|
209
|
+
// Trailing newline is REQUIRED: when this receipt flushes at SessionStart
|
|
210
|
+
// (leftover episode after /clear or /compact), the startup dashboard writes a
|
|
211
|
+
// second hookSpecificOutput object right after. Without the '\n' the two land
|
|
212
|
+
// back-to-back as `}{` on one line and Claude Code's line-based JSON parser
|
|
213
|
+
// drops both — losing the episode-flush / cite-back context exactly at the
|
|
214
|
+
// session boundary. Every other hookSpecificOutput write appends '\n'; this
|
|
215
|
+
// was the lone exception.
|
|
216
|
+
process.stdout.write(JSON.stringify({
|
|
217
|
+
suppressOutput: true,
|
|
218
|
+
hookSpecificOutput: {
|
|
219
|
+
hookEventName,
|
|
220
|
+
additionalContext: lines.join('\n'),
|
|
221
|
+
},
|
|
222
|
+
}) + '\n');
|
|
223
|
+
} catch { /* never block on receipt */ }
|
|
224
|
+
}
|
|
166
225
|
|
|
167
|
-
//
|
|
168
|
-
|
|
226
|
+
// Remove episode buffer AFTER spawning background workers to prevent concurrent overwrites
|
|
227
|
+
try { unlinkSync(episodeFile()); } catch {}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Save one episode-shaped object: immediate rule-based observation (if
|
|
231
|
+
// significant) + flush file + llm-episode enrichment spawn. Extracted from
|
|
232
|
+
// flushEpisode so each CC-session slice (from planEpisodeFlush) flushes
|
|
233
|
+
// independently and carries its OWN savedId into its OWN flush file — the
|
|
234
|
+
// llm-episode worker upgrades the pre-saved obs by that id. Returns
|
|
235
|
+
// 'significant' | 'insignificant' | 'writefail'. CLAUDE_MEM_SKIP_EPISODE_LLM
|
|
236
|
+
// suppresses the detached enrichment spawn (test determinism; sibling of
|
|
237
|
+
// CLAUDE_MEM_SKIP_COMPRESS / _OPTIMIZE) — the synchronous immediate obs still lands.
|
|
238
|
+
function flushEpisodeGroup(ep) {
|
|
239
|
+
const isSignificant = episodeHasSignificantContent(ep);
|
|
240
|
+
|
|
241
|
+
// Immediate save: rule-based observation for instant visibility; the LLM
|
|
242
|
+
// background worker upgrades title/narrative/importance later.
|
|
169
243
|
if (isSignificant) {
|
|
170
244
|
try {
|
|
171
|
-
const obs = buildImmediateObservation(
|
|
172
|
-
const id = saveObservation(obs,
|
|
173
|
-
if (id)
|
|
245
|
+
const obs = buildImmediateObservation(ep);
|
|
246
|
+
const id = saveObservation(obs, ep.project, ep.sessionId);
|
|
247
|
+
if (id) ep.savedId = id;
|
|
174
248
|
} catch (e) { debugCatch(e, 'flushEpisode-immediateSave'); }
|
|
175
249
|
}
|
|
176
250
|
|
|
177
|
-
// Write episode to flush file, then remove buffer AFTER spawn to prevent race
|
|
178
251
|
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
179
252
|
try {
|
|
180
|
-
writeFileSync(flushFile, JSON.stringify(
|
|
253
|
+
writeFileSync(flushFile, JSON.stringify(ep));
|
|
181
254
|
} catch {
|
|
182
|
-
return;
|
|
255
|
+
return 'writefail';
|
|
183
256
|
}
|
|
184
257
|
|
|
185
|
-
if (isSignificant) {
|
|
258
|
+
if (isSignificant && !process.env.CLAUDE_MEM_SKIP_EPISODE_LLM) {
|
|
186
259
|
spawnBackground('llm-episode', flushFile);
|
|
187
|
-
|
|
188
|
-
// v2.33.1: structured flush receipt so Claude sees what mem just captured
|
|
189
|
-
// and the legacy error→fix nudge consolidates here. PostToolUse JSON with
|
|
190
|
-
// hookSpecificOutput.additionalContext reliably renders across CC variants;
|
|
191
|
-
// the old plain-text stdout write was invisible on some variants.
|
|
192
|
-
// v2.33.4: Stop event rejects hookSpecificOutput entirely — skip receipt.
|
|
193
|
-
if (RECEIPT_EVENTS.has(hookEventName)) {
|
|
194
|
-
try {
|
|
195
|
-
const entries = episode.entries || [];
|
|
196
|
-
const toolCounts = {};
|
|
197
|
-
for (const e of entries) toolCounts[e.tool] = (toolCounts[e.tool] || 0) + 1;
|
|
198
|
-
const toolSummary = Object.entries(toolCounts)
|
|
199
|
-
.sort((a, b) => b[1] - a[1])
|
|
200
|
-
.slice(0, 3)
|
|
201
|
-
.map(([t, n]) => `${t}×${n}`)
|
|
202
|
-
.join(', ');
|
|
203
|
-
const lines = [`[mem] episode flushed: ${entries.length} entries (${toolSummary})`];
|
|
204
|
-
// v2.83: error→fix nudge lifted to lib/cite-back-hint.mjs::buildUnsavedBugfixHint
|
|
205
|
-
// so the wording (count + "Save now" verb) stays in sync with cite-back.
|
|
206
|
-
const bugfixHint = buildUnsavedBugfixHint(episode);
|
|
207
|
-
if (bugfixHint) lines.push(bugfixHint);
|
|
208
|
-
// v2.81: cite-back hint — fires when this episode edits a file that
|
|
209
|
-
// PreToolUse:Read/Edit nudged earlier in the same session. Precision
|
|
210
|
-
// signal (we know the file was warned about); orthogonal to the
|
|
211
|
-
// bugfix-shape nudge above and may co-fire.
|
|
212
|
-
const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
|
|
213
|
-
if (citeBack) lines.push(citeBack);
|
|
214
|
-
// Trailing newline is REQUIRED: when this receipt flushes at SessionStart
|
|
215
|
-
// (leftover episode after /clear or /compact), the startup dashboard writes a
|
|
216
|
-
// second hookSpecificOutput object right after. Without the '\n' the two land
|
|
217
|
-
// back-to-back as `}{` on one line and Claude Code's line-based JSON parser
|
|
218
|
-
// drops both — losing the episode-flush / cite-back context exactly at the
|
|
219
|
-
// session boundary. Every other hookSpecificOutput write appends '\n'; this
|
|
220
|
-
// was the lone exception.
|
|
221
|
-
process.stdout.write(JSON.stringify({
|
|
222
|
-
suppressOutput: true,
|
|
223
|
-
hookSpecificOutput: {
|
|
224
|
-
hookEventName,
|
|
225
|
-
additionalContext: lines.join('\n'),
|
|
226
|
-
},
|
|
227
|
-
}) + '\n');
|
|
228
|
-
} catch { /* never block on receipt */ }
|
|
229
|
-
}
|
|
230
260
|
} else {
|
|
231
261
|
try { unlinkSync(flushFile); } catch {}
|
|
232
262
|
}
|
|
233
|
-
|
|
234
|
-
// Remove episode buffer AFTER spawning background worker to prevent concurrent overwrites
|
|
235
|
-
try { unlinkSync(episodeFile()); } catch {}
|
|
263
|
+
return isSignificant ? 'significant' : 'insignificant';
|
|
236
264
|
}
|
|
237
265
|
|
|
238
266
|
// ─── PostToolUse Handler ────────────────────────────────────────────────────
|
|
@@ -294,6 +322,10 @@ async function handlePostToolUse() {
|
|
|
294
322
|
isSignificant: EDIT_TOOLS.has(tool_name) ||
|
|
295
323
|
bashSig?.isSignificant || false,
|
|
296
324
|
bashSig: bashSig || null,
|
|
325
|
+
// CC UUID from hook stdin — lets flushEpisode split a buffer shared by
|
|
326
|
+
// concurrent same-project sessions into per-session observations. Null for
|
|
327
|
+
// legacy/stdin-less invocations (→ single __none__ group = old behavior).
|
|
328
|
+
ccSession: hookData.session_id || null,
|
|
297
329
|
};
|
|
298
330
|
|
|
299
331
|
// Episode buffer management (locked to prevent TOCTOU race)
|
|
@@ -437,16 +469,24 @@ async function handleStop() {
|
|
|
437
469
|
if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
|
|
438
470
|
if (!episode.sessionId) episode.sessionId = sessionId;
|
|
439
471
|
if (!episode.project) episode.project = project;
|
|
440
|
-
//
|
|
441
|
-
// Without this,
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
472
|
+
// Split by CC session before saving — parity with flushEpisode's non-contended path
|
|
473
|
+
// (v3.35.2). Without this, the lock-contended fallback re-merged exactly the interleaved
|
|
474
|
+
// concurrent-session buffers v3.35.2 split apart, co-attributing two sessions' work into
|
|
475
|
+
// one garbled observation. planEpisodeFlush returns [episode] by reference for the common
|
|
476
|
+
// single-session case, so this is a no-op there. Immediate-save each group BEFORE its
|
|
477
|
+
// flush-file write (same ordering as flushEpisodeGroup) so a worker crash can't lose it.
|
|
478
|
+
for (const sub of planEpisodeFlush(episode)) {
|
|
479
|
+
if (!sub.sessionId) sub.sessionId = sessionId;
|
|
480
|
+
if (!sub.project) sub.project = project;
|
|
481
|
+
try {
|
|
482
|
+
const obs = buildImmediateObservation(sub);
|
|
483
|
+
const id = saveObservation(obs, sub.project, sub.sessionId);
|
|
484
|
+
if (id) sub.savedId = id;
|
|
485
|
+
} catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
|
|
486
|
+
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
487
|
+
writeFileSync(flushFile, JSON.stringify(sub));
|
|
488
|
+
spawnBackground('llm-episode', flushFile);
|
|
489
|
+
}
|
|
450
490
|
}
|
|
451
491
|
} finally {
|
|
452
492
|
try { unlinkSync(claimFile); } catch {}
|
package/install.mjs
CHANGED
|
@@ -1979,30 +1979,52 @@ async function manualUpdate() {
|
|
|
1979
1979
|
// latest code even when local install.mjs / hook-update.mjs are themselves
|
|
1980
1980
|
// buggy on disk.
|
|
1981
1981
|
async function repair() {
|
|
1982
|
-
console.log('\nclaude-mem-lite repair — re-syncing from latest GitHub release\n');
|
|
1982
|
+
console.log('\nclaude-mem-lite repair — re-syncing from the latest SIGNED GitHub release\n');
|
|
1983
1983
|
const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
|
|
1984
1984
|
try {
|
|
1985
|
-
|
|
1985
|
+
// Resolve the latest RELEASE (tag) and cryptographically VERIFY it before running any
|
|
1986
|
+
// downloaded code — parity with the auto-update path (hook-update.downloadAndInstall).
|
|
1987
|
+
// The old code fetched `/tarball` (default-branch main HEAD, unreleased WIP) and ran its
|
|
1988
|
+
// install.mjs UNVERIFIED, and this path is auto-triggered by hook-launcher on any
|
|
1989
|
+
// ERR_MODULE_NOT_FOUND — so a drifted install silently self-healed onto main, and a
|
|
1990
|
+
// repo/TLS-MITM compromise achieved RCE, bypassing the Ed25519 signed-release control that
|
|
1991
|
+
// the manual `update` path enforces. Lazy import so a missing/broken hook-update dependency
|
|
1992
|
+
// degrades to the manual fallback (fail-closed) rather than to unverified auto-install.
|
|
1993
|
+
let fetchLatestRelease, verifyReleaseAuthenticity;
|
|
1994
|
+
try {
|
|
1995
|
+
({ fetchLatestRelease, verifyReleaseAuthenticity } = await import('./hook-update.mjs'));
|
|
1996
|
+
} catch (e) {
|
|
1997
|
+
throw new Error(`cannot load the verified-update path (${e.message}) — refusing to auto-install unverified code`, { cause: e });
|
|
1998
|
+
}
|
|
1999
|
+
const rel = await fetchLatestRelease();
|
|
2000
|
+
if (!rel || !rel.tarballUrl) throw new Error('could not resolve the latest release (network / rate-limit)');
|
|
2001
|
+
// URL allow-list mirrors hook-update.downloadAndInstall — only github.com tarball URLs.
|
|
2002
|
+
if (!/^https:\/\/(?:api\.)?github\.com\/[a-zA-Z0-9./_-]+$/.test(rel.tarballUrl)) {
|
|
2003
|
+
throw new Error(`refusing suspicious tarball URL: ${rel.tarballUrl}`);
|
|
2004
|
+
}
|
|
1986
2005
|
const tarballPath = join(stagingDir, 'release.tgz');
|
|
1987
|
-
log(
|
|
1988
|
-
execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', tarballUrl, '-o', tarballPath],
|
|
2006
|
+
log(`Downloading release v${rel.version}...`);
|
|
2007
|
+
execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', rel.tarballUrl, '-o', tarballPath],
|
|
1989
2008
|
{ timeout: 60000, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
1990
2009
|
log('Extracting...');
|
|
1991
2010
|
execFileSync('tar', ['xzf', tarballPath, '-C', stagingDir, '--strip-components=1'],
|
|
1992
2011
|
{ timeout: 30000, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
2012
|
+
// Verify the Ed25519 signature BEFORE running the downloaded install.mjs. Fail-closed:
|
|
2013
|
+
// any tampering / missing-signature / fetch-failure aborts to the manual fallback.
|
|
2014
|
+
log('Verifying release signature...');
|
|
2015
|
+
const authentic = await verifyReleaseAuthenticity(stagingDir, rel.assets);
|
|
2016
|
+
if (!authentic.ok) throw new Error(`release signature check failed (${authentic.action})`);
|
|
1993
2017
|
const tarballInstaller = join(stagingDir, 'install.mjs');
|
|
1994
|
-
if (!existsSync(tarballInstaller))
|
|
1995
|
-
|
|
1996
|
-
process.exit(1);
|
|
1997
|
-
}
|
|
1998
|
-
log('Re-running install from freshly-downloaded sources...');
|
|
2018
|
+
if (!existsSync(tarballInstaller)) throw new Error('verified tarball missing install.mjs');
|
|
2019
|
+
log('Re-running install from the verified release sources...');
|
|
1999
2020
|
execFileSync(process.execPath, [tarballInstaller, 'install'],
|
|
2000
2021
|
{ stdio: 'inherit', timeout: 300000 });
|
|
2001
|
-
ok(
|
|
2022
|
+
ok(`Repair complete — resynced from verified release v${rel.version}`);
|
|
2002
2023
|
} catch (e) {
|
|
2003
2024
|
fail(`Repair failed: ${e.message}`);
|
|
2004
2025
|
console.log('');
|
|
2005
|
-
console.log('
|
|
2026
|
+
console.log(' Automatic repair fails closed rather than run unverified code.');
|
|
2027
|
+
console.log(' Manual fallback — run this in any shell (you are choosing to trust it):');
|
|
2006
2028
|
console.log('');
|
|
2007
2029
|
console.log(' T=$(mktemp -d) && curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/tarball | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install');
|
|
2008
2030
|
console.log('');
|