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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +0 -49
- package/README.zh-CN.md +0 -48
- package/adopt-content.mjs +0 -1
- package/cli/common.mjs +0 -17
- package/cli.mjs +19 -3
- package/commands/update.md +4 -11
- package/format-utils.mjs +0 -21
- package/hook-update.mjs +37 -12
- package/hook.mjs +5 -29
- package/hooks/hooks.json +0 -10
- package/install.mjs +24 -666
- package/lib/doctor-drift.mjs +0 -1
- package/lib/frontmatter.mjs +1 -2
- package/lib/hook-prune.mjs +133 -0
- package/lib/hook-stdin.mjs +1 -1
- package/lib/hook-telemetry.mjs +1 -1
- package/lib/metrics.mjs +2 -2
- package/lib/shard-gc.mjs +11 -7
- package/mem-cli.mjs +2 -423
- package/nlp.mjs +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -15
- package/schema.mjs +0 -1
- package/scripts/hook-launcher.mjs +2 -2
- package/scripts/prompt-search-utils.mjs +0 -30
- package/scripts/user-prompt-search.js +3 -125
- package/server.mjs +11 -449
- package/source-files.mjs +11 -28
- package/synonyms.mjs +2 -1
- package/tool-schemas.mjs +0 -80
- package/utils.mjs +5 -13
- package/commands/tools.md +0 -67
- package/install-metadata.mjs +0 -2193
- package/lib/registry-core.mjs +0 -264
- package/registry/preinstalled.json +0 -2419
- package/registry-enricher.mjs +0 -124
- package/registry-github.mjs +0 -86
- package/registry-importer.mjs +0 -569
- package/registry-recommend.mjs +0 -503
- package/registry-retriever.mjs +0 -611
- package/registry-scanner.mjs +0 -261
- package/registry.mjs +0 -665
- package/resource-discovery.mjs +0 -199
- 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
|
|
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 {
|
|
26
|
-
import { join
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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
|
|
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:
|
|
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));
|