claude-mem-lite 3.36.0 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/haiku-client.mjs +16 -10
- package/hook-llm.mjs +116 -23
- package/hook-optimize.mjs +37 -8
- package/hook.mjs +7 -1
- package/lib/citation-tracker.mjs +5 -2
- package/mem-cli.mjs +18 -17
- package/nlp.mjs +36 -4
- package/package.json +1 -1
- package/schema.mjs +47 -3
- package/secret-scrub.mjs +17 -4
- package/utils.mjs +5 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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/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
|
-
|
|
425
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
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
|
-
|
|
722
|
-
if
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {}
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -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
|
|
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,7 +568,10 @@ 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
|
-
|
|
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 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
|
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
|
-
//
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
1799
|
-
|
|
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
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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.
|
|
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
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/schema.mjs
CHANGED
|
@@ -89,7 +89,16 @@ 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
|
-
|
|
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
|
+
export const CURRENT_SCHEMA_VERSION = 40;
|
|
93
102
|
|
|
94
103
|
// Sentinel column for the LATEST migration set. The fast-path uses this to
|
|
95
104
|
// self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
|
|
@@ -981,8 +990,32 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
981
990
|
const newVals = columns.map(c => `new.${c}`).join(', ');
|
|
982
991
|
const oldVals = columns.map(c => `old.${c}`).join(', ');
|
|
983
992
|
|
|
984
|
-
|
|
985
|
-
|
|
993
|
+
// Column-aware (re)creation. An existing FTS table is never silently reused when its
|
|
994
|
+
// indexed-column set has drifted from `columns`. Root cause of a silent-write bug class:
|
|
995
|
+
// a DB created before a column was added to an FTS list (session_summaries_fts predates
|
|
996
|
+
// `remaining_items`, added v2.2.0) kept the OLD narrow table forever, because ensureFTS
|
|
997
|
+
// only created the table when it was absent. The triggers below, however, are rebuilt
|
|
998
|
+
// from the CURRENT (wider) column list, so every UPDATE fired a trigger that INSERTed
|
|
999
|
+
// into a column the stale FTS table lacked and threw "no column named <X>", silently
|
|
1000
|
+
// failing the write (session-summary Haiku enrichment was discarded every session for the
|
|
1001
|
+
// early-adopter cohort, and the new column stayed unindexed). On drift, drop the triggers +
|
|
1002
|
+
// table and fall through to CREATE + repopulate. Generalizes the one-off observations_fts
|
|
1003
|
+
// guard in ensureDb so ALL three ensureFTS-managed tables self-heal on any column addition.
|
|
1004
|
+
const ftsRow = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(ftsName);
|
|
1005
|
+
let recreated = false;
|
|
1006
|
+
if (ftsRow) {
|
|
1007
|
+
let existingCols = [];
|
|
1008
|
+
try { existingCols = db.prepare(`PRAGMA table_info(${ftsName})`).all().map(c => c.name); } catch { /* unreadable → treat as drifted, recreate */ }
|
|
1009
|
+
const drifted = existingCols.length !== columns.length || columns.some(c => !existingCols.includes(c));
|
|
1010
|
+
if (drifted) {
|
|
1011
|
+
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ai`);
|
|
1012
|
+
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ad`);
|
|
1013
|
+
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_au`);
|
|
1014
|
+
db.exec(`DROP TABLE IF EXISTS ${ftsName}`);
|
|
1015
|
+
recreated = true;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (!ftsRow || recreated) {
|
|
986
1019
|
db.exec(`CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id')`);
|
|
987
1020
|
}
|
|
988
1021
|
|
|
@@ -1008,4 +1041,15 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
1008
1041
|
INSERT INTO ${ftsName}(rowid, ${colList}) VALUES (new.id, ${newVals});
|
|
1009
1042
|
END;
|
|
1010
1043
|
`);
|
|
1044
|
+
|
|
1045
|
+
// Repopulate a freshly (re)created external-content FTS index from its content table.
|
|
1046
|
+
// An empty index otherwise returns 0 rows until each row is next written — and unlike
|
|
1047
|
+
// observations_fts (rebuilt via the obsFtsRecreated flag in ensureDb), session_summaries_fts
|
|
1048
|
+
// and user_prompts_fts have no other rebuild path, so a widened table must repopulate here.
|
|
1049
|
+
if (recreated) {
|
|
1050
|
+
try {
|
|
1051
|
+
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM ${tableName}`).get();
|
|
1052
|
+
if (cnt.c > 0) db.exec(`INSERT INTO ${ftsName}(${ftsName}) VALUES('rebuild')`);
|
|
1053
|
+
} catch { /* non-critical — index repopulates lazily on next write */ }
|
|
1054
|
+
}
|
|
1011
1055
|
}
|
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
|
-
|
|
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);
|