claude-mem-lite 3.91.0 → 3.92.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.
@@ -15,11 +15,10 @@ import { join, sep } from 'path';
15
15
  import { pathToFileURL } from 'url';
16
16
  import Database from 'better-sqlite3';
17
17
  import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, extractDeferredRefs, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
18
- import { injectedIdsFileName } from '../lib/injected-ids.mjs';
18
+ import { injectedIdsFileName, mergeInjectedMarker } from '../lib/injected-ids.mjs';
19
19
  import { getDeferredByIds } from '../lib/deferred-work.mjs';
20
20
  import { recommendSkill } from '../registry-recommend.mjs';
21
21
  import { recordHookError } from '../lib/hook-telemetry.mjs';
22
- import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
23
22
 
24
23
  import { DAY_MS } from '../lib/time-constants.mjs';
25
24
  // ─── Constants ──────────────────────────────────────────────────────────────
@@ -671,26 +670,12 @@ async function main() {
671
670
  // window skips re-injection. A later FTS-path write replaces ids wholesale
672
671
  // (accepted: worst case is one cheap re-injection after an obs-emitting
673
672
  // prompt inside the same 5-min window).
673
+ // union: the M-6 same-session/staleness gate + the atomic write live in
674
+ // lib/injected-ids.mjs (audit 2026-09-02 P1-2). `dedupIds` are `D<id>` strings, so
675
+ // the lib's union-side String() is the identity here — same bytes as before.
674
676
  try {
675
- let prevIds = [];
676
- let prevCount = 0;
677
- try {
678
- const prev = JSON.parse(readFileSync(injectedIdsFile, 'utf8'));
679
- // M-6: inherit only same-session (or legacy) state — another session's
680
- // ids/count must not carry over. Atomic write below: a torn concurrent
681
- // write left the shared marker as invalid JSON (dedup silently off).
682
- if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
683
- && !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
684
- prevIds = Array.isArray(prev.ids) ? prev.ids : [];
685
- prevCount = prev.count || 0;
686
- }
687
- } catch {}
688
- atomicWriteFileSync(injectedIdsFile, JSON.stringify({
689
- ids: [...new Set([...prevIds.map(String), ...dedupIds])],
690
- ts: Date.now(),
691
- count: prevCount + 1,
692
- ...(hookData.session_id ? { session: hookData.session_id } : {}),
693
- }));
677
+ mergeInjectedMarker(injectedIdsFile, dedupIds,
678
+ { sessionId: hookData.session_id, maxAgeMs: DEDUP_STALE_MS, mode: 'union' });
694
679
  } catch {}
695
680
  }
696
681
  }
@@ -932,22 +917,14 @@ async function main() {
932
917
  if (output) {
933
918
  process.stdout.write(output + '\n');
934
919
  // Write injected IDs for dedup with hook.mjs handleUserPrompt + self-dedup
920
+ // replace, NOT union: this leg writes the prompt's own result set wholesale, and it
921
+ // is the ONE writer that puts raw observation numbers (mixed with `P<id>` strings)
922
+ // into the marker. `mode:'replace'` writes `candidateIds` verbatim for exactly that
923
+ // reason — stringifying here would flip the D#213 exclude from inert to live, which
924
+ // is a behaviour change with its own ruler and its own decision to make.
935
925
  try {
936
- let prevCount = 0;
937
- try {
938
- const prev = JSON.parse(readFileSync(injectedIdsFile, 'utf8'));
939
- // M-6: same-session (or legacy) count only; atomic write (torn-write guard).
940
- if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
941
- && !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
942
- prevCount = prev.count || 0;
943
- }
944
- } catch {}
945
- atomicWriteFileSync(injectedIdsFile, JSON.stringify({
946
- ids: candidateIds,
947
- ts: Date.now(),
948
- count: prevCount + 1,
949
- ...(hookData.session_id ? { session: hookData.session_id } : {}),
950
- }));
926
+ mergeInjectedMarker(injectedIdsFile, candidateIds,
927
+ { sessionId: hookData.session_id, maxAgeMs: DEDUP_STALE_MS, mode: 'replace' });
951
928
  } catch {}
952
929
  // v26 P0: bump injection_count for obs-based emits only (prompt-corpus
953
930
  // rows have "P<id>" string IDs; skip those — they live in user_prompts).
@@ -308,6 +308,13 @@ export function runIdleCleanup(db) {
308
308
  const marked = db.prepare(`
309
309
  UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
310
310
  WHERE importance <= 1 AND COALESCE(access_count, 0) = 0
311
+ -- injection_count=0, the second half of decayAndMarkIdle's engagement guard
312
+ -- (audit 2026-09-02 P0-4). Since v2.56 an injected-but-never-accessed row counts
313
+ -- as PROVEN RELEVANT and decay protects it; this MCP sibling carried the lesson
314
+ -- guard (CHANGELOG "the sixth enforcement site") and not this one, so a row the
315
+ -- CLI/hook paths preserve was pending-purge'd 5 minutes into an idle server —
316
+ -- the same guard-on-one-path shape the docblock below claims was consolidated.
317
+ AND COALESCE(injection_count, 0) = 0
311
318
  AND type IN (${types})
312
319
  AND created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0
313
320
  -- Never auto-mark a lesson-bearing row for purge. This idle path is the
@@ -321,6 +328,9 @@ export function runIdleCleanup(db) {
321
328
  const compressed = db.prepare(`
322
329
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
323
330
  WHERE COALESCE(compressed_into, 0) = 0 AND importance = 1
331
+ -- Same engagement guard as the mark-idle pass above: COMPRESSED_AUTO also hides
332
+ -- the row from every retrieval surface, so an injected row must not reach it.
333
+ AND COALESCE(injection_count, 0) = 0
324
334
  AND type IN (${types})
325
335
  AND created_at_epoch < ?
326
336
  -- Same lesson guard: auto-compress (-1) hides the row from all retrieval and
package/server.mjs CHANGED
@@ -15,13 +15,11 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
15
15
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
16
16
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
17
17
  import {
18
- cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
19
- recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
20
- purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
21
- hardDeleteCandidateCount,
18
+ runMaintainOps, findDuplicates, maintenanceStats,
22
19
  OP_CAP, STALE_AGE_MS, resolveDefaultMaintainOps, DEFAULT_MAINTAIN_OPS,
23
20
  } from './lib/maintain-core.mjs';
24
- import { snapshotDb } from './lib/db-backup.mjs';
21
+ // snapshotDb left with maintain-core: the pre-maintain snapshot is part of the op ORDER
22
+ // (it must see the pre-existing pending rows), so it moved into runMaintainOps (P1-5).
25
23
  import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
26
24
  import { fetchObsDetail, fetchPromptDetail, fetchEventDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS, PROMPT_DETAIL_FIELDS, EVENT_DETAIL_FIELDS, supersededNotice } from './lib/get-core.mjs';
27
25
  import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
@@ -47,10 +45,10 @@ import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
47
45
  import { join, sep } from 'path';
48
46
  import { homedir } from 'os';
49
47
  import { ensureRegistryDb, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
50
- import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources } from './lib/registry-core.mjs';
48
+ import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources, enrichImportedResources, enrichNamedResource } from './lib/registry-core.mjs';
51
49
  import { searchResources } from './registry-retriever.mjs';
52
50
  import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
53
- import { saveObservation, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
51
+ import { saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
54
52
  import { applyObsUpdate } from './lib/observation-write.mjs';
55
53
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
56
54
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
@@ -59,7 +57,7 @@ import { fetchRecent } from './lib/recent-core.mjs';
59
57
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
60
58
  import {
61
59
  insertDeferred, listOpenWithOrdinal, dropDeferred, formatDropReasonHint,
62
- resolveDeferredIds, closeDeferredItems,
60
+ resolveDeferredIds,
63
61
  getDeferredByIds, formatDeferredDetail,
64
62
  searchDeferredWork, formatDeferredSearchTrailer,
65
63
  formatDeferListRow, countStaleOpen, formatDeferStaleHint,
@@ -833,33 +831,23 @@ server.registerTool(
833
831
  if (args.project) args = { ...args, project: resolveProject(args.project) };
834
832
  const project = args.project || inferProject();
835
833
 
836
- let closesIds = null;
837
- let result;
834
+ let closesIds, result;
838
835
  try {
839
- result = db.transaction(() => {
840
- const r = saveObservation(db, {
841
- // Size ceiling applied here rather than in lib/save-observation.mjs so the
842
- // shared pipeline keeps its "caller validates" contract (see its header).
843
- content: clampSaveText(args.content, SAVE_TEXT_LIMITS.content),
844
- title: clampSaveText(args.title, SAVE_TEXT_LIMITS.title),
845
- type: args.type || 'discovery',
846
- importance: args.importance,
847
- project,
848
- files: args.files || [],
849
- lesson_learned: clampSaveText(args.lesson_learned, SAVE_TEXT_LIMITS.lesson_learned),
850
- supersedes: args.supersedes,
851
- });
852
- if (r.kind === 'duplicate') return r; // dedup short-circuits BEFORE resolver — replay is idempotent
853
- // Resolve INSIDE tx + after dedup check so duplicate replays don't throw on
854
- // already-closed items. Mirrors mem-cli.mjs cmdSave shape.
855
- if (args.closes_deferred && args.closes_deferred.length > 0) {
856
- // D#195: 'dropped' is closable by the close verb (kept in sync with
857
- // mem-cli.mjs cmdSave — same policy, both faces).
858
- closesIds = resolveDeferredIds(db, project, args.closes_deferred, { allowStatuses: ['open', 'dropped'] });
859
- closeDeferredItems(db, closesIds, r.id);
860
- }
861
- return r;
862
- })();
836
+ // The transaction body — dedup short-circuit BEFORE the resolver, and D#195's
837
+ // allowStatuses — is lib/save-observation.mjs's (audit 2026-09-02 P1-6). Both faces
838
+ // used to write it out and each carried a "kept in sync with the other" comment.
839
+ ({ result, closesIds } = saveWithClosures(db, {
840
+ // Size ceiling applied here rather than in lib/save-observation.mjs so the
841
+ // shared pipeline keeps its "caller validates" contract (see its header).
842
+ content: clampSaveText(args.content, SAVE_TEXT_LIMITS.content),
843
+ title: clampSaveText(args.title, SAVE_TEXT_LIMITS.title),
844
+ type: args.type || 'discovery',
845
+ importance: args.importance,
846
+ project,
847
+ files: args.files || [],
848
+ lesson_learned: clampSaveText(args.lesson_learned, SAVE_TEXT_LIMITS.lesson_learned),
849
+ supersedes: args.supersedes,
850
+ }, { closesTokens: args.closes_deferred, project }));
863
851
  } catch (e) {
864
852
  if (args.closes_deferred && args.closes_deferred.length > 0) {
865
853
  // Re-throw with a clearer prefix so MCP error response names the
@@ -1164,7 +1152,6 @@ server.registerTool(
1164
1152
  if (args.operations && args.operations.length === 0) {
1165
1153
  return { content: [{ type: 'text', text: `operations array is empty. Pass a non-empty list (e.g. ${JSON.stringify(DEFAULT_MAINTAIN_OPS)}) or omit operations to use the default set.` }], isError: true };
1166
1154
  }
1167
- const results = [];
1168
1155
  const staleAge = Date.now() - STALE_AGE_MS;
1169
1156
  const mctx = { projectFilter, baseParams, staleAge, opCap: OP_CAP };
1170
1157
 
@@ -1180,116 +1167,33 @@ server.registerTool(
1180
1167
  // runs unconfirmed on either surface now or before).
1181
1168
  const purgeConfirmed = args.confirm === true;
1182
1169
 
1183
- // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
1184
- // only when rows will actually be removed, and OUTSIDE the transaction below
1185
- // (VACUUM cannot run inside one). Best-effort; snapshotDb never throws.
1186
- if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') && purgeConfirmed }) > 0) {
1187
- snapshotDb(db, { tag: 'pre-maintain' });
1188
- }
1189
-
1190
- db.transaction(() => {
1191
- // PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766) and the CLI
1192
- // cmdMaintain. Running decay BEFORE purge in one transaction marked a stale row
1193
- // pending-purge AND deleted it in the SAME call (zero grace), while the pre-txn
1194
- // snapshot guard counts only PRE-EXISTING pending rows so it skipped the backup →
1195
- // permanent loss of notable imp-2/3 memories (audit HIGH-1). Purging first deletes
1196
- // only rows a PRIOR run marked (backed up); rows decay marks below wait one cycle.
1197
- if (ops.includes('purge_stale')) {
1198
- const retainDays = args.retain_days ?? 30;
1199
- const retainCutoff = Date.now() - retainDays * DAY_MS;
1200
- if (!purgeConfirmed) {
1201
- // Dry-run preview (parity with CLI `maintain` without --confirm): the other
1202
- // requested non-destructive ops still run below.
1203
- const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1204
- const lines = [
1205
- 'purge_stale preview (confirm=false):',
1206
- ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
1207
- ];
1208
- if (previewRow.candidates > 0) {
1209
- lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1210
- lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1211
- }
1212
- lines.push(' Nothing was deleted. To delete, re-run with confirm=true:');
1213
- 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}"` : ''})`);
1214
- results.push(lines.join('\n'));
1215
- } else {
1216
- const purged = purgeStale(db, mctx, retainCutoff);
1217
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1170
+ // The op ORDER, the pre-transaction snapshot and the three post-transaction ops all
1171
+ // live in maintain-core (audit 2026-09-02 P1-5) — this face keeps only what is
1172
+ // genuinely MCP-shaped: how `merge_ids` is spelled in a warning, and what the purge
1173
+ // preview tells the caller to type next. Before the extraction this block rendered
1174
+ // `inj>=8` as a literal while the CLI twin interpolated PINNED_INJ_THRESHOLD.
1175
+ const results = runMaintainOps(db, mctx, ops, {
1176
+ retainDays: args.retain_days ?? 30,
1177
+ retainCutoff: Date.now() - (args.retain_days ?? 30) * DAY_MS,
1178
+ confirmed: purgeConfirmed,
1179
+ mergeGroups: args.merge_ids || null,
1180
+ mergeIdsProvided: Boolean(args.merge_ids),
1181
+ mergeIdsFlagName: 'merge_ids',
1182
+ onError: debugCatch,
1183
+ renderPurgePreview: (previewRow, retainDays) => {
1184
+ const lines = [
1185
+ 'purge_stale preview (confirm=false):',
1186
+ ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`,
1187
+ ];
1188
+ if (previewRow.candidates > 0) {
1189
+ lines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1190
+ lines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1218
1191
  }
1219
- }
1220
-
1221
- if (ops.includes('cleanup')) {
1222
- const deleted = cleanupBroken(db, mctx);
1223
- results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1224
- // Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf):
1225
- // resurface unreachable children. Non-destructive — un-hide only, no delete.
1226
- const orphans = recoverOrphanedChildren(db, mctx);
1227
- if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
1228
- // Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
1229
- // lesson-bearing rows only; idempotent no-op once none remain.
1230
- const lessonsHealed = recoverBuriedLessons(db, mctx);
1231
- if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
1232
- // Heal deferred_work rows whose closing obs / source prompt was deleted while FK was
1233
- // OFF (dangling ref foreign_key_check flags). Applies the ON DELETE SET NULL the FK would.
1234
- const deferredHealed = sweepDeferredWorkOrphans(db, mctx);
1235
- if (deferredHealed > 0) results.push(`Healed ${deferredHealed} deferred-work rows with dangling references`);
1236
- }
1237
-
1238
- if (ops.includes('decay')) {
1239
- // injection_count>0 protected (maintain-core; unifies with CLI + hook —
1240
- // the MCP copy previously lacked this clause and decayed/purged injected memories).
1241
- const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
1242
- results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge` + ((decayed >= OP_CAP || idleMarked >= OP_CAP) ? ' (cap reached, re-run for more)' : ''));
1243
- }
1244
-
1245
- if (ops.includes('boost')) {
1246
- const boosted = boostAccessed(db, mctx);
1247
- results.push(`Boosted ${boosted} frequently-accessed observations` + (boosted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1248
- }
1249
-
1250
- if (ops.includes('demote_pinned')) {
1251
- const demoted = demotePinned(db, mctx);
1252
- results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=8, cited=0; no lesson → importance 1, lesson → 2)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1253
- }
1254
-
1255
- if (ops.includes('dedup') && args.merge_ids) {
1256
- const totalMerged = mergeDuplicates(db, args.merge_ids);
1257
- results.push(`Merged ${totalMerged} duplicate observations`);
1258
- }
1259
-
1260
- if (!ops.includes('dedup') && args.merge_ids) {
1261
- results.push('Warning: merge_ids provided but "dedup" not in operations — merge_ids ignored');
1262
- }
1263
- })();
1264
-
1265
- // FTS5 optimize (outside transaction)
1266
- db.exec("INSERT INTO observations_fts(observations_fts) VALUES('optimize')");
1267
- results.push('FTS5 index optimized');
1268
-
1269
- // rebuild_vectors: outside main transaction (maintain-core, shared with CLI).
1270
- if (ops.includes('rebuild_vectors')) {
1271
- try {
1272
- const r = rebuildVectors(db);
1273
- results.push(r.ok
1274
- ? `Vectors: rebuilt vocabulary (${r.terms} terms), updated ${r.updated}/${r.total} vectors`
1275
- : `Vectors: ${r.reason}`);
1276
- } catch (e) {
1277
- debugCatch(e, 'rebuild_vectors');
1278
- results.push(`Vectors: rebuild failed — ${e.message}`);
1279
- }
1280
- }
1281
-
1282
- // vacuum: reclaim freelist dead space left by DELETEs. Whole-DB, outside any
1283
- // transaction. maintain-core, shared with CLI.
1284
- if (ops.includes('vacuum')) {
1285
- try {
1286
- const v = vacuum(db);
1287
- results.push(`VACUUM: reclaimed ~${v.reclaimedMB}MB (freelist ${v.freeBefore} → ${v.freeAfter} pages)`);
1288
- } catch (e) {
1289
- debugCatch(e, 'vacuum');
1290
- results.push(`VACUUM failed — ${e.message}`);
1291
- }
1292
- }
1192
+ lines.push(' Nothing was deleted. To delete, re-run with confirm=true:');
1193
+ 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}"` : ''})`);
1194
+ return lines.join('\n');
1195
+ },
1196
+ });
1293
1197
 
1294
1198
  return { content: [{ type: 'text', text: results.join('\n') }] };
1295
1199
  }
@@ -1500,16 +1404,8 @@ server.registerTool(
1500
1404
  let enrichMsg = '';
1501
1405
  if (args.enrich) {
1502
1406
  const { enrichResource } = await import('./registry-enricher.mjs');
1503
- let ok = 0;
1504
- for (const r of results) {
1505
- const row = rdb.prepare('SELECT local_path FROM resources WHERE id = ?').get(r.id);
1506
- if (!row?.local_path) continue;
1507
- try {
1508
- const content = readFileSync(row.local_path, 'utf8');
1509
- if (await enrichResource(rdb, r.name, r.type, content)) ok++;
1510
- } catch {}
1511
- }
1512
- enrichMsg = `\nEnriched: ${ok}/${results.length}`;
1407
+ const { ok, denied } = await enrichImportedResources(rdb, results, { confineTo: DB_DIR, enrichResource });
1408
+ enrichMsg = `\nEnriched: ${ok}/${results.length}` + (denied ? ` (${denied} refused: path outside managed directory)` : '');
1513
1409
  }
1514
1410
 
1515
1411
  const lines = results.map(r => `${r.type === 'skill' ? 'S' : 'A'} ${r.name} (id=${r.id})`);
@@ -1523,25 +1419,27 @@ server.registerTool(
1523
1419
  if (!args.name) {
1524
1420
  return { content: [{ type: 'text', text: 'enrich requires a name parameter' }], isError: true };
1525
1421
  }
1526
- const row = rdb.prepare("SELECT name, type, local_path FROM resources WHERE name = ? AND status = 'active'").get(args.name);
1527
- if (!row) {
1528
- return { content: [{ type: 'text', text: `Resource not found: ${args.name}` }], isError: true };
1529
- }
1530
- if (!row.local_path) {
1531
- return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
1532
- }
1533
1422
  // Confine to the env-aware data dir (managed/ relocates with CLAUDE_MEM_DIR, D#29);
1534
- // === homedir when the env is unset, so non-relocated confinement is unchanged.
1535
- const enrichBase = DB_DIR;
1536
- if (!isPathConfined(row.local_path, enrichBase)) {
1537
- return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
1538
- }
1539
-
1423
+ // === homedir when the env is unset, so non-relocated confinement is unchanged. The
1424
+ // gate itself now lives in lib/registry-core.mjs so all four enrichment legs share it.
1540
1425
  const { enrichResource } = await import('./registry-enricher.mjs');
1541
1426
  try {
1542
- const content = readFileSync(row.local_path, 'utf8');
1543
- const ok = await enrichResource(rdb, row.name, row.type, content);
1544
- return { content: [{ type: 'text', text: ok ? `Enriched: ${args.name}` : `Enrichment failed for ${args.name}` }] };
1427
+ const { status, error } = await enrichNamedResource(rdb, args.name, { confineTo: DB_DIR, enrichResource });
1428
+ if (status === 'not-found') {
1429
+ return { content: [{ type: 'text', text: `Resource not found: ${args.name}` }], isError: true };
1430
+ }
1431
+ if (status === 'no-path') {
1432
+ return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
1433
+ }
1434
+ if (status === 'denied') {
1435
+ return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
1436
+ }
1437
+ if (status === 'unreadable') {
1438
+ // Pre-refactor this threw out of readFileSync into the catch below and rendered
1439
+ // `Enrich error: <errno>`. Keep the errno: a stale local_path is the common cause.
1440
+ return { content: [{ type: 'text', text: `Enrich error: ${error?.message || 'cannot read local_path'}` }], isError: true };
1441
+ }
1442
+ return { content: [{ type: 'text', text: status === 'enriched' ? `Enriched: ${args.name}` : `Enrichment failed for ${args.name}` }] };
1545
1443
  } catch (e) {
1546
1444
  return { content: [{ type: 'text', text: `Enrich error: ${e.message}` }], isError: true };
1547
1445
  }
package/source-files.mjs CHANGED
@@ -63,6 +63,8 @@ export const SOURCE_FILES = [
63
63
  'lib/cite-back-hint.mjs',
64
64
  // The one definition of the pre-recall cooldown path — shared by its writer
65
65
  // (scripts/pre-tool-recall.js) and both readers (cite-back-hint, edge-attribution).
66
+ 'lib/cite-recall-path.mjs',
67
+ 'lib/frontmatter.mjs',
66
68
  'lib/cooldown-path.mjs',
67
69
  // v2.85: stale test-fixture sweeper. Imported by install.mjs (cleanup) + cli.mjs.
68
70
  // Missing from manifest → tarball ships install.mjs that ERR_MODULE_NOT_FOUND on cleanup.