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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.35.1",
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.35.1",
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
- const cmd = (input.command || '').toLowerCase();
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
- if (i === 0) return searchFn(db, v, params) || [];
458
- try {
459
- return searchFn(db, v, params) || [];
460
- } catch {
461
- return [];
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
- const head = ` #${r.id} [${r.type}] ${truncate(r.title, 60)}`;
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
- const cost = estimateTokens((o.title || '') + (o.narrative || ''));
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('\n');
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
- if (files.length > 0) lines.push('## Key Files', files.map(f => basename(f)).join(', '), '');
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
- const lessonTag = obs.lesson_learned ? ` | Lesson: ${obs.lesson_learned}` : '';
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
- // Sweep stale `ep-flush-*` and `pending-*` files in `runtimeDir` whose mtime
79
- // is older than `ageMs` (default 1h). Returns the number of files removed.
80
- // fs-only no DB / no network. Used by handleSessionStart auto-maintain to
81
- // prevent the doctor "Stale temp files" warning from accumulating across
82
- // crashes; equivalent to the manual path in `node install.mjs cleanup` but
83
- // age-gated so concurrent in-flight workers are never raced.
84
- export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, now = Date.now() } = {}) {
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
- if (!(f.startsWith('ep-flush-') || f.startsWith('pending-'))) continue;
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 BEFORE writing the flush
122
- // file that file has no consumer, so this is the only thing that prevents
123
- // the in-flight episode being lost on abnormal termination (audit #6).
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
- const isSignificant = episodeHasSignificantContent(episode);
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
- // Immediate save: create rule-based observation for instant visibility.
168
- // LLM background worker will upgrade title/narrative/importance later.
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(episode);
172
- const id = saveObservation(obs, episode.project, episode.sessionId);
173
- if (id) episode.savedId = 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(episode));
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
- // Immediate save: persist rule-based observation to DB before spawning background worker.
441
- // Without this, data is lost if the background worker fails.
442
- try {
443
- const obs = buildImmediateObservation(episode);
444
- const id = saveObservation(obs, episode.project, episode.sessionId);
445
- if (id) episode.savedId = id;
446
- } catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
447
- const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
448
- writeFileSync(flushFile, JSON.stringify(episode));
449
- spawnBackground('llm-episode', flushFile);
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
- const tarballUrl = 'https://api.github.com/repos/sdsrss/claude-mem-lite/tarball';
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('Downloading latest release tarball...');
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
- fail('Tarball missing install.mjs repair aborted');
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('Repair complete — broken install resynced from latest release');
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(' Manual fallback run this in any shell:');
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('');