claude-mem-lite 3.35.2 → 3.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bash-utils.mjs +7 -1
- package/deep-search.mjs +13 -6
- package/format-utils.mjs +6 -2
- package/haiku-client.mjs +16 -10
- package/hook-context.mjs +12 -1
- package/hook-handoff.mjs +9 -3
- package/hook-llm.mjs +116 -23
- package/hook-memory.mjs +8 -2
- package/hook-optimize.mjs +45 -8
- package/hook-shared.mjs +22 -9
- package/hook-update.mjs +5 -2
- package/hook.mjs +31 -16
- package/install.mjs +33 -11
- package/lib/atomic-write.mjs +19 -8
- package/lib/citation-tracker.mjs +5 -2
- package/lib/deferred-work.mjs +3 -1
- package/lib/doctor-benchmark.mjs +6 -1
- package/lib/maintain-core.mjs +12 -0
- package/lib/task-imperative.mjs +4 -2
- package/mem-cli.mjs +57 -28
- package/nlp.mjs +36 -4
- package/package.json +1 -2
- package/registry-recommend.mjs +55 -14
- package/registry.mjs +19 -7
- package/schema.mjs +55 -4
- package/scripts/post-tool-use.sh +10 -1
- package/search-engine.mjs +5 -1
- package/search-scoring.mjs +10 -0
- package/secret-scrub.mjs +17 -4
- package/source-files.mjs +1 -1
- package/tool-schemas.mjs +2 -2
- package/utils.mjs +5 -1
- package/registry-indexer.mjs +0 -175
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.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/bash-utils.mjs
CHANGED
|
@@ -53,7 +53,13 @@ function isExcludedPath(p) {
|
|
|
53
53
|
* @returns {{isError: boolean, isTest: boolean, isBuild: boolean, isGit: boolean, isDeploy: boolean, isSignificant: boolean}}
|
|
54
54
|
*/
|
|
55
55
|
export function detectBashSignificance(input, response) {
|
|
56
|
-
|
|
56
|
+
// Coerce command to a string at the source. A malformed PostToolUse payload can
|
|
57
|
+
// hand us a non-string `command` (object/number); `(input.command||'').toLowerCase()`
|
|
58
|
+
// then threw, and this is called UNGUARDED in the hottest hook path (hook.mjs:309) —
|
|
59
|
+
// the throw propagated to main()'s exit(0), dropping the ENTIRE tool event (no episode
|
|
60
|
+
// entry, not even a pending file). A non-string command has nothing to analyze, so
|
|
61
|
+
// degrading to '' (no significance) is the correct, event-preserving fallback.
|
|
62
|
+
const cmd = (typeof input?.command === 'string' ? input.command : '').toLowerCase();
|
|
57
63
|
// Skip error keyword matching only when the PRIMARY command is a read/search op (its
|
|
58
64
|
// output naturally contains "error"-like keywords that aren't failures). Anchored on the
|
|
59
65
|
// primary command — NOT "search verb appears anywhere" — so `npm run build 2>&1 | tail`
|
package/deep-search.mjs
CHANGED
|
@@ -454,12 +454,19 @@ export async function deepSearch(db, params, { llm, searchFn = defaultSearchFn,
|
|
|
454
454
|
// it does on the single-query baseline path, so "never worse than baseline"
|
|
455
455
|
// holds in the error dimension too — a DB failure must not be silently
|
|
456
456
|
// swallowed into an empty result (F5). Only rewrite variants are best-effort.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
457
|
+
let list;
|
|
458
|
+
if (i === 0) list = searchFn(db, v, params) || [];
|
|
459
|
+
else { try { list = searchFn(db, v, params) || []; } catch { list = []; } }
|
|
460
|
+
// rrfFuseN fuses by array index as rank, so each list MUST already be in
|
|
461
|
+
// composite-score order. searchObservationsHybrid appends downweighted
|
|
462
|
+
// concept(×0.7)/PRF(×0.6) expansion rows to the TAIL unsorted and, on the
|
|
463
|
+
// vectors-disabled path, returns BEFORE the sort its vector arm applies
|
|
464
|
+
// (search-engine.mjs:430) — so a sparse variant (common in deep search, the
|
|
465
|
+
// vocabulary-mismatch path) would hand a tail-ranked expansion row to RRF at a
|
|
466
|
+
// worse rank than its score earns. Sort so index == composite rank, mirroring
|
|
467
|
+
// the in-engine sort that already guards the vector-RRF merge.
|
|
468
|
+
list.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
469
|
+
return list;
|
|
463
470
|
});
|
|
464
471
|
|
|
465
472
|
const fused = rrfFuseN(lists, rrfK);
|
package/format-utils.mjs
CHANGED
|
@@ -74,10 +74,14 @@ export function neutralizeContextDelimiters(s) {
|
|
|
74
74
|
export function formatErrorRecallHints(rows) {
|
|
75
75
|
if (!rows || rows.length === 0) return '';
|
|
76
76
|
const lines = rows.map((r, i) => {
|
|
77
|
-
|
|
77
|
+
// Defang title + lesson: this block is written to PostToolUse stdout \u2192 model context,
|
|
78
|
+
// and observations are stored raw (defense is at the injection boundary, not at save),
|
|
79
|
+
// so a poisoned lesson carrying a forged <system-reminder>/tool tag would inject here
|
|
80
|
+
// un-neutralized. Same guard the handoff render applies to its replayed fields.
|
|
81
|
+
const head = ` #${r.id} [${r.type}] ${neutralizeContextDelimiters(truncate(r.title, 60))}`;
|
|
78
82
|
// Inline the lesson body for the single most-relevant hit only (bounded payload).
|
|
79
83
|
if (i === 0 && typeof r.lesson_learned === 'string' && r.lesson_learned.trim()) {
|
|
80
|
-
return `${head} \u2014 ${truncate(r.lesson_learned.trim(), 200)}`;
|
|
84
|
+
return `${head} \u2014 ${neutralizeContextDelimiters(truncate(r.lesson_learned.trim(), 200))}`;
|
|
81
85
|
}
|
|
82
86
|
return head;
|
|
83
87
|
});
|
package/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-context.mjs
CHANGED
|
@@ -79,6 +79,11 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
79
79
|
SELECT id, type, title, narrative, importance, created_at_epoch, files_modified, lesson_learned
|
|
80
80
|
FROM observations
|
|
81
81
|
WHERE project = ? AND COALESCE(compressed_into, 0) = 0
|
|
82
|
+
-- superseded invisibility: auto-dedup (hook.mjs) sets superseded_at but leaves
|
|
83
|
+
-- compressed_into=0, so the compressed filter alone lets the hidden near-duplicate
|
|
84
|
+
-- resurface in the most-visible surface (injected every SessionStart). Sibling
|
|
85
|
+
-- keyObs already filters this; obsPool + fallbackObs must match.
|
|
86
|
+
AND superseded_at IS NULL
|
|
82
87
|
AND ${notLowSignalTitleClause('')}
|
|
83
88
|
AND (
|
|
84
89
|
(created_at_epoch > ? AND importance >= 1)
|
|
@@ -116,7 +121,12 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
116
121
|
const impBoost = 0.5 + 0.5 * (o.importance || 1);
|
|
117
122
|
const lessonBoost = o.lesson_learned ? 1.3 : 1.0;
|
|
118
123
|
const value = recency * typeQuality * impBoost * lessonBoost;
|
|
119
|
-
|
|
124
|
+
// Cost = ONLY what is injected. The Recent table renders title-only (narrative is neither
|
|
125
|
+
// pushed nor emitted), so charging title+narrative made the budget measure ~5x the real
|
|
126
|
+
// injected size — it filled to ~44% of capacity and, worse, the √cost density term penalized
|
|
127
|
+
// long-narrative rows that cost nothing to inject. Title-only cost fills the budget with the
|
|
128
|
+
// rows actually shown and makes valueDensity = value per injected token.
|
|
129
|
+
const cost = estimateTokens(o.title || '');
|
|
120
130
|
return { ...o, value, cost, valueDensity: cost > 0 ? value / Math.sqrt(cost) : 0 };
|
|
121
131
|
});
|
|
122
132
|
|
|
@@ -294,6 +304,7 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
294
304
|
SELECT id, type, title, project, created_at
|
|
295
305
|
FROM observations
|
|
296
306
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
307
|
+
AND superseded_at IS NULL
|
|
297
308
|
AND (
|
|
298
309
|
(created_at_epoch > ? AND importance >= 1)
|
|
299
310
|
OR (created_at_epoch > ? AND importance >= 2)
|
package/hook-handoff.mjs
CHANGED
|
@@ -136,10 +136,13 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
136
136
|
try {
|
|
137
137
|
const tasks = taskReaderModule.readProjectTasks({ projectPath: process.cwd() });
|
|
138
138
|
if (tasks.length > 0) {
|
|
139
|
+
// Join with the ENTRY separator ('; '), NOT '\n': extractUnfinishedSummary
|
|
140
|
+
// and renderHandoffFromRow split pending work on UNFINISHED_ENTRY_SEP, so a
|
|
141
|
+
// '\n'-join collapsed the whole task list into one unreadable multi-line bullet.
|
|
139
142
|
unfinished = tasks
|
|
140
143
|
.slice(0, 5)
|
|
141
144
|
.map(t => `[${t.status}] ${t.title}`)
|
|
142
|
-
.join(
|
|
145
|
+
.join(UNFINISHED_ENTRY_SEP);
|
|
143
146
|
}
|
|
144
147
|
} catch { /* task reader is best-effort; never block handoff */ }
|
|
145
148
|
}
|
|
@@ -227,7 +230,7 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
|
|
|
227
230
|
project, type, storedSessionId,
|
|
228
231
|
truncate(safe.working_on, 1000),
|
|
229
232
|
safe.completed,
|
|
230
|
-
safe.unfinished.length > 3000 ? safe.unfinished.slice(0, 2999) + '…' : safe.unfinished,
|
|
233
|
+
safe.unfinished.length > 3000 ? Array.from(safe.unfinished).slice(0, 2999).join('') + '…' : safe.unfinished,
|
|
231
234
|
safeKeyFiles,
|
|
232
235
|
safe.key_decisions,
|
|
233
236
|
safe.match_keywords,
|
|
@@ -441,7 +444,10 @@ function renderHandoffFromRow(handoff, db, project) {
|
|
|
441
444
|
if (handoff.key_files) {
|
|
442
445
|
try {
|
|
443
446
|
const files = JSON.parse(handoff.key_files);
|
|
444
|
-
|
|
447
|
+
// Defang basenames too: a filename on disk can contain a literal authority tag
|
|
448
|
+
// (Linux allows almost any char but '/'), and this is the one field in this block
|
|
449
|
+
// that was rendered raw while working_on/unfinished/key_decisions all neutralize.
|
|
450
|
+
if (files.length > 0) lines.push('## Key Files', neutralizeContextDelimiters(files.map(f => basename(f)).join(', ')), '');
|
|
445
451
|
} catch {}
|
|
446
452
|
}
|
|
447
453
|
if (handoff.key_decisions) {
|
package/hook-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-memory.mjs
CHANGED
|
@@ -118,7 +118,12 @@ const STALE_OBS_THRESHOLD_MS = 30 * 86400000;
|
|
|
118
118
|
* @returns {string} `- [type] title[ | Lesson: X] (#id)[ [verify-before-use]]`
|
|
119
119
|
*/
|
|
120
120
|
export function formatMemoryLine(obs) {
|
|
121
|
-
|
|
121
|
+
// truncate (not raw): caps the per-hit token cost (an unbounded lesson bloated every
|
|
122
|
+
// memory-context line) AND collapses newlines — a multi-line lesson pushed the trailing
|
|
123
|
+
// "(#NN)" onto a later physical line that failed the "- [" prefix gate in
|
|
124
|
+
// citation-tracker's UserPromptSubmit extractor, so the obs never entered the
|
|
125
|
+
// citation-decay denominator (its promote/demote loop was silently dead).
|
|
126
|
+
const lessonTag = obs.lesson_learned ? ` | Lesson: ${truncate(obs.lesson_learned, 200)}` : '';
|
|
122
127
|
let staleHint = '';
|
|
123
128
|
if (typeof obs.created_at_epoch === 'number'
|
|
124
129
|
&& Date.now() - obs.created_at_epoch > STALE_OBS_THRESHOLD_MS
|
|
@@ -310,7 +315,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
310
315
|
// Adaptive threshold: scales with corpus size to filter noise.
|
|
311
316
|
// Each result must individually exceed the threshold (not just the top one).
|
|
312
317
|
const obsCount = db.prepare(
|
|
313
|
-
'SELECT COUNT(*) as c FROM observations WHERE project = ? AND COALESCE(compressed_into, 0) = 0',
|
|
318
|
+
'SELECT COUNT(*) as c FROM observations WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL',
|
|
314
319
|
).get(project)?.c || 0;
|
|
315
320
|
const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
|
|
316
321
|
const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
|
|
@@ -422,6 +427,7 @@ export function selectImperativeLesson(db, userPrompt, project, excludeIds = [])
|
|
|
422
427
|
FROM observations
|
|
423
428
|
WHERE project = ?
|
|
424
429
|
AND COALESCE(compressed_into, 0) = 0
|
|
430
|
+
AND superseded_at IS NULL
|
|
425
431
|
AND COALESCE(importance, 1) >= 2
|
|
426
432
|
AND lesson_learned IS NOT NULL
|
|
427
433
|
AND TRIM(lesson_learned) != ''
|
package/hook-optimize.mjs
CHANGED
|
@@ -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(' ');
|
|
@@ -539,6 +568,14 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
|
|
|
539
568
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
540
569
|
AND COALESCE(importance, 1) = 1
|
|
541
570
|
AND COALESCE(access_count, 0) = 0
|
|
571
|
+
-- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
|
|
572
|
+
-- on the originals (line ~693), which hides them from every injection/search
|
|
573
|
+
-- surface AND puts them out of recoverBuriedLessons' reach (it only lifts
|
|
574
|
+
-- compressed_into=0). A lesson demoted to imp=1 by citation-decay would be
|
|
575
|
+
-- silently buried by the unattended 24h llm-optimize run. Exact parity with the
|
|
576
|
+
-- canonical compress sibling selectCompressionCandidates (compress-core.mjs) —
|
|
577
|
+
-- "lessons never auto-GC" (also enforced in decayAndMarkIdle, maintain-core.mjs).
|
|
578
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
542
579
|
AND created_at_epoch < ?
|
|
543
580
|
${projectClause}
|
|
544
581
|
ORDER BY project, created_at_epoch
|
package/hook-shared.mjs
CHANGED
|
@@ -75,22 +75,35 @@ export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚
|
|
|
75
75
|
// caller will ever pick up, so sweeping them on SessionStart is safe.
|
|
76
76
|
export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
|
|
77
77
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
78
|
+
// `reads-<project>.txt` (bash fast-path Read tracker) is consumed by flushEpisode's
|
|
79
|
+
// rename-collect on the next edit-flush, NOT by a background worker — so a project
|
|
80
|
+
// that reads but never triggers an edit-flush leaves it uncollected and unswept, and
|
|
81
|
+
// it grows without bound (the 1h episode threshold is far too eager: a long read-only
|
|
82
|
+
// investigation legitimately appends to it for hours). A dedicated 24h floor sweeps
|
|
83
|
+
// only genuinely-abandoned trackers (no append AND no flush in a day → its paths are
|
|
84
|
+
// stale to any current episode) while leaving every active session's file untouched.
|
|
85
|
+
export const ORPHAN_READS_AGE_MS = 24 * 60 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
// Sweep stale `ep-flush-*` / `pending-*` (older than `ageMs`, default 1h) and
|
|
88
|
+
// `reads-*.txt` (older than `readsAgeMs`, default 24h) files in `runtimeDir` by
|
|
89
|
+
// mtime. Returns the number of files removed. fs-only — no DB / no network. Used by
|
|
90
|
+
// handleSessionStart auto-maintain to prevent the doctor "Stale temp files" warning
|
|
91
|
+
// from accumulating across crashes; equivalent to the manual path in
|
|
92
|
+
// `node install.mjs cleanup` but age-gated so concurrent in-flight workers / active
|
|
93
|
+
// read sessions are never raced.
|
|
94
|
+
export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, now = Date.now() } = {}) {
|
|
85
95
|
let entries;
|
|
86
96
|
try { entries = readdirSync(runtimeDir); } catch { return 0; }
|
|
87
97
|
const cutoff = now - ageMs;
|
|
98
|
+
const readsCutoff = now - readsAgeMs;
|
|
88
99
|
let count = 0;
|
|
89
100
|
for (const f of entries) {
|
|
90
|
-
|
|
101
|
+
const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
|
|
102
|
+
const isReads = f.startsWith('reads-') && f.endsWith('.txt');
|
|
103
|
+
if (!isEpisode && !isReads) continue;
|
|
91
104
|
const full = join(runtimeDir, f);
|
|
92
105
|
try {
|
|
93
|
-
if (statSync(full).mtimeMs < cutoff) {
|
|
106
|
+
if (statSync(full).mtimeMs < (isReads ? readsCutoff : cutoff)) {
|
|
94
107
|
unlinkSync(full);
|
|
95
108
|
count++;
|
|
96
109
|
}
|
package/hook-update.mjs
CHANGED
|
@@ -187,7 +187,7 @@ function shouldCheck(state) {
|
|
|
187
187
|
|
|
188
188
|
// ── GitHub API ─────────────────────────────────────────────
|
|
189
189
|
// Try releases/latest first, fallback to tags (some repos only use tags)
|
|
190
|
-
async function fetchLatestRelease() {
|
|
190
|
+
export async function fetchLatestRelease() {
|
|
191
191
|
const headers = {
|
|
192
192
|
'Accept': 'application/vnd.github+json',
|
|
193
193
|
'User-Agent': 'claude-mem-lite-updater/1.0',
|
|
@@ -235,7 +235,10 @@ async function fetchWithTimeout(url, headers) {
|
|
|
235
235
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
236
236
|
try {
|
|
237
237
|
const res = await fetch(url, { signal: controller.signal, headers });
|
|
238
|
-
if (res.status === 403) {
|
|
238
|
+
if (res.status === 403 || res.status === 429) {
|
|
239
|
+
// 429 = GitHub secondary rate limit (403 = primary). Both must route to the 6h
|
|
240
|
+
// rate-limit backoff, not the 24h transient-failure path — else a 429 defers the
|
|
241
|
+
// next check a full day instead of retrying on the shorter rate-limit cadence.
|
|
239
242
|
const state = readState();
|
|
240
243
|
saveState({ ...state, rateLimited: true });
|
|
241
244
|
debugLog('DEBUG', 'hook-update', 'GitHub API rate limited; will retry on the 6h rate-limit cadence');
|