claude-mem-lite 4.0.4 → 5.0.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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +0 -49
  4. package/README.zh-CN.md +0 -48
  5. package/adopt-content.mjs +0 -1
  6. package/cli/common.mjs +0 -17
  7. package/cli.mjs +19 -3
  8. package/commands/update.md +4 -11
  9. package/format-utils.mjs +0 -21
  10. package/hook-update.mjs +37 -12
  11. package/hook.mjs +5 -29
  12. package/hooks/hooks.json +0 -10
  13. package/install.mjs +24 -666
  14. package/lib/doctor-drift.mjs +0 -1
  15. package/lib/frontmatter.mjs +1 -2
  16. package/lib/hook-prune.mjs +133 -0
  17. package/lib/hook-stdin.mjs +1 -1
  18. package/lib/hook-telemetry.mjs +1 -1
  19. package/lib/metrics.mjs +2 -2
  20. package/lib/shard-gc.mjs +11 -7
  21. package/mem-cli.mjs +2 -423
  22. package/nlp.mjs +1 -1
  23. package/npm-shrinkwrap.json +2 -2
  24. package/package.json +3 -15
  25. package/schema.mjs +0 -1
  26. package/scripts/hook-launcher.mjs +2 -2
  27. package/scripts/prompt-search-utils.mjs +0 -30
  28. package/scripts/user-prompt-search.js +3 -125
  29. package/server.mjs +11 -449
  30. package/source-files.mjs +11 -28
  31. package/synonyms.mjs +2 -1
  32. package/tool-schemas.mjs +0 -80
  33. package/utils.mjs +5 -13
  34. package/commands/tools.md +0 -67
  35. package/install-metadata.mjs +0 -2193
  36. package/lib/registry-core.mjs +0 -264
  37. package/registry/preinstalled.json +0 -2419
  38. package/registry-enricher.mjs +0 -124
  39. package/registry-github.mjs +0 -86
  40. package/registry-importer.mjs +0 -569
  41. package/registry-recommend.mjs +0 -503
  42. package/registry-retriever.mjs +0 -611
  43. package/registry-scanner.mjs +0 -261
  44. package/registry.mjs +0 -665
  45. package/resource-discovery.mjs +0 -199
  46. package/scripts/pre-skill-bridge.js +0 -146
@@ -3,7 +3,7 @@
3
3
  // Runs as UserPromptSubmit hook — injects relevant memories before Claude sees the prompt
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
- import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
6
+ import { ensureDb, DB_DIR } from '../schema.mjs';
7
7
  import {
8
8
  relaxFtsQueryToOr,
9
9
  truncate,
@@ -22,10 +22,9 @@ import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-ed
22
22
  import { cjkPrecisionOk } from '../nlp.mjs';
23
23
  import { upsFtsQuery } from '../lib/ups-query.mjs';
24
24
  import { corpusFloorScale } from '../lib/relevance-floor.mjs';
25
- import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
26
- import { join, sep } from 'path';
25
+ import { readFileSync } from 'fs';
26
+ import { join } from 'path';
27
27
  import { pathToFileURL } from 'url';
28
- import Database from 'better-sqlite3';
29
28
  import {
30
29
  shouldSkip,
31
30
  computeEffectiveLen,
@@ -35,12 +34,10 @@ import {
35
34
  extractErrorSignature,
36
35
  extractDeferredRefs,
37
36
  DEDUP_STALE_MS,
38
- matchRegistrySkillName,
39
37
  detectMemOverride,
40
38
  } from './prompt-search-utils.mjs';
41
39
  import { injectedIdsFileName, mergeInjectedMarker } from '../lib/injected-ids.mjs';
42
40
  import { getDeferredByIds } from '../lib/deferred-work.mjs';
43
- import { recommendSkill } from '../registry-recommend.mjs';
44
41
  import { recordHookError } from '../lib/hook-telemetry.mjs';
45
42
 
46
43
  import { DAY_MS } from '../lib/time-constants.mjs';
@@ -649,73 +646,6 @@ function formatPromptResults(rows) {
649
646
  return lines.join('\n');
650
647
  }
651
648
 
652
- // ─── Registry Skill Pointer (T4 v2.31) ─────────────────────────────────────
653
- // Formerly "auto-load": we used to read the full SKILL.md body (up to 16KB)
654
- // and inject it into stdout on keyword match. Now we only emit a short
655
- // pointer line so Claude can decide to invoke via SkillTool. The cooldown
656
- // and match mechanics below are unchanged.
657
-
658
- const SKILL_COOLDOWN_FILE = join(RUNTIME_DIR, `.skill-cooldown-${inferProject()}`);
659
- const SKILL_COOLDOWN_MS = 300_000; // 5 minutes
660
-
661
- function loadManagedSkillNames() {
662
- if (!existsSync(REGISTRY_DB_PATH)) return new Set();
663
- try {
664
- const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
665
- rdb.pragma('busy_timeout = 500');
666
- try {
667
- // D#29: derive the managed marker from the env-aware data dir, not a hardcoded
668
- // homedir literal — under CLAUDE_MEM_DIR relocation the stored local_path lives at
669
- // DB_DIR/managed, so the old literal matched nothing and dropped every managed skill
670
- // from injection. Coarse LIKE prefilter; resource names are re-validated downstream.
671
- // D#29: derive the managed marker from the env-aware data dir, not a hardcoded
672
- // homedir literal — under CLAUDE_MEM_DIR relocation the stored local_path lives at
673
- // DB_DIR/managed, so the old literal matched nothing and dropped every managed skill
674
- // from injection. Coarse LIKE prefilter; resource names are re-validated downstream.
675
- const rows = rdb
676
- .prepare(
677
- `
678
- SELECT name FROM resources
679
- WHERE status = 'active' AND local_path LIKE ?
680
- `,
681
- )
682
- .all(`%${join(DB_DIR, 'managed') + sep}%`);
683
- return new Set(rows.map((r) => r.name.toLowerCase()));
684
- } finally {
685
- rdb.close();
686
- }
687
- } catch {
688
- return new Set();
689
- }
690
- }
691
-
692
- function getSkillCooldown() {
693
- try {
694
- const raw = readFileSync(SKILL_COOLDOWN_FILE, 'utf8');
695
- const data = JSON.parse(raw);
696
- const now = Date.now();
697
- const cleaned = {};
698
- for (const [k, v] of Object.entries(data)) {
699
- if (now - v < SKILL_COOLDOWN_MS) cleaned[k] = v;
700
- }
701
- return cleaned;
702
- } catch {
703
- return {};
704
- }
705
- }
706
-
707
- function setSkillCooldown(name) {
708
- try {
709
- const data = getSkillCooldown();
710
- data[name] = Date.now();
711
- const tmp = SKILL_COOLDOWN_FILE + `.tmp-${process.pid}`;
712
- writeFileSync(tmp, JSON.stringify(data));
713
- renameSync(tmp, SKILL_COOLDOWN_FILE);
714
- } catch {
715
- /* silent */
716
- }
717
- }
718
-
719
649
  // ─── Main ───────────────────────────────────────────────────────────────────
720
650
 
721
651
  async function main() {
@@ -1091,58 +1021,6 @@ async function main() {
1091
1021
  } catch {}
1092
1022
  }
1093
1023
  }
1094
-
1095
- // ─── L1: Registry skill pointer (T4 v2.31) ──────────────────────────
1096
- // Previously this block injected the full skill body (up to 16KB) on
1097
- // keyword match, silently inflating every matched prompt. We now emit a
1098
- // single pointer line so Claude can decide to invoke via SkillTool on
1099
- // demand — the cooldown and match preconditions stay identical.
1100
- try {
1101
- const skillNames = loadManagedSkillNames();
1102
- const matched = matchRegistrySkillName(promptText, skillNames);
1103
- if (matched) {
1104
- const cooldown = getSkillCooldown();
1105
- if (!cooldown[matched]) {
1106
- // Registry skill names come from third-party repos (tools/adopt import) — an
1107
- // untrusted boundary like every other DB-derived string on this surface.
1108
- const safeName = neutralizeContextDelimiters(matched);
1109
- process.stdout.write(
1110
- `\n[mem] Skill "${safeName}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${safeName}\n`,
1111
- );
1112
- setSkillCooldown(matched);
1113
- }
1114
- }
1115
- } catch {
1116
- /* silent — never block on registry failure */
1117
- }
1118
-
1119
- // ─── L2: Intent-based skill recommendation (shadow-first, v3.12) ─────
1120
- // Distinct from L1 (explicit-name pointer): fires on intent even when the
1121
- // user did not name a skill. Phase 1 = shadow only (logs, never emits).
1122
- // Reuses a readonly registry DB; cooldown/shadow writes go to the FS.
1123
- try {
1124
- if (existsSync(REGISTRY_DB_PATH)) {
1125
- // #8259: explicit-signal presence is the decisive lever for UPS injection
1126
- // quality (UPS cite-recall was 25.8% until gated on it). Logged in shadow so
1127
- // Phase 2 can decide whether live injection gates on it. Shadow measures broadly.
1128
- const hasSignal = !!(
1129
- extractErrorSignature(promptText) ||
1130
- extractFiles(promptText).length > 0 ||
1131
- detectIntent(promptText)
1132
- );
1133
- const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
1134
- rdb.pragma('busy_timeout = 500');
1135
- // CC session_id (hook stdin) is the cross-hook pairing key: PostToolUse adoptions
1136
- // in this same session join back to this reco for matched precision (B1).
1137
- try {
1138
- recommendSkill(rdb, promptText, inferProject(), { hasSignal, sessionId: hookData.session_id });
1139
- } finally {
1140
- rdb.close();
1141
- }
1142
- }
1143
- } catch {
1144
- /* silent — never block on recommendation failure */
1145
- }
1146
1024
  } catch (e) {
1147
1025
  // Hooks must never break Claude Code — swallow, but RECORD: this catch wraps
1148
1026
  // every FTS query on the surface, so a schema/FTS drift here would zero out
package/server.mjs CHANGED
@@ -5,9 +5,9 @@
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, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
8
+ import { truncate, typeIcon, inferProject, fmtDate, debugLog, debugCatch } from './utils.mjs';
9
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
- import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
10
+ import { ensureDbWithWalRecovery, DB_PATH, DB_DIR } from './schema.mjs';
11
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';
@@ -77,9 +77,7 @@ import {
77
77
  memExportSchema,
78
78
  memRecallSchema,
79
79
  memFtsCheckSchema,
80
- memRegistrySchema,
81
80
  memBrowseSchema,
82
- memUseSchema,
83
81
  memDeferSchema,
84
82
  memDeferListSchema,
85
83
  memDeferDropSchema,
@@ -95,24 +93,7 @@ function descriptionOf(name) {
95
93
  return d;
96
94
  }
97
95
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
98
- import { join, sep } from 'path';
99
- import { homedir } from 'os';
100
- import {
101
- ensureRegistryDb,
102
- collectRegistryStats,
103
- listResourcesRanked,
104
- formatRegistryListLine,
105
- } from './registry.mjs';
106
- import {
107
- IMPORT_STRING_FIELDS,
108
- importResource,
109
- removeResource,
110
- reindexResources,
111
- enrichImportedResources,
112
- enrichNamedResource,
113
- resourceUseHint,
114
- } from './lib/registry-core.mjs';
115
- import { searchResources } from './registry-retriever.mjs';
96
+ import { join } from 'path';
116
97
  import {
117
98
  probeOtherSources as probeIdSources,
118
99
  bucketIdTokens,
@@ -182,21 +163,6 @@ try {
182
163
  // Server process uses longer busy_timeout for concurrent MCP requests
183
164
  db.pragma('busy_timeout = 5000');
184
165
 
185
- // ─── Registry Database (lazy-loaded on first mem_registry call) ─────────────
186
-
187
- let registryDb = null;
188
-
189
- function getRegistryDb() {
190
- if (registryDb) return registryDb;
191
- try {
192
- registryDb = ensureRegistryDb(REGISTRY_DB_PATH);
193
- registryDb.pragma('busy_timeout = 5000'); // match main DB + ensureRegistryDb (was 3000, overriding it back down)
194
- } catch (e) {
195
- debugLog('WARN', 'server', `Registry DB not available: ${e.message}`);
196
- }
197
- return registryDb;
198
- }
199
-
200
166
  // inferProject, typeIcon, truncate, fmtDate imported from utils.mjs
201
167
 
202
168
  // ─── Project Name Resolution ────────────────────────────────────────────────
@@ -325,18 +291,20 @@ function defangResult(result, { skillBlocks = true } = {}) {
325
291
  * @param {boolean} [opts.verbatim=false] Skip the defang pass. Only for payloads that
326
292
  * must round-trip byte-exact — `mem_export` feeds `restore`, so neutralizing it would
327
293
  * silently corrupt backups of any memory that legitimately discusses these tags.
328
- * @param {boolean} [opts.emitsSkillBlock=false] This handler legitimately emits a real
329
- * `<skill-loaded>` wrapper, so the chokepoint must not strip it. `mem_use` is the only
330
- * one, and it neutralizes its own untrusted body/name/path per call site (R6 P1-1).
331
- * Applies to the SUCCESS path only — an error message never emits a wrapper.
294
+ *
295
+ * There used to be an `emitsSkillBlock` opt-out here, for the one handler that
296
+ * legitimately emitted a real `<skill-loaded>` wrapper: `mem_use`. It went with the skill
297
+ * registry, so the chokepoint is now unconditional — every non-verbatim result is defanged
298
+ * for skill blocks. Do not reintroduce the opt-out for a handler that merely wants to emit
299
+ * a wrapper; R6 P1-1 was exactly the shape where one exempt face carried the whole risk.
332
300
  */
333
- function safeHandler(fn, { verbatim = false, emitsSkillBlock = false } = {}) {
301
+ function safeHandler(fn, { verbatim = false } = {}) {
334
302
  return async (args, extra) => {
335
303
  try {
336
304
  lastMcpRequestTime = Date.now();
337
305
  idleCleanupRan = false;
338
306
  const result = await fn(args, extra);
339
- return verbatim ? result : defangResult(result, { skillBlocks: !emitsSkillBlock });
307
+ return verbatim ? result : defangResult(result, { skillBlocks: true });
340
308
  } catch (err) {
341
309
  return defangResult({ content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
342
310
  }
@@ -1569,409 +1537,6 @@ server.registerTool(
1569
1537
  }),
1570
1538
  );
1571
1539
 
1572
- // ─── Tool: mem_registry ─────────────────────────────────────────────────────
1573
-
1574
- server.registerTool(
1575
- 'mem_registry',
1576
- {
1577
- description: descriptionOf('mem_registry'),
1578
- inputSchema: memRegistrySchema,
1579
- },
1580
- safeHandler(async (args) => {
1581
- const rdb = getRegistryDb();
1582
- if (!rdb) {
1583
- return {
1584
- content: [{ type: 'text', text: 'Registry DB not available. Run install first.' }],
1585
- isError: true,
1586
- };
1587
- }
1588
-
1589
- const action = args.action;
1590
-
1591
- if (action === 'search') {
1592
- if (!args.query) {
1593
- return { content: [{ type: 'text', text: 'search requires a query parameter' }], isError: true };
1594
- }
1595
- let results = searchResources(rdb, args.query, {
1596
- type: args.type || undefined,
1597
- limit: args.category || args.quality ? 20 : 10, // fetch more for post-filtering
1598
- });
1599
- // Apply category/quality filters if provided
1600
- if (args.category) results = results.filter((r) => r.category === args.category);
1601
- if (args.quality) results = results.filter((r) => r.quality_tier === args.quality);
1602
- // Prioritize directly invocable resources (with invocation_name) over community resources
1603
- results.sort((a, b) => {
1604
- const aInvocable = a.invocation_name ? 1 : 0;
1605
- const bInvocable = b.invocation_name ? 1 : 0;
1606
- if (aInvocable !== bInvocable) return bInvocable - aInvocable;
1607
- return 0; // preserve FTS5 ranking within same tier
1608
- });
1609
- results = results.slice(0, 5);
1610
- if (results.length === 0) {
1611
- return { content: [{ type: 'text', text: `No matching resources for: "${args.query}"` }] };
1612
- }
1613
- const home = homedir();
1614
- const lines = results.map((r) => {
1615
- const qualityBadge =
1616
- r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
1617
- const categoryLabel = r.category ? ` [${r.category}]` : '';
1618
- // P2-6: the invocation rule is shared; only the Markdown bold and the two-space
1619
- // indent below are this face's.
1620
- const { portablePath, howToUse } = resourceUseHint(r, {
1621
- home,
1622
- managedPrefix: join(DB_DIR, 'managed') + sep,
1623
- });
1624
- const pathLine = portablePath ? `\n Path: ${portablePath}` : '';
1625
- return `${qualityBadge} ${r.type === 'skill' ? 'S' : 'A'} **${r.name}**${categoryLabel} — ${truncate(r.capability_summary || '', 80)}${pathLine}\n Use: ${howToUse}`;
1626
- });
1627
- return {
1628
- content: [
1629
- {
1630
- type: 'text',
1631
- text: `Found ${results.length} resource(s) for "${args.query}":\n\n${lines.join('\n\n')}`,
1632
- },
1633
- ],
1634
- };
1635
- }
1636
-
1637
- if (action === 'list') {
1638
- // Shared ranked query + row line (registry.mjs, P2-12) — single source with CLI list.
1639
- const resources = listResourcesRanked(rdb, { type: args.type });
1640
- if (resources.length === 0) return { content: [{ type: 'text', text: 'No resources found.' }] };
1641
- const lines = resources.map(formatRegistryListLine);
1642
- return { content: [{ type: 'text', text: `Resources (${resources.length}):\n${lines.join('\n')}` }] };
1643
- }
1644
-
1645
- if (action === 'stats') {
1646
- // Shared collection (registry.mjs collectRegistryStats, P2-12) — single
1647
- // source with the CLI registry stats action.
1648
- const s = collectRegistryStats(rdb);
1649
- const lines = [
1650
- `Registry Stats:`,
1651
- ` Total active: ${s.total}`,
1652
- ...s.byType.map((t) => ` ${t.type}: ${t.c}`),
1653
- ` User-added: ${s.userAdded}`,
1654
- ` Zero adoption (recommended but never adopted): ${s.zeroAdopt}`,
1655
- ];
1656
- if (s.topAdopted.length > 0) {
1657
- lines.push('', 'Top adopted:');
1658
- for (const r of s.topAdopted) {
1659
- lines.push(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
1660
- }
1661
- }
1662
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1663
- }
1664
-
1665
- if (action === 'import') {
1666
- if (!args.name || !args.resource_type) {
1667
- return { content: [{ type: 'text', text: 'import requires name and resource_type' }], isError: true };
1668
- }
1669
- // Provenance preservation + the 'installed' tier grant live in lib/registry-core.mjs,
1670
- // shared with the `registry import` CLI twin (audit 2026-08-22 P1-3). Before that
1671
- // extraction this side wrote its own SQL and silently skipped the tier grant.
1672
- const fields = {};
1673
- for (const f of IMPORT_STRING_FIELDS) fields[f] = args[f] || '';
1674
- const { id } = importResource(rdb, {
1675
- name: args.name,
1676
- type: args.resource_type,
1677
- source: args.source,
1678
- fields,
1679
- });
1680
- return { content: [{ type: 'text', text: `Imported: ${args.resource_type}:${args.name} (id=${id})` }] };
1681
- }
1682
-
1683
- if (action === 'remove') {
1684
- if (!args.name || !args.resource_type) {
1685
- return { content: [{ type: 'text', text: 'remove requires name and resource_type' }], isError: true };
1686
- }
1687
- const { removed } = removeResource(rdb, { name: args.name, type: args.resource_type });
1688
- return {
1689
- content: [
1690
- { type: 'text', text: removed ? `Removed: ${args.resource_type}:${args.name}` : 'Not found.' },
1691
- ],
1692
- };
1693
- }
1694
-
1695
- if (action === 'reindex') {
1696
- const { activeCount } = reindexResources(rdb);
1697
- return { content: [{ type: 'text', text: `FTS5 reindexed. ${activeCount} active resources.` }] };
1698
- }
1699
-
1700
- if (action === 'import_url') {
1701
- if (!args.url) {
1702
- return { content: [{ type: 'text', text: 'import_url requires a url parameter' }], isError: true };
1703
- }
1704
- const { importFromGitHub, formatImportSkips } = await import('./registry-importer.mjs');
1705
- try {
1706
- // `skipped` sink + shared summary (R6 Q1): the import bounds must REFUSE visibly, and
1707
- // the CLI twin renders the identical string from the identical helper.
1708
- const skipped = [];
1709
- const results = await importFromGitHub(rdb, args.url, { skipped });
1710
- const refusal = formatImportSkips(skipped);
1711
- if (results.length === 0) {
1712
- const head = `No skills/agents found in: ${args.url}`;
1713
- return { content: [{ type: 'text', text: refusal ? `${head}\n${refusal}` : head }] };
1714
- }
1715
-
1716
- let enrichMsg = '';
1717
- if (args.enrich) {
1718
- const { enrichResource } = await import('./registry-enricher.mjs');
1719
- const { ok, denied } = await enrichImportedResources(rdb, results, {
1720
- confineTo: DB_DIR,
1721
- enrichResource,
1722
- });
1723
- enrichMsg =
1724
- `\nEnriched: ${ok}/${results.length}` +
1725
- (denied ? ` (${denied} refused: path outside managed directory)` : '');
1726
- }
1727
-
1728
- const lines = results.map((r) => `${r.type === 'skill' ? 'S' : 'A'} ${r.name} (id=${r.id})`);
1729
- return {
1730
- content: [
1731
- {
1732
- type: 'text',
1733
- text: `Imported ${results.length} resource(s) from ${args.url}:\n${lines.join('\n')}${enrichMsg}${refusal ? `\n${refusal}` : ''}`,
1734
- },
1735
- ],
1736
- };
1737
- } catch (e) {
1738
- return { content: [{ type: 'text', text: `Import failed: ${e.message}` }], isError: true };
1739
- }
1740
- }
1741
-
1742
- if (action === 'enrich') {
1743
- if (!args.name) {
1744
- return { content: [{ type: 'text', text: 'enrich requires a name parameter' }], isError: true };
1745
- }
1746
- // Confine to the env-aware data dir (managed/ relocates with CLAUDE_MEM_DIR, D#29);
1747
- // === homedir when the env is unset, so non-relocated confinement is unchanged. The
1748
- // gate itself now lives in lib/registry-core.mjs so all four enrichment legs share it.
1749
- const { enrichResource } = await import('./registry-enricher.mjs');
1750
- try {
1751
- const { status, error } = await enrichNamedResource(rdb, args.name, {
1752
- confineTo: DB_DIR,
1753
- enrichResource,
1754
- });
1755
- if (status === 'not-found') {
1756
- return { content: [{ type: 'text', text: `Resource not found: ${args.name}` }], isError: true };
1757
- }
1758
- if (status === 'no-path') {
1759
- return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
1760
- }
1761
- if (status === 'denied') {
1762
- return {
1763
- content: [{ type: 'text', text: `Access denied: path outside managed directory` }],
1764
- isError: true,
1765
- };
1766
- }
1767
- if (status === 'unreadable') {
1768
- // Pre-refactor this threw out of readFileSync into the catch below and rendered
1769
- // `Enrich error: <errno>`. Keep the errno: a stale local_path is the common cause.
1770
- return {
1771
- content: [{ type: 'text', text: `Enrich error: ${error?.message || 'cannot read local_path'}` }],
1772
- isError: true,
1773
- };
1774
- }
1775
- return {
1776
- content: [
1777
- {
1778
- type: 'text',
1779
- text: status === 'enriched' ? `Enriched: ${args.name}` : `Enrichment failed for ${args.name}`,
1780
- },
1781
- ],
1782
- };
1783
- } catch (e) {
1784
- return { content: [{ type: 'text', text: `Enrich error: ${e.message}` }], isError: true };
1785
- }
1786
- }
1787
-
1788
- return {
1789
- content: [
1790
- {
1791
- type: 'text',
1792
- text: `Unknown action: ${action}. Valid: search, list, stats, import, remove, reindex, import_url, enrich`,
1793
- },
1794
- ],
1795
- isError: true,
1796
- };
1797
- }),
1798
- );
1799
-
1800
- // ─── Tool: mem_use ──────────────────────────────────────────────────────────
1801
-
1802
- // Cap on the caller-supplied name echoed back in a miss message. Well past any real
1803
- // skill/agent name (the longest registered one here is 23 chars), short enough that an
1804
- // unbounded argument cannot pad the response — the echo appears twice.
1805
- const ECHO_NAME_MAX = 80;
1806
-
1807
- server.registerTool(
1808
- 'mem_use',
1809
- {
1810
- description: descriptionOf('mem_use'),
1811
- inputSchema: memUseSchema,
1812
- },
1813
- safeHandler(
1814
- async (args) => {
1815
- const rdb = getRegistryDb();
1816
- if (!rdb) {
1817
- return { content: [{ type: 'text', text: 'Registry DB not available.' }], isError: true };
1818
- }
1819
-
1820
- const name = args.name.trim();
1821
- const type = args.type || 'skill';
1822
-
1823
- // 1. Exact match by name or invocation_name — the ONLY path that loads content.
1824
- const row = rdb
1825
- .prepare(
1826
- `
1827
- SELECT id, name, type, local_path, invocation_name, capability_summary
1828
- FROM resources
1829
- WHERE status = 'active' AND type = ?
1830
- AND (name = ? OR invocation_name = ?)
1831
- LIMIT 1
1832
- `,
1833
- )
1834
- .get(type, name, name);
1835
-
1836
- // 2. Name miss → SUGGEST, never substitute. The FTS5 search still runs (it is what
1837
- // produces the candidate list), but its result is only ever rendered as names: loading
1838
- // the top hit under the caller's requested name shipped a different skill's body inside
1839
- // <skill-loaded> plus "Follow the instructions above to execute this <type>." — with
1840
- // nothing marking the swap, so an agent that asked for A executed B (audit F1,
1841
- // 2026-08-14: with only `deploy-rollback-runbook` registered, `deploy-notes` /
1842
- // `rollback-checklist` / `runbook-index` each returned its full body). Loading stays an
1843
- // exact-name decision the caller makes.
1844
- if (!row) {
1845
- let candidates = [];
1846
- try {
1847
- candidates = searchResources(rdb, name, { type, limit: 5 })
1848
- .map((r) => r.name)
1849
- .filter(Boolean);
1850
- } catch {
1851
- /* a suggestion is best-effort; the miss message below still stands */
1852
- }
1853
- // Every echo of the caller's own name below is bounded + delimiter-inert (audit F7):
1854
- // raw interpolation let a crafted `name` forge a <skill-loaded> block and the execute
1855
- // imperative inside this message, and the handler-wide defangResult cannot catch it —
1856
- // <skill-loaded> is off CONTEXT_DELIMITER_RE precisely so the real load path can emit
1857
- // it. `truncate` also folds newlines, so a multi-line name cannot fake block structure.
1858
- // Registered names are defanged too (a crafted one can be imported), but NOT truncated:
1859
- // the suggestion tells the caller to load one by its exact name, so it must stay exact.
1860
- const echoed = neutralizeSkillDelimiters(truncate(name, ECHO_NAME_MAX));
1861
- const echoedCandidates = candidates.map((n) => neutralizeSkillDelimiters(n));
1862
- const head = `No ${type} found for "${echoed}".`;
1863
- const browse = `mem_registry(action="search", query="${echoed}")`;
1864
- if (candidates.length === 0) {
1865
- return { content: [{ type: 'text', text: `${head} Try ${browse} to browse.` }] };
1866
- }
1867
- const list = echoedCandidates.map((n) => ` - ${n}`).join('\n');
1868
- return {
1869
- content: [
1870
- {
1871
- type: 'text',
1872
- text:
1873
- `${head} Closest ${type}s by search (NOT loaded — none matched the name you asked for):\n${list}\n\n` +
1874
- `Load one deliberately with its exact name, e.g. mem_use(name="${echoedCandidates[0]}"${type === 'skill' ? '' : `, type="${type}"`}), or browse with ${browse}.`,
1875
- },
1876
- ],
1877
- };
1878
- }
1879
-
1880
- // 3. Resolve path: directory skills → SKILL.md (agents always have full .md paths)
1881
- let skillPath = row.local_path || '';
1882
- if (skillPath && !skillPath.endsWith('.md')) {
1883
- for (const candidate of [
1884
- join(skillPath, 'SKILL.md'),
1885
- join(skillPath, `skills/${row.name}/SKILL.md`),
1886
- ]) {
1887
- if (existsSync(candidate)) {
1888
- skillPath = candidate;
1889
- break;
1890
- }
1891
- }
1892
- }
1893
-
1894
- // 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
1895
- // Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
1896
- // equals homedir when unset, so this does not weaken the non-relocated confinement.
1897
- const managedBase = DB_DIR;
1898
- if (skillPath && !isPathConfined(skillPath, managedBase)) {
1899
- return {
1900
- content: [
1901
- { type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` },
1902
- ],
1903
- isError: true,
1904
- };
1905
- }
1906
-
1907
- // 5. Read content
1908
- let content;
1909
- try {
1910
- content = readFileSync(skillPath, 'utf8');
1911
- } catch {
1912
- const msg = skillPath.endsWith('.md')
1913
- ? `Found ${type} "${row.name}" but cannot read file: ${skillPath}`
1914
- : `Found ${type} "${row.name}" but no .md file in: ${skillPath}`;
1915
- return { content: [{ type: 'text', text: msg }], isError: true };
1916
- }
1917
-
1918
- // 5. Record invocation
1919
- try {
1920
- rdb
1921
- .prepare(
1922
- `
1923
- INSERT INTO invocations (resource_id, session_id, trigger, adopted, outcome)
1924
- VALUES (?, ?, 'user_explicit', 1, 'success')
1925
- `,
1926
- )
1927
- .run(row.id, process.env.CLAUDE_SESSION_ID || 'unknown');
1928
- } catch {
1929
- /* non-critical */
1930
- }
1931
-
1932
- const _home = homedir();
1933
- const portablePath =
1934
- skillPath && skillPath.startsWith(_home) ? '~' + skillPath.slice(_home.length) : skillPath || '';
1935
-
1936
- // Defang the untrusted pieces before wrapping (audit 2026-09-05 R6 P1-1). All three come
1937
- // from a third-party repo by way of the registry — `registry import-url` stores a body
1938
- // verbatim, and `registry import --name` stores a name with no charset filter at all — so
1939
- // this emitter is the containment boundary, not the import.
1940
- //
1941
- // The handler-wide defangResult only runs neutralizeContextDelimiters, and <skill-loaded>
1942
- // is deliberately OFF that list (format-utils.mjs) so this very line can emit a real
1943
- // wrapper. So the body needs the per-call-site neutralizer: a literal `</skill-loaded>`
1944
- // in it closed the wrapper and forged a second block attributed to another skill, with
1945
- // the "Follow the instructions above" sentence below landing after it as an endorsement.
1946
- // Name and path are stripped rather than neutralized because they land in ATTRIBUTE
1947
- // position, where a bare `"` breaks out of the tag regardless of any tag-shaped pattern.
1948
- //
1949
- // Same treatment the sibling face already applies (scripts/pre-skill-bridge.js, audit
1950
- // 2026-08-14 M-4 + D#122 ③). The wrapper itself stays live — that is the counter-case
1951
- // pinned by tests/audit-findings-20260814.test.mjs:605 and this file's last case.
1952
- // `row.type` is not defanged: the resources CHECK constraint admits only 'skill'|'agent'.
1953
- const attrSafe = (s) => String(s ?? '').replace(/["'<>]/g, '');
1954
- const safeName = attrSafe(row.name);
1955
- const safePath = attrSafe(portablePath);
1956
- const safeBody = neutralizeSkillDelimiters(content);
1957
- const pathAttr = safePath ? ` path="${safePath}"` : '';
1958
- const reloadHint = safePath ? ` Reload: Read("${safePath}")` : '';
1959
- return {
1960
- content: [
1961
- {
1962
- type: 'text',
1963
- text: `<skill-loaded name="${safeName}" type="${row.type}"${pathAttr}>\n${safeBody}\n</skill-loaded>\n\nFollow the instructions above to execute this ${row.type}.${reloadHint}`,
1964
- },
1965
- ],
1966
- };
1967
- // The one handler that emits a real <skill-loaded> wrapper, so the chokepoint's
1968
- // skill-block pass is turned OFF here — see defangResult. Everything untrusted inside
1969
- // the wrapper is neutralized above, per call site.
1970
- },
1971
- { emitsSkillBlock: true },
1972
- ),
1973
- );
1974
-
1975
1540
  // ─── Tool: mem_update ────────────────────────────────────────────────────────
1976
1541
 
1977
1542
  server.registerTool(
@@ -2335,9 +1900,6 @@ function shutdown(exitCode = 0) {
2335
1900
  try {
2336
1901
  if (db) db.close();
2337
1902
  } catch {}
2338
- try {
2339
- if (registryDb) registryDb.close();
2340
- } catch {}
2341
1903
  process.exit(exitCode);
2342
1904
  }
2343
1905
  process.on('SIGINT', () => shutdown(0));