claude-mem-lite 3.35.2 → 3.37.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.2",
13
+ "version": "3.37.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.2",
3
+ "version": "3.37.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/haiku-client.mjs CHANGED
@@ -421,9 +421,12 @@ function callModelCLI(prompt, model, { timeout }) {
421
421
  return text ? { text } : null;
422
422
  } catch (e) {
423
423
  const out = e.stdout?.toString?.()?.trim() || e.output?.[1]?.toString?.()?.trim();
424
- if (out && out.startsWith('{') && out.endsWith('}')) {
425
- try { JSON.parse(out); return { text: out }; } catch {}
426
- }
424
+ // Salvage a complete JSON payload from partial stdout on timeout. Haiku almost
425
+ // always wraps JSON in ```json fences (#8605), so a raw brace check rejects a
426
+ // complete-but-fenced buffer and the already-emitted JSON is discarded.
427
+ // parseJsonFromLLM strips fences before validating; return the raw text (the
428
+ // caller re-parses it identically) only when JSON is actually recoverable.
429
+ if (out && parseJsonFromLLM(out) !== null) return { text: out };
427
430
  debugCatch(e, `${model}-cli`);
428
431
  return null;
429
432
  }
@@ -471,9 +474,11 @@ export function callModelCLIAsync(prompt, model, { timeout }) {
471
474
  const timer = setTimeout(() => {
472
475
  try { child.kill('SIGKILL'); } catch { /* already gone */ }
473
476
  const t = stdout.trim();
474
- if (t.startsWith('{') && t.endsWith('}')) {
475
- try { JSON.parse(t); done({ text: t }); return; } catch { /* not complete JSON */ }
476
- }
477
+ // Salvage fenced-or-bare JSON from partial stdout (mirrors callModelCLI). A raw
478
+ // brace check would discard a complete-but-```json-fenced payload (#8605);
479
+ // parseJsonFromLLM strips fences before validating, and the caller re-parses
480
+ // the returned text the same way.
481
+ if (t && parseJsonFromLLM(t) !== null) { done({ text: t }); return; }
477
482
  done(null);
478
483
  }, timeout);
479
484
  child.stdout?.setEncoding('utf8'); // decode multi-byte UTF-8 (CJK) across chunk boundaries
@@ -609,11 +614,12 @@ function callHaikuCLI(prompt, { timeout }) {
609
614
  const text = result.trim();
610
615
  return text ? { text } : null;
611
616
  } catch (e) {
612
- // Try to extract partial output on timeout — validate JSON before returning
617
+ // Try to extract partial output on timeout — validate via parseJsonFromLLM
618
+ // (strips ```json fences per #8605) before returning. A raw brace check would
619
+ // discard a complete-but-fenced payload the caller could still parse, throwing
620
+ // away the JSON Haiku already emitted.
613
621
  const out = e.stdout?.toString?.()?.trim() || e.output?.[1]?.toString?.()?.trim();
614
- if (out && out.startsWith('{') && out.endsWith('}')) {
615
- try { JSON.parse(out); return { text: out }; } catch {}
616
- }
622
+ if (out && parseJsonFromLLM(out) !== null) return { text: out };
617
623
  debugCatch(e, 'haiku-cli');
618
624
  return null;
619
625
  }
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-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-llm.mjs CHANGED
@@ -588,6 +588,58 @@ export function buildImmediateObservation(episode) {
588
588
  };
589
589
  }
590
590
 
591
+ // ─── Haiku extraction recovery helpers ──────────────────────────────────────
592
+
593
+ // Haiku's throwaway lesson sentinels + a min-length floor. A lesson equal to one
594
+ // of these (case-insensitive) or shorter than 12 chars teaches a future session
595
+ // nothing, so it is treated as "no lesson". Single source for the episode gate,
596
+ // the P3 retry check, and the importance=0 discard guard (all three previously
597
+ // duplicated the literal). Every sentinel is <12 chars, so the length floor
598
+ // alone already excludes them — the set guards the exact-match cases.
599
+ const LOW_SIGNAL_LESSON = new Set(['none', '', 'n/a', 'null', 'todo', 'tbd', 'na', '-', 'nothing', 'nil']);
600
+ export function isLowSignalLesson(lesson) {
601
+ const t = typeof lesson === 'string' ? lesson.trim() : '';
602
+ return LOW_SIGNAL_LESSON.has(t.toLowerCase()) || t.length < 12;
603
+ }
604
+
605
+ // Haiku sometimes wraps the observation object in an envelope that
606
+ // parseJsonFromLLM preserves verbatim (it strips ```json fences but does not
607
+ // unwrap structure): a single-element array `[{...}]`, or a single object-valued
608
+ // key such as `{"observation":{...}}`. Peel one such layer so the enrichment
609
+ // fields (title / lesson_learned / narrative / facts) are reachable by the gate
610
+ // in handleLLMEpisode. Returns the inner object, or `parsed` unchanged when it is
611
+ // not a recognized envelope (incl. multi-element arrays — ambiguous, left for the
612
+ // degraded fallback). Only unwraps when there is no usable top-level title, so a
613
+ // legitimate `{title, ...}` observation is never disturbed.
614
+ export function unwrapObservationEnvelope(parsed) {
615
+ if (Array.isArray(parsed)) {
616
+ const [only] = parsed;
617
+ return parsed.length === 1 && only && typeof only === 'object' && !Array.isArray(only) ? only : parsed;
618
+ }
619
+ if (parsed && typeof parsed === 'object' && typeof parsed.title !== 'string') {
620
+ const keys = Object.keys(parsed);
621
+ if (keys.length === 1) {
622
+ const inner = parsed[keys[0]];
623
+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner;
624
+ }
625
+ }
626
+ return parsed;
627
+ }
628
+
629
+ // True when a parsed Haiku object carries content worth preserving even if its
630
+ // title is unusable: a substantive lesson (not a low-signal sentinel / too short),
631
+ // a non-empty narrative, or >=1 non-empty fact. Gates the title-recovery path in
632
+ // handleLLMEpisode so a genuinely empty parse still falls through to the
633
+ // episode-inferred degraded observation (which classifies type/importance better
634
+ // than a forced 'change' would).
635
+ export function hasEnrichmentContent(parsed) {
636
+ if (!parsed || typeof parsed !== 'object') return false;
637
+ if (typeof parsed.lesson_learned === 'string' && !isLowSignalLesson(parsed.lesson_learned)) return true;
638
+ if (typeof parsed.narrative === 'string' && parsed.narrative.trim().length > 0) return true;
639
+ if (Array.isArray(parsed.facts) && parsed.facts.some(f => typeof f === 'string' && f.trim().length > 0)) return true;
640
+ return false;
641
+ }
642
+
591
643
  // ─── Lesson retry prompt (P3) ───────────────────────────────────────────────
592
644
 
593
645
  /**
@@ -712,14 +764,36 @@ ${actionList}`;
712
764
  releaseLLMSlot();
713
765
  }
714
766
 
715
- // Require a STRING title: a truthy non-string (LLM returned title as an array/number/
716
- // object) would pass a bare `parsed.title` check, then crash truncate() downstream,
717
- // aborting the worker before tmpFile cleanup (leak) and leaving the obs degraded.
718
- if (parsed && typeof parsed.title === 'string' && parsed.title) {
767
+ // Recover from common Haiku envelope shapes before the title gate: a single-
768
+ // element array `[{...}]` or a single object-valued wrapper key
769
+ // `{"observation":{...}}`. parseJsonFromLLM strips fences but does NOT peel
770
+ // these, so the payload (title AND lesson) sits one level down. See
771
+ // unwrapObservationEnvelope.
772
+ if (parsed && typeof parsed === 'object') parsed = unwrapObservationEnvelope(parsed);
773
+
774
+ // Enter enrichment whenever Haiku returned a usable object — even if its TITLE
775
+ // is missing/empty/non-string. The gate was previously `typeof parsed.title ===
776
+ // 'string' && parsed.title` (guarding a truncate() crash on non-string titles),
777
+ // so a valid extraction with a bad title silently discarded Haiku's
778
+ // lesson_learned / narrative / facts: `obs` stayed undefined and control fell to
779
+ // the degraded fallback. Now, when there is substantive content but no usable
780
+ // title, degrade ONLY the title (buildDegradedTitle) and keep the lesson. A parse
781
+ // with neither a usable title nor content still falls through to
782
+ // buildImmediateObservation, which infers type/importance from the episode.
783
+ const titleUsable = parsed && typeof parsed.title === 'string' && !!parsed.title;
784
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && (titleUsable || hasEnrichmentContent(parsed))) {
785
+ // Synthesize a rule-based title when Haiku's is unusable (crash-safe, and the
786
+ // lesson survives). Only the title degrades; every other field is kept.
787
+ if (!titleUsable) parsed.title = buildDegradedTitle(episode);
719
788
  // Normalize narrative to a string too — same non-string crash risk in truncate().
720
789
  if (typeof parsed.narrative !== 'string') parsed.narrative = '';
721
- // Discard if LLM judges observation has no learning value
722
- if (parsed.importance === 0 || parsed.importance === '0') {
790
+ const rawLesson = typeof parsed.lesson_learned === 'string' ? parsed.lesson_learned.trim() : '';
791
+ // Discard if LLM judges observation has no learning value — UNLESS it co-emitted
792
+ // a substantive lesson. importance=0 + a real lesson is contradictory (Haiku
793
+ // flags "zero value" yet still teaches something); the lesson is the
794
+ // higher-value signal, so keep the row (importance clamps to >=1 downstream)
795
+ // rather than deleting it. Only the genuinely empty case is dropped.
796
+ if ((parsed.importance === 0 || parsed.importance === '0') && isLowSignalLesson(rawLesson)) {
723
797
  debugLog('DEBUG', 'llm-episode', `Discarded low-value observation: ${parsed.title}`);
724
798
  // If pre-saved, delete it too
725
799
  if (episode.savedId) {
@@ -740,9 +814,7 @@ ${actionList}`;
740
814
  // When filtered, downgrade importance to 0 so rule-based fallback in
741
815
  // hook.mjs:saveObservation writes the obs but hook queries (which all
742
816
  // require importance >= 1) ignore it.
743
- const rawLesson = typeof parsed.lesson_learned === 'string' ? parsed.lesson_learned.trim() : '';
744
- const lowSignalLesson = new Set(['none', '', 'n/a', 'null', 'todo', 'tbd', 'na', '-', 'nothing', 'nil']);
745
- const isLessonLowSignal = lowSignalLesson.has(rawLesson.toLowerCase()) || rawLesson.length < 12;
817
+ const isLessonLowSignal = isLowSignalLesson(rawLesson);
746
818
  let lessonLearned = isLessonLowSignal ? null : rawLesson.slice(0, 500);
747
819
 
748
820
  // P3: for bugfix/decision, retry once with a lesson-focused prompt.
@@ -768,7 +840,7 @@ ${actionList}`;
768
840
  if (retryRaw) {
769
841
  const retry = parseJsonFromLLM(retryRaw);
770
842
  const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
771
- const retryIsLow = lowSignalLesson.has(retryLesson.toLowerCase()) || retryLesson.length < 12;
843
+ const retryIsLow = isLowSignalLesson(retryLesson);
772
844
  if (!retryIsLow) {
773
845
  lessonLearned = retryLesson.slice(0, 500);
774
846
  retryRecovered = true;
@@ -1044,7 +1116,28 @@ ${obsList}`;
1044
1116
  releaseLLMSlot();
1045
1117
  }
1046
1118
 
1047
- if (llmParsed && llmParsed.request) {
1119
+ // Coerce a prose field to a string before scrub/bind. Haiku sometimes returns a LIST for
1120
+ // completed/next_steps/remaining_items; binding a non-string straight to SQL throws
1121
+ // ("Too many parameter values") out of this try/finally and drops the WHOLE summary incl.
1122
+ // lessons + key_decisions. Join array items; non-strings → ''. (lessons/key_decisions are
1123
+ // JSON.stringify'd separately below.)
1124
+ const asText = v => Array.isArray(v)
1125
+ ? v.filter(x => typeof x === 'string' && x.trim()).join('; ')
1126
+ : (typeof v === 'string' ? v : '');
1127
+
1128
+ // Persist when ANY meaningful field is present — not just `request`. Gating on `request`
1129
+ // alone dropped the whole INSERT/UPDATE (losing the session's highest-value fields:
1130
+ // lessons + key_decisions) whenever Haiku returned an empty request string but a rich
1131
+ // `{completed, lessons, key_decisions}` — a common degraded shape. Downstream tolerates an
1132
+ // empty request: INSERT writes '' and the UPDATE COALESCE(NULLIF(?, ''), request) preserves
1133
+ // the prior value. Use asText in the gate so a non-string / empty-array field can't falsely
1134
+ // trigger it.
1135
+ const hasSummaryContent = llmParsed && (
1136
+ asText(llmParsed.request) || asText(llmParsed.completed) || asText(llmParsed.remaining_items) || asText(llmParsed.next_steps) ||
1137
+ (Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0) ||
1138
+ (Array.isArray(llmParsed.key_decisions) && llmParsed.key_decisions.length > 0)
1139
+ );
1140
+ if (hasSummaryContent) {
1048
1141
  const now = new Date();
1049
1142
  const lessonsJson = Array.isArray(llmParsed.lessons) && llmParsed.lessons.length > 0
1050
1143
  ? JSON.stringify(llmParsed.lessons) : null;
@@ -1072,12 +1165,12 @@ ${obsList}`;
1072
1165
  // pre-scrub remains safer in principle but would diverge from the
1073
1166
  // merged INSERT contract.
1074
1167
  const safe = scrubRecord('session_summaries', {
1075
- request: llmParsed.request || '',
1076
- investigated: llmParsed.investigated || '',
1077
- learned: llmParsed.learned || '',
1078
- completed: llmParsed.completed || '',
1079
- next_steps: llmParsed.next_steps || '',
1080
- remaining_items: llmParsed.remaining_items || '',
1168
+ request: asText(llmParsed.request),
1169
+ investigated: asText(llmParsed.investigated),
1170
+ learned: asText(llmParsed.learned),
1171
+ completed: asText(llmParsed.completed),
1172
+ next_steps: asText(llmParsed.next_steps),
1173
+ remaining_items: asText(llmParsed.remaining_items),
1081
1174
  lessons: lessonsJson,
1082
1175
  key_decisions: decisionsJson,
1083
1176
  });
@@ -1105,12 +1198,12 @@ ${obsList}`;
1105
1198
  );
1106
1199
  } else {
1107
1200
  const safe = scrubRecord('session_summaries', {
1108
- request: llmParsed.request || '',
1109
- investigated: llmParsed.investigated || '',
1110
- learned: llmParsed.learned || '',
1111
- completed: llmParsed.completed || '',
1112
- next_steps: llmParsed.next_steps || '',
1113
- remaining_items: llmParsed.remaining_items || '',
1201
+ request: asText(llmParsed.request),
1202
+ investigated: asText(llmParsed.investigated),
1203
+ learned: asText(llmParsed.learned),
1204
+ completed: asText(llmParsed.completed),
1205
+ next_steps: asText(llmParsed.next_steps),
1206
+ remaining_items: asText(llmParsed.remaining_items),
1114
1207
  lessons: lessonsJson,
1115
1208
  key_decisions: decisionsJson,
1116
1209
  });
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
@@ -81,7 +81,7 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
81
81
  const projectClause = project ? 'AND project = ?' : '';
82
82
  if (scope === 'wide') {
83
83
  const stmt = db.prepare(`
84
- SELECT id, title, narrative, type, subtitle, concepts, facts, project
84
+ SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, project
85
85
  FROM observations
86
86
  WHERE COALESCE(compressed_into, 0) = 0
87
87
  AND superseded_at IS NULL
@@ -140,7 +140,13 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
140
140
  const parsed = await callModelJSON(prompt, 'haiku', { timeout: 15000, maxTokens: 500 });
141
141
  if (!parsed || !parsed.title) { skipped++; continue; }
142
142
 
143
- if (parsed.importance === 0 || parsed.importance === '0') {
143
+ // Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
144
+ // the wide-scope widening). A wide candidate has a substantive narrative (>100 chars)
145
+ // and a real bugfix/feature/decision type by construction, and COMPRESSED_AUTO(-1) is
146
+ // reachable by no auto-recovery pass — so one Haiku "importance 0" misjudgment would
147
+ // hide a real observation until manual surgery. In wide scope, fall through and let
148
+ // clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
149
+ if ((parsed.importance === 0 || parsed.importance === '0') && scope !== 'wide') {
144
150
  db.prepare(`UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ? WHERE id = ?`)
145
151
  .run(Date.now(), cand.id);
146
152
  processed++;
@@ -150,8 +156,14 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
150
156
  const type = validTypes.has(parsed.type) ? parsed.type : cand.type || 'change';
151
157
  const concepts = Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [];
152
158
  const facts = Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [];
153
- const conceptsText = concepts.join(' ');
154
- const factsText = facts.join(' ');
159
+ // Preserve-on-empty: wide-scope candidates can already carry concepts/facts/aliases
160
+ // (findReenrichCandidates requires only lesson_learned empty), so a partial re-enrich
161
+ // that returns a lesson but omits/empties these must NOT wipe them — the same UPDATE
162
+ // sets optimized_at, locking the row out of any future re-enrich (:88), so the loss is
163
+ // permanent. Keep the candidate's existing value when the LLM returned nothing. (Narrow
164
+ // candidates are all-null on these by their WHERE, so cand.* is falsy → no-op there.)
165
+ const conceptsText = concepts.length ? concepts.join(' ') : (cand.concepts || '');
166
+ const factsText = facts.length ? facts.join(' ') : (cand.facts || '');
155
167
  // Scrub BEFORE truncate so a secret straddling the cut can't leave a sub-6-char
156
168
  // head that scrubSecrets's value-length floor no longer matches (the scrubRecord
157
169
  // below would then miss it too). Mirrors the hook-llm save-path fix.
@@ -159,8 +171,8 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
159
171
  && parsed.lesson_learned.toLowerCase() !== 'none'
160
172
  && parsed.lesson_learned.trim().length > 0
161
173
  ? scrubSecrets(parsed.lesson_learned).slice(0, 500) : null;
162
- const searchAliases = Array.isArray(parsed.search_aliases)
163
- ? parsed.search_aliases.slice(0, 6).join(' ') : null;
174
+ const searchAliases = Array.isArray(parsed.search_aliases) && parsed.search_aliases.length
175
+ ? parsed.search_aliases.slice(0, 6).join(' ') : (cand.search_aliases || null);
164
176
  const title = truncate(scrubSecrets(parsed.title || ''), 120);
165
177
  const narrative = truncate(scrubSecrets(parsed.narrative || cand.narrative || ''), 500);
166
178
  const importance = clampImportance(parsed.importance);
@@ -361,9 +373,10 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
361
373
  const cutoff = Date.now() - MERGE_TIME_WINDOW_MS;
362
374
  const projectClause = project ? 'AND project = ?' : '';
363
375
  const stmt = db.prepare(`
364
- SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig
376
+ SELECT id, title, narrative, project, type, access_count, importance, created_at_epoch, minhash_sig, lesson_learned
365
377
  FROM observations
366
378
  WHERE COALESCE(compressed_into, 0) = 0
379
+ AND superseded_at IS NULL
367
380
  AND optimized_at IS NULL
368
381
  AND title IS NOT NULL AND title != ''
369
382
  AND created_at_epoch > ?
@@ -453,9 +466,25 @@ Return ONLY valid JSON:
453
466
  // already-scrubbed text so a straddling secret can't leak a sub-floor head.
454
467
  const title = truncate(scrubSecrets(parsed.merged_title || ''), 120);
455
468
  const narrative = truncate(scrubSecrets(parsed.merged_narrative || ''), 800);
456
- const lessonLearned = typeof parsed.merged_lesson === 'string'
469
+ // Preserve-on-empty. The merge overwrites the keeper in place and hides every non-keeper
470
+ // member (compressed_into=keeper.id), so if the LLM returns merged_lesson:null (the prompt
471
+ // at :429 explicitly permits it) every cluster lesson would leave all live surfaces at once
472
+ // with no auto-recovery — the keeper snapshot and hidden members sit at compressed_into>0,
473
+ // which recoverBuriedLessons (compressed_into=0 only) skips. So use the LLM's synthesized
474
+ // lesson when non-empty, else fall back to the union of the members' own non-empty lessons.
475
+ // findMergeCandidates filters superseded_at IS NULL, so the union pulls only LIVE members
476
+ // (a tombstoned/retired lesson can't resurrect onto the keeper). The union is scrubbed then
477
+ // capped at 500 chars like a single lesson, so an unusually long union may truncate trailing
478
+ // members — still strictly better than the prior unconditional null (partial > total loss).
479
+ let lessonLearned = typeof parsed.merged_lesson === 'string'
457
480
  && parsed.merged_lesson.trim().length > 0
458
481
  ? scrubSecrets(parsed.merged_lesson).slice(0, 500) : null;
482
+ if (!lessonLearned) {
483
+ const memberLessons = [...new Set(cluster
484
+ .map(o => (o.lesson_learned || '').trim())
485
+ .filter(l => l && l.toLowerCase() !== 'none'))];
486
+ if (memberLessons.length) lessonLearned = scrubSecrets(memberLessons.join(' — ')).slice(0, 500);
487
+ }
459
488
 
460
489
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
461
490
  const textField = [conceptsText, factsText, bigramText].filter(Boolean).join(' ');
@@ -539,6 +568,14 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
539
568
  WHERE COALESCE(compressed_into, 0) = 0
540
569
  AND COALESCE(importance, 1) = 1
541
570
  AND COALESCE(access_count, 0) = 0
571
+ -- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
572
+ -- on the originals (line ~693), which hides them from every injection/search
573
+ -- surface AND puts them out of recoverBuriedLessons' reach (it only lifts
574
+ -- compressed_into=0). A lesson demoted to imp=1 by citation-decay would be
575
+ -- silently buried by the unattended 24h llm-optimize run. Exact parity with the
576
+ -- canonical compress sibling selectCompressionCandidates (compress-core.mjs) —
577
+ -- "lessons never auto-GC" (also enforced in decayAndMarkIdle, maintain-core.mjs).
578
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
542
579
  AND created_at_epoch < ?
543
580
  ${projectClause}
544
581
  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');