claude-mem-lite 3.36.0 → 3.38.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.36.0",
13
+ "version": "3.38.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.36.0",
3
+ "version": "3.38.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/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-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-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(' ');
package/hook.mjs CHANGED
@@ -124,7 +124,13 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
124
124
  // ep-flush-* file is written here: it would have NO consumer AND would make
125
125
  // every later handleLLMSummary poll the full CLAUDE_MEM_FLUSH_TIMEOUT (~15s)
126
126
  // waiting for a file that only the 24h orphan-sweep ever removes.
127
- saveEpisodeImmediate(ep);
127
+ // Split by CC session first (v3.35.2 parity): the normal flush and the Stop
128
+ // lock-contended fallback both planEpisodeFlush, but this crash path flushed the
129
+ // WHOLE buffer as one observation, co-attributing two interleaved same-project
130
+ // sessions into one garbled row. planEpisodeFlush returns [ep] by reference when
131
+ // there is ≤1 CC session (the common case → identical to before), else one sub
132
+ // per session. Pure/sync → safe inside the signal handler.
133
+ for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub);
128
134
  try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
129
135
  }
130
136
  } catch {}
@@ -525,7 +525,7 @@ export function computeCitationAdoption(db, project) {
525
525
  * decide cited vs uncited and mutate importance/streak/cited_count per spec.
526
526
  *
527
527
  * - cited: importance += 1 (cap 3), cited_count += 1, streak = 0.
528
- * - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 0), streak = 0.
528
+ * - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 1, IMPORTANCE_FLOOR), streak = 0.
529
529
  * - per-(session, obs) idempotent via last_decided_session_id; re-running for
530
530
  * the same session is a no-op (Stop hook may fire more than once).
531
531
  * - cross-project IDs are silently ignored by the WHERE clause.
@@ -568,18 +568,27 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
568
568
  const suppressDemotion = adoption.seen >= ADOPTION_MIN_SEEN && adoption.rate < adoptionThreshold;
569
569
 
570
570
  const selectStmt = db.prepare(
571
- 'SELECT id, importance, uncited_streak, last_decided_session_id FROM observations WHERE id = ? AND project = ?'
571
+ // superseded_at IS NULL: mirror computeCitationAdoption + the 4 injection SELECTs so a
572
+ // row superseded mid-session (injected live, then auto-dedup supersedes it before this
573
+ // decay resolves) is not decayed/streaked/mutated — defense-in-depth parity.
574
+ 'SELECT id, importance, uncited_streak, last_decided_session_id, last_cited_session_id FROM observations WHERE id = ? AND project = ? AND superseded_at IS NULL'
572
575
  );
573
576
  // decay_seen_count (v34) bumps on every resolution branch — gives
574
577
  // citation-stats a denominator that's same-source as cited_count, so the
575
578
  // ratio actually means "cite-rate" instead of mixing decay + UserPromptSubmit.
579
+ // Promote also stamps last_cited_session_id (v41 promote-idempotency key) so a
580
+ // same-session re-fire is a no-op. decay_seen_count is bumped by a PARAM, not a
581
+ // literal +1: a FIRST resolution counts the obs into the denominator (1); a
582
+ // cross-turn LATE upgrade of an already-resolved obs must NOT re-count it (0), else
583
+ // cite-rate reads N/2 for a single injected-then-cited obs instead of N/1.
576
584
  const updatePromote = db.prepare(`
577
585
  UPDATE observations
578
586
  SET importance = MIN(?, importance + 1),
579
587
  cited_count = cited_count + 1,
580
588
  uncited_streak = 0,
581
589
  last_decided_session_id = ?,
582
- decay_seen_count = decay_seen_count + 1
590
+ last_cited_session_id = ?,
591
+ decay_seen_count = decay_seen_count + ?
583
592
  WHERE id = ?
584
593
  `);
585
594
  const updateStreakOnly = db.prepare(`
@@ -616,12 +625,28 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
616
625
  for (const id of injected) {
617
626
  const row = selectStmt.get(id, project);
618
627
  if (!row) continue; // cross-project or deleted
619
- if (row.last_decided_session_id === sessionId) continue; // idempotent skip
620
- touched++;
628
+ const decidedThisSession = row.last_decided_session_id === sessionId;
629
+
621
630
  if (cited.has(id)) {
622
- updatePromote.run(IMPORTANCE_CAP, sessionId, id);
631
+ // Promote path — idempotent on last_cited_session_id, NOT last_decided. This is
632
+ // what lets a citation in a LATER turn upgrade an obs this session already
633
+ // resolved as uncited: the contract is "cite NEXT time you produce user-visible
634
+ // text," which may be several turns after injection. A promote resets
635
+ // uncited_streak and lifts importance, so a same-session demotion that fired at
636
+ // an earlier turn is naturally undone. Re-firing after the promote is a no-op.
637
+ if (row.last_cited_session_id === sessionId) continue; // already promoted this session
638
+ // touched/decay_seen only on the FIRST resolution — a late upgrade re-decides an
639
+ // obs already in the injected denominator, so re-counting it would inflate both
640
+ // decay_seen_count and the funnel's injected_n (cite-rate would read N/2, not N/1).
641
+ const firstResolution = !decidedThisSession;
642
+ updatePromote.run(IMPORTANCE_CAP, sessionId, sessionId, firstResolution ? 1 : 0, id);
623
643
  promoted++;
644
+ if (firstResolution) touched++;
624
645
  } else {
646
+ // Uncited path — idempotent on last_decided_session_id (unchanged): don't
647
+ // re-streak an obs already resolved this session.
648
+ if (decidedThisSession) continue;
649
+ touched++;
625
650
  const nextStreak = (row.uncited_streak || 0) + 1;
626
651
  // Demote only when the streak is up AND the project has demonstrably
627
652
  // adopted citations. A non-adopting project advances the streak (idempotent
@@ -646,9 +671,11 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
646
671
  * R1 — persist one accumulating per-session row of the invocation→cite funnel.
647
672
  * Fed by applyCitationDecay's return: `injectedDelta` = obs RESOLVED this Stop
648
673
  * (touched), `citedDelta` = obs CITED this Stop (promoted). Idempotent against
649
- * Stop multi-fire by construction — a re-fired Stop re-resolves nothing (touched
650
- * is 0 for already-decided obs), so the no-op gate below skips it. A later turn
651
- * that resolves NEW injections accumulates onto the same (project, session) row.
674
+ * Stop multi-fire by construction — a pure re-fire re-resolves nothing (touched=0
675
+ * AND promoted=0), so the (0,0) gate below skips it. A later turn that resolves NEW
676
+ * injections (touched>0) OR lands a cross-turn LATE citation (touched=0, promoted>0)
677
+ * accumulates onto the same (project, session) row — the numerator must accrue even
678
+ * when the denominator doesn't, so the gate skips only when BOTH deltas are 0.
652
679
  *
653
680
  * Unlike the per-obs cited_count/decay_seen_count counters (lifetime-cumulative,
654
681
  * session breakdown lost), this preserves the per-session series that
@@ -664,8 +691,13 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
664
691
  export function recordCitationFunnel(db, project, sessionId, injectedDelta, citedDelta) {
665
692
  if (!db || !project || !sessionId) return;
666
693
  const inj = Number(injectedDelta) || 0;
667
- if (inj <= 0) return; // nothing resolved this run → no row noise
668
694
  const cited = Math.max(0, Number(citedDelta) || 0);
695
+ // Skip only when BOTH deltas are 0: a pure cross-turn late upgrade contributes
696
+ // injectedDelta=0 (the obs was already counted at its first resolution) but
697
+ // citedDelta>0, and that numerator must still fold onto the existing session row
698
+ // (a late upgrade always has a prior first-resolution row). Pre-v41 the `inj<=0`
699
+ // gate silently dropped it, under-counting cites in the funnel.
700
+ if (inj <= 0 && cited <= 0) return; // nothing resolved this run → no row noise
669
701
  try {
670
702
  db.prepare(`
671
703
  INSERT INTO citation_log (project, memory_session_id, resolved_at, injected_n, cited_n)
package/mem-cli.mjs CHANGED
@@ -605,21 +605,17 @@ function cmdTimeline(db, args) {
605
605
  // (nlp.mjs string ops on a boolean). No sensible default for a search anchor — reject
606
606
  // cleanly (#8470). (`--project` bare is absorbed by resolveProject's non-string guard.)
607
607
  if (rejectBareStringFlags(flags, ['query'])) return;
608
- // parseInt('-5') === -5 is truthy, so `|| 5` doesn't rescue negative input.
609
- // Match cmdSearch's warn-then-default pattern for consistency across CLI flags.
610
- const parseWindow = (label, raw) => {
611
- if (raw === undefined) return 5;
612
- const n = parseInt(raw, 10);
613
- // isNumericToken first: "2abc"→2 / "1e2"→1 are non-negative integers the bare check
614
- // accepted silently; reject garbage tokens like the negative path already does.
615
- if (!isNumericToken(raw) || !Number.isInteger(n) || n < 0) {
616
- process.stderr.write(`[mem] Invalid --${label} "${raw}" (must be a non-negative integer); using default 5\n`);
617
- return 5;
618
- }
619
- return n;
620
- };
621
- const before = parseWindow('before', flags.before);
622
- const after = parseWindow('after', flags.after);
608
+ // Route --before/--after through the shared bounded parser, range [0,50] (mirrors MCP
609
+ // mem_timeline's before/after .min(0).max(50)). NOTE this is parseIntFlag's reject-to-default
610
+ // convention, NOT a clamp: an out-of-range value (e.g. `--before 100`) warns to stderr and
611
+ // falls back to the default 5 — same as recent/search/recall, so the behavior is consistent
612
+ // across the CLI (the user sees the valid range and can retry). The point is the UPPER bound:
613
+ // the old hand-rolled validator had none, so `--before 999999999` flowed straight into
614
+ // `LIMIT before+after+1` / the window fetch as a raw SQL LIMIT (whole-table dump) — the
615
+ // #8802 uncapped-LIMIT footgun. min:0 keeps a 0 window legal; parseIntFlag preserves the
616
+ // warn-then-default garbage handling (float truncation + "2abc"/"1e2" rejection).
617
+ const before = parseIntFlag(flags.before, { name: '--before', defaultValue: 5, min: 0, max: 50 });
618
+ const after = parseIntFlag(flags.after, { name: '--after', defaultValue: 5, min: 0, max: 50 });
623
619
  const project = flags.project ? resolveProject(db, flags.project) : null;
624
620
  const jsonOutput = flags.json === true || flags.json === 'true';
625
621
 
@@ -1795,8 +1791,13 @@ function cmdCompress(db, args) {
1795
1791
  // isNumericToken (not bare parseInt) so "1e5"→1 and "30x"→30 are rejected rather than
1796
1792
  // silently mis-parsed into a far-too-broad cutoff — parity with recent/search/maintain.
1797
1793
  const parsed = Number(flags['age-days']);
1798
- if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 1) {
1799
- fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
1794
+ // [30,365] floor/ceil = parity with mem_compress (tool-schemas memCompressSchema
1795
+ // .min(30).max(365)). The CLI previously accepted any positive int, so `--age-days 1`
1796
+ // compressed day-old rows while the MCP description claimed the CLI "rejects <30 anyway"
1797
+ // — a false parity claim (the candidate gate is the real data guard, but the age floor
1798
+ // should match the tool the description promises equivalence with).
1799
+ if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 30 || parsed > 365) {
1800
+ fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be an integer between 30 and 365 (parity with mem_compress).`);
1800
1801
  return;
1801
1802
  }
1802
1803
  ageDays = parsed;
package/nlp.mjs CHANGED
@@ -151,10 +151,14 @@ export function extractCjkLikePatterns(query) {
151
151
  /**
152
152
  * Post-FTS precision filter for CJK queries.
153
153
  *
154
- * Background: FTS5 unicode61 tokenizer splits every CJK character into its
155
- * own token. An application-layer bigram query like "我是" then reduces to
156
- * (我 AND 是) at match time matching any document that happens to contain
157
- * both chars anywhere, which is extremely permissive in Chinese prose.
154
+ * Background: this build's FTS5 unicode61 tokenizer indexes an entire CJK run
155
+ * as ONE token (it does NOT split each CJK character). CJK text is made
156
+ * searchable by the write path, which stores the content plus its space-
157
+ * separated overlapping bigrams; a query is likewise reduced to bigrams. An
158
+ * application-layer bigram query therefore matches via those stored bigrams,
159
+ * and after the AND→OR fallback (relaxFtsQueryToOr) any document sharing even a
160
+ * single query bigram becomes a hit — extremely permissive in Chinese prose,
161
+ * where common bigrams recur across unrelated topics.
158
162
  *
159
163
  * Precision check: given the raw query and a candidate result's full text,
160
164
  * require that at least `threshold` fraction of the query's CJK bigrams
@@ -276,6 +280,15 @@ export function sanitizeFtsQuery(query) {
276
280
  t && !/^-+$/.test(t) && !FTS5_KEYWORDS.has(t.toUpperCase()) && !/^NEAR(\/\d*)?$/i.test(t)
277
281
  // Skip single ASCII-letter tokens — too noisy for FTS5 (CJK single chars handled separately below)
278
282
  && !(t.length === 1 && /^[a-zA-Z]$/.test(t))
283
+ // Drop tokens with NO index-able character — emoji 💥, symbols ★☆✦, pure
284
+ // punctuation. unicode61 strips those at index time, so ftsToken would phrase-quote
285
+ // such a token ("💥") into a REQUIRED AND term that can never match → strict FTS
286
+ // returns 0 (and a lone-emoji query has no OR recovery). Gate on any Unicode LETTER
287
+ // or NUMBER (\p{L}/\p{N}), NOT an ASCII+Han allowlist: unicode61 indexes every
288
+ // script's letters (Cyrillic / Greek / kana / Hangul / Thai / accented Latin …), so
289
+ // an allowlist silently killed search for all non-Latin/non-Han scripts (round-5
290
+ // review catch). Letters are kept; only true symbols/emoji/punctuation are dropped.
291
+ && /[\p{L}\p{N}]/u.test(t)
279
292
  );
280
293
  // Filter stop words (but keep all if filtering would empty the query)
281
294
  const filtered = tokens.filter(t => !FTS_STOP_WORDS.has(t.toLowerCase()));
@@ -303,6 +316,25 @@ export function sanitizeFtsQuery(query) {
303
316
  }
304
317
  continue;
305
318
  }
319
+ // No dictionary word matched. For a PURE-CJK run, pushing the whole
320
+ // unsegmented token creates a required AND term that matches neither the
321
+ // stored full-run token nor its overlapping bigrams: the write path stores
322
+ // content + space-separated bigrams, so a run like "同义词扩展" is indexed
323
+ // only as the longer whole-run token AND as 同义/义词/词扩/扩展 — never as
324
+ // "同义词扩展" itself. The strict AND is thus unsatisfiable (strict FTS = 0);
325
+ // only relaxFtsQueryToOr in the callers salvaged recall. Emit the non-noise
326
+ // bigrams the index actually holds instead. Mixed-script tokens (latin+CJK,
327
+ // e.g. "xyzAbc不存在") stay whole — the latin portion is a literal anchor and
328
+ // bigramming the CJK suffix over-recalls (mirrors the bigram guard below).
329
+ if (!/[A-Za-z0-9]/.test(t)) {
330
+ const fallbackBigrams = cjkBigrams(t)
331
+ .split(' ')
332
+ .filter(bg => bg && !isCjkNoiseBigram(bg));
333
+ if (fallbackBigrams.length > 0) {
334
+ expandedTokens.push(...fallbackBigrams);
335
+ continue;
336
+ }
337
+ }
306
338
  }
307
339
  expandedTokens.push(t);
308
340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.36.0",
3
+ "version": "3.38.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -89,13 +89,28 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
89
89
  // failure leaves the marker unset and retries on the next open. New TABLE via
90
90
  // CORE_SCHEMA on the forced pass; LATEST_MIGRATION_COLUMN unchanged (no new
91
91
  // column) — same pattern as v35/v36/v38.
92
- export const CURRENT_SCHEMA_VERSION = 39;
92
+ // v40 (round-5 audit HIGH): forces one migration pass so the now column-aware ensureFTS
93
+ // widens any STALE FTS table on existing DBs. Early-adopter stores created before a column
94
+ // was added to an FTS list (session_summaries_fts predates `remaining_items`, v2.2.0) carried
95
+ // a narrow FTS table forever — the old ensureFTS only created a table when absent, never
96
+ // widened it — while its triggers were rebuilt with the current wider column list, so every
97
+ // session_summaries UPDATE threw "no column named remaining_items" and was silently swallowed
98
+ // (Haiku summary enrichment lost every session). Pure index reheal (no data migration, no
99
+ // column drop); idempotent. New behavior via the forced pass; LATEST_MIGRATION_COLUMN
100
+ // unchanged (no new column) — same pattern as v35/v36/v38/v39.
101
+ // v41 (cross-turn late-citation): adds observations.last_cited_session_id (additive,
102
+ // nullable) — the promote idempotency key, split from last_decided_session_id so a
103
+ // citation landing in a LATER turn of the same session can still upgrade a
104
+ // previously-uncited obs (see applyCitationDecay). REAL new column, so unlike v38-v40
105
+ // this DOES advance LATEST_MIGRATION_COLUMN (→ observations.last_cited_session_id);
106
+ // existing DBs reach the ALTER because version 40 != 41 falls through the fast-path.
107
+ export const CURRENT_SCHEMA_VERSION = 41;
93
108
 
94
109
  // Sentinel column for the LATEST migration set. The fast-path uses this to
95
110
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
96
111
  // back (observed once in dev during v2.74.0). Update both the column AND
97
112
  // (if needed) the table when adding a new migration batch.
98
- const LATEST_MIGRATION_COLUMN = { table: 'user_prompts', column: 'cc_session_id' };
113
+ const LATEST_MIGRATION_COLUMN = { table: 'observations', column: 'last_cited_session_id' };
99
114
 
100
115
  function hasLatestMigrationColumn(db) {
101
116
  try {
@@ -255,6 +270,14 @@ const MIGRATIONS = [
255
270
  // legacy rows + non-CC/no-stdin invocations read back NULL and the handoff falls
256
271
  // back to its legacy unfiltered query.
257
272
  'ALTER TABLE user_prompts ADD COLUMN cc_session_id TEXT DEFAULT NULL',
273
+ // v41 (cross-turn late-citation): promote-idempotency key, split from
274
+ // last_decided_session_id. applyCitationDecay guards the cited/promote branch on
275
+ // THIS column so a citation in a LATER turn of the same session can still upgrade a
276
+ // previously-uncited obs; the uncited/streak branch stays guarded on
277
+ // last_decided_session_id (so it never double-streaks within a session). Nullable:
278
+ // legacy rows read NULL and behave exactly as before until their first same-session
279
+ // late cite.
280
+ 'ALTER TABLE observations ADD COLUMN last_cited_session_id TEXT DEFAULT NULL',
258
281
  ];
259
282
 
260
283
  /**
@@ -981,8 +1004,32 @@ export function ensureFTS(db, ftsName, tableName, columns) {
981
1004
  const newVals = columns.map(c => `new.${c}`).join(', ');
982
1005
  const oldVals = columns.map(c => `old.${c}`).join(', ');
983
1006
 
984
- const ftsExists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(ftsName);
985
- if (!ftsExists) {
1007
+ // Column-aware (re)creation. An existing FTS table is never silently reused when its
1008
+ // indexed-column set has drifted from `columns`. Root cause of a silent-write bug class:
1009
+ // a DB created before a column was added to an FTS list (session_summaries_fts predates
1010
+ // `remaining_items`, added v2.2.0) kept the OLD narrow table forever, because ensureFTS
1011
+ // only created the table when it was absent. The triggers below, however, are rebuilt
1012
+ // from the CURRENT (wider) column list, so every UPDATE fired a trigger that INSERTed
1013
+ // into a column the stale FTS table lacked and threw "no column named <X>", silently
1014
+ // failing the write (session-summary Haiku enrichment was discarded every session for the
1015
+ // early-adopter cohort, and the new column stayed unindexed). On drift, drop the triggers +
1016
+ // table and fall through to CREATE + repopulate. Generalizes the one-off observations_fts
1017
+ // guard in ensureDb so ALL three ensureFTS-managed tables self-heal on any column addition.
1018
+ const ftsRow = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(ftsName);
1019
+ let recreated = false;
1020
+ if (ftsRow) {
1021
+ let existingCols = [];
1022
+ try { existingCols = db.prepare(`PRAGMA table_info(${ftsName})`).all().map(c => c.name); } catch { /* unreadable → treat as drifted, recreate */ }
1023
+ const drifted = existingCols.length !== columns.length || columns.some(c => !existingCols.includes(c));
1024
+ if (drifted) {
1025
+ db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ai`);
1026
+ db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ad`);
1027
+ db.exec(`DROP TRIGGER IF EXISTS ${tableName}_au`);
1028
+ db.exec(`DROP TABLE IF EXISTS ${ftsName}`);
1029
+ recreated = true;
1030
+ }
1031
+ }
1032
+ if (!ftsRow || recreated) {
986
1033
  db.exec(`CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id')`);
987
1034
  }
988
1035
 
@@ -1008,4 +1055,15 @@ export function ensureFTS(db, ftsName, tableName, columns) {
1008
1055
  INSERT INTO ${ftsName}(rowid, ${colList}) VALUES (new.id, ${newVals});
1009
1056
  END;
1010
1057
  `);
1058
+
1059
+ // Repopulate a freshly (re)created external-content FTS index from its content table.
1060
+ // An empty index otherwise returns 0 rows until each row is next written — and unlike
1061
+ // observations_fts (rebuilt via the obsFtsRecreated flag in ensureDb), session_summaries_fts
1062
+ // and user_prompts_fts have no other rebuild path, so a widened table must repopulate here.
1063
+ if (recreated) {
1064
+ try {
1065
+ const cnt = db.prepare(`SELECT COUNT(*) AS c FROM ${tableName}`).get();
1066
+ if (cnt.c > 0) db.exec(`INSERT INTO ${ftsName}(${ftsName}) VALUES('rebuild')`);
1067
+ } catch { /* non-critical — index repopulates lazily on next write */ }
1068
+ }
1011
1069
  }
package/secret-scrub.mjs CHANGED
@@ -29,9 +29,9 @@ export const SECRET_PATTERNS = [
29
29
  // excludes "Marker token: …". `secret` added so a bare SECRET=… with a mixed-alnum
30
30
  // value is covered (the hex-only assignment pattern below misses non-hex values).
31
31
  // 1a. `=` assignment → ALWAYS scrub (config syntax, never prose):
32
- [/((?:\b|_)(?:password|passwd|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
32
+ [/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
33
33
  // 1b. `:` separator → keep the prose lookbehind ("the token: alice" is prose):
34
- [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
34
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*:\s*)(?!process\.env\.)(?!new\s)(?!\w+\()(?!(?:null|undefined|true|false|None|nil|empty|""|''|0)\b)[^\s,;'"}\]]{6,}/gi, '$1***'],
35
35
  // access_token / refresh_token are the canonical OAuth2 field names — they were
36
36
  // missing from this KV list (drift vs the JSON list below). `(?:\b|_)` for the same
37
37
  // underscore-prefix reason.
@@ -55,8 +55,8 @@ export const SECRET_PATTERNS = [
55
55
  // (a) bare credential nouns: `=` always scrubs; `:` keeps the prose lookbehind
56
56
  // (mirrors the unquoted 1a/1b split — a quoted value doesn't turn `:` prose
57
57
  // into config, but `<word> password="x"` is still a leak):
58
- [/((?:\b|_)(?:password|passwd|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
59
- [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
58
+ [/((?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*=\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
59
+ [/((?<![A-Za-z][ \t])(?:\b|_)(?:password|passwd|passphrase|token|bearer|secret)\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
60
60
  // (b) structured keys + named env vars are unambiguous config even after a word
61
61
  // (`see api_key: "x"` DOES scrub, mirroring the unquoted structured-key path):
62
62
  [/((?:\b|_)(?:pgpassword|pgpass|mysql_pwd|api[_-]?key|api[_-]?secret|secret[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token)\s*[=:]\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
@@ -121,6 +121,19 @@ export const SECRET_PATTERNS = [
121
121
  // `"token_count"` value (numeric, <6 non-quote chars after scrub) and prose
122
122
  // keys stay low-FP; over-scrub is the safe direction for at-rest memory.
123
123
  [/("\w*(?:password|passwd|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w*"\s*:\s*")[^"]{6,}(")/gi, '$1***$2'],
124
+ // Quoted-KEY credential values — Python dict reprs `{'api_key': '...'}`, single-quoted
125
+ // JS/JSON, and any mixed quoting. The quoted-VALUE patterns above match an UNQUOTED key
126
+ // (the key's closing quote sits between the key name and the `[=:]`, so `keyword\s*[=:]`
127
+ // never fires); the JSON patterns require BOTH key and value DOUBLE-quoted. So a single-
128
+ // quoted or mixed-quoted pair — the most common at-rest shape for opaque app secrets in
129
+ // stored LLM output / error payloads / code snippets — slipped through unredacted (#8805
130
+ // sibling). A quoted key is unambiguous config/data, so — like the JSON patterns — no prose
131
+ // guard is needed. Key quote and value quote are matched independently (`['"]` each); the
132
+ // value's close is a backref (\2) to its own opening quote. Same credential-noun set as the
133
+ // vendor-prefix JSON pattern above (bare `token`/`bearer` deliberately excluded to avoid
134
+ // `'token_count': 123456`); `passphrase` added here too (double-quoted JSON passphrase is
135
+ // subsumed by this pattern since `['"]` matches `"`). Over-scrub is the safe direction.
136
+ [/(['"]\w*(?:password|passwd|passphrase|secret|api[_-]?key|auth[_-]?token|access[_-]?token|private[_-]?key)\w*['"]\s*:\s*)(['"])[^'"]{6,}\2/gi, '$1$2***$2'],
124
137
  // Session cookies in headers / urlencoded bodies (sessionid=, session_id=, JSESSIONID=, PHPSESSID=).
125
138
  // 16+ chars filters out short test fixtures like sessionid=abc.
126
139
  [/\b((?:session[_-]?id|sessionid|jsessionid|phpsessid)\s*[=:]\s*)[^\s,;'"}\]]{16,}/gi, '$1***'],
package/utils.mjs CHANGED
@@ -309,7 +309,11 @@ function firstBalancedJsonObject(text) {
309
309
  export function parseJsonFromLLM(text) {
310
310
  if (!text) return null;
311
311
  try { return JSON.parse(text); } catch {}
312
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
312
+ // No `\s*` around the lazy capture: `\s*([\s\S]*?)\s*` catastrophically backtracks
313
+ // (O(n²)) on a fence + long whitespace + no closing fence — a ~5KB partial buffer hung
314
+ // the CLI-timeout salvage path for 10+s (round-5 review). `([\s\S]*?)```` is a single
315
+ // O(n) lazy scan; JSON.parse tolerates the surrounding whitespace the trim used to strip.
316
+ const fenced = text.match(/```(?:json)?([\s\S]*?)```/);
313
317
  if (fenced) try { return JSON.parse(fenced[1]); } catch {}
314
318
  // First balanced object — survives unfenced output wrapped in brace-containing prose.
315
319
  const balanced = firstBalancedJsonObject(text);