claude-mem-lite 3.39.2 → 3.40.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/claudemd.mjs +4 -1
- package/hook-memory.mjs +1 -0
- package/hook-optimize.mjs +13 -5
- package/lib/maintain-core.mjs +5 -0
- package/lib/search-core.mjs +23 -5
- package/lib/timeline-core.mjs +18 -6
- package/mem-cli.mjs +29 -3
- package/nlp.mjs +10 -0
- package/package.json +1 -1
- package/registry-importer.mjs +8 -1
- package/registry.mjs +8 -2
- package/scripts/prompt-search-utils.mjs +8 -2
- package/server.mjs +18 -4
- package/source-files.mjs +13 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.40.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.40.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/claudemd.mjs
CHANGED
|
@@ -143,7 +143,10 @@ export function writeManaged(cwd, { slug, version, block, doc }) {
|
|
|
143
143
|
else next = raw + '\n\n' + section + '\n';
|
|
144
144
|
action = 'created';
|
|
145
145
|
} else {
|
|
146
|
-
|
|
146
|
+
// Function replacer (not a string): a `$`-sequence in `section` (a future template with
|
|
147
|
+
// a shell example / regex / `$1`) would otherwise be interpreted as a replacement
|
|
148
|
+
// back-reference and corrupt the block on every SessionStart refresh. Matches line 153.
|
|
149
|
+
next = raw.replace(m[0], () => section);
|
|
147
150
|
action = next !== raw ? 'updated' : 'unchanged';
|
|
148
151
|
}
|
|
149
152
|
// H2: collapse any DUPLICATE same-slug blocks (keep the first, drop the rest).
|
package/hook-memory.mjs
CHANGED
|
@@ -255,6 +255,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
255
255
|
try {
|
|
256
256
|
const crossStmt = db.prepare(`
|
|
257
257
|
SELECT o.id, o.type, o.title, o.subtitle, o.narrative, o.importance, o.lesson_learned, o.project,
|
|
258
|
+
o.created_at_epoch, o.files_modified,
|
|
258
259
|
o.cited_count, o.uncited_streak,
|
|
259
260
|
${OBS_BM25} as relevance,
|
|
260
261
|
${noisePenaltyClause('o')} as noise_penalty
|
package/hook-optimize.mjs
CHANGED
|
@@ -282,7 +282,12 @@ export function _normalizeGateOpen(last, now) {
|
|
|
282
282
|
return now - epoch >= NORMALIZE_INTERVAL_MS;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
export function shouldRunNormalize() {
|
|
285
|
+
export function shouldRunNormalize(project = null) {
|
|
286
|
+
// The 7-day gate rate-limits the UNSCOPED whole-store normalize. An explicit --project is
|
|
287
|
+
// targeted work: it must not be blocked by a prior global (or other-project) run, and it
|
|
288
|
+
// does not advance the shared timer (see executeNormalize). Without this, `optimize --run
|
|
289
|
+
// --task normalize --project B` returned skipped(gate) for 7 days if ANY project had run.
|
|
290
|
+
if (project) return true;
|
|
286
291
|
try {
|
|
287
292
|
const last = JSON.parse(readFileSync(NORMALIZE_GATE_FILE, 'utf8'));
|
|
288
293
|
return _normalizeGateOpen(last, Date.now());
|
|
@@ -399,7 +404,7 @@ export function applyNormalization(db, groups, { project = null } = {}) {
|
|
|
399
404
|
}
|
|
400
405
|
|
|
401
406
|
export async function executeNormalize(db, force = false, { project } = {}) {
|
|
402
|
-
if (!force && !shouldRunNormalize()) return { skipped: true, reason: 'gate' };
|
|
407
|
+
if (!force && !shouldRunNormalize(project)) return { skipped: true, reason: 'gate' };
|
|
403
408
|
|
|
404
409
|
const concepts = extractUniqueConcepts(db, 500, { project });
|
|
405
410
|
if (concepts.length < 5) return { skipped: true, reason: 'too few concepts' };
|
|
@@ -409,7 +414,10 @@ export async function executeNormalize(db, force = false, { project } = {}) {
|
|
|
409
414
|
|
|
410
415
|
const result = applyNormalization(db, groups, { project });
|
|
411
416
|
|
|
412
|
-
|
|
417
|
+
// Only the UNSCOPED (whole-store) run advances the shared 7-day gate. A project-scoped run
|
|
418
|
+
// must not reset the global timer (it never consulted it — shouldRunNormalize(project) is
|
|
419
|
+
// always open), or one `--project X` run would silently block the next global normalize.
|
|
420
|
+
if (!project) { try { writeFileSync(NORMALIZE_GATE_FILE, JSON.stringify({ epoch: Date.now() })); } catch { /* best-effort */ } }
|
|
413
421
|
|
|
414
422
|
return { processed: result.updated, groups: groups.length };
|
|
415
423
|
}
|
|
@@ -837,7 +845,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
837
845
|
const reenrichAliases = findReenrichCandidates(db, 5000, { scope: 'aliases', project }).length;
|
|
838
846
|
|
|
839
847
|
const concepts = extractUniqueConcepts(db, 500, { project });
|
|
840
|
-
const normalizeReady = shouldRunNormalize() && concepts.length >= 5;
|
|
848
|
+
const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
|
|
841
849
|
|
|
842
850
|
const mergeClusters = findMergeCandidates(db, 50, { project });
|
|
843
851
|
const clusterMerge = mergeClusters.length;
|
|
@@ -851,7 +859,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
851
859
|
reenrichWide,
|
|
852
860
|
reenrichAliases,
|
|
853
861
|
normalize: normalizeReady ? concepts.length : 0,
|
|
854
|
-
normalizeGateOpen: shouldRunNormalize(),
|
|
862
|
+
normalizeGateOpen: shouldRunNormalize(project),
|
|
855
863
|
clusterMerge,
|
|
856
864
|
smartCompress,
|
|
857
865
|
total: reenrich + (normalizeReady ? 1 : 0) + clusterMerge + smartCompress,
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -397,10 +397,15 @@ export function maintenanceStats(db, { projectFilter, baseParams, staleAge }) {
|
|
|
397
397
|
-- the scan stat previews what decay will mark idle, and decay protects
|
|
398
398
|
-- injected rows. Omitting it over-counted "stale" by the injected-but-decayed
|
|
399
399
|
-- rows decay never touches (e.g. demote_pinned's output: imp=1 but inj>0).
|
|
400
|
+
-- lesson_learned guard mirrors decayAndMarkIdle (:188) / cleanupBroken (:153): those
|
|
401
|
+
-- ops NEVER touch a lesson-bearing row ("lessons never auto-GC"), so the scan preview
|
|
402
|
+
-- must exclude them too or it over-forecasts "Stale"/"Broken" vs what execute does.
|
|
400
403
|
COALESCE(SUM(CASE WHEN COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
|
|
401
404
|
AND COALESCE(injection_count, 0) = 0
|
|
405
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
402
406
|
AND created_at_epoch < ? THEN 1 ELSE 0 END), 0) as stale,
|
|
403
407
|
COALESCE(SUM(CASE WHEN (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
|
|
408
|
+
AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
|
|
404
409
|
THEN 1 ELSE 0 END), 0) as broken,
|
|
405
410
|
COALESCE(SUM(CASE WHEN COALESCE(access_count, 0) > 3 AND COALESCE(importance, 1) < 3
|
|
406
411
|
THEN 1 ELSE 0 END), 0) as boostable,
|
package/lib/search-core.mjs
CHANGED
|
@@ -54,6 +54,27 @@ export function parseDuration(raw) {
|
|
|
54
54
|
return { ok: true, ms: n * DURATION_UNIT_MS[m[2].toLowerCase()] };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Parse one from/to bound to epoch ms. A bare `YYYY-MM-DD` is the user's LOCAL
|
|
59
|
+
* calendar day (created_at_epoch is Date.now() = local wall-clock), so it must be
|
|
60
|
+
* built in local time — `new Date('YYYY-MM-DD')` parses as UTC midnight and shifts the
|
|
61
|
+
* boundary by the tz offset (8h for the UTC+8 base), silently dropping early-morning
|
|
62
|
+
* rows and leaking the next day's early hours. `endOfDay` extends a date-only bound to
|
|
63
|
+
* 23:59:59.999 local (the inclusive `--to` day). Full ISO 8601 (with time/offset) is
|
|
64
|
+
* parsed as-is, honoring any explicit zone. Out-of-range date-only strings return NaN so
|
|
65
|
+
* the caller's isNaN guard rejects them (matches the old UTC parse's strict Invalid Date).
|
|
66
|
+
*/
|
|
67
|
+
function parseCalendarBound(raw, endOfDay) {
|
|
68
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
69
|
+
const [y, m, d] = raw.split('-').map(Number);
|
|
70
|
+
const dt = endOfDay ? new Date(y, m - 1, d, 23, 59, 59, 999) : new Date(y, m - 1, d, 0, 0, 0, 0);
|
|
71
|
+
// new Date(2026, 12, 45) silently rolls over; reject so 2026-13-45 fails like before.
|
|
72
|
+
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return NaN;
|
|
73
|
+
return dt.getTime();
|
|
74
|
+
}
|
|
75
|
+
return new Date(raw).getTime();
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
/**
|
|
58
79
|
* Parse from/to date bounds to epoch ms. Date-only `to` (YYYY-MM-DD) extends
|
|
59
80
|
* to end-of-day so "to 2026-06-12" includes that day's rows. An optional
|
|
@@ -63,11 +84,8 @@ export function parseDuration(raw) {
|
|
|
63
84
|
* | { ok: false, bad: 'from'|'to'|'since', value: string }}
|
|
64
85
|
*/
|
|
65
86
|
export function parseDateBounds(fromRaw, toRaw, sinceRaw = null, now = Date.now()) {
|
|
66
|
-
let epochFrom = fromRaw ?
|
|
67
|
-
|
|
68
|
-
if (epochTo !== null && toRaw && /^\d{4}-\d{2}-\d{2}$/.test(toRaw)) {
|
|
69
|
-
epochTo += 86400000 - 1; // extend to 23:59:59.999
|
|
70
|
-
}
|
|
87
|
+
let epochFrom = fromRaw ? parseCalendarBound(fromRaw, false) : null;
|
|
88
|
+
const epochTo = toRaw ? parseCalendarBound(toRaw, true) : null;
|
|
71
89
|
if (epochFrom !== null && isNaN(epochFrom)) return { ok: false, bad: 'from', value: fromRaw };
|
|
72
90
|
if (epochTo !== null && isNaN(epochTo)) return { ok: false, bad: 'to', value: toRaw };
|
|
73
91
|
if (sinceRaw) {
|
package/lib/timeline-core.mjs
CHANGED
|
@@ -17,11 +17,11 @@ import { sanitizeFtsQuery } from '../utils.mjs';
|
|
|
17
17
|
|
|
18
18
|
const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch';
|
|
19
19
|
|
|
20
|
-
/** Nearest non-compressed observation to `epoch
|
|
20
|
+
/** Nearest live (non-compressed, non-superseded) observation to `epoch`. */
|
|
21
21
|
function nearestObservation(db, epoch, project) {
|
|
22
22
|
return db.prepare(`
|
|
23
23
|
SELECT id FROM observations
|
|
24
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${project ? 'AND project = ?' : ''}
|
|
24
|
+
WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${project ? 'AND project = ?' : ''}
|
|
25
25
|
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
26
26
|
`).get(...(project ? [project, epoch] : [epoch]));
|
|
27
27
|
}
|
|
@@ -56,7 +56,7 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
|
|
|
56
56
|
// Bare "#N" or "N" — observation first. Route compressed obs to its live
|
|
57
57
|
// parent so the window (which filters compressed) isn't shown around a dead
|
|
58
58
|
// record; negative sentinels (-1 dropped, -2 pending purge) have no parent.
|
|
59
|
-
const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
|
|
59
|
+
const obsRow = db.prepare('SELECT compressed_into, superseded_at, superseded_by FROM observations WHERE id = ?').get(parsed.id);
|
|
60
60
|
if (obsRow) {
|
|
61
61
|
const ci = obsRow.compressed_into;
|
|
62
62
|
if (ci && ci > 0) {
|
|
@@ -65,6 +65,16 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
|
|
|
65
65
|
if (ci && ci < 0) {
|
|
66
66
|
return { ok: false, error: { code: 'compressed-pruned', id: parsed.id } };
|
|
67
67
|
}
|
|
68
|
+
// Superseded obs → hop to its live successor, mirroring compressed→parent. A
|
|
69
|
+
// superseded row is dropped from every other read path (and from the before/after
|
|
70
|
+
// window legs below), so anchoring ON it would surface a dead record. superseded_by
|
|
71
|
+
// is polymorphic: a numeric obs id for explicit supersession (save-observation), or a
|
|
72
|
+
// string marker ('auto-dedup'/'auto-dedup-fuzzy') for hook auto-dedup — only the
|
|
73
|
+
// numeric case has a successor to redirect to, so guard on `typeof … number` (not
|
|
74
|
+
// `> 0`, which a string sentinel would silently pass as NaN→false anyway but reads wrong).
|
|
75
|
+
if (obsRow.superseded_at !== null && typeof obsRow.superseded_by === 'number' && obsRow.superseded_by > 0) {
|
|
76
|
+
return { ok: true, anchorId: obsRow.superseded_by, anchorNote: `(anchored to #${obsRow.superseded_by}, #${parsed.id} was superseded by it)` };
|
|
77
|
+
}
|
|
68
78
|
return { ok: true, anchorId: parsed.id, anchorNote: null };
|
|
69
79
|
}
|
|
70
80
|
|
|
@@ -138,10 +148,12 @@ export function resolveQueryAnchor(db, queryStr, { project = null } = {}) {
|
|
|
138
148
|
};
|
|
139
149
|
}
|
|
140
150
|
|
|
141
|
-
/** No-anchor fallback: most recent non-compressed
|
|
151
|
+
/** No-anchor fallback: most recent live (non-compressed, non-superseded) obs, newest first. */
|
|
142
152
|
export function fetchRecentTimeline(db, { project = null, limit }) {
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
// superseded_at IS NULL mirrors the before/after window legs (fetchTimelineWindow) and
|
|
154
|
+
// every other read path — a superseded row must not lead the "most recent" timeline.
|
|
155
|
+
const liveFilter = 'COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL';
|
|
156
|
+
const where = project ? `WHERE ${liveFilter} AND project = ?` : `WHERE ${liveFilter}`;
|
|
145
157
|
const params = project ? [project, limit] : [limit];
|
|
146
158
|
return db.prepare(`
|
|
147
159
|
SELECT ${TIMELINE_COLS}
|
package/mem-cli.mjs
CHANGED
|
@@ -1569,7 +1569,10 @@ function cmdUpdate(db, args) {
|
|
|
1569
1569
|
updates.push('lesson_learned = ?');
|
|
1570
1570
|
params.push(scrubSecrets(rawLesson));
|
|
1571
1571
|
}
|
|
1572
|
-
|
|
1572
|
+
// Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
|
|
1573
|
+
// mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
|
|
1574
|
+
// lands searchable + exportable (rebuildObservationDerived folds it into `text`).
|
|
1575
|
+
if (flags.concepts !== undefined) { updates.push('concepts = ?'); params.push(scrubSecrets(flags.concepts)); }
|
|
1573
1576
|
|
|
1574
1577
|
if (updates.length === 0) {
|
|
1575
1578
|
fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
|
|
@@ -1769,8 +1772,16 @@ function cmdRestore(db, argv) {
|
|
|
1769
1772
|
// Re-apply the fields saveObservation zeros/derives so the backup is faithful.
|
|
1770
1773
|
// search_aliases is its own FTS5 column, so this UPDATE re-syncs the index
|
|
1771
1774
|
// (via the observations FTS triggers) and restored aliases stay searchable.
|
|
1775
|
+
// Scrub the FTS-indexed text fields on the way in — the sibling ingest paths
|
|
1776
|
+
// (import-jsonl, compress-core) scrub as defense-in-depth, and restore is the only
|
|
1777
|
+
// rewrite path that skipped it. A backup made before a SECRET_PATTERNS entry existed
|
|
1778
|
+
// would otherwise re-index an old secret in facts/concepts even though narrative
|
|
1779
|
+
// (routed through saveObservation) gets re-scrubbed. files_read/branch are
|
|
1780
|
+
// paths/identifiers, not scrub-target text — left as-is.
|
|
1772
1781
|
signalUpdate.run(
|
|
1773
|
-
r.subtitle || '', r.concepts || '', r.facts || '',
|
|
1782
|
+
scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
|
|
1783
|
+
r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
|
|
1784
|
+
r.files_read || '[]', r.branch ?? null,
|
|
1774
1785
|
num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
|
|
1775
1786
|
num(r.decay_seen_count), r.last_accessed_at ?? null,
|
|
1776
1787
|
res.id,
|
|
@@ -2239,7 +2250,22 @@ function cmdRegistry(_memDb, args) {
|
|
|
2239
2250
|
const name = flags.name;
|
|
2240
2251
|
const resourceType = flags['resource-type'];
|
|
2241
2252
|
if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
|
|
2242
|
-
|
|
2253
|
+
// Validate --source against its CHECK enum (parity with memRegistrySchema.source on MCP):
|
|
2254
|
+
// an invalid value otherwise reaches the INSERT and throws a raw SqliteError stacktrace
|
|
2255
|
+
// (the dispatcher's catch only special-cases SQLITE_BUSY/LOCKED).
|
|
2256
|
+
if (flags.source && !new Set(['preinstalled', 'user', 'github']).has(flags.source)) {
|
|
2257
|
+
fail(`[mem] Invalid --source "${flags.source}". Valid: preinstalled, user, github`);
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
// Preserve provenance on a metadata-only re-import: default source to 'user' only for
|
|
2261
|
+
// a genuinely NEW resource. Re-importing an existing github/preinstalled row without
|
|
2262
|
+
// --source must not flip it to 'user' (which also mis-grants the user-source rank boost).
|
|
2263
|
+
let source = flags.source;
|
|
2264
|
+
if (!source) {
|
|
2265
|
+
const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(resourceType, name);
|
|
2266
|
+
source = existing ? existing.source : 'user';
|
|
2267
|
+
}
|
|
2268
|
+
const fields = { name, type: resourceType, status: 'active', source };
|
|
2243
2269
|
for (const f of ['repo-url', 'local-path', 'invocation-name', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
|
|
2244
2270
|
const camel = f.replace(/-([a-z])/g, (_, c) => '_' + c);
|
|
2245
2271
|
fields[camel] = flags[f] || '';
|
package/nlp.mjs
CHANGED
|
@@ -314,6 +314,16 @@ export function sanitizeFtsQuery(query) {
|
|
|
314
314
|
if (bg && !isCjkNoiseBigram(bg) && !matched.has(bg)) expandedTokens.push(bg);
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
|
+
// Preserve embedded Latin/English identifiers glued into the CJK token — e.g. the
|
|
318
|
+
// "redis" in "redis缓存问题". extractCjkKeywords + cjkBigrams only see CJK runs, so
|
|
319
|
+
// without this the Latin anchor (often the single most precise term — a proper noun
|
|
320
|
+
// like redis/grafana/oauth with no CJK synonym) was dropped, zeroing recall on
|
|
321
|
+
// whitespace-free mixed-script prompts. Mirrors registry-retriever.mjs's embedded-
|
|
322
|
+
// English extraction. Lowercased for parity with the write-path unicode61 folding.
|
|
323
|
+
for (const en of (remainder.match(/[a-zA-Z]{2,}/g) || [])) {
|
|
324
|
+
const low = en.toLowerCase();
|
|
325
|
+
if (!FTS_STOP_WORDS.has(low) && !expandedTokens.includes(low)) expandedTokens.push(low);
|
|
326
|
+
}
|
|
317
327
|
continue;
|
|
318
328
|
}
|
|
319
329
|
// No dictionary word matched. For a PURE-CJK run, pushing the whole
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.40.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/registry-importer.mjs
CHANGED
|
@@ -233,7 +233,7 @@ export async function importFromGitHub(db, url, opts = {}) {
|
|
|
233
233
|
// 1. Parse GitHub URL
|
|
234
234
|
const parsed = parseGitHubUrl(url);
|
|
235
235
|
if (!parsed) throw new Error('Invalid GitHub URL');
|
|
236
|
-
const { owner, repo, branch, path: pathFilter } = parsed;
|
|
236
|
+
const { owner, repo, branch: parsedBranch, path: pathFilter } = parsed;
|
|
237
237
|
|
|
238
238
|
// 2. Fetch repo metadata (stars, forks, updated_at)
|
|
239
239
|
const repoResp = await fetchFn(buildRepoUrl(owner, repo), { headers });
|
|
@@ -247,6 +247,13 @@ export async function importFromGitHub(db, url, opts = {}) {
|
|
|
247
247
|
const repoForks = repoMeta.forks_count || 0;
|
|
248
248
|
const repoUpdatedAt = repoMeta.updated_at || null;
|
|
249
249
|
|
|
250
|
+
// parseGitHubUrl defaults branch to 'main' when the URL omits `/tree/<branch>`. Prefer the
|
|
251
|
+
// repo's ACTUAL default branch in that case: a repo defaulting to master/develop/trunk
|
|
252
|
+
// otherwise 404s on a non-existent 'main' (GitHub does not redirect a missing ref), failing
|
|
253
|
+
// a URL that opens fine in the browser. An explicit `/tree/<branch>` in the URL still wins.
|
|
254
|
+
const branchExplicit = /\/tree\//.test(url.split(/[?#]/)[0]);
|
|
255
|
+
const branch = branchExplicit ? parsedBranch : (repoMeta.default_branch || parsedBranch);
|
|
256
|
+
|
|
250
257
|
// 3. Fetch file tree via GitHub API (recursive)
|
|
251
258
|
const treeResp = await fetchFn(buildTreeUrl(owner, repo, branch), { headers });
|
|
252
259
|
if (!treeResp.ok) {
|
package/registry.mjs
CHANGED
|
@@ -368,9 +368,15 @@ const UPSERT_SQL = `
|
|
|
368
368
|
indexed_at, updated_at)
|
|
369
369
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
370
370
|
ON CONFLICT(type, name) DO UPDATE SET
|
|
371
|
-
status=excluded.status, source=excluded.source,
|
|
371
|
+
status=excluded.status, source=excluded.source,
|
|
372
|
+
-- Preserve-on-empty (mirror the FTS text columns below): a PARTIAL re-upsert defaults
|
|
373
|
+
-- repo_url/local_path to null/'' in the caller (import is the only edit path). Clobbering
|
|
374
|
+
-- them ORPHANS the resource — mem_use/enrich read local_path (NOT NULL, so '' passes the
|
|
375
|
+
-- constraint and silently breaks reads), and the scanner needs local_path to disable it.
|
|
376
|
+
repo_url=CASE WHEN excluded.repo_url != '' THEN excluded.repo_url ELSE repo_url END,
|
|
372
377
|
repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
|
|
373
|
-
local_path=excluded.local_path
|
|
378
|
+
local_path=CASE WHEN excluded.local_path != '' THEN excluded.local_path ELSE local_path END,
|
|
379
|
+
file_hash=excluded.file_hash,
|
|
374
380
|
invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
|
|
375
381
|
-- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
|
|
376
382
|
-- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
|
|
@@ -164,8 +164,14 @@ export function shouldSkipByDedup(newIds, injectedFile) {
|
|
|
164
164
|
if (count >= MAX_SESSION_INJECTIONS) return true;
|
|
165
165
|
if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
|
|
166
166
|
if (!Array.isArray(prevIds) || prevIds.length === 0) return false;
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
// Normalize both sides to strings before comparing: UPS writes obs ids as numbers
|
|
168
|
+
// (rows.map(r => r.id)) while pre-tool-recall's mergeCrossHookInjected writes them as
|
|
169
|
+
// strings (.map(String)), and both hooks SHARE this file. Without normalization
|
|
170
|
+
// Set.has(8829) misses "8829" → cross-hook dedup never fires and the same lesson
|
|
171
|
+
// double-injects within the window. (pre-tool-recall's readCrossHookInjected already
|
|
172
|
+
// String-normalizes; this brings the UPS-side reader in line.)
|
|
173
|
+
const prevSet = new Set(prevIds.map(String));
|
|
174
|
+
const overlapCount = newIds.filter(id => prevSet.has(String(id))).length;
|
|
169
175
|
return overlapCount / newIds.length >= 0.8;
|
|
170
176
|
} catch { return false; }
|
|
171
177
|
}
|
package/server.mjs
CHANGED
|
@@ -286,9 +286,15 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
|
|
|
286
286
|
return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
// obs_type ⇒ observations-only; deep is observations-only too
|
|
290
|
-
// hybrid-obs lists). args.type is the source filter
|
|
291
|
-
|
|
289
|
+
// obs_type/importance/branch/tier ⇒ observations-only; deep is observations-only too
|
|
290
|
+
// (deepSearch fuses hybrid-obs lists). args.type is the source filter
|
|
291
|
+
// (observations|sessions|prompts). Forcing obs-only for the obs-exclusive fields
|
|
292
|
+
// matches the CLI (mem-cli.mjs:177): session/prompt tables have no importance/branch/tier
|
|
293
|
+
// column, so without the force those legs return UNFILTERED and leak rows that can't be
|
|
294
|
+
// scoped to the filter (branch/importance/tier previously leaked cross-source on MCP).
|
|
295
|
+
const effectiveType = deepMode === 'deep'
|
|
296
|
+
? 'observations'
|
|
297
|
+
: (args.type || ((args.obs_type || args.importance || args.branch || args.tier) ? 'observations' : undefined));
|
|
292
298
|
|
|
293
299
|
const r = await coreRunSearchPipeline(
|
|
294
300
|
{
|
|
@@ -1402,7 +1408,15 @@ server.registerTool(
|
|
|
1402
1408
|
}
|
|
1403
1409
|
const IMPORT_STRING_FIELDS = ['repo_url', 'local_path', 'invocation_name', 'intent_tags',
|
|
1404
1410
|
'domain_tags', 'trigger_patterns', 'capability_summary', 'keywords', 'tech_stack', 'use_cases'];
|
|
1405
|
-
|
|
1411
|
+
// Preserve provenance on a metadata-only re-import (parity with cmdRegistry): default
|
|
1412
|
+
// source to 'user' only for a NEW resource — a partial re-upsert of an existing
|
|
1413
|
+
// github/preinstalled row without `source` must not flip it to 'user'.
|
|
1414
|
+
let source = args.source;
|
|
1415
|
+
if (!source) {
|
|
1416
|
+
const existing = rdb.prepare('SELECT source FROM resources WHERE type = ? AND name = ?').get(args.resource_type, args.name);
|
|
1417
|
+
source = existing ? existing.source : 'user';
|
|
1418
|
+
}
|
|
1419
|
+
const fields = { name: args.name, type: args.resource_type, status: 'active', source };
|
|
1406
1420
|
for (const f of IMPORT_STRING_FIELDS) fields[f] = args[f] || '';
|
|
1407
1421
|
const id = upsertResource(rdb, fields);
|
|
1408
1422
|
return { content: [{ type: 'text', text: `Imported: ${args.resource_type}:${args.name} (id=${id})` }] };
|
package/source-files.mjs
CHANGED
|
@@ -198,3 +198,16 @@ export const HOOK_SCRIPT_FILES = [
|
|
|
198
198
|
// through this wrapper so any partial-install drift heals automatically.
|
|
199
199
|
'hook-launcher.mjs',
|
|
200
200
|
];
|
|
201
|
+
|
|
202
|
+
// The complete set of files the release signature MUST cover: every runtime .mjs
|
|
203
|
+
// (SOURCE_FILES) PLUS the executable hook scripts (copyReleaseIntoStaging installs these
|
|
204
|
+
// into the live dir and they run on every hook fire). HOOK_SCRIPT_FILES were historically
|
|
205
|
+
// NOT in the signed manifest, so an attacker able to PUBLISH a release — but without the
|
|
206
|
+
// signing key — could swap a hook script (e.g. post-tool-use.sh / hook-launcher.mjs) while
|
|
207
|
+
// every SOURCE_FILES hash still matched, and fail-closed verification would still pass →
|
|
208
|
+
// RCE on the next hook fire. Keys are ROOT-relative, matching the extracted-tarball layout
|
|
209
|
+
// that verifyReleaseFiles hashes against.
|
|
210
|
+
export const RELEASE_SIGNED_FILES = [
|
|
211
|
+
...SOURCE_FILES,
|
|
212
|
+
...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
213
|
+
];
|