claude-mem-lite 3.62.0 → 3.64.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/format-utils.mjs +21 -0
- package/hook-memory.mjs +6 -9
- package/hook.mjs +44 -17
- package/install.mjs +21 -0
- package/lib/browse-core.mjs +67 -0
- package/lib/db-backup.mjs +84 -1
- package/lib/delete-core.mjs +18 -1
- package/lib/export-columns.mjs +7 -1
- package/lib/get-core.mjs +33 -0
- package/lib/inject-search-core.mjs +84 -0
- package/lib/injected-ids.mjs +29 -0
- package/lib/observation-write.mjs +38 -2
- package/lib/search-core.mjs +10 -1
- package/mem-cli.mjs +86 -129
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/registry.mjs +53 -1
- package/scoring-sql.mjs +5 -4
- package/scripts/post-tool-recall.js +12 -2
- package/scripts/pre-agent-inject.js +22 -4
- package/scripts/pre-skill-bridge.js +18 -3
- package/scripts/pre-tool-recall.js +39 -19
- package/scripts/prompt-search-utils.mjs +9 -2
- package/scripts/user-prompt-search.js +59 -31
- package/search-engine.mjs +56 -20
- package/search-scoring.mjs +7 -2
- package/server.mjs +75 -137
- package/source-files.mjs +44 -1
package/search-engine.mjs
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
// module exists to eliminate.
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
OBS_BM25,
|
|
9
|
+
OBS_BM25, TYPE_QUALITY_CASE,
|
|
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';
|
|
18
|
+
import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
|
|
16
19
|
|
|
17
20
|
// Scoring expressions — full adds project boost + access bonus; simple is for
|
|
18
21
|
// expansion paths where boost would over-amplify already-loose matches.
|
|
@@ -21,19 +24,37 @@ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
|
|
|
21
24
|
// exponent large-positive → EXP overflowed to +Infinity → score -Infinity → that row sorted
|
|
22
25
|
// #1 for any match AND JSON.stringify emitted `"score": null` (numeric-contract break). A
|
|
23
26
|
// future row now reads as age 0 = max (finite) recency, not Infinity.
|
|
27
|
+
// M-3 (audit 2026-08-14): cite/noise behavior factors joined FULL_SCORE — they had
|
|
28
|
+
// shipped for months on every AUTO surface (UPS / pre-tool-recall / hook-memory)
|
|
29
|
+
// while the EXPLICIT surfaces (mem_search / CLI search) discarded the accumulated
|
|
30
|
+
// citation + noise signal (the mirror image of "guards wired on the auto face,
|
|
31
|
+
// missing on the explicit face"). Both factors are hard-bounded (cite ∈ [0.4, 3.0],
|
|
32
|
+
// noise ∈ {0.2, 0.5, 1.0}), so they reorder, never dominate. algo-F6 note: the
|
|
33
|
+
// access-count LN bonus below stays — noisePenalty uses access_count only inside a
|
|
34
|
+
// ratio GUARD (never as a bonus) and citeFactor reads cited/uncited columns, so no
|
|
35
|
+
// term is counted twice as a reward. Denoise A/B 2026-08-16: NEUTRAL (suite rows
|
|
36
|
+
// carry zero cite/noise state); the behavioral pin lives in
|
|
37
|
+
// tests/audit-fixes-20260816.test.mjs (M-3).
|
|
24
38
|
const FULL_SCORE = `${OBS_BM25}
|
|
25
|
-
* (
|
|
39
|
+
* ${recencyDecaySql({ tsExpr: 'MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))' })}
|
|
26
40
|
* ${TYPE_QUALITY_CASE}
|
|
27
41
|
* (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
|
|
28
42
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
29
43
|
* (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')))
|
|
44
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
|
|
45
|
+
* ${noisePenaltyClause('o')}
|
|
46
|
+
* ${citeFactorClause('o')}`;
|
|
31
47
|
|
|
48
|
+
// D#121: noisePenalty joins SIMPLE (an entrenched-noise row demoted 0.2× on every
|
|
49
|
+
// direct surface re-entered concept/PRF expansion at full magnitude); citeFactor
|
|
50
|
+
// stays OUT deliberately — it can amplify 3×, and SIMPLE exists precisely to avoid
|
|
51
|
+
// amplifying already-loose expansion matches. Noise only shrinks: safe direction.
|
|
32
52
|
const SIMPLE_SCORE = `${OBS_BM25}
|
|
33
|
-
* (
|
|
53
|
+
* ${recencyDecaySql({ tsExpr: 'MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))' })}
|
|
34
54
|
* ${TYPE_QUALITY_CASE}
|
|
35
55
|
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
36
|
-
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
|
|
56
|
+
* (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
|
|
57
|
+
* ${noisePenaltyClause('o')}`;
|
|
37
58
|
|
|
38
59
|
// Shared column set for fetching an observation surfaced by the vector arm — used by BOTH
|
|
39
60
|
// the RRF-merge branch (FTS also had results) and the FTS-empty fallback branch. Single
|
|
@@ -56,8 +77,7 @@ export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset,
|
|
|
56
77
|
FROM observations_fts
|
|
57
78
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
58
79
|
WHERE observations_fts MATCH ?
|
|
59
|
-
AND
|
|
60
|
-
AND o.superseded_at IS NULL
|
|
80
|
+
AND ${liveObsFilterSql('o')}
|
|
61
81
|
AND (? IS NULL OR o.project = ?)
|
|
62
82
|
AND (? IS NULL OR o.type = ?)
|
|
63
83
|
AND (? IS NULL OR o.created_at_epoch >= ?)
|
|
@@ -108,8 +128,7 @@ export function countObsFtsMatches(db, { ftsQuery, args = {}, epochFrom = null,
|
|
|
108
128
|
FROM observations_fts
|
|
109
129
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
110
130
|
WHERE observations_fts MATCH ?
|
|
111
|
-
AND
|
|
112
|
-
AND o.superseded_at IS NULL
|
|
131
|
+
AND ${liveObsFilterSql('o')}
|
|
113
132
|
AND (? IS NULL OR o.project = ?)
|
|
114
133
|
AND (? IS NULL OR o.type = ?)
|
|
115
134
|
AND (? IS NULL OR o.created_at_epoch >= ?)
|
|
@@ -294,15 +313,18 @@ function expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise =
|
|
|
294
313
|
function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includeNoise = false) {
|
|
295
314
|
const { ftsQuery, args, epochFrom, epochTo, limit } = ctx;
|
|
296
315
|
if (primaryCount < 3) return;
|
|
316
|
+
// effectiveFtsQuery = the query that actually matched (OR-relaxed when the strict
|
|
317
|
+
// AND missed and the fallback rescued rows — M-2). The strict query here returned
|
|
318
|
+
// zero top docs in that case, silently disabling PRF where it helps most.
|
|
319
|
+
const seedQuery = ctx.effectiveFtsQuery || ftsQuery;
|
|
297
320
|
const topResults = db.prepare(`
|
|
298
321
|
SELECT o.title, o.narrative FROM observations_fts
|
|
299
322
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
300
|
-
WHERE observations_fts MATCH ? AND
|
|
301
|
-
AND o.superseded_at IS NULL
|
|
323
|
+
WHERE observations_fts MATCH ? AND ${liveObsFilterSql('o')}
|
|
302
324
|
AND (? IS NULL OR o.project = ?)
|
|
303
325
|
ORDER BY ${OBS_BM25}
|
|
304
326
|
LIMIT 8
|
|
305
|
-
`).all(
|
|
327
|
+
`).all(seedQuery, args.project ?? null, args.project ?? null);
|
|
306
328
|
const prfTerms = extractPRFTerms(topResults, ftsQuery);
|
|
307
329
|
if (prfTerms.length === 0) return;
|
|
308
330
|
const prfFts = prfTerms.map(t => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
@@ -365,10 +387,9 @@ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfL
|
|
|
365
387
|
JOIN observations o ON observations_fts.rowid = o.id
|
|
366
388
|
WHERE observations_fts MATCH ?
|
|
367
389
|
AND (? IS NULL OR o.project = ?)
|
|
368
|
-
AND
|
|
369
|
-
AND o.superseded_at IS NULL
|
|
390
|
+
AND ${liveObsFilterSql('o')}
|
|
370
391
|
ORDER BY ${OBS_BM25}
|
|
371
|
-
* (
|
|
392
|
+
* ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: `${halfLifeMs}.0` })}
|
|
372
393
|
LIMIT 1
|
|
373
394
|
`;
|
|
374
395
|
const stmt = db.prepare(sql);
|
|
@@ -393,7 +414,7 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
393
414
|
|
|
394
415
|
if (!ftsQuery) {
|
|
395
416
|
const params = [];
|
|
396
|
-
const wheres = [
|
|
417
|
+
const wheres = [liveObsFilterSql('')];
|
|
397
418
|
if (args.project) { wheres.push('project = ?'); params.push(args.project); }
|
|
398
419
|
if (args.obs_type) { wheres.push('type = ?'); params.push(args.obs_type); }
|
|
399
420
|
if (epochFrom !== null) { wheres.push('created_at_epoch >= ?'); params.push(epochFrom); }
|
|
@@ -428,17 +449,32 @@ export function searchObservationsHybrid(db, ctx) {
|
|
|
428
449
|
try {
|
|
429
450
|
const orRows = db.prepare(buildObsFtsQuery('full', { multiplier: 0.5, withSnippet: true, withOffset: true, includeNoise }))
|
|
430
451
|
.all(...buildObsFtsParams({ now, projectBoost, ftsQuery: orQuery, args, epochFrom, epochTo, limit: perSourceLimit, offset: perSourceOffset }));
|
|
431
|
-
if (orRows.length > 0)
|
|
452
|
+
if (orRows.length > 0) {
|
|
453
|
+
ctx.orFallbackFired = true;
|
|
454
|
+
// M-2: PRF's top-doc probe re-queries FTS itself — with the strict-AND
|
|
455
|
+
// query it reads 0 docs in exactly the OR-rescue case, keeping PRF inert.
|
|
456
|
+
// Record the query that actually produced the evidence rows.
|
|
457
|
+
ctx.effectiveFtsQuery = orQuery;
|
|
458
|
+
}
|
|
432
459
|
for (const r of orRows) results.push(ftsRowToResult(r, { snippet: true }));
|
|
433
460
|
} catch (e) { debugCatch(e, 'searchObservationsHybrid-or-fallback'); }
|
|
434
461
|
}
|
|
435
462
|
}
|
|
436
463
|
|
|
437
|
-
// Two-phase query expansion (only when well below limit)
|
|
438
|
-
|
|
464
|
+
// Two-phase query expansion (only when well below limit). Gate on results.length,
|
|
465
|
+
// NOT rows.length (M-2, audit 2026-08-14): `rows` is the strict-AND set only, so
|
|
466
|
+
// when strict-AND missed and the OR fallback rescued a few rows — exactly the
|
|
467
|
+
// vocab-mismatch shape where expansion helps most — the old gate read rows.length
|
|
468
|
+
// === 0 and skipped concept/PRF expansion entirely. PRF likewise seeds from the
|
|
469
|
+
// rescued rows now (they are the only relevance evidence available).
|
|
470
|
+
if (results.length > 0 && results.length < Math.ceil(limit / 2)) {
|
|
439
471
|
const existingIds = new Set(results.map(r => r.id));
|
|
472
|
+
// D#122 ②: capture the PRIMARY count before concept expansion mutates
|
|
473
|
+
// `results` — PRF's >=3 gate is meant to read direct-match evidence, not
|
|
474
|
+
// rows the concept pass just added.
|
|
475
|
+
const primaryCount = results.length;
|
|
440
476
|
expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise);
|
|
441
|
-
expandObsByPRF(db, ctx, now,
|
|
477
|
+
expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includeNoise);
|
|
442
478
|
}
|
|
443
479
|
|
|
444
480
|
// 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
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
-
import { truncate, typeIcon, inferProject,
|
|
8
|
+
import { truncate, typeIcon, inferProject, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
|
|
9
9
|
import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
|
|
10
10
|
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
11
|
-
import { reRankWithContext,
|
|
11
|
+
import { reRankWithContext, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
|
|
12
12
|
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
13
13
|
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
|
|
14
14
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -22,9 +22,10 @@ import {
|
|
|
22
22
|
OP_CAP, STALE_AGE_MS,
|
|
23
23
|
} from './lib/maintain-core.mjs';
|
|
24
24
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
25
|
-
import { deleteObservations } from './lib/delete-core.mjs';
|
|
25
|
+
import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
|
|
26
|
+
import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
|
|
27
|
+
import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
|
|
26
28
|
import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
|
|
27
|
-
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
28
29
|
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
29
30
|
import { buildLessonNudge } from './lib/save-nudge.mjs';
|
|
30
31
|
import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
@@ -45,11 +46,11 @@ function descriptionOf(name) {
|
|
|
45
46
|
import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
|
|
46
47
|
import { join, sep } from 'path';
|
|
47
48
|
import { homedir } from 'os';
|
|
48
|
-
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
49
|
+
import { ensureRegistryDb, upsertResource, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
|
|
49
50
|
import { searchResources } from './registry-retriever.mjs';
|
|
50
51
|
import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
|
|
51
52
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
52
|
-
import {
|
|
53
|
+
import { applyObsUpdate } from './lib/observation-write.mjs';
|
|
53
54
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
54
55
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
55
56
|
import { fetchRecent } from './lib/recent-core.mjs';
|
|
@@ -599,8 +600,6 @@ server.registerTool(
|
|
|
599
600
|
return { content: [{ type: 'text', text: 'No valid IDs provided.' }] };
|
|
600
601
|
}
|
|
601
602
|
|
|
602
|
-
const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
|
|
603
|
-
|
|
604
603
|
// `fields` filter only makes sense for obs rows; session/prompt ignore it.
|
|
605
604
|
// Validate when obs is queried — throw on all-invalid, note on partial-invalid.
|
|
606
605
|
let fieldsNote = '';
|
|
@@ -622,12 +621,8 @@ server.registerTool(
|
|
|
622
621
|
const foundBySource = { obs: new Set(), session: new Set(), prompt: new Set(), event: new Set() };
|
|
623
622
|
|
|
624
623
|
if (bySrc.obs.length > 0) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...bySrc.obs);
|
|
628
|
-
autoBoostIfNeeded(db, bySrc.obs);
|
|
629
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
630
|
-
const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.obs);
|
|
624
|
+
// Access-bump + fetch via the shared get-core (P2-12) — single source with CLI get.
|
|
625
|
+
const rows = fetchObsDetail(db, bySrc.obs);
|
|
631
626
|
const renderFields = obsFieldFilter || OBS_FIELDS;
|
|
632
627
|
for (const row of rows) {
|
|
633
628
|
foundBySource.obs.add(row.id);
|
|
@@ -650,7 +645,9 @@ server.registerTool(
|
|
|
650
645
|
if (bySrc.session.length > 0) {
|
|
651
646
|
const ph = bySrc.session.map(() => '?').join(',');
|
|
652
647
|
const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.session);
|
|
653
|
-
|
|
648
|
+
// SESSION_DETAIL_FIELDS (get-core, P2-12): shared full set — adds remaining_items
|
|
649
|
+
// (searchable via FTS but previously unrendered on BOTH detail faces).
|
|
650
|
+
const sessFields = SESSION_DETAIL_FIELDS;
|
|
654
651
|
for (const row of rows) {
|
|
655
652
|
foundBySource.session.add(row.id);
|
|
656
653
|
const lines = [`── S#${row.id} ──`];
|
|
@@ -765,21 +762,15 @@ server.registerTool(
|
|
|
765
762
|
inputSchema: memDeleteSchema,
|
|
766
763
|
},
|
|
767
764
|
safeHandler(async (args) => {
|
|
768
|
-
|
|
769
|
-
const rows = db.
|
|
770
|
-
SELECT id, type, title, project FROM observations WHERE id IN (${placeholders})
|
|
771
|
-
`).all(...args.ids);
|
|
765
|
+
// Shared preview body (lib/delete-core, P2-12) — single source with CLI delete.
|
|
766
|
+
const { rows, lines: previewLines } = previewDeleteRows(db, args.ids);
|
|
772
767
|
|
|
773
768
|
if (rows.length === 0) {
|
|
774
769
|
return { content: [{ type: 'text', text: 'No observations found for given IDs.' }] };
|
|
775
770
|
}
|
|
776
771
|
|
|
777
772
|
if (!args.confirm) {
|
|
778
|
-
|
|
779
|
-
const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`];
|
|
780
|
-
for (const r of rows) {
|
|
781
|
-
lines.push(` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
|
|
782
|
-
}
|
|
773
|
+
const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`, ...previewLines];
|
|
783
774
|
lines.push(`\nCall mem_delete(ids=[...], confirm=true) to execute.`);
|
|
784
775
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
785
776
|
}
|
|
@@ -1162,32 +1153,22 @@ server.registerTool(
|
|
|
1162
1153
|
const staleAge = Date.now() - STALE_AGE_MS;
|
|
1163
1154
|
const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
|
|
1164
1155
|
|
|
1165
|
-
// T2-P0-A: purge_stale is the only
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
if (previewRow.candidates > 0) {
|
|
1177
|
-
lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1178
|
-
lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1179
|
-
}
|
|
1180
|
-
lines.push('');
|
|
1181
|
-
lines.push('Nothing was deleted. To execute, re-run with confirm=true:');
|
|
1182
|
-
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}"` : ''})`);
|
|
1183
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
1184
|
-
}
|
|
1156
|
+
// T2-P0-A: purge_stale is the only op gated on confirm=true — cleanupBroken also
|
|
1157
|
+
// hard-deletes (broken rows only) but always ran unconfirmed on both surfaces, and
|
|
1158
|
+
// the pre-transaction snapshot above covers it. An unconfirmed call gets a dry-run
|
|
1159
|
+
// preview of the purge INSTEAD OF the purge.
|
|
1160
|
+
// M-7 (audit 2026-08-14): preview-instead-of-early-return — the old
|
|
1161
|
+
// `return`-on-unconfirmed skipped EVERY requested op, while the CLI twin ran the
|
|
1162
|
+
// non-destructive ones (cleanup/decay/boost) and previewed only the purge. Same
|
|
1163
|
+
// op list, two different amounts of work done, both reporting success. Aligned
|
|
1164
|
+
// to the CLI semantics (the safer surface changed less: nothing destructive
|
|
1165
|
+
// runs unconfirmed on either surface now or before).
|
|
1166
|
+
const purgeConfirmed = args.confirm === true;
|
|
1185
1167
|
|
|
1186
1168
|
// MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
|
|
1187
1169
|
// only when rows will actually be removed, and OUTSIDE the transaction below
|
|
1188
|
-
// (VACUUM cannot run inside one).
|
|
1189
|
-
|
|
1190
|
-
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
|
|
1170
|
+
// (VACUUM cannot run inside one). Best-effort; snapshotDb never throws.
|
|
1171
|
+
if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') && purgeConfirmed }) > 0) {
|
|
1191
1172
|
snapshotDb(db, { tag: 'pre-maintain' });
|
|
1192
1173
|
}
|
|
1193
1174
|
|
|
@@ -1201,8 +1182,25 @@ server.registerTool(
|
|
|
1201
1182
|
if (ops.includes('purge_stale')) {
|
|
1202
1183
|
const retainDays = args.retain_days ?? 30;
|
|
1203
1184
|
const retainCutoff = Date.now() - retainDays * 86400000;
|
|
1204
|
-
|
|
1205
|
-
|
|
1185
|
+
if (!purgeConfirmed) {
|
|
1186
|
+
// Dry-run preview (parity with CLI `maintain` without --confirm): the other
|
|
1187
|
+
// requested non-destructive ops still run below.
|
|
1188
|
+
const previewRow = purgeStalePreview(db, mctx, retainCutoff);
|
|
1189
|
+
const lines = [
|
|
1190
|
+
'purge_stale preview (confirm=false):',
|
|
1191
|
+
` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
|
|
1192
|
+
];
|
|
1193
|
+
if (previewRow.candidates > 0) {
|
|
1194
|
+
lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
|
|
1195
|
+
lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
|
|
1196
|
+
}
|
|
1197
|
+
lines.push(' Nothing was deleted. To delete, re-run with confirm=true:');
|
|
1198
|
+
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}"` : ''})`);
|
|
1199
|
+
results.push(lines.join('\n'));
|
|
1200
|
+
} else {
|
|
1201
|
+
const purged = purgeStale(db, mctx, retainCutoff);
|
|
1202
|
+
results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
|
|
1203
|
+
}
|
|
1206
1204
|
}
|
|
1207
1205
|
|
|
1208
1206
|
if (ops.includes('cleanup')) {
|
|
@@ -1418,51 +1416,27 @@ server.registerTool(
|
|
|
1418
1416
|
}
|
|
1419
1417
|
|
|
1420
1418
|
if (action === 'list') {
|
|
1421
|
-
|
|
1422
|
-
const
|
|
1423
|
-
const params = typeFilter ? [typeFilter, 'active'] : ['active'];
|
|
1424
|
-
// T3-P2-A: order by adoption then recommendation (CLI parity), and coalesce NULL counts
|
|
1425
|
-
// so the output shows "adopt:0" rather than the jarring "adopt:null".
|
|
1426
|
-
const resources = rdb.prepare(`
|
|
1427
|
-
SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
|
|
1428
|
-
FROM resources ${where}
|
|
1429
|
-
ORDER BY COALESCE(adopt_count, 0) DESC, COALESCE(recommend_count, 0) DESC, type, name
|
|
1430
|
-
`).all(...params);
|
|
1431
|
-
|
|
1419
|
+
// Shared ranked query + row line (registry.mjs, P2-12) — single source with CLI list.
|
|
1420
|
+
const resources = listResourcesRanked(rdb, { type: args.type });
|
|
1432
1421
|
if (resources.length === 0) return { content: [{ type: 'text', text: 'No resources found.' }] };
|
|
1433
|
-
|
|
1434
|
-
const lines = resources.map(r =>
|
|
1435
|
-
`${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count ?? 0} adopt:${r.adopt_count ?? 0} — ${truncate(r.capability_summary || '', 80)}`
|
|
1436
|
-
);
|
|
1422
|
+
const lines = resources.map(formatRegistryListLine);
|
|
1437
1423
|
return { content: [{ type: 'text', text: `Resources (${resources.length}):\n${lines.join('\n')}` }] };
|
|
1438
1424
|
}
|
|
1439
1425
|
|
|
1440
1426
|
if (action === 'stats') {
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const
|
|
1444
|
-
SELECT name, type, adopt_count, recommend_count
|
|
1445
|
-
FROM resources WHERE status = ? AND adopt_count > 0
|
|
1446
|
-
ORDER BY adopt_count DESC LIMIT 10
|
|
1447
|
-
`).all('active');
|
|
1448
|
-
const zeroAdopt = rdb.prepare(`
|
|
1449
|
-
SELECT COUNT(*) as c FROM resources
|
|
1450
|
-
WHERE status = ? AND recommend_count > 0 AND adopt_count = 0
|
|
1451
|
-
`).get('active');
|
|
1452
|
-
const userAdded = rdb.prepare(`
|
|
1453
|
-
SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'
|
|
1454
|
-
`).get('active');
|
|
1455
|
-
|
|
1427
|
+
// Shared collection (registry.mjs collectRegistryStats, P2-12) — single
|
|
1428
|
+
// source with the CLI registry stats action.
|
|
1429
|
+
const s = collectRegistryStats(rdb);
|
|
1456
1430
|
const lines = [
|
|
1457
1431
|
`Registry Stats:`,
|
|
1458
|
-
` Total active: ${total
|
|
1459
|
-
...byType.map(t => ` ${t.type}: ${t.c}`),
|
|
1460
|
-
` User-added: ${userAdded
|
|
1461
|
-
` Zero adoption (recommended but never adopted): ${zeroAdopt
|
|
1432
|
+
` Total active: ${s.total}`,
|
|
1433
|
+
...s.byType.map(t => ` ${t.type}: ${t.c}`),
|
|
1434
|
+
` User-added: ${s.userAdded}`,
|
|
1435
|
+
` Zero adoption (recommended but never adopted): ${s.zeroAdopt}`,
|
|
1462
1436
|
];
|
|
1463
|
-
if (topAdopted.length > 0) {
|
|
1437
|
+
if (s.topAdopted.length > 0) {
|
|
1464
1438
|
lines.push('', 'Top adopted:');
|
|
1465
|
-
for (const r of topAdopted) {
|
|
1439
|
+
for (const r of s.topAdopted) {
|
|
1466
1440
|
lines.push(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
|
|
1467
1441
|
}
|
|
1468
1442
|
}
|
|
@@ -1689,26 +1663,15 @@ server.registerTool(
|
|
|
1689
1663
|
const obs = db.prepare('SELECT id, title FROM observations WHERE id = ?').get(args.id);
|
|
1690
1664
|
if (!obs) return { content: [{ type: 'text', text: `Observation #${args.id} not found` }], isError: true };
|
|
1691
1665
|
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
}
|
|
1700
|
-
if (updates.length === 0) return { content: [{ type: 'text', text: 'No fields to update' }], isError: true };
|
|
1701
|
-
|
|
1702
|
-
params.push(args.id);
|
|
1703
|
-
|
|
1704
|
-
// Atomic: update fields + rebuild derived columns (FTS text + vector) via the
|
|
1705
|
-
// shared core — single source with CLI cmdUpdate (lib/observation-write.mjs).
|
|
1706
|
-
db.transaction(() => {
|
|
1707
|
-
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
1708
|
-
rebuildObservationDerived(db, args.id);
|
|
1709
|
-
})();
|
|
1666
|
+
// Shared mutation (lib/observation-write applyObsUpdate, P2-12): scrub + UPDATE +
|
|
1667
|
+
// derived-column rebuild in one transaction — single source with CLI cmdUpdate.
|
|
1668
|
+
const updatedCols = applyObsUpdate(db, args.id, {
|
|
1669
|
+
title: args.title, narrative: args.narrative, type: args.type,
|
|
1670
|
+
importance: args.importance, lesson_learned: args.lesson_learned, concepts: args.concepts,
|
|
1671
|
+
});
|
|
1672
|
+
if (updatedCols.length === 0) return { content: [{ type: 'text', text: 'No fields to update' }], isError: true };
|
|
1710
1673
|
|
|
1711
|
-
return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${
|
|
1674
|
+
return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updatedCols.join(', ')}` }] };
|
|
1712
1675
|
})
|
|
1713
1676
|
);
|
|
1714
1677
|
|
|
@@ -1862,33 +1825,18 @@ server.registerTool(
|
|
|
1862
1825
|
const limit = args.limit ?? (tierFilter ? 20 : 5);
|
|
1863
1826
|
const now = Date.now();
|
|
1864
1827
|
|
|
1865
|
-
//
|
|
1866
|
-
const
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
const
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
const tiers = ['working', 'active', 'archive'];
|
|
1874
|
-
const tierLabels = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
|
|
1875
|
-
const showTiers = tierFilter ? [tierFilter] : tiers;
|
|
1828
|
+
// Shared collection (lib/browse-core, P2-12) — single source with CLI browse.
|
|
1829
|
+
const { showTiers, tierData, tierCounts, grandTotal } = collectBrowseTiers(db, {
|
|
1830
|
+
project, tierFilter, limit, now,
|
|
1831
|
+
currentSessionId: getActiveMemorySessionId(db, project),
|
|
1832
|
+
});
|
|
1833
|
+
const tiers = BROWSE_TIERS;
|
|
1834
|
+
const tierLabels = BROWSE_TIER_LABELS;
|
|
1876
1835
|
|
|
1877
1836
|
const lines = [`Memory Dashboard (${project})\n`];
|
|
1878
|
-
let grandTotal = 0;
|
|
1879
|
-
const tierCounts = {};
|
|
1880
1837
|
|
|
1881
1838
|
for (const tier of showTiers) {
|
|
1882
|
-
const
|
|
1883
|
-
SELECT COUNT(*) as c FROM (
|
|
1884
|
-
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
1885
|
-
WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
|
|
1886
|
-
) WHERE tier = ?
|
|
1887
|
-
`).get(...params, project, tier);
|
|
1888
|
-
const count = countRow?.c ?? 0;
|
|
1889
|
-
tierCounts[tier] = count;
|
|
1890
|
-
grandTotal += count;
|
|
1891
|
-
|
|
1839
|
+
const { count, rows } = tierData[tier];
|
|
1892
1840
|
lines.push(`${tierLabels[tier]} (${count})`);
|
|
1893
1841
|
|
|
1894
1842
|
if (tier === 'archive' && !tierFilter) {
|
|
@@ -1898,16 +1846,6 @@ server.registerTool(
|
|
|
1898
1846
|
|
|
1899
1847
|
if (count === 0) { lines.push(''); continue; }
|
|
1900
1848
|
|
|
1901
|
-
const rows = db.prepare(`
|
|
1902
|
-
SELECT * FROM (
|
|
1903
|
-
SELECT id, type, title, created_at, created_at_epoch, ${TIER_CASE_SQL} as tier
|
|
1904
|
-
FROM observations
|
|
1905
|
-
WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
|
|
1906
|
-
) WHERE tier = ?
|
|
1907
|
-
ORDER BY created_at_epoch DESC
|
|
1908
|
-
LIMIT ?
|
|
1909
|
-
`).all(...params, project, tier, limit);
|
|
1910
|
-
|
|
1911
1849
|
for (const r of rows) {
|
|
1912
1850
|
lines.push(` #${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${fmtDate(r.created_at)}`);
|
|
1913
1851
|
}
|
package/source-files.mjs
CHANGED
|
@@ -119,6 +119,20 @@ export const SOURCE_FILES = [
|
|
|
119
119
|
// colliding with the scripts/ directory rename in installExtractedRelease
|
|
120
120
|
// — see the SWITCHABLE_PATHS loop in hook-update.mjs.
|
|
121
121
|
'lib/mem-override.mjs',
|
|
122
|
+
// D#120: injected-ids marker file-name derivation, shared by hook.mjs +
|
|
123
|
+
// scripts/user-prompt-search.js + scripts/pre-tool-recall.js. Under lib/ for
|
|
124
|
+
// the same scripts-dir-rename reason as mem-override.mjs above.
|
|
125
|
+
'lib/injected-ids.mjs',
|
|
126
|
+
// P2-11: injection-side shared SQL core (live-row filter / clamped decay /
|
|
127
|
+
// injection relevance chain). Statically imported by hook.mjs, hook-memory.mjs,
|
|
128
|
+
// search-engine.mjs AND the standalone hook scripts — missing it from the
|
|
129
|
+
// manifest kills every retrieval surface on auto-update.
|
|
130
|
+
'lib/inject-search-core.mjs',
|
|
131
|
+
// P2-12 twin cores: get/browse shared data collection for the CLI/MCP pairs
|
|
132
|
+
// (update lives in observation-write, delete-preview in delete-core, registry
|
|
133
|
+
// stats/list in registry.mjs — all already listed).
|
|
134
|
+
'lib/get-core.mjs',
|
|
135
|
+
'lib/browse-core.mjs',
|
|
122
136
|
// v2.61 dedup refactor: shared "save one observation" pipeline used by both
|
|
123
137
|
// mem-cli.mjs::cmdSave and server.mjs::mem_save. Statically imported from both
|
|
124
138
|
// entry points; missing it from the manifest broke MCP saves on auto-update.
|
|
@@ -264,9 +278,37 @@ const LAUNCHER_SCRIPT_FILES = [
|
|
|
264
278
|
'binding-probe-cli.mjs',
|
|
265
279
|
];
|
|
266
280
|
|
|
281
|
+
// Plugin/marketplace DECLARATION files (audit 2026-08-14 P-2). Not executable
|
|
282
|
+
// themselves, but they NAME what gets executed: hooks/hooks.json declares the
|
|
283
|
+
// command lines Claude Code runs on every hook fire, .mcp.json declares the MCP
|
|
284
|
+
// server launch command, plugin.json/marketplace.json steer the install source,
|
|
285
|
+
// commands/*.md are model-visible skill bodies, registry/preinstalled.json seeds
|
|
286
|
+
// the resource registry. All ship in the tarball; none were signed — the same
|
|
287
|
+
// shape as the two closed RCE gaps (hook scripts v3.40, launch.mjs v3.42): a
|
|
288
|
+
// release published without the signing key could swap hooks.json to point a
|
|
289
|
+
// hook event at an arbitrary command while every signed hash still matched.
|
|
290
|
+
// Additive: buildReleaseManifest skips entries absent at sign time, and
|
|
291
|
+
// verifyReleaseFiles needs no change to enforce whatever the manifest carries.
|
|
292
|
+
const PLUGIN_DECLARATION_FILES = [
|
|
293
|
+
'hooks/hooks.json',
|
|
294
|
+
'.mcp.json',
|
|
295
|
+
'.claude-plugin/plugin.json',
|
|
296
|
+
'.claude-plugin/marketplace.json',
|
|
297
|
+
'registry/preinstalled.json',
|
|
298
|
+
'commands/mem.md',
|
|
299
|
+
'commands/memory.md',
|
|
300
|
+
'commands/update.md',
|
|
301
|
+
'commands/tools.md',
|
|
302
|
+
'commands/adopt.md',
|
|
303
|
+
'commands/unadopt.md',
|
|
304
|
+
'commands/lesson.md',
|
|
305
|
+
'commands/bug.md',
|
|
306
|
+
];
|
|
307
|
+
|
|
267
308
|
// The complete set of files the release signature MUST cover: every runtime .mjs
|
|
268
309
|
// (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
|
|
310
|
+
// into the live dir and they run on every hook fire) PLUS the launcher/setup scripts
|
|
311
|
+
// PLUS the plugin declaration files above.
|
|
270
312
|
// HOOK_SCRIPT_FILES were historically NOT in the signed manifest, so an attacker able to
|
|
271
313
|
// PUBLISH a release — but without the signing key — could swap a hook script (e.g.
|
|
272
314
|
// post-tool-use.sh / hook-launcher.mjs) while every SOURCE_FILES hash still matched, and
|
|
@@ -278,4 +320,5 @@ export const RELEASE_SIGNED_FILES = [
|
|
|
278
320
|
...SOURCE_FILES,
|
|
279
321
|
...HOOK_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
280
322
|
...LAUNCHER_SCRIPT_FILES.map(name => `scripts/${name}`),
|
|
323
|
+
...PLUGIN_DECLARATION_FILES,
|
|
281
324
|
];
|