claude-mem-lite 3.35.2 → 3.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/hook-context.mjs +12 -1
- package/hook-handoff.mjs +9 -3
- package/hook-memory.mjs +8 -2
- package/hook-optimize.mjs +8 -0
- package/hook-shared.mjs +22 -9
- package/hook-update.mjs +5 -2
- package/hook.mjs +24 -15
- package/install.mjs +33 -11
- package/lib/atomic-write.mjs +19 -8
- 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 +39 -11
- package/package.json +1 -2
- package/registry-recommend.mjs +55 -14
- package/registry.mjs +19 -7
- package/schema.mjs +8 -1
- package/scripts/post-tool-use.sh +10 -1
- package/search-engine.mjs +5 -1
- package/search-scoring.mjs +10 -0
- package/source-files.mjs +1 -1
- package/tool-schemas.mjs +2 -2
- 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.36.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.36.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/bash-utils.mjs
CHANGED
|
@@ -53,7 +53,13 @@ function isExcludedPath(p) {
|
|
|
53
53
|
* @returns {{isError: boolean, isTest: boolean, isBuild: boolean, isGit: boolean, isDeploy: boolean, isSignificant: boolean}}
|
|
54
54
|
*/
|
|
55
55
|
export function detectBashSignificance(input, response) {
|
|
56
|
-
|
|
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/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-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
|
@@ -539,6 +539,14 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
|
|
|
539
539
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
540
540
|
AND COALESCE(importance, 1) = 1
|
|
541
541
|
AND COALESCE(access_count, 0) = 0
|
|
542
|
+
-- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
|
|
543
|
+
-- on the originals (line ~693), which hides them from every injection/search
|
|
544
|
+
-- surface AND puts them out of recoverBuriedLessons' reach (it only lifts
|
|
545
|
+
-- compressed_into=0). A lesson demoted to imp=1 by citation-decay would be
|
|
546
|
+
-- silently buried by the unattended 24h llm-optimize run. Exact parity with the
|
|
547
|
+
-- canonical compress sibling selectCompressionCandidates (compress-core.mjs) —
|
|
548
|
+
-- "lessons never auto-GC" (also enforced in decayAndMarkIdle, maintain-core.mjs).
|
|
549
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
542
550
|
AND created_at_epoch < ?
|
|
543
551
|
${projectClause}
|
|
544
552
|
ORDER BY project, created_at_epoch
|
package/hook-shared.mjs
CHANGED
|
@@ -75,22 +75,35 @@ export const CONTINUE_KEYWORDS = /继续|接着|上次|之前的|前面的|刚
|
|
|
75
75
|
// caller will ever pick up, so sweeping them on SessionStart is safe.
|
|
76
76
|
export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
|
|
77
77
|
|
|
78
|
-
//
|
|
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');
|
package/hook.mjs
CHANGED
|
@@ -118,12 +118,13 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
|
118
118
|
try {
|
|
119
119
|
const ep = readEpisodeRaw();
|
|
120
120
|
if (ep && ep.entries && ep.entries.length > 0) {
|
|
121
|
-
// Persist a rule-based observation synchronously
|
|
122
|
-
//
|
|
123
|
-
//
|
|
121
|
+
// Persist a rule-based observation synchronously — the ONLY thing that
|
|
122
|
+
// salvages the in-flight episode on abnormal termination (audit #6). A
|
|
123
|
+
// detached llm-episode child can't be spawned from a dying process, so no
|
|
124
|
+
// ep-flush-* file is written here: it would have NO consumer AND would make
|
|
125
|
+
// every later handleLLMSummary poll the full CLAUDE_MEM_FLUSH_TIMEOUT (~15s)
|
|
126
|
+
// waiting for a file that only the 24h orphan-sweep ever removes.
|
|
124
127
|
saveEpisodeImmediate(ep);
|
|
125
|
-
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
126
|
-
writeFileSync(flushFile, JSON.stringify(ep));
|
|
127
128
|
try { unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`)); } catch {}
|
|
128
129
|
}
|
|
129
130
|
} catch {}
|
|
@@ -468,16 +469,24 @@ async function handleStop() {
|
|
|
468
469
|
if (episode && episode.entries && episode.entries.length > 0 && episodeHasSignificantContent(episode)) {
|
|
469
470
|
if (!episode.sessionId) episode.sessionId = sessionId;
|
|
470
471
|
if (!episode.project) episode.project = project;
|
|
471
|
-
//
|
|
472
|
-
// Without this,
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
472
|
+
// Split by CC session before saving — parity with flushEpisode's non-contended path
|
|
473
|
+
// (v3.35.2). Without this, the lock-contended fallback re-merged exactly the interleaved
|
|
474
|
+
// concurrent-session buffers v3.35.2 split apart, co-attributing two sessions' work into
|
|
475
|
+
// one garbled observation. planEpisodeFlush returns [episode] by reference for the common
|
|
476
|
+
// single-session case, so this is a no-op there. Immediate-save each group BEFORE its
|
|
477
|
+
// flush-file write (same ordering as flushEpisodeGroup) so a worker crash can't lose it.
|
|
478
|
+
for (const sub of planEpisodeFlush(episode)) {
|
|
479
|
+
if (!sub.sessionId) sub.sessionId = sessionId;
|
|
480
|
+
if (!sub.project) sub.project = project;
|
|
481
|
+
try {
|
|
482
|
+
const obs = buildImmediateObservation(sub);
|
|
483
|
+
const id = saveObservation(obs, sub.project, sub.sessionId);
|
|
484
|
+
if (id) sub.savedId = id;
|
|
485
|
+
} catch (e) { debugCatch(e, 'handleStop-fallback-immediateSave'); }
|
|
486
|
+
const flushFile = join(RUNTIME_DIR, `ep-flush-${Date.now()}-${randomUUID().slice(0, 8)}.json`);
|
|
487
|
+
writeFileSync(flushFile, JSON.stringify(sub));
|
|
488
|
+
spawnBackground('llm-episode', flushFile);
|
|
489
|
+
}
|
|
481
490
|
}
|
|
482
491
|
} finally {
|
|
483
492
|
try { unlinkSync(claimFile); } catch {}
|
package/install.mjs
CHANGED
|
@@ -1979,30 +1979,52 @@ async function manualUpdate() {
|
|
|
1979
1979
|
// latest code even when local install.mjs / hook-update.mjs are themselves
|
|
1980
1980
|
// buggy on disk.
|
|
1981
1981
|
async function repair() {
|
|
1982
|
-
console.log('\nclaude-mem-lite repair — re-syncing from latest GitHub release\n');
|
|
1982
|
+
console.log('\nclaude-mem-lite repair — re-syncing from the latest SIGNED GitHub release\n');
|
|
1983
1983
|
const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
|
|
1984
1984
|
try {
|
|
1985
|
-
|
|
1985
|
+
// Resolve the latest RELEASE (tag) and cryptographically VERIFY it before running any
|
|
1986
|
+
// downloaded code — parity with the auto-update path (hook-update.downloadAndInstall).
|
|
1987
|
+
// The old code fetched `/tarball` (default-branch main HEAD, unreleased WIP) and ran its
|
|
1988
|
+
// install.mjs UNVERIFIED, and this path is auto-triggered by hook-launcher on any
|
|
1989
|
+
// ERR_MODULE_NOT_FOUND — so a drifted install silently self-healed onto main, and a
|
|
1990
|
+
// repo/TLS-MITM compromise achieved RCE, bypassing the Ed25519 signed-release control that
|
|
1991
|
+
// the manual `update` path enforces. Lazy import so a missing/broken hook-update dependency
|
|
1992
|
+
// degrades to the manual fallback (fail-closed) rather than to unverified auto-install.
|
|
1993
|
+
let fetchLatestRelease, verifyReleaseAuthenticity;
|
|
1994
|
+
try {
|
|
1995
|
+
({ fetchLatestRelease, verifyReleaseAuthenticity } = await import('./hook-update.mjs'));
|
|
1996
|
+
} catch (e) {
|
|
1997
|
+
throw new Error(`cannot load the verified-update path (${e.message}) — refusing to auto-install unverified code`, { cause: e });
|
|
1998
|
+
}
|
|
1999
|
+
const rel = await fetchLatestRelease();
|
|
2000
|
+
if (!rel || !rel.tarballUrl) throw new Error('could not resolve the latest release (network / rate-limit)');
|
|
2001
|
+
// URL allow-list mirrors hook-update.downloadAndInstall — only github.com tarball URLs.
|
|
2002
|
+
if (!/^https:\/\/(?:api\.)?github\.com\/[a-zA-Z0-9./_-]+$/.test(rel.tarballUrl)) {
|
|
2003
|
+
throw new Error(`refusing suspicious tarball URL: ${rel.tarballUrl}`);
|
|
2004
|
+
}
|
|
1986
2005
|
const tarballPath = join(stagingDir, 'release.tgz');
|
|
1987
|
-
log(
|
|
1988
|
-
execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', tarballUrl, '-o', tarballPath],
|
|
2006
|
+
log(`Downloading release v${rel.version}...`);
|
|
2007
|
+
execFileSync('curl', ['-sL', '-f', '-H', 'Accept: application/vnd.github+json', rel.tarballUrl, '-o', tarballPath],
|
|
1989
2008
|
{ timeout: 60000, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
1990
2009
|
log('Extracting...');
|
|
1991
2010
|
execFileSync('tar', ['xzf', tarballPath, '-C', stagingDir, '--strip-components=1'],
|
|
1992
2011
|
{ timeout: 30000, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
2012
|
+
// Verify the Ed25519 signature BEFORE running the downloaded install.mjs. Fail-closed:
|
|
2013
|
+
// any tampering / missing-signature / fetch-failure aborts to the manual fallback.
|
|
2014
|
+
log('Verifying release signature...');
|
|
2015
|
+
const authentic = await verifyReleaseAuthenticity(stagingDir, rel.assets);
|
|
2016
|
+
if (!authentic.ok) throw new Error(`release signature check failed (${authentic.action})`);
|
|
1993
2017
|
const tarballInstaller = join(stagingDir, 'install.mjs');
|
|
1994
|
-
if (!existsSync(tarballInstaller))
|
|
1995
|
-
|
|
1996
|
-
process.exit(1);
|
|
1997
|
-
}
|
|
1998
|
-
log('Re-running install from freshly-downloaded sources...');
|
|
2018
|
+
if (!existsSync(tarballInstaller)) throw new Error('verified tarball missing install.mjs');
|
|
2019
|
+
log('Re-running install from the verified release sources...');
|
|
1999
2020
|
execFileSync(process.execPath, [tarballInstaller, 'install'],
|
|
2000
2021
|
{ stdio: 'inherit', timeout: 300000 });
|
|
2001
|
-
ok(
|
|
2022
|
+
ok(`Repair complete — resynced from verified release v${rel.version}`);
|
|
2002
2023
|
} catch (e) {
|
|
2003
2024
|
fail(`Repair failed: ${e.message}`);
|
|
2004
2025
|
console.log('');
|
|
2005
|
-
console.log('
|
|
2026
|
+
console.log(' Automatic repair fails closed rather than run unverified code.');
|
|
2027
|
+
console.log(' Manual fallback — run this in any shell (you are choosing to trust it):');
|
|
2006
2028
|
console.log('');
|
|
2007
2029
|
console.log(' T=$(mktemp -d) && curl -sL https://api.github.com/repos/sdsrss/claude-mem-lite/tarball | tar xz -C "$T" --strip-components=1 && node "$T/install.mjs" install');
|
|
2008
2030
|
console.log('');
|
package/lib/atomic-write.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// This writes to a pid-unique temp then renames (atomic on POSIX), and can drop
|
|
9
9
|
// a one-time ".bak" so a logic bug in the caller's merge is recoverable.
|
|
10
10
|
|
|
11
|
-
import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { writeFileSync, renameSync, existsSync, copyFileSync, mkdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -22,17 +22,28 @@ import { dirname } from 'node:path';
|
|
|
22
22
|
* preserves the last-known-good rather than being overwritten each run.
|
|
23
23
|
*/
|
|
24
24
|
export function atomicWriteFileSync(filePath, data, { backup = false } = {}) {
|
|
25
|
-
|
|
25
|
+
// Write THROUGH a symlink to its real target. renameSync onto a symlink NAME replaces the
|
|
26
|
+
// link with a regular file, silently orphaning a dotfiles-managed (chezmoi/stow/yadm)
|
|
27
|
+
// config: ~/.claude/settings.json (and ~/.claude.json) get severed from the dotfiles
|
|
28
|
+
// repo, so future dotfiles edits stop applying and .bak captures the wrong content.
|
|
29
|
+
// Resolve first, then put the temp in the TARGET's dir so the rename stays same-device
|
|
30
|
+
// (atomic, no EXDEV). A broken/absent symlink falls through to a direct write.
|
|
31
|
+
let target = filePath;
|
|
32
|
+
try {
|
|
33
|
+
if (lstatSync(filePath).isSymbolicLink()) target = realpathSync(filePath);
|
|
34
|
+
} catch { /* not a symlink, or missing — write filePath directly */ }
|
|
35
|
+
|
|
36
|
+
const dir = dirname(target);
|
|
26
37
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
38
|
|
|
28
|
-
if (backup && existsSync(
|
|
29
|
-
try { copyFileSync(
|
|
39
|
+
if (backup && existsSync(target) && !existsSync(target + '.bak')) {
|
|
40
|
+
try { copyFileSync(target, target + '.bak'); } catch { /* best-effort backup */ }
|
|
30
41
|
}
|
|
31
42
|
|
|
32
43
|
// pid-unique temp: a fixed ".tmp" name lets two concurrent installs clobber
|
|
33
|
-
// each other's temp mid-write. Same-dir temp keeps the rename atomic (no
|
|
34
|
-
// cross-device move).
|
|
35
|
-
const tmp = `${
|
|
44
|
+
// each other's temp mid-write. Same-dir-as-target temp keeps the rename atomic (no
|
|
45
|
+
// cross-device move) even when the target lives in a dotfiles repo on another mount.
|
|
46
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
36
47
|
writeFileSync(tmp, data);
|
|
37
|
-
renameSync(tmp,
|
|
48
|
+
renameSync(tmp, target);
|
|
38
49
|
}
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -119,7 +119,9 @@ export function resolveDeferredIds(db, project, tokens) {
|
|
|
119
119
|
throw new Error(`D#${id} belongs to project "${row.project}", not "${project}"`);
|
|
120
120
|
}
|
|
121
121
|
if (row.status !== 'open') {
|
|
122
|
-
|
|
122
|
+
// Verb-neutral: resolveDeferredIds is shared by close (save --closes-deferred)
|
|
123
|
+
// AND drop (mem_defer_drop), so "cannot close" mis-described the drop path.
|
|
124
|
+
throw new Error(`D#${id} status is "${row.status}" — only 'open' items can be closed or dropped`);
|
|
123
125
|
}
|
|
124
126
|
} else {
|
|
125
127
|
throw new Error(`invalid token type ${typeof t} — expected D#N or integer ordinal`);
|
package/lib/doctor-benchmark.mjs
CHANGED
|
@@ -110,7 +110,12 @@ export function runBenchmark(db, { prompts = [], project = 'mem', skipHookLatenc
|
|
|
110
110
|
* Returns true iff the simulator would produce a non-empty injection.
|
|
111
111
|
*/
|
|
112
112
|
const runInjection = (promptText) => {
|
|
113
|
-
|
|
113
|
+
// Mirror the real hook's min-length gate (hook-memory.mjs searchRelevantMemories):
|
|
114
|
+
// 5-char floor for non-CJK, 2 for CJK. The old flat 15 was 3x stricter and dropped
|
|
115
|
+
// every 5-14-char prompt (and all 2-14-char CJK) the live hook processes, so the
|
|
116
|
+
// simulated injection_rate systematically under-counted "how often memory fires".
|
|
117
|
+
const cjk = /[一-鿿㐀-䶿]/.test(promptText || '');
|
|
118
|
+
if (!promptText || promptText.length < (cjk ? 2 : 5)) return false;
|
|
114
119
|
const q = sanitizeFtsQuery(promptText);
|
|
115
120
|
if (!q) return false;
|
|
116
121
|
try {
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -145,6 +145,12 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
|
|
|
145
145
|
SELECT id FROM observations
|
|
146
146
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
147
147
|
AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
|
|
148
|
+
-- A lesson-bearing row is NOT "broken" — it still carries the distilled value,
|
|
149
|
+
-- so empty title+narrative isn't grounds to hard-delete it (a degenerate
|
|
150
|
+
-- cluster-merge can write merged_title='' onto a row that kept a synthesized
|
|
151
|
+
-- lesson). Parity with the "lessons never auto-GC" guards in
|
|
152
|
+
-- decayAndMarkIdle / selectCompressionCandidates / findSmartCompressCandidates.
|
|
153
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
148
154
|
${projectFilter} LIMIT ${opCap}
|
|
149
155
|
`).all(...baseParams).map(r => r.id);
|
|
150
156
|
if (!doomed.length) return 0;
|
|
@@ -329,6 +335,12 @@ export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutof
|
|
|
329
335
|
|
|
330
336
|
/** Delete pending-purge observations older than the retain cutoff. Returns rows deleted. */
|
|
331
337
|
export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, retainCutoff) {
|
|
338
|
+
// No lesson guard HERE by design: this hard-DELETE only touches rows already
|
|
339
|
+
// marked COMPRESSED_PENDING_PURGE, and every writer of that sentinel is itself
|
|
340
|
+
// lesson-guarded (decayAndMarkIdle above + search-scoring.runIdleCleanup), so a
|
|
341
|
+
// lesson row can never reach here. INVARIANT: any NEW code that sets
|
|
342
|
+
// compressed_into = COMPRESSED_PENDING_PURGE MUST carry the "lessons never auto-GC"
|
|
343
|
+
// guard, or it re-opens the path that hard-deletes lessons through this DELETE.
|
|
332
344
|
const doomed = db.prepare(`
|
|
333
345
|
SELECT id FROM observations
|
|
334
346
|
WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
|
package/lib/task-imperative.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// measurement that gates Phase 2): ONE tested source of truth so the measured
|
|
4
4
|
// framing and the shipped framing cannot drift. Hot-path-shared → regex/string
|
|
5
5
|
// only, NO heavy imports (lesson #8447), mirroring lib/lesson-idents.mjs.
|
|
6
|
+
// format-utils.mjs is a zero-import pure-string module, so it respects that constraint.
|
|
7
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
6
8
|
//
|
|
7
9
|
// Delivers a high-value lesson at the task-prompt position as an imperative,
|
|
8
10
|
// task-bound constraint: attribution kept (honest + #NN cite-traceable), the
|
|
@@ -11,7 +13,7 @@
|
|
|
11
13
|
// Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
|
|
12
14
|
|
|
13
15
|
export function formatTaskImperative(lesson, id) {
|
|
14
|
-
const body = String(lesson || '').trim().replace(/\.$/, '');
|
|
16
|
+
const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
|
|
15
17
|
if (!body) return '';
|
|
16
18
|
const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
|
|
17
19
|
return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
|
|
@@ -27,7 +29,7 @@ export function formatTaskImperative(lesson, id) {
|
|
|
27
29
|
// / "reference, not an instruction" / appended-below-the-task / no adversarial
|
|
28
30
|
// tokens) are the measured difference between adopt and refuse — do not drift them.
|
|
29
31
|
export function formatSubagentContext(lesson, id) {
|
|
30
|
-
const body = String(lesson || '').trim().replace(/\.$/, '');
|
|
32
|
+
const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
|
|
31
33
|
if (!body) return '';
|
|
32
34
|
const tag = (id === undefined || id === null || id === '') ? '' : `#${id} — `;
|
|
33
35
|
return [
|
package/mem-cli.mjs
CHANGED
|
@@ -1621,7 +1621,17 @@ function cmdExport(db, args) {
|
|
|
1621
1621
|
process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
|
|
1622
1622
|
}
|
|
1623
1623
|
|
|
1624
|
-
|
|
1624
|
+
// Backup default: with no --limit, export the COMPLETE matching set. `export` is
|
|
1625
|
+
// the documented backup half of backup/restore (README; cmdRestore header), yet its
|
|
1626
|
+
// old default capped at 200 (hard max 1000) and the "capped" warning went only to
|
|
1627
|
+
// stderr — so a bare `export > backup.json` on a >200-row store silently wrote a
|
|
1628
|
+
// truncated backup that lost rows on restore, and `--limit 5000` was REJECTED back
|
|
1629
|
+
// to 200 (can't back up >1000 at all). Now: omit --limit → LIMIT -1 (SQLite = no
|
|
1630
|
+
// limit); pass --limit N → honor any positive N (a backup may exceed 1000).
|
|
1631
|
+
const limitGiven = flags.limit !== undefined && flags.limit !== null && flags.limit !== '';
|
|
1632
|
+
const limit = limitGiven
|
|
1633
|
+
? parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200 })
|
|
1634
|
+
: -1;
|
|
1625
1635
|
const format = flags.format || 'json';
|
|
1626
1636
|
if (!['json', 'jsonl'].includes(format)) {
|
|
1627
1637
|
fail(`[mem] Invalid format "${format}". Use: json or jsonl`);
|
|
@@ -1662,8 +1672,8 @@ function cmdExport(db, args) {
|
|
|
1662
1672
|
out(JSON.stringify(rows, null, 2));
|
|
1663
1673
|
}
|
|
1664
1674
|
|
|
1665
|
-
if (rows.length >= limit) {
|
|
1666
|
-
process.stderr.write(`[mem] Note: Results capped at ${limit}.
|
|
1675
|
+
if (limitGiven && rows.length >= limit) {
|
|
1676
|
+
process.stderr.write(`[mem] Note: Results capped at ${limit}. Raise --limit or narrow --from/--to to export more.\n`);
|
|
1667
1677
|
}
|
|
1668
1678
|
}
|
|
1669
1679
|
|
|
@@ -2575,15 +2585,17 @@ Commands:
|
|
|
2575
2585
|
--narrative T New narrative
|
|
2576
2586
|
--concepts T Space-separated concept tags
|
|
2577
2587
|
|
|
2578
|
-
export Export observations as JSON/JSONL
|
|
2579
|
-
restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
|
|
2588
|
+
export Export observations as JSON/JSONL (complete backup by default)
|
|
2580
2589
|
--project P Filter by project
|
|
2581
2590
|
--type T Filter by type
|
|
2582
2591
|
--format F json (default) or jsonl
|
|
2583
2592
|
--from DATE Start date
|
|
2584
2593
|
--to DATE End date
|
|
2585
2594
|
--include-compressed Include compressed observations
|
|
2586
|
-
--limit N
|
|
2595
|
+
--limit N Cap output at N rows (default: export ALL matching rows)
|
|
2596
|
+
restore <file> Restore observations from an export file (JSON/JSONL)
|
|
2597
|
+
--project P Override the restored project for every row
|
|
2598
|
+
--dry-run Preview what would be restored without writing
|
|
2587
2599
|
|
|
2588
2600
|
compress Compress old low-value observations
|
|
2589
2601
|
--execute Execute compression (preview by default)
|
|
@@ -2876,6 +2888,18 @@ async function cmdEnrich(argv) {
|
|
|
2876
2888
|
}
|
|
2877
2889
|
|
|
2878
2890
|
async function cmdOptimize(db, args) {
|
|
2891
|
+
// cmdOptimize parses flags positionally (args.indexOf('--task') + args[idx+1])
|
|
2892
|
+
// instead of the shared parseArgs, so the GNU `--flag=value` form silently
|
|
2893
|
+
// vanished: indexOf found no bare `--flag`, dropping the value with zero signal.
|
|
2894
|
+
// On the mutating --run path this was a real footgun — `optimize --run
|
|
2895
|
+
// --task=smart-compress --project=p --max=5` ran ALL tasks across ALL projects at
|
|
2896
|
+
// the default budget (tasks/project undefined → run-everything). Normalize
|
|
2897
|
+
// `--flag=value` into `--flag value` up front so both forms parse identically;
|
|
2898
|
+
// the `--execute=` special-case below already anticipated this for one flag.
|
|
2899
|
+
args = args.flatMap(a => {
|
|
2900
|
+
const m = /^(--[a-z][a-z-]*)=([\s\S]*)$/.exec(a);
|
|
2901
|
+
return m ? [m[1], m[2]] : [a];
|
|
2902
|
+
});
|
|
2879
2903
|
const run = args.includes('--run');
|
|
2880
2904
|
const runAll = args.includes('--run-all');
|
|
2881
2905
|
// Sibling-command flag footgun: optimize executes with --run (compress uses
|
|
@@ -3045,11 +3069,15 @@ export async function run(argv) {
|
|
|
3045
3069
|
const JSON_SUPPORTED_CMDS = new Set([
|
|
3046
3070
|
'search', 'context', 'recent', 'recall', 'timeline', 'stats', 'browse', 'export', 'citation-stats',
|
|
3047
3071
|
]);
|
|
3048
|
-
//
|
|
3049
|
-
// "
|
|
3050
|
-
//
|
|
3051
|
-
|
|
3052
|
-
|
|
3072
|
+
// Suppress the note on subpaths that DO emit JSON, so it never FALSELY tells a script
|
|
3073
|
+
// "outputs text" while writing valid JSON to stdout — a false note is worse than a
|
|
3074
|
+
// missing one (it makes the consumer skip a parse that would have succeeded). doctor
|
|
3075
|
+
// emits JSON for --benchmark / --metrics / --session-audit; activity's show/save emit
|
|
3076
|
+
// JSON. Without those sub-flags, doctor is text and the note stays useful.
|
|
3077
|
+
const jsonCapableSubpath =
|
|
3078
|
+
(cmd === 'doctor' && (cmdArgs.includes('--benchmark') || cmdArgs.includes('--metrics') || cmdArgs.includes('--session-audit'))) ||
|
|
3079
|
+
cmd === 'activity';
|
|
3080
|
+
if (cmdArgs.includes('--json') && !JSON_SUPPORTED_CMDS.has(cmd) && !jsonCapableSubpath) {
|
|
3053
3081
|
process.stderr.write(`[mem] Note: --json is supported only on: ${[...JSON_SUPPORTED_CMDS].join(', ')}. "${cmd}" outputs text.\n`);
|
|
3054
3082
|
}
|
|
3055
3083
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.36.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -102,7 +102,6 @@
|
|
|
102
102
|
"registry.mjs",
|
|
103
103
|
"registry-retriever.mjs",
|
|
104
104
|
"registry-recommend.mjs",
|
|
105
|
-
"registry-indexer.mjs",
|
|
106
105
|
"registry-scanner.mjs",
|
|
107
106
|
"registry-github.mjs",
|
|
108
107
|
"registry-importer.mjs",
|
package/registry-recommend.mjs
CHANGED
|
@@ -18,8 +18,12 @@ export function getRecommendMode() {
|
|
|
18
18
|
|
|
19
19
|
// Provisional gate thresholds — calibrated from shadow data before Phase 2 (spec §8).
|
|
20
20
|
// relevance is raw bm25 (negative; more negative = better). Candidate must clear |floor|.
|
|
21
|
-
export const RECO_BM25_FLOOR = -1.5; //
|
|
22
|
-
|
|
21
|
+
export const RECO_BM25_FLOOR = -1.5; // floor stays on raw bm25 (absolute topical relevance)
|
|
22
|
+
// Margin is measured in COMPOSITE space (the metric that orders + selects the winner), NOT raw
|
|
23
|
+
// bm25 — see applyGate. composite = bm25*0.4 - priors, so the scale is ~0.4x raw; 0.5 raw ≈ 0.2
|
|
24
|
+
// composite is the principled starting point. Final value comes from `recommend-stats --sweep`
|
|
25
|
+
// after dogfood (D#47); DEFAULT_SWEEP_MARGINS is likewise on the composite scale.
|
|
26
|
+
export const RECO_MARGIN = 0.2; // require candidates[1].composite_score - candidates[0].composite_score >= this
|
|
23
27
|
const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS (internal)
|
|
24
28
|
|
|
25
29
|
// Lazy runtime-path resolution: read CLAUDE_MEM_DIR at call time (mirrors schema.mjs:13
|
|
@@ -35,9 +39,14 @@ const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
|
|
|
35
39
|
export function fetchInstalledSkillCandidates(rdb, promptText, limit = 10) {
|
|
36
40
|
if (!rdb || !promptText) return [];
|
|
37
41
|
let rows;
|
|
38
|
-
|
|
42
|
+
// Over-fetch, THEN filter to installed. quality_tier='installed' is a post-SQL JS filter, so a
|
|
43
|
+
// plain LIMIT starves installed skills that rank below other-tier matches: a weak installed
|
|
44
|
+
// match sitting past the top-N is sliced off → the gate sees no candidate → spurious
|
|
45
|
+
// no_candidate BLOCK (the −0.15 installed composite bonus is negligible vs the bm25 spread).
|
|
46
|
+
// A wide headroom lets the composite-best installed skill actually survive to the gate.
|
|
47
|
+
try { rows = searchResources(rdb, promptText, { type: 'skill', limit: Math.max(limit * 5, 50) }); }
|
|
39
48
|
catch { return []; }
|
|
40
|
-
return rows.filter(r => r.quality_tier === 'installed');
|
|
49
|
+
return rows.filter(r => r.quality_tier === 'installed').slice(0, limit);
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/**
|
|
@@ -65,7 +74,13 @@ export function applyGate(candidates, promptText, cooldownSet) {
|
|
|
65
74
|
const top = candidates[0];
|
|
66
75
|
if (!(top.relevance <= RECO_BM25_FLOOR)) return { verdict: 'BLOCK', reason: 'below_floor', candidate: top };
|
|
67
76
|
if (candidates.length >= 2) {
|
|
68
|
-
|
|
77
|
+
// Separation measured in the SAME metric that ordered the candidates (composite_score),
|
|
78
|
+
// NOT relevance. candidates arrive composite ASC so `top` IS the composite winner; a raw
|
|
79
|
+
// relevance margin let a composite-promoted top sit BELOW candidates[1] in relevance →
|
|
80
|
+
// negative margin → spurious low_margin BLOCK (and a permanent dead-zone in the ROC sweep).
|
|
81
|
+
// composite2 - composite1 is >= 0 by sort order. The floor above stays on raw bm25 — the
|
|
82
|
+
// absolute-relevance gate that stops a popular-but-irrelevant composite promotion.
|
|
83
|
+
const margin = candidates[1].composite_score - top.composite_score;
|
|
69
84
|
if (!(margin >= RECO_MARGIN)) return { verdict: 'BLOCK', reason: 'low_margin', candidate: top };
|
|
70
85
|
}
|
|
71
86
|
if (!intentMatch(promptText, top)) return { verdict: 'BLOCK', reason: 'intent_mismatch', candidate: top };
|
|
@@ -192,11 +207,13 @@ export function computeFunnel(days = 7) {
|
|
|
192
207
|
return stats;
|
|
193
208
|
}
|
|
194
209
|
|
|
195
|
-
// Default sweep grid (B3).
|
|
196
|
-
//
|
|
197
|
-
//
|
|
210
|
+
// Default sweep grid (B3). FLOORS are raw bm25 (real matches run ≈ -10..-15, so the shipped
|
|
211
|
+
// -1.5 floor is far more permissive than it looks; the grid spans that real range). MARGINS are
|
|
212
|
+
// on the COMPOSITE scale (composite = bm25*0.4 - priors), matching replayGate's margin metric —
|
|
213
|
+
// the old raw-bm25 grid [0,0.5,1,2,4] was ~2.5x too wide for composite separations.
|
|
214
|
+
// Override via `recommend-stats --sweep --floors a,b --margins x,y`.
|
|
198
215
|
export const DEFAULT_SWEEP_FLOORS = [-1.5, -5, -8, -10, -12];
|
|
199
|
-
export const DEFAULT_SWEEP_MARGINS = [0, 0.
|
|
216
|
+
export const DEFAULT_SWEEP_MARGINS = [0, 0.05, 0.1, 0.2, 0.4];
|
|
200
217
|
|
|
201
218
|
/**
|
|
202
219
|
* Recompute the gate verdict for a logged reco row at a hypothetical (floor, margin),
|
|
@@ -207,7 +224,15 @@ export function replayGate(row, floor, margin) {
|
|
|
207
224
|
const r1 = row.relevance;
|
|
208
225
|
// null/undefined relevance coerces to 0/NaN, so `r1 <= floor` is false → BLOCK (no null check).
|
|
209
226
|
if (!(r1 <= floor)) return 'BLOCK';
|
|
210
|
-
|
|
227
|
+
// Margin on composite when the row logged it (post-switch rows), matching the live applyGate
|
|
228
|
+
// metric; fall back to the legacy relevance margin for rows written before composite1/composite2
|
|
229
|
+
// existed (they age out of the 7d sweep window). Single-candidate rows (no rel2/composite2) skip
|
|
230
|
+
// the margin gate either way. NOTE: sweep `margin` is on the COMPOSITE scale for new rows.
|
|
231
|
+
if (Number.isFinite(row.composite1) && Number.isFinite(row.composite2)) {
|
|
232
|
+
if (!((row.composite2 - row.composite1) >= margin)) return 'BLOCK';
|
|
233
|
+
} else if (Number.isFinite(row.rel2)) {
|
|
234
|
+
if (!((row.rel2 - r1) >= margin)) return 'BLOCK';
|
|
235
|
+
}
|
|
211
236
|
if (!row.intentTop) return 'BLOCK';
|
|
212
237
|
if (row.cooldownTop) return 'BLOCK';
|
|
213
238
|
return 'PASS';
|
|
@@ -230,11 +255,25 @@ export function computeSweep(days = 7, floors = DEFAULT_SWEEP_FLOORS, margins =
|
|
|
230
255
|
}
|
|
231
256
|
const grid = [];
|
|
232
257
|
for (const floor of floors) for (const margin of margins) {
|
|
233
|
-
let pass = 0
|
|
258
|
+
let pass = 0;
|
|
259
|
+
// Matched precision MUST dedup (session, skill) exactly like computeFunnel (mPass/mAdopt
|
|
260
|
+
// via per-session Sets). Counting raw reco rows here inflated matchPass by every repeat
|
|
261
|
+
// recommendation of the same skill in one session, so the sweep's precision silently
|
|
262
|
+
// disagreed with the funnel headline and was biased toward frequently-recommended skills —
|
|
263
|
+
// corrupting the exact (floor,margin) signal the sweep exists to inform. `pass` stays a raw
|
|
264
|
+
// volume count (it is not a precision denominator).
|
|
265
|
+
const passBySession = new Map(); // session -> Set<skillLower> that PASSED at this cell
|
|
234
266
|
for (const r of recos) {
|
|
235
267
|
if (replayGate(r, floor, margin) !== 'PASS') continue;
|
|
236
268
|
pass++;
|
|
237
|
-
if (r.session)
|
|
269
|
+
if (!r.session) continue;
|
|
270
|
+
if (!passBySession.has(r.session)) passBySession.set(r.session, new Set());
|
|
271
|
+
passBySession.get(r.session).add(lc(r.skill));
|
|
272
|
+
}
|
|
273
|
+
let matchPass = 0, matchAdopt = 0;
|
|
274
|
+
for (const [session, skills] of passBySession) {
|
|
275
|
+
const adopted = adoptBySession.get(session);
|
|
276
|
+
for (const sk of skills) { matchPass++; if (adopted?.has(sk)) matchAdopt++; }
|
|
238
277
|
}
|
|
239
278
|
grid.push({ floor, margin, pass, matchPass, matchAdopt, precision: matchPass ? matchAdopt / matchPass : null });
|
|
240
279
|
}
|
|
@@ -265,8 +304,10 @@ export function recommendSkill(rdb, promptText, project, opts = {}) {
|
|
|
265
304
|
// NOT the registry short name ('superpowers-tdd') — adoption rows log toolInput.skill (the slug),
|
|
266
305
|
// so logging top.name made in-session matched precision a guaranteed 0 for every namespaced skill.
|
|
267
306
|
skill: top ? (top.invocation_name || top.name) : null,
|
|
268
|
-
relevance: top ? top.relevance : null,
|
|
269
|
-
rel2: candidates[1] ? candidates[1].relevance : null,
|
|
307
|
+
relevance: top ? top.relevance : null, // floor replay (absolute bm25)
|
|
308
|
+
rel2: candidates[1] ? candidates[1].relevance : null, // legacy margin replay (pre-composite rows)
|
|
309
|
+
composite1: top ? top.composite_score : null, // margin replay (composite — the live gate metric)
|
|
310
|
+
composite2: candidates[1] ? candidates[1].composite_score : null,
|
|
270
311
|
intentTop: top ? intentMatch(promptText, top) : false,
|
|
271
312
|
cooldownTop: top ? cooldownSet.has(String(top.name).toLowerCase()) : false,
|
|
272
313
|
ncand: candidates.length,
|
package/registry.mjs
CHANGED
|
@@ -62,8 +62,9 @@ const RESOURCES_SCHEMA = `
|
|
|
62
62
|
`;
|
|
63
63
|
|
|
64
64
|
// Canonical FTS5 column order — all consumers must use this order.
|
|
65
|
-
// BM25 weights (positional): trigger_patterns(
|
|
65
|
+
// BM25 weights (positional): trigger_patterns(3), keywords(3), capability_summary(3),
|
|
66
66
|
// intent_tags(2), use_cases(2), domain_tags(1), tech_stack(1), name(1)
|
|
67
|
+
// — must match the bm25(resources_fts, 3,3,3,2,2,1,1,1) call in COMPOSITE_EXPR.
|
|
67
68
|
const FTS5_SCHEMA = `
|
|
68
69
|
CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
|
|
69
70
|
trigger_patterns,
|
|
@@ -371,12 +372,23 @@ const UPSERT_SQL = `
|
|
|
371
372
|
repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
|
|
372
373
|
local_path=excluded.local_path, file_hash=excluded.file_hash,
|
|
373
374
|
invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
375
|
+
-- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
|
|
376
|
+
-- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
|
|
377
|
+
-- other flag to '' -- must NOT blank the FTS text columns and silently drop the resource
|
|
378
|
+
-- out of search (import is the ONLY registry edit path; there is no update subcommand).
|
|
379
|
+
-- Full upserts are unaffected: every field is non-empty, so the CASE picks excluded.
|
|
380
|
+
intent_tags=CASE WHEN excluded.intent_tags != '' THEN excluded.intent_tags ELSE intent_tags END,
|
|
381
|
+
domain_tags=CASE WHEN excluded.domain_tags != '' THEN excluded.domain_tags ELSE domain_tags END,
|
|
382
|
+
action_type=CASE WHEN excluded.action_type != '' THEN excluded.action_type ELSE action_type END,
|
|
383
|
+
trigger_patterns=CASE WHEN excluded.trigger_patterns != '' THEN excluded.trigger_patterns ELSE trigger_patterns END,
|
|
384
|
+
capability_summary=CASE WHEN excluded.capability_summary != '' THEN excluded.capability_summary ELSE capability_summary END,
|
|
385
|
+
input_type=CASE WHEN excluded.input_type != '' THEN excluded.input_type ELSE input_type END,
|
|
386
|
+
output_type=CASE WHEN excluded.output_type != '' THEN excluded.output_type ELSE output_type END,
|
|
387
|
+
prerequisites=excluded.prerequisites,
|
|
388
|
+
keywords=CASE WHEN excluded.keywords != '' THEN excluded.keywords ELSE keywords END,
|
|
389
|
+
tech_stack=CASE WHEN excluded.tech_stack != '' THEN excluded.tech_stack ELSE tech_stack END,
|
|
390
|
+
use_cases=CASE WHEN excluded.use_cases != '' THEN excluded.use_cases ELSE use_cases END,
|
|
391
|
+
complexity=excluded.complexity,
|
|
380
392
|
indexed_at=excluded.indexed_at, updated_at=datetime('now')
|
|
381
393
|
`;
|
|
382
394
|
|
package/schema.mjs
CHANGED
|
@@ -816,7 +816,14 @@ const DEFERRED_CLEANUPS = [
|
|
|
816
816
|
}
|
|
817
817
|
}
|
|
818
818
|
if (canonical) {
|
|
819
|
-
|
|
819
|
+
// Rename the short project to canonical on EVERY project-scoped table.
|
|
820
|
+
// Originally only the first three were rewritten, so a short-named
|
|
821
|
+
// project's deferred TODOs (deferred_work), activity (events), citation
|
|
822
|
+
// history (citation_log), and /clear-/exit handoffs (session_handoffs)
|
|
823
|
+
// were stranded on the old name — invisible to every project-scoped query
|
|
824
|
+
// after normalization. All seven carry a `project` column (verified).
|
|
825
|
+
for (const table of ['observations', 'sdk_sessions', 'session_summaries',
|
|
826
|
+
'session_handoffs', 'citation_log', 'events', 'deferred_work']) {
|
|
820
827
|
db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(canonical.project, shortName);
|
|
821
828
|
}
|
|
822
829
|
}
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -22,6 +22,12 @@ if [[ "$tool" == "Read" ]]; then
|
|
|
22
22
|
if [[ "$input" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
|
|
23
23
|
file_path="${BASH_REMATCH[1]}"
|
|
24
24
|
_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
25
|
+
# Strip trailing slashes so ${_dir##*/} / ${_dir%/*} match Node's path.basename /
|
|
26
|
+
# path.dirname in inferProject(). Without this, CLAUDE_PROJECT_DIR="/org/proj/"
|
|
27
|
+
# gave bash "proj--" (empty base) while JS gave "org--proj" — a name mismatch that
|
|
28
|
+
# made flushEpisode read a DIFFERENT reads-<project>.txt, silently dropping this
|
|
29
|
+
# session's Read context AND orphaning the bash-named file (nothing ever collects it).
|
|
30
|
+
while [[ "$_dir" == */ && ${#_dir} -gt 1 ]]; do _dir="${_dir%/}"; done
|
|
25
31
|
_base="${_dir##*/}"
|
|
26
32
|
_parent="${_dir%/*}"; _parent="${_parent##*/}"
|
|
27
33
|
if [[ -n "$_parent" && "$_parent" != "." && "$_parent" != "/" ]]; then
|
|
@@ -29,8 +35,11 @@ if [[ "$tool" == "Read" ]]; then
|
|
|
29
35
|
else
|
|
30
36
|
project="${_base}"
|
|
31
37
|
fi
|
|
32
|
-
# Sanitize
|
|
38
|
+
# Sanitize + truncate to 100 to match utils.mjs inferProject() EXACTLY
|
|
39
|
+
# (raw.replace(/[^a-zA-Z0-9_.-]/g,'-').slice(0,100)). A >100-char parent--base
|
|
40
|
+
# otherwise diverges from the JS side (same reads-file mismatch as above).
|
|
33
41
|
project="${project//[^a-zA-Z0-9_.-]/-}"
|
|
42
|
+
project="${project:0:100}"
|
|
34
43
|
project="${project:-unknown}"
|
|
35
44
|
# Honor CLAUDE_MEM_DIR relocation (mirrors schema.mjs DB_DIR → hook-shared RUNTIME_DIR).
|
|
36
45
|
# hook.mjs flushEpisode reads reads-<project>.txt from CLAUDE_MEM_DIR/runtime; if this
|
package/search-engine.mjs
CHANGED
|
@@ -259,6 +259,7 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
|
|
|
259
259
|
SELECT o.title, o.narrative FROM observations_fts
|
|
260
260
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
261
261
|
WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
|
|
262
|
+
AND o.superseded_at IS NULL
|
|
262
263
|
AND (? IS NULL OR o.project = ?)
|
|
263
264
|
ORDER BY ${OBS_BM25}
|
|
264
265
|
LIMIT 8
|
|
@@ -304,7 +305,9 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
|
|
|
304
305
|
* 1. FTS5 MATCH with the sanitized query (AND-by-default), recency-weighted
|
|
305
306
|
* 2. If AND returns 0 → relaxFtsQueryToOr fallback (mirrors searchObservationsHybrid)
|
|
306
307
|
*
|
|
307
|
-
* Always skips compressed rows
|
|
308
|
+
* Always skips compressed AND superseded rows — paired with searchObservationsHybrid /
|
|
309
|
+
* buildObsFtsQuery so `timeline --query` / mem_timeline never anchor on a memory that
|
|
310
|
+
* search itself hides (an anchor on a replaced row strands navigation on stale content).
|
|
308
311
|
*
|
|
309
312
|
* @param {Database} db
|
|
310
313
|
* @param {object} opts
|
|
@@ -324,6 +327,7 @@ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfL
|
|
|
324
327
|
WHERE observations_fts MATCH ?
|
|
325
328
|
AND (? IS NULL OR o.project = ?)
|
|
326
329
|
AND COALESCE(o.compressed_into, 0) = 0
|
|
330
|
+
AND o.superseded_at IS NULL
|
|
327
331
|
ORDER BY ${OBS_BM25}
|
|
328
332
|
* (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${halfLifeMs}.0))
|
|
329
333
|
LIMIT 1
|
package/search-scoring.mjs
CHANGED
|
@@ -199,6 +199,7 @@ export function expandQueryByConcepts(db, ftsQuery, project) {
|
|
|
199
199
|
SELECT o.concepts FROM observations_fts
|
|
200
200
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
201
201
|
WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
|
|
202
|
+
AND o.superseded_at IS NULL
|
|
202
203
|
AND (? IS NULL OR o.project = ?)
|
|
203
204
|
ORDER BY ${OBS_BM25}
|
|
204
205
|
LIMIT 20
|
|
@@ -281,6 +282,11 @@ export function runIdleCleanup(db) {
|
|
|
281
282
|
WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
|
|
282
283
|
AND type IN (${types})
|
|
283
284
|
AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
|
|
285
|
+
-- Never auto-mark a lesson-bearing row for purge. This idle path is the
|
|
286
|
+
-- MCP-server sibling of maintain-core.decayAndMarkIdle and must carry the
|
|
287
|
+
-- SAME "lessons never auto-GC" guard; without it a lesson demoted to imp≤1
|
|
288
|
+
-- by citation-decay gets pending-purge'd here and hard-deleted by purgeStale.
|
|
289
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
284
290
|
`).run(cutoff);
|
|
285
291
|
totalMarked += marked.changes;
|
|
286
292
|
|
|
@@ -289,6 +295,10 @@ export function runIdleCleanup(db) {
|
|
|
289
295
|
WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
|
|
290
296
|
AND type IN (${types})
|
|
291
297
|
AND created_at_epoch < ?
|
|
298
|
+
-- Same lesson guard: auto-compress (-1) hides the row from all retrieval and
|
|
299
|
+
-- recoverBuriedLessons only re-floors live (compressed_into=0) rows, so a
|
|
300
|
+
-- compressed lesson is unrecoverable. Parity with selectCompressionCandidates.
|
|
301
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
292
302
|
`).run(cutoff);
|
|
293
303
|
totalCompressed += compressed.changes;
|
|
294
304
|
}
|
package/source-files.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export const SOURCE_FILES = [
|
|
|
13
13
|
'plugin-cache-guard.mjs',
|
|
14
14
|
'haiku-client.mjs', 'utils.mjs', 'schema.mjs',
|
|
15
15
|
'package.json', 'package-lock.json', 'skill.md',
|
|
16
|
-
'registry.mjs', 'registry-scanner.mjs',
|
|
16
|
+
'registry.mjs', 'registry-scanner.mjs',
|
|
17
17
|
'registry-retriever.mjs', 'resource-discovery.mjs',
|
|
18
18
|
// registry-recommend.mjs: statically imported by hook.mjs (PostToolUse adoption probe)
|
|
19
19
|
// and scripts/user-prompt-search.js (UserPromptSubmit shadow recommendation).
|
package/tool-schemas.mjs
CHANGED
|
@@ -92,7 +92,7 @@ export const memSearchSchema = {
|
|
|
92
92
|
limit: coerceInt.pipe(z.number().int().min(1).max(100)).optional().describe('Max results (default 20)'),
|
|
93
93
|
offset: coerceInt.pipe(z.number().int().min(0)).optional().describe('Offset for pagination'),
|
|
94
94
|
sort: z.enum(['relevance', 'time', 'importance']).optional().describe('Sort order: relevance (default, BM25), time (newest first), importance (highest first)'),
|
|
95
|
-
include_noise:
|
|
95
|
+
include_noise: coerceBool.optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default as they have ~3% access rate'),
|
|
96
96
|
or: coerceBool.optional().describe('Force OR semantics between query terms from the start (default: AND with automatic OR-fallback when AND returns 0). Aligns with CLI --or.'),
|
|
97
97
|
deep: coerceBool.optional().describe('Tri-state LLM multi-query/HyDE deep search (observations-only). true=force; false=never; omit=AUTO (default ON for mem_search): a normal search that returns weak/few results auto-escalates with ONE Haiku call (query rewritten to keyword/concept/HyDE variants, RRF-fused). Set CLAUDE_MEM_AUTO_DEEP=0 to disable AUTO. Passive recall stays single-query.'),
|
|
98
98
|
rerank: coerceBool.optional().describe('Opt-in: LLM-rerank the deep-search candidates for ranking precision (one extra Haiku call, ~1.4s). Requires deep=true (no effect on AUTO/normal). Reserve for hard, ranking-sensitive queries where the right memory is likely retrieved but mis-ranked — skip for routine search. Default off.'),
|
|
@@ -249,7 +249,7 @@ export const memExportSchema = {
|
|
|
249
249
|
export const memRecallSchema = {
|
|
250
250
|
file: z.string().min(1).describe('File path or filename to recall observations for'),
|
|
251
251
|
limit: coerceInt.pipe(z.number().int().min(1).max(50)).optional().describe('Max results (default 10)'),
|
|
252
|
-
include_noise:
|
|
252
|
+
include_noise: coerceBool.optional().describe('Include hook-llm fallback titles ("Modified X", "Worked on X", raw error logs) — hidden by default for parity with mem_search'),
|
|
253
253
|
};
|
|
254
254
|
|
|
255
255
|
export const memFtsCheckSchema = {
|
package/registry-indexer.mjs
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
// claude-mem-lite: Resource indexer — extracts metadata via Haiku LLM
|
|
2
|
-
// Called during installation and when resource content changes
|
|
3
|
-
|
|
4
|
-
import { upsertResource } from './registry.mjs';
|
|
5
|
-
import { callHaikuJSON } from './haiku-client.mjs';
|
|
6
|
-
import { debugLog, debugCatch, truncate } from './utils.mjs';
|
|
7
|
-
|
|
8
|
-
// ─── Index Prompt ────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Build the LLM prompt for extracting structured metadata from a resource.
|
|
12
|
-
* @param {object} resource Scanned resource with name, type, and content
|
|
13
|
-
* @returns {string} Prompt string for Haiku JSON extraction
|
|
14
|
-
*/
|
|
15
|
-
function buildIndexPrompt(resource) {
|
|
16
|
-
const content = truncate(resource.content, 3000);
|
|
17
|
-
return `Analyze this Claude Code ${resource.type} definition and extract structured metadata.
|
|
18
|
-
Return ONLY valid JSON, no markdown fences.
|
|
19
|
-
|
|
20
|
-
<resource-name>${resource.name}</resource-name>
|
|
21
|
-
<resource-type>${resource.type}</resource-type>
|
|
22
|
-
<content>
|
|
23
|
-
${content}
|
|
24
|
-
</content>
|
|
25
|
-
|
|
26
|
-
JSON format:
|
|
27
|
-
{"intent_tags":"comma-separated intent keywords (e.g. test,debug,deploy,review)","domain_tags":"comma-separated tech/language tags (e.g. javascript,react,python)","action_type":"analyze|generate|transform|validate|deploy|configure|review","trigger_patterns":"natural language describing when to use this (e.g. when user needs to write tests; when debugging errors)","capability_summary":"50-100 char description of what it does","keywords":"specific technical terms, framework names, method names not duplicating intent_tags (e.g. jest,vitest,red-green-refactor,pytest)","tech_stack":"specific frameworks and tools this works with (e.g. jest,vitest,mocha,pytest)","use_cases":"3-5 specific usage scenarios separated by semicolons","input_type":"code|file|directory|url|text","output_type":"report|file|diff|terminal_output|analysis"}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ─── Fallback Extraction ─────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Extract basic metadata from resource content using regex when Haiku fails.
|
|
34
|
-
* Better than nothing — provides minimal FTS5 searchability.
|
|
35
|
-
*/
|
|
36
|
-
function fallbackExtract(resource) {
|
|
37
|
-
const content = (resource.content || '').toLowerCase();
|
|
38
|
-
const name = resource.name.toLowerCase();
|
|
39
|
-
|
|
40
|
-
// Infer intent tags from name and common patterns
|
|
41
|
-
const intentMap = {
|
|
42
|
-
commit: 'commit,git,version-control',
|
|
43
|
-
test: 'test,testing,tdd,qa',
|
|
44
|
-
debug: 'debug,troubleshoot,fix,error',
|
|
45
|
-
review: 'review,code-review,quality',
|
|
46
|
-
lint: 'lint,format,style,quality',
|
|
47
|
-
deploy: 'deploy,release,ci,cd',
|
|
48
|
-
build: 'build,compile,bundle',
|
|
49
|
-
refactor: 'refactor,clean,simplify',
|
|
50
|
-
security: 'security,audit,vulnerability',
|
|
51
|
-
perf: 'performance,optimize,benchmark',
|
|
52
|
-
doc: 'documentation,readme,docs',
|
|
53
|
-
design: 'design,ui,ux,frontend',
|
|
54
|
-
infra: 'infrastructure,devops,cloud',
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const intentTagSet = new Set();
|
|
58
|
-
for (const [key, tags] of Object.entries(intentMap)) {
|
|
59
|
-
if (name.includes(key) || content.includes(key)) {
|
|
60
|
-
for (const t of tags.split(',')) intentTagSet.add(t);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
const intentTags = [...intentTagSet].join(',');
|
|
64
|
-
|
|
65
|
-
// Infer domain tags from content
|
|
66
|
-
const domainPatterns = [
|
|
67
|
-
[/\b(javascript|typescript|node|npm|yarn|pnpm)\b/, 'javascript,typescript,node'],
|
|
68
|
-
[/\b(python|pip|poetry|django|flask)\b/, 'python'],
|
|
69
|
-
[/\b(react|vue|angular|svelte|next)\b/, 'frontend,javascript'],
|
|
70
|
-
[/\b(rust|cargo)\b/, 'rust'],
|
|
71
|
-
[/\b(go|golang)\b/, 'go'],
|
|
72
|
-
[/\b(docker|kubernetes|k8s|terraform)\b/, 'infrastructure,devops'],
|
|
73
|
-
[/\b(postgres|mysql|sqlite|mongodb)\b/, 'database'],
|
|
74
|
-
[/\b(api|rest|graphql|grpc)\b/, 'api,backend'],
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
let domainTags = '';
|
|
78
|
-
for (const [pattern, tags] of domainPatterns) {
|
|
79
|
-
if (pattern.test(content)) {
|
|
80
|
-
domainTags = domainTags ? `${domainTags},${tags}` : tags;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
intent_tags: intentTags || resource.type,
|
|
86
|
-
domain_tags: domainTags || '',
|
|
87
|
-
action_type: resource.type === 'agent' ? 'analyze' : 'generate',
|
|
88
|
-
trigger_patterns: `when user needs ${name.replace(/-/g, ' ')} functionality`,
|
|
89
|
-
capability_summary: truncate(`${resource.type}: ${name.replace(/-/g, ' ')}`, 100),
|
|
90
|
-
input_type: 'code',
|
|
91
|
-
output_type: resource.type === 'agent' ? 'analysis' : 'file',
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ─── Single Resource Indexing ────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Index a single resource: call Haiku for metadata, then upsert to DB.
|
|
99
|
-
* Falls back to regex extraction if Haiku fails.
|
|
100
|
-
* @param {Database} db Registry database
|
|
101
|
-
* @param {object} resource Scanned resource object
|
|
102
|
-
* @returns {Promise<number>} Resource ID
|
|
103
|
-
*/
|
|
104
|
-
export async function indexResource(db, resource) {
|
|
105
|
-
const prompt = buildIndexPrompt(resource);
|
|
106
|
-
let metadata = await callHaikuJSON(prompt, { timeout: 15000, maxTokens: 400 });
|
|
107
|
-
|
|
108
|
-
if (!metadata || !metadata.capability_summary) {
|
|
109
|
-
debugLog('WARN', 'indexer', `Haiku failed for ${resource.name}, using fallback`);
|
|
110
|
-
metadata = fallbackExtract(resource);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const now = new Date().toISOString();
|
|
114
|
-
const id = upsertResource(db, {
|
|
115
|
-
name: resource.name,
|
|
116
|
-
type: resource.type,
|
|
117
|
-
status: 'active',
|
|
118
|
-
source: resource.source,
|
|
119
|
-
repo_url: resource.repoUrl || null,
|
|
120
|
-
repo_stars: resource.repoStars || 0,
|
|
121
|
-
local_path: resource.localPath,
|
|
122
|
-
file_hash: resource.fileHash,
|
|
123
|
-
intent_tags: metadata.intent_tags || '',
|
|
124
|
-
domain_tags: metadata.domain_tags || '',
|
|
125
|
-
action_type: metadata.action_type || '',
|
|
126
|
-
trigger_patterns: metadata.trigger_patterns || '',
|
|
127
|
-
capability_summary: metadata.capability_summary || '',
|
|
128
|
-
input_type: metadata.input_type || '',
|
|
129
|
-
output_type: metadata.output_type || '',
|
|
130
|
-
keywords: metadata.keywords || '',
|
|
131
|
-
tech_stack: metadata.tech_stack || '',
|
|
132
|
-
use_cases: metadata.use_cases || '',
|
|
133
|
-
prerequisites: typeof metadata.prerequisites === 'object'
|
|
134
|
-
? JSON.stringify(metadata.prerequisites) : '{}',
|
|
135
|
-
indexed_at: now,
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
return id;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Index multiple resources sequentially.
|
|
143
|
-
* Logs progress and continues on individual failures.
|
|
144
|
-
* @param {Database} db Registry database
|
|
145
|
-
* @param {object[]} resources Array of scanned resources
|
|
146
|
-
* @returns {Promise<{indexed: number, failed: number}>} Results summary
|
|
147
|
-
*/
|
|
148
|
-
export async function indexAll(db, resources) {
|
|
149
|
-
let indexed = 0;
|
|
150
|
-
let failed = 0;
|
|
151
|
-
|
|
152
|
-
for (const resource of resources) {
|
|
153
|
-
try {
|
|
154
|
-
await indexResource(db, resource);
|
|
155
|
-
indexed++;
|
|
156
|
-
debugLog('DEBUG', 'indexer', `indexed: ${resource.type}/${resource.name}`);
|
|
157
|
-
} catch (e) {
|
|
158
|
-
failed++;
|
|
159
|
-
debugCatch(e, `indexResource(${resource.name})`);
|
|
160
|
-
// Mark as error in DB so it can be retried later
|
|
161
|
-
try {
|
|
162
|
-
upsertResource(db, {
|
|
163
|
-
name: resource.name,
|
|
164
|
-
type: resource.type,
|
|
165
|
-
status: 'error',
|
|
166
|
-
source: resource.source,
|
|
167
|
-
local_path: resource.localPath,
|
|
168
|
-
file_hash: resource.fileHash,
|
|
169
|
-
});
|
|
170
|
-
} catch {}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return { indexed, failed };
|
|
175
|
-
}
|