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/search-engine.mjs CHANGED
@@ -6,13 +6,16 @@
6
6
  // module exists to eliminate.
7
7
 
8
8
  import {
9
- OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
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
- * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
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
- * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
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 COALESCE(o.compressed_into, 0) = 0
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 COALESCE(o.compressed_into, 0) = 0
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 COALESCE(o.compressed_into, 0) = 0
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(ftsQuery, args.project ?? null, args.project ?? null);
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 COALESCE(o.compressed_into, 0) = 0
369
- AND o.superseded_at IS NULL
390
+ AND ${liveObsFilterSql('o')}
370
391
  ORDER BY ${OBS_BM25}
371
- * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${halfLifeMs}.0))
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 = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
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) ctx.orFallbackFired = true;
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
- if (rows.length > 0 && results.length < Math.ceil(limit / 2)) {
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, rows.length, existingIds, results, includeNoise);
477
+ expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includeNoise);
442
478
  }
443
479
 
444
480
  // Vector search + RRF hybrid merge
@@ -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
- const stemDocCount = {}; // stem -> # of top docs it appears in (the >=2 bar)
169
- const stemSurfaces = {}; // stem -> Map(surface -> total occurrences)
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, scrubSecrets, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
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, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
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 { rebuildObservationDerived } from './lib/observation-write.mjs';
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
- const ph = bySrc.obs.map(() => '?').join(',');
626
- try {
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
- const sessFields = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
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
- const placeholders = args.ids.map(() => '?').join(',');
769
- const rows = db.prepare(`
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
- // Preview mode
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 DELETE in this handler. Require confirm=true;
1166
- // a first call without confirm returns a dry-run preview so callers know the blast radius.
1167
- const purgeRequested = ops.includes('purge_stale');
1168
- if (purgeRequested && args.confirm !== true) {
1169
- const retainDays = args.retain_days ?? 30;
1170
- const retainCutoff = Date.now() - retainDays * 86400000;
1171
- const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1172
- const lines = [
1173
- 'purge_stale preview (confirm=false):',
1174
- ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
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). purge_stale is already confirmed by here (the
1189
- // preview branch returned above otherwise). Best-effort; snapshotDb never throws.
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
- const purged = purgeStale(db, mctx, retainCutoff);
1205
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
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
- const typeFilter = args.type;
1422
- const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
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
- const total = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
1442
- const byType = rdb.prepare('SELECT type, COUNT(*) as c FROM resources WHERE status = ? GROUP BY type').all('active');
1443
- const topAdopted = rdb.prepare(`
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.c}`,
1459
- ...byType.map(t => ` ${t.type}: ${t.c}`),
1460
- ` User-added: ${userAdded.c}`,
1461
- ` Zero adoption (recommended but never adopted): ${zeroAdopt.c}`,
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
- const updates = [];
1693
- const params = [];
1694
- for (const [key, col] of [['title','title'],['narrative','narrative'],['type','type'],['importance','importance'],['lesson_learned','lesson_learned'],['concepts','concepts']]) {
1695
- if (args[key] !== undefined) {
1696
- updates.push(`${col} = ?`);
1697
- params.push(typeof args[key] === 'string' ? scrubSecrets(args[key]) : args[key]);
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}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
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
- // Get active session for tier classification
1866
- const activeSession = db.prepare(
1867
- "SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
1868
- ).get(project);
1869
-
1870
- const ctx = { now, currentProject: project, currentSessionId: activeSession?.memory_session_id ?? '' };
1871
- const params = tierSqlParams(ctx);
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 countRow = db.prepare(`
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
  ];