claude-mem-lite 3.61.1 → 3.63.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/README.md +26 -0
- package/cli/common.mjs +78 -5
- package/format-utils.mjs +86 -1
- package/hook-handoff.mjs +11 -2
- package/hook-llm.mjs +3 -3
- package/hook-shared.mjs +14 -1
- package/hook.mjs +134 -18
- package/hooks/hooks.json +10 -0
- package/install.mjs +64 -2
- package/lib/db-backup.mjs +84 -1
- package/lib/export-columns.mjs +7 -1
- package/lib/recall-core.mjs +8 -0
- package/mem-cli.mjs +50 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/post-tool-recall.js +12 -2
- package/scripts/pre-agent-inject.js +22 -4
- package/scripts/pre-skill-bridge.js +16 -3
- package/scripts/pre-tool-recall.js +26 -10
- package/scripts/prompt-search-utils.mjs +6 -2
- package/scripts/user-prompt-search.js +38 -11
- package/search-engine.mjs +36 -6
- package/search-scoring.mjs +7 -2
- package/server.mjs +102 -41
- package/source-files.mjs +30 -1
- package/tool-schemas.mjs +10 -3
|
@@ -154,13 +154,17 @@ export const DEDUP_STALE_MS = 300_000; // 5 minutes
|
|
|
154
154
|
* Check if injection should be skipped based on deduplication state.
|
|
155
155
|
* @param {number[]} newIds - candidate observation IDs
|
|
156
156
|
* @param {string} injectedFile - path to the dedup state file
|
|
157
|
+
* @param {string} [sessionId] - CC session id; a payload written by a DIFFERENT
|
|
158
|
+
* session never suppresses (M-6, audit 2026-08-14: the file is keyed by project,
|
|
159
|
+
* so session A's injections + count cap silently carried into session B)
|
|
157
160
|
* @returns {boolean} true if injection should be skipped
|
|
158
161
|
*/
|
|
159
|
-
export function shouldSkipByDedup(newIds, injectedFile) {
|
|
162
|
+
export function shouldSkipByDedup(newIds, injectedFile, sessionId) {
|
|
160
163
|
if (!newIds || newIds.length === 0) return true;
|
|
161
164
|
try {
|
|
162
165
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
163
|
-
const { ids: prevIds, ts, count = 0 } = JSON.parse(raw);
|
|
166
|
+
const { ids: prevIds, ts, count = 0, session } = JSON.parse(raw);
|
|
167
|
+
if (session && sessionId && session !== sessionId) return false;
|
|
164
168
|
if (count >= MAX_SESSION_INJECTIONS) return true;
|
|
165
169
|
if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
|
|
166
170
|
if (!Array.isArray(prevIds) || prevIds.length === 0) return false;
|
|
@@ -14,9 +14,14 @@ import Database from 'better-sqlite3';
|
|
|
14
14
|
import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, extractDeferredRefs, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
|
|
15
15
|
import { getDeferredByIds } from '../lib/deferred-work.mjs';
|
|
16
16
|
import { recommendSkill } from '../registry-recommend.mjs';
|
|
17
|
+
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
18
|
+
import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
|
|
17
19
|
|
|
18
20
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
19
21
|
|
|
22
|
+
// Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
|
|
23
|
+
// <data-dir>/runtime — the same dir the sibling hook scripts + `stats` read.
|
|
24
|
+
const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DB_DIR, 'runtime');
|
|
20
25
|
const INJECTED_IDS_FILE = join(DB_DIR, 'runtime', `.claude-mem-injected-${inferProject()}`);
|
|
21
26
|
// Per-prompt UPS cap. Cut from 5 → 3 after the 2026-05-09 per-hook recall
|
|
22
27
|
// scan (#8255): UPS contributed 74% of silent injected IDs (131/177) at 26%
|
|
@@ -344,7 +349,7 @@ export function searchByFts(db, queryText, project, limit, typeFilter,
|
|
|
344
349
|
SELECT o.id, o.type, o.title, o.lesson_learned,
|
|
345
350
|
${OBS_BM25} as bm25_raw,
|
|
346
351
|
${OBS_BM25}
|
|
347
|
-
* (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
|
|
352
|
+
* (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
|
|
348
353
|
* ${TYPE_QUALITY_CASE}
|
|
349
354
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
350
355
|
* ${noisePenaltyClause('o')}
|
|
@@ -660,7 +665,7 @@ async function main() {
|
|
|
660
665
|
// Namespace dedup ids as "D<id>" (parity with the "P<id>" prompt-corpus
|
|
661
666
|
// convention) so obs ids can't collide in the shared injected-ids file.
|
|
662
667
|
const dedupIds = openRows.map(r => `D${r.id}`);
|
|
663
|
-
if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE)) {
|
|
668
|
+
if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE, hookData.session_id)) {
|
|
664
669
|
const lines = ['[mem] Deferred work referenced in prompt (open items, full detail):'];
|
|
665
670
|
for (const r of openRows) {
|
|
666
671
|
const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
|
|
@@ -680,15 +685,20 @@ async function main() {
|
|
|
680
685
|
let prevCount = 0;
|
|
681
686
|
try {
|
|
682
687
|
const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
|
|
683
|
-
|
|
688
|
+
// M-6: inherit only same-session (or legacy) state — another session's
|
|
689
|
+
// ids/count must not carry over. Atomic write below: a torn concurrent
|
|
690
|
+
// write left the shared marker as invalid JSON (dedup silently off).
|
|
691
|
+
if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
|
|
692
|
+
&& !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
|
|
684
693
|
prevIds = Array.isArray(prev.ids) ? prev.ids : [];
|
|
685
694
|
prevCount = prev.count || 0;
|
|
686
695
|
}
|
|
687
696
|
} catch {}
|
|
688
|
-
|
|
697
|
+
atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
|
|
689
698
|
ids: [...new Set([...prevIds.map(String), ...dedupIds])],
|
|
690
699
|
ts: Date.now(),
|
|
691
700
|
count: prevCount + 1,
|
|
701
|
+
...(hookData.session_id ? { session: hookData.session_id } : {}),
|
|
692
702
|
}));
|
|
693
703
|
} catch {}
|
|
694
704
|
}
|
|
@@ -712,7 +722,13 @@ async function main() {
|
|
|
712
722
|
if (!db) {
|
|
713
723
|
try {
|
|
714
724
|
db = ensureDb();
|
|
715
|
-
} catch {
|
|
725
|
+
} catch (e) {
|
|
726
|
+
// A failed DB open silently kills EVERY prompt-time injection while `stats`
|
|
727
|
+
// reads zero errors (audit 2026-08-14 M-5) — record before the mandatory
|
|
728
|
+
// swallow. Exact blindness class of the 2026-08-13 pre-recall:db-open outage.
|
|
729
|
+
recordHookError('ups:db-open', e, RUNTIME_DIR);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
716
732
|
}
|
|
717
733
|
|
|
718
734
|
try {
|
|
@@ -854,7 +870,7 @@ async function main() {
|
|
|
854
870
|
const candidateIds = rows.length > 0
|
|
855
871
|
? rows.map(r => r.id)
|
|
856
872
|
: promptRows.map(r => `P${r.id}`);
|
|
857
|
-
const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE);
|
|
873
|
+
const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE, hookData.session_id);
|
|
858
874
|
|
|
859
875
|
const output = !dedupSkip
|
|
860
876
|
? (rows.length > 0 ? formatResults(rows) : formatPromptResults(promptRows))
|
|
@@ -866,12 +882,17 @@ async function main() {
|
|
|
866
882
|
let prevCount = 0;
|
|
867
883
|
try {
|
|
868
884
|
const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
|
|
869
|
-
|
|
885
|
+
// M-6: same-session (or legacy) count only; atomic write (torn-write guard).
|
|
886
|
+
if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
|
|
887
|
+
&& !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
|
|
888
|
+
prevCount = prev.count || 0;
|
|
889
|
+
}
|
|
870
890
|
} catch {}
|
|
871
|
-
|
|
891
|
+
atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
|
|
872
892
|
ids: candidateIds,
|
|
873
893
|
ts: Date.now(),
|
|
874
894
|
count: prevCount + 1,
|
|
895
|
+
...(hookData.session_id ? { session: hookData.session_id } : {}),
|
|
875
896
|
}));
|
|
876
897
|
} catch {}
|
|
877
898
|
// v26 P0: bump injection_count for obs-based emits only (prompt-corpus
|
|
@@ -932,8 +953,11 @@ async function main() {
|
|
|
932
953
|
finally { rdb.close(); }
|
|
933
954
|
}
|
|
934
955
|
} catch { /* silent — never block on recommendation failure */ }
|
|
935
|
-
} catch {
|
|
936
|
-
// Hooks must never break Claude Code — swallow
|
|
956
|
+
} catch (e) {
|
|
957
|
+
// Hooks must never break Claude Code — swallow, but RECORD: this catch wraps
|
|
958
|
+
// every FTS query on the surface, so a schema/FTS drift here would zero out
|
|
959
|
+
// prompt-time injection with no trace anywhere (audit 2026-08-14 M-5).
|
|
960
|
+
recordHookError('ups:search', e, RUNTIME_DIR);
|
|
937
961
|
} finally {
|
|
938
962
|
try { db.close(); } catch {}
|
|
939
963
|
}
|
|
@@ -962,5 +986,8 @@ export function isDirectInvocation(metaUrl, argv1) {
|
|
|
962
986
|
return metaUrl.split('?')[0] === pathToFileURL(argv1).href;
|
|
963
987
|
}
|
|
964
988
|
if (isDirectInvocation(import.meta.url, process.argv[1])) {
|
|
965
|
-
main().
|
|
989
|
+
// Last-resort telemetry for anything that escapes main()'s own catches (e.g. a
|
|
990
|
+
// throw between the entry and the guarded body). Recorder never throws; the
|
|
991
|
+
// outer catch keeps the never-non-zero-exit invariant regardless.
|
|
992
|
+
main().catch((e) => { try { recordHookError('ups:main', e, RUNTIME_DIR); } catch { /* never */ } });
|
|
966
993
|
}
|
package/search-engine.mjs
CHANGED
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
DEFAULT_DECAY_HALF_LIFE_MS,
|
|
11
11
|
notLowSignalTitleClause, LOW_SIGNAL_TITLE,
|
|
12
12
|
relaxFtsQueryToOr, debugLog, debugCatch, estimateTokens,
|
|
13
|
+
noisePenaltyClause,
|
|
13
14
|
} from './utils.mjs';
|
|
15
|
+
import { citeFactorClause } from './scoring-sql.mjs';
|
|
14
16
|
import { getVocabulary, computeVector, vectorSearch, rrfMerge, vectorsEnabled } from './tfidf.mjs';
|
|
15
17
|
import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
|
|
16
18
|
|
|
@@ -21,13 +23,26 @@ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
|
|
|
21
23
|
// exponent large-positive → EXP overflowed to +Infinity → score -Infinity → that row sorted
|
|
22
24
|
// #1 for any match AND JSON.stringify emitted `"score": null` (numeric-contract break). A
|
|
23
25
|
// future row now reads as age 0 = max (finite) recency, not Infinity.
|
|
26
|
+
// M-3 (audit 2026-08-14): cite/noise behavior factors joined FULL_SCORE — they had
|
|
27
|
+
// shipped for months on every AUTO surface (UPS / pre-tool-recall / hook-memory)
|
|
28
|
+
// while the EXPLICIT surfaces (mem_search / CLI search) discarded the accumulated
|
|
29
|
+
// citation + noise signal (the mirror image of "guards wired on the auto face,
|
|
30
|
+
// missing on the explicit face"). Both factors are hard-bounded (cite ∈ [0.4, 3.0],
|
|
31
|
+
// noise ∈ {0.2, 0.5, 1.0}), so they reorder, never dominate. algo-F6 note: the
|
|
32
|
+
// access-count LN bonus below stays — noisePenalty uses access_count only inside a
|
|
33
|
+
// ratio GUARD (never as a bonus) and citeFactor reads cited/uncited columns, so no
|
|
34
|
+
// term is counted twice as a reward. Denoise A/B 2026-08-16: NEUTRAL (suite rows
|
|
35
|
+
// carry zero cite/noise state); the behavioral pin lives in
|
|
36
|
+
// tests/audit-fixes-20260816.test.mjs (M-3).
|
|
24
37
|
const FULL_SCORE = `${OBS_BM25}
|
|
25
38
|
* (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
26
39
|
* ${TYPE_QUALITY_CASE}
|
|
27
40
|
* (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
|
|
28
41
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
29
42
|
* (1.0 + 0.1 * LN(1 + COALESCE(o.access_count, 0)))
|
|
30
|
-
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
|
|
43
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
|
|
44
|
+
* ${noisePenaltyClause('o')}
|
|
45
|
+
* ${citeFactorClause('o')}`;
|
|
31
46
|
|
|
32
47
|
const SIMPLE_SCORE = `${OBS_BM25}
|
|
33
48
|
* (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
|
|
@@ -294,6 +309,10 @@ function expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise =
|
|
|
294
309
|
function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includeNoise = false) {
|
|
295
310
|
const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
|
|
296
311
|
if (primaryCount < 3) return;
|
|
312
|
+
// effectiveFtsQuery = the query that actually matched (OR-relaxed when the strict
|
|
313
|
+
// AND missed and the fallback rescued rows — M-2). The strict query here returned
|
|
314
|
+
// zero top docs in that case, silently disabling PRF where it helps most.
|
|
315
|
+
const seedQuery = ctx.effectiveFtsQuery || ftsQuery;
|
|
297
316
|
const topResults = db.prepare(`
|
|
298
317
|
SELECT o.title, o.narrative FROM observations_fts
|
|
299
318
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
@@ -302,7 +321,7 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
|
|
|
302
321
|
AND (? IS NULL OR o.project = ?)
|
|
303
322
|
ORDER BY ${OBS_BM25}
|
|
304
323
|
LIMIT 8
|
|
305
|
-
`).all(
|
|
324
|
+
`).all(seedQuery, args.project ?? null, args.project ?? null);
|
|
306
325
|
const prfTerms = extractPRFTerms(topResults, ftsQuery);
|
|
307
326
|
if (prfTerms.length === 0) return;
|
|
308
327
|
const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
@@ -428,17 +447,28 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
428
447
|
try {
|
|
429
448
|
const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true, includeNoise }))
|
|
430
449
|
.all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
|
|
431
|
-
if (orRows.length > 0)
|
|
450
|
+
if (orRows.length > 0) {
|
|
451
|
+
ctx.orFallbackFired = true;
|
|
452
|
+
// M-2: PRF's top-doc probe re-queries FTS itself — with the strict-AND
|
|
453
|
+
// query it reads 0 docs in exactly the OR-rescue case, keeping PRF inert.
|
|
454
|
+
// Record the query that actually produced the evidence rows.
|
|
455
|
+
ctx.effectiveFtsQuery = orQuery;
|
|
456
|
+
}
|
|
432
457
|
for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
|
|
433
458
|
} catch (e) { debugCatch(e, 'searchObservationsHybrid-or-fallback'); }
|
|
434
459
|
}
|
|
435
460
|
}
|
|
436
461
|
|
|
437
|
-
// Two-phase query expansion (only when well below limit)
|
|
438
|
-
|
|
462
|
+
// Two-phase query expansion (only when well below limit). Gate on results.length,
|
|
463
|
+
// NOT rows.length (M-2, audit 2026-08-14): `rows` is the strict-AND set only, so
|
|
464
|
+
// when strict-AND missed and the OR fallback rescued a few rows — exactly the
|
|
465
|
+
// vocab-mismatch shape where expansion helps most — the old gate read rows.length
|
|
466
|
+
// === 0 and skipped concept/PRF expansion entirely. PRF likewise seeds from the
|
|
467
|
+
// rescued rows now (they are the only relevance evidence available).
|
|
468
|
+
if (results.length > 0 && results.length < Math.ceil(limit / 2)) {
|
|
439
469
|
const existingIds = new Set(results.map(r => r.id));
|
|
440
470
|
expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise);
|
|
441
|
-
expandObsByPRF(db, ctx, now,
|
|
471
|
+
expandObsByPRF(db, ctx, now, results.length, existingIds, results, includeNoise);
|
|
442
472
|
}
|
|
443
473
|
|
|
444
474
|
// Vector search + RRF hybrid merge
|
package/search-scoring.mjs
CHANGED
|
@@ -165,8 +165,13 @@ export function extractPRFTerms(results, ftsQuery, limit = 3) {
|
|
|
165
165
|
// "cach" returns zero rows, only "caching"/"cache" match). Emitting a bare stem would
|
|
166
166
|
// silently match nothing and kill expansion recall. Track each stem's surface forms with
|
|
167
167
|
// their occurrence counts and emit the most frequent (best-matchable) surface.
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
// Prototype-less: doc text tokenizes to arbitrary identifiers, and a stem that
|
|
169
|
+
// collides with an Object.prototype property ("constructor" — common in code
|
|
170
|
+
// narratives) made `stemSurfaces[stem] ||= new Map()` read the INHERITED function
|
|
171
|
+
// as truthy, skip the assignment, and crash on sm.get (surfaced 2026-08-16 when
|
|
172
|
+
// the M-2 gate fix first ran PRF over OR-rescued rows).
|
|
173
|
+
const stemDocCount = Object.create(null); // stem -> # of top docs it appears in (the >=2 bar)
|
|
174
|
+
const stemSurfaces = Object.create(null); // stem -> Map(surface -> total occurrences)
|
|
170
175
|
const docCount = Math.min(results.length, 8);
|
|
171
176
|
for (let i = 0; i < docCount; i++) {
|
|
172
177
|
const r = results[i];
|
package/server.mjs
CHANGED
|
@@ -27,8 +27,11 @@ import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
|
27
27
|
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
28
28
|
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
29
29
|
import { buildLessonNudge } from './lib/save-nudge.mjs';
|
|
30
|
-
import { formatObsFieldValue } from './cli/common.mjs';
|
|
31
|
-
|
|
30
|
+
import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
31
|
+
// The partial-export warning points the caller at the CLI twin, which exports the complete
|
|
32
|
+
// set by default — the invocation has to be the one that actually works on this install.
|
|
33
|
+
import { CLI_INVOKE } from './cli-path.mjs';
|
|
34
|
+
import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from './format-utils.mjs';
|
|
32
35
|
import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
|
|
33
36
|
|
|
34
37
|
// Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
|
|
@@ -638,7 +641,7 @@ server.registerTool(
|
|
|
638
641
|
// gets a scannable hint instead of a bare millisecond integer.
|
|
639
642
|
const display = formatObsFieldValue(f, val);
|
|
640
643
|
const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
|
|
641
|
-
lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
|
|
644
|
+
lines.push(`${obsFieldLabel(f)}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
|
|
642
645
|
}
|
|
643
646
|
sections.push(lines.join('\n'));
|
|
644
647
|
}
|
|
@@ -1113,7 +1116,7 @@ server.registerTool(
|
|
|
1113
1116
|
` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`,
|
|
1114
1117
|
` Broken (no title/narrative): ${stats.broken}`,
|
|
1115
1118
|
` Boostable (accessed>3, imp<3): ${stats.boostable}`,
|
|
1116
|
-
|
|
1119
|
+
formatPendingPurgeLine(stats.pendingPurge),
|
|
1117
1120
|
];
|
|
1118
1121
|
if (duplicates.length > 0) {
|
|
1119
1122
|
const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
|
|
@@ -1159,32 +1162,22 @@ server.registerTool(
|
|
|
1159
1162
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
1160
1163
|
const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
|
|
1161
1164
|
|
|
1162
|
-
// T2-P0-A: purge_stale is the only
|
|
1163
|
-
//
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
if (previewRow.candidates > 0) {
|
|
1174
|
-
lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1175
|
-
lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1176
|
-
}
|
|
1177
|
-
lines.push('');
|
|
1178
|
-
lines.push('Nothing was deleted. To execute, re-run with confirm=true:');
|
|
1179
|
-
lines.push(` mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
|
|
1180
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1181
|
-
}
|
|
1165
|
+
// T2-P0-A: purge_stale is the only op gated on confirm=true — cleanupBroken also
|
|
1166
|
+
// hard-deletes (broken rows only) but always ran unconfirmed on both surfaces, and
|
|
1167
|
+
// the pre-transaction snapshot above covers it. An unconfirmed call gets a dry-run
|
|
1168
|
+
// preview of the purge INSTEAD OF the purge.
|
|
1169
|
+
// M-7 (audit 2026-08-14): preview-instead-of-early-return — the old
|
|
1170
|
+
// `return`-on-unconfirmed skipped EVERY requested op, while the CLI twin ran the
|
|
1171
|
+
// non-destructive ones (cleanup/decay/boost) and previewed only the purge. Same
|
|
1172
|
+
// op list, two different amounts of work done, both reporting success. Aligned
|
|
1173
|
+
// to the CLI semantics (the safer surface changed less: nothing destructive
|
|
1174
|
+
// runs unconfirmed on either surface now or before).
|
|
1175
|
+
const purgeConfirmed = args.confirm === true;
|
|
1182
1176
|
|
|
1183
1177
|
// MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
|
|
1184
1178
|
// only when rows will actually be removed, and OUTSIDE the transaction below
|
|
1185
|
-
// (VACUUM cannot run inside one).
|
|
1186
|
-
|
|
1187
|
-
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
|
|
1179
|
+
// (VACUUM cannot run inside one). Best-effort; snapshotDb never throws.
|
|
1180
|
+
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') && purgeConfirmed }) > 0) {
|
|
1188
1181
|
snapshotDb(db, { tag: 'pre-maintain' });
|
|
1189
1182
|
}
|
|
1190
1183
|
|
|
@@ -1198,8 +1191,25 @@ server.registerTool(
|
|
|
1198
1191
|
if (ops.includes('purge_stale')) {
|
|
1199
1192
|
const retainDays = args.retain_days ?? 30;
|
|
1200
1193
|
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1201
|
-
|
|
1202
|
-
|
|
1194
|
+
if (!purgeConfirmed) {
|
|
1195
|
+
// Dry-run preview (parity with CLI `maintain` without --confirm): the other
|
|
1196
|
+
// requested non-destructive ops still run below.
|
|
1197
|
+
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1198
|
+
const lines = [
|
|
1199
|
+
'purge_stale preview (confirm=false):',
|
|
1200
|
+
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
|
|
1201
|
+
];
|
|
1202
|
+
if (previewRow.candidates > 0) {
|
|
1203
|
+
lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1204
|
+
lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1205
|
+
}
|
|
1206
|
+
lines.push(' Nothing was deleted. To delete, re-run with confirm=true:');
|
|
1207
|
+
lines.push(` mem_maintain(action="execute", operations=${JSON.stringify(ops)}, confirm=true${args.retain_days ? `, retain_days=${args.retain_days}` : ''}${args.project ? `, project="${args.project}"` : ''})`);
|
|
1208
|
+
results.push(lines.join('\n'));
|
|
1209
|
+
} else {
|
|
1210
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1211
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1212
|
+
}
|
|
1203
1213
|
}
|
|
1204
1214
|
|
|
1205
1215
|
if (ops.includes('cleanup')) {
|
|
@@ -1567,6 +1577,11 @@ server.registerTool(
|
|
|
1567
1577
|
|
|
1568
1578
|
// ─── Tool: mem_use ──────────────────────────────────────────────────────────
|
|
1569
1579
|
|
|
1580
|
+
// Cap on the caller-supplied name echoed back in a miss message. Well past any real
|
|
1581
|
+
// skill/agent name (the longest registered one here is 23 chars), short enough that an
|
|
1582
|
+
// unbounded argument cannot pad the response — the echo appears twice.
|
|
1583
|
+
const ECHO_NAME_MAX = 80;
|
|
1584
|
+
|
|
1570
1585
|
server.registerTool(
|
|
1571
1586
|
'mem_use',
|
|
1572
1587
|
{
|
|
@@ -1582,8 +1597,8 @@ server.registerTool(
|
|
|
1582
1597
|
const name = args.name.trim();
|
|
1583
1598
|
const type = args.type || 'skill';
|
|
1584
1599
|
|
|
1585
|
-
// 1. Exact match by name or invocation_name
|
|
1586
|
-
|
|
1600
|
+
// 1. Exact match by name or invocation_name — the ONLY path that loads content.
|
|
1601
|
+
const row = rdb.prepare(`
|
|
1587
1602
|
SELECT id, name, type, local_path, invocation_name, capability_summary
|
|
1588
1603
|
FROM resources
|
|
1589
1604
|
WHERE status = 'active' AND type = ?
|
|
@@ -1591,16 +1606,36 @@ server.registerTool(
|
|
|
1591
1606
|
LIMIT 1
|
|
1592
1607
|
`).get(type, name, name);
|
|
1593
1608
|
|
|
1594
|
-
// 2.
|
|
1609
|
+
// 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
|
|
1610
|
+
// produces the candidate list), but its result is only ever rendered as names: loading
|
|
1611
|
+
// the top hit under the caller's requested name shipped a different skill's body inside
|
|
1612
|
+
// <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
|
|
1613
|
+
// nothing marking the swap, so an agent that asked for A executed B (audit F1,
|
|
1614
|
+
// 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
|
|
1615
|
+
// `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
|
|
1616
|
+
// exact-name decision the caller makes.
|
|
1595
1617
|
if (!row) {
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1618
|
+
let candidates = [];
|
|
1619
|
+
try { candidates = searchResources(rdb, name, { type, limit: 5 }).map((r) => r.name).filter(Boolean); }
|
|
1620
|
+
catch { /* a suggestion is best-effort; the miss message below still stands */ }
|
|
1621
|
+
// Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
|
|
1622
|
+
// raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
|
|
1623
|
+
// imperative inside this message, and the handler-wide defangResult cannot catch it —
|
|
1624
|
+
// <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
|
|
1625
|
+
// it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
|
|
1626
|
+
// Registered names are defanged too (a crafted one can be imported), but NOT truncated:
|
|
1627
|
+
// the suggestion tells the caller to load one by its exact name, so it must stay exact.
|
|
1628
|
+
const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
|
|
1629
|
+
const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
|
|
1630
|
+
const head = `No ${type} found for "${echoed}".`;
|
|
1631
|
+
const browse = `mem_registry(action="search", query="${echoed}")`;
|
|
1632
|
+
if (candidates.length === 0) {
|
|
1633
|
+
return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
|
|
1599
1634
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1635
|
+
const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
|
|
1636
|
+
return { content: [{ type: 'text', text:
|
|
1637
|
+
`${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
|
|
1638
|
+
`Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.` }] };
|
|
1604
1639
|
}
|
|
1605
1640
|
|
|
1606
1641
|
// 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
|
|
@@ -1717,7 +1752,13 @@ async function runExport(db, args) {
|
|
|
1717
1752
|
}
|
|
1718
1753
|
|
|
1719
1754
|
const where = wheres.length > 0 ? 'WHERE ' + wheres.join(' AND ') : '';
|
|
1720
|
-
|
|
1755
|
+
// No clamp (audit 2026-08-14 A2): `Math.min(args.limit ?? 200, 1000)` made an MCP-driven
|
|
1756
|
+
// backup of a >1000-row store impossible, on the tool whose own description says "USE
|
|
1757
|
+
// when: Backing up memory before a migration or reinstall" — while the CLI twin exported
|
|
1758
|
+
// the complete matching set (mem-cli.mjs cmdExport, fixed there for the same reason). The
|
|
1759
|
+
// DEFAULT stays 200: an MCP result is model context, so a bare exploratory call must not
|
|
1760
|
+
// dump a whole store into the transcript. An explicit limit is now honoured at any size.
|
|
1761
|
+
const exportLimit = args.limit ?? 200;
|
|
1721
1762
|
// T3-P2-B: probe limit+1 so we can tell "user hit their own limit with more waiting" from
|
|
1722
1763
|
// "user got exactly what existed". Trim to exportLimit before rendering.
|
|
1723
1764
|
// EXPORT_COLUMNS_SQL: shared with CLI cmdExport — the full round-trippable set restore
|
|
@@ -1733,7 +1774,27 @@ async function runExport(db, args) {
|
|
|
1733
1774
|
? rows.map(r => JSON.stringify(r)).join('\n')
|
|
1734
1775
|
: JSON.stringify(rows, null, 2);
|
|
1735
1776
|
|
|
1736
|
-
|
|
1777
|
+
// A truncated backup is the failure mode this tool must never produce quietly: the old
|
|
1778
|
+
// note ("Results capped at N … increase limit (max 1000)") never said how much was
|
|
1779
|
+
// missing, and its advice was a dead end on a store past the ceiling. Name the real
|
|
1780
|
+
// total, the number of rows left out, and a remedy that actually returns all of them.
|
|
1781
|
+
//
|
|
1782
|
+
// The remedy has to lead with a FILE REDIRECT (pre-tag review, 2026-08-14). Removing the
|
|
1783
|
+
// 1000-row ceiling took away the only bound on an MCP export's size, and the 200 default
|
|
1784
|
+
// is kept precisely because an MCP result IS model context — so "re-run with
|
|
1785
|
+
// limit: <total>", the previous first suggestion, told the caller to put the entire store
|
|
1786
|
+
// into one tool result (thin fixture rows measure ~612 bytes each, so a few-thousand-row
|
|
1787
|
+
// store is megabytes in a single message), and a bare `cli.mjs export` had the same
|
|
1788
|
+
// property unredirected. The limit re-run stays mentioned but demoted, with its cost
|
|
1789
|
+
// stated: it is occasionally what the caller actually wants, and silently dropping it
|
|
1790
|
+
// would send them back to guessing.
|
|
1791
|
+
let cap = '';
|
|
1792
|
+
if (moreAvailable) {
|
|
1793
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM observations ${where}`).get(...params).c;
|
|
1794
|
+
cap = `\nWARNING — PARTIAL EXPORT, NOT A COMPLETE BACKUP: capped at ${exportLimit} of ${total} matching observations; ${total - exportLimit} rows are missing from this payload and restoring it would lose them.` +
|
|
1795
|
+
`\nFor a complete backup, write it to a FILE instead of pulling it through this conversation: \`${CLI_INVOKE} export --format jsonl > backup.jsonl\` (the CLI exports the complete set by default). Narrowing with date_from/date_to also works.` +
|
|
1796
|
+
`\nRaising \`limit\` here is the last resort, not the first: this result is model context, so all ${total} rows would be loaded into the transcript.`;
|
|
1797
|
+
}
|
|
1737
1798
|
return { content: [{ type: 'text', text: `Exported ${rows.length} observations:${cap}\n${output}` }] };
|
|
1738
1799
|
}
|
|
1739
1800
|
|
package/source-files.mjs
CHANGED
|
@@ -264,9 +264,37 @@ const LAUNCHER_SCRIPT_FILES = [
|
|
|
264
264
|
'binding-probe-cli.mjs',
|
|
265
265
|
];
|
|
266
266
|
|
|
267
|
+
// Plugin/marketplace DECLARATION files (audit 2026-08-14 P-2). Not executable
|
|
268
|
+
// themselves, but they NAME what gets executed: hooks/hooks.json declares the
|
|
269
|
+
// command lines Claude Code runs on every hook fire, .mcp.json declares the MCP
|
|
270
|
+
// server launch command, plugin.json/marketplace.json steer the install source,
|
|
271
|
+
// commands/*.md are model-visible skill bodies, registry/preinstalled.json seeds
|
|
272
|
+
// the resource registry. All ship in the tarball; none were signed — the same
|
|
273
|
+
// shape as the two closed RCE gaps (hook scripts v3.40, launch.mjs v3.42): a
|
|
274
|
+
// release published without the signing key could swap hooks.json to point a
|
|
275
|
+
// hook event at an arbitrary command while every signed hash still matched.
|
|
276
|
+
// Additive: buildReleaseManifest skips entries absent at sign time, and
|
|
277
|
+
// verifyReleaseFiles needs no change to enforce whatever the manifest carries.
|
|
278
|
+
const PLUGIN_DECLARATION_FILES = [
|
|
279
|
+
'hooks/hooks.json',
|
|
280
|
+
'.mcp.json',
|
|
281
|
+
'.claude-plugin/plugin.json',
|
|
282
|
+
'.claude-plugin/marketplace.json',
|
|
283
|
+
'registry/preinstalled.json',
|
|
284
|
+
'commands/mem.md',
|
|
285
|
+
'commands/memory.md',
|
|
286
|
+
'commands/update.md',
|
|
287
|
+
'commands/tools.md',
|
|
288
|
+
'commands/adopt.md',
|
|
289
|
+
'commands/unadopt.md',
|
|
290
|
+
'commands/lesson.md',
|
|
291
|
+
'commands/bug.md',
|
|
292
|
+
];
|
|
293
|
+
|
|
267
294
|
// The complete set of files the release signature MUST cover: every runtime .mjs
|
|
268
295
|
// (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
|
|
269
|
-
// into the live dir and they run on every hook fire) PLUS the launcher/setup scripts
|
|
296
|
+
// into the live dir and they run on every hook fire) PLUS the launcher/setup scripts
|
|
297
|
+
// PLUS the plugin declaration files above.
|
|
270
298
|
// HOOK_SCRIPT_FILES were historically NOT in the signed manifest, so an attacker able to
|
|
271
299
|
// PUBLISH a release — but without the signing key — could swap a hook script (e.g.
|
|
272
300
|
// post-tool-use.sh / hook-launcher.mjs) while every SOURCE_FILES hash still matched, and
|
|
@@ -278,4 +306,5 @@ export const RELEASE_SIGNED_FILES = [
|
|
|
278
306
|
...SOURCE_FILES,
|
|
279
307
|
...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
280
308
|
...LAUNCHER_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
309
|
+
...PLUGIN_DECLARATION_FILES,
|
|
281
310
|
];
|
package/tool-schemas.mjs
CHANGED
|
@@ -206,7 +206,7 @@ export const memSaveSchema = {
|
|
|
206
206
|
type: OBS_TYPE_ENUM.optional().describe('Observation type (default: discovery)'),
|
|
207
207
|
project: z.string().optional().describe('Project name (default: inferred from CWD)'),
|
|
208
208
|
importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('Importance level: 1=routine, 2=notable, 3=critical (default: 2 for explicit saves)'),
|
|
209
|
-
files: coerceStringArray.optional().describe('File paths associated with this observation'),
|
|
209
|
+
files: coerceStringArray.optional().describe('File paths associated with this observation. Stored in the `files_modified` column and rendered as `files` — passing a path here does not assert the file was edited; a file you only read belongs here too'),
|
|
210
210
|
lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway, ≤500 chars (for bugfix: root cause & fix; for decision: rationale)'),
|
|
211
211
|
closes_deferred: coerceDeferredTokens.optional().describe('Close one or more deferred_work items in the same project. Mixed array: bare integer = ordinal-within-project, "D#<n>" string = raw id. Transactional with the obs insert — a single invalid id rolls back the whole save.'),
|
|
212
212
|
supersedes: coerceSupersedes.optional().describe('Observation ids (same project) that this save overturns. They are marked superseded — dropped from live search — and linked to the new row (superseded_by). Use ONLY when this genuinely replaces a prior conclusion; do NOT use for merely-related or updated-but-still-valid memories.'),
|
|
@@ -280,7 +280,13 @@ export const memExportSchema = {
|
|
|
280
280
|
from: z.string().optional().describe('Alias for `date_from` (CLI `export --from`)'),
|
|
281
281
|
to: z.string().optional().describe('Alias for `date_to` (CLI `export --to`)'),
|
|
282
282
|
include_compressed: coerceBool.optional().describe('Include compressed observations (default: false)'),
|
|
283
|
-
|
|
283
|
+
// No upper bound: this is the BACKUP tool ("USE when: Backing up memory before a
|
|
284
|
+
// migration or reinstall"), and a 1000-row ceiling made a complete backup of a bigger
|
|
285
|
+
// store impossible over MCP while the CLI twin exported everything (audit 2026-08-14 A2).
|
|
286
|
+
// The default stays 200 because an MCP result is model context — a bare exploratory call
|
|
287
|
+
// must not dump a whole store into the transcript — but a capped result now announces
|
|
288
|
+
// itself as PARTIAL and names the limit that would return all of it.
|
|
289
|
+
limit: coerceInt.pipe(z.number().int().min(1)).optional().describe('Max observations to export (default: 200 — a capped result is flagged PARTIAL and names the total; pass that total for a complete backup, no upper bound)'),
|
|
284
290
|
};
|
|
285
291
|
|
|
286
292
|
export const memRecallSchema = {
|
|
@@ -589,7 +595,8 @@ export const tools = [
|
|
|
589
595
|
{
|
|
590
596
|
name: 'mem_use',
|
|
591
597
|
description:
|
|
592
|
-
'Load and activate a skill or agent from the registry by name
|
|
598
|
+
'Load and activate a skill or agent from the registry by EXACT name or invocation_name.\n' +
|
|
599
|
+
'A name that matches nothing returns closest-match names to pick from — never another resource\'s content.\n' +
|
|
593
600
|
'\n' +
|
|
594
601
|
'DO NOT use when:\n' +
|
|
595
602
|
' - You have not confirmed the skill exists (run mem_registry action="list" first)\n' +
|