clementine-agent 1.0.58 → 1.0.60
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/dist/agent/assistant.js +1 -0
- package/dist/agent/auto-skills.d.ts +27 -0
- package/dist/agent/auto-skills.js +298 -0
- package/dist/agent/contradiction-validator.js +8 -2
- package/dist/agent/mcp-schemas.d.ts +49 -0
- package/dist/agent/mcp-schemas.js +150 -0
- package/dist/agent/skill-extractor.js +79 -9
- package/dist/index.js +15 -1
- package/dist/tools/admin-tools.js +17 -0
- package/package.json +1 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -1423,6 +1423,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1423
1423
|
mcpTool('list_allowed_tools'),
|
|
1424
1424
|
mcpTool('disallow_tool'),
|
|
1425
1425
|
mcpTool('refresh_tool_inventory'),
|
|
1426
|
+
mcpTool('refresh_skills'),
|
|
1426
1427
|
mcpTool('self_restart'),
|
|
1427
1428
|
mcpTool('self_update'),
|
|
1428
1429
|
mcpTool('where_is_source'),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-synthesize a skill document for every tool discovered via MCP
|
|
3
|
+
* schema fetching. Pure schema → markdown transform, no LLM call.
|
|
4
|
+
*
|
|
5
|
+
* User-authored skills under `skills/<name>.md` (top-level) always win
|
|
6
|
+
* on retrieval over auto-generated skills under `skills/auto/<server>/<tool>.md`.
|
|
7
|
+
* A user can shadow any auto-skill by dropping a hand-written file at
|
|
8
|
+
* top level — same triggers, their version serves.
|
|
9
|
+
*
|
|
10
|
+
* Regeneration: each auto-skill's frontmatter includes a `schemaHash`
|
|
11
|
+
* computed from the tool's inputSchema. On every boot we diff and only
|
|
12
|
+
* rewrite skills whose hash changed. User edits to auto-skills aren't
|
|
13
|
+
* preserved — they should shadow, not edit.
|
|
14
|
+
*/
|
|
15
|
+
import type { AllSchemas } from './mcp-schemas.js';
|
|
16
|
+
/**
|
|
17
|
+
* Given the fetched schemas, write one auto-skill per tool. Idempotent —
|
|
18
|
+
* only writes when the schema hash changes or the file is missing.
|
|
19
|
+
* Prunes stale auto-skills for tools the server no longer declares.
|
|
20
|
+
*/
|
|
21
|
+
export declare function synthesizeSkillsFromSchemas(schemas: AllSchemas): {
|
|
22
|
+
written: number;
|
|
23
|
+
unchanged: number;
|
|
24
|
+
pruned: number;
|
|
25
|
+
toolCount: number;
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=auto-skills.d.ts.map
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-synthesize a skill document for every tool discovered via MCP
|
|
3
|
+
* schema fetching. Pure schema → markdown transform, no LLM call.
|
|
4
|
+
*
|
|
5
|
+
* User-authored skills under `skills/<name>.md` (top-level) always win
|
|
6
|
+
* on retrieval over auto-generated skills under `skills/auto/<server>/<tool>.md`.
|
|
7
|
+
* A user can shadow any auto-skill by dropping a hand-written file at
|
|
8
|
+
* top level — same triggers, their version serves.
|
|
9
|
+
*
|
|
10
|
+
* Regeneration: each auto-skill's frontmatter includes a `schemaHash`
|
|
11
|
+
* computed from the tool's inputSchema. On every boot we diff and only
|
|
12
|
+
* rewrite skills whose hash changed. User edits to auto-skills aren't
|
|
13
|
+
* preserved — they should shadow, not edit.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
|
18
|
+
import matter from 'gray-matter';
|
|
19
|
+
import { VAULT_DIR } from '../config.js';
|
|
20
|
+
import { logger } from '../tools/shared.js';
|
|
21
|
+
import { loadToolInventory } from './mcp-bridge.js';
|
|
22
|
+
const SKILLS_ROOT = path.join(VAULT_DIR, '00-System', 'skills');
|
|
23
|
+
const AUTO_ROOT = path.join(SKILLS_ROOT, 'auto');
|
|
24
|
+
function schemaHash(schema) {
|
|
25
|
+
return createHash('sha1').update(JSON.stringify(schema ?? {})).digest('hex').slice(0, 16);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Split a snake_case/camelCase identifier into readable words.
|
|
29
|
+
* `read_imessages` → "read imessages", `pageSize` → "page size"
|
|
30
|
+
*/
|
|
31
|
+
function humanize(s) {
|
|
32
|
+
return s
|
|
33
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
34
|
+
.replace(/[_-]+/g, ' ')
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.trim();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Derive trigger phrases a user might use to invoke this tool. Every trigger
|
|
40
|
+
* must include the server name (or a server-specific noun derived from the
|
|
41
|
+
* tool name) so generic verbs like "list", "check", "read" don't trip skills
|
|
42
|
+
* across all connectors. The server name is the disambiguator.
|
|
43
|
+
*/
|
|
44
|
+
function deriveTriggers(server, tool) {
|
|
45
|
+
// Normalize the server name for use in triggers. claude_ai_Google_Drive →
|
|
46
|
+
// "google drive"; Bright_Data → "bright data"; imessage → "imessage".
|
|
47
|
+
const rawServer = server.replace(/^claude_ai_/, '');
|
|
48
|
+
const serverWords = humanize(rawServer);
|
|
49
|
+
const toolWords = humanize(tool.name);
|
|
50
|
+
// Alias shortening for common server names so user phrasings match.
|
|
51
|
+
// "Google Drive" → also match "drive", "Microsoft 365" → also match "outlook", etc.
|
|
52
|
+
const serverAliases = new Set([serverWords]);
|
|
53
|
+
const aliasMap = {
|
|
54
|
+
'google drive': ['drive', 'gdrive', 'google drive'],
|
|
55
|
+
'google calendar': ['calendar', 'gcal'],
|
|
56
|
+
'gmail': ['gmail', 'email', 'mail'],
|
|
57
|
+
'microsoft 365': ['outlook', 'microsoft', 'office'],
|
|
58
|
+
'imessage': ['imessage', 'messages', 'texts', 'text messages', 'sms'],
|
|
59
|
+
'figma': ['figma', 'design'],
|
|
60
|
+
'hostinger-mcp': ['hostinger'],
|
|
61
|
+
'bright data': ['brightdata'],
|
|
62
|
+
'dataforseo': ['seo', 'serp'],
|
|
63
|
+
'elevenlabs': ['voice', 'tts', 'text to speech'],
|
|
64
|
+
'supabase': ['database', 'postgres'],
|
|
65
|
+
};
|
|
66
|
+
for (const alias of aliasMap[serverWords] ?? [])
|
|
67
|
+
serverAliases.add(alias);
|
|
68
|
+
const triggers = new Set();
|
|
69
|
+
// Every trigger is a phrase, not a single generic verb. The server name
|
|
70
|
+
// (or an alias) is always present to scope the match to this connector.
|
|
71
|
+
for (const alias of serverAliases) {
|
|
72
|
+
triggers.add(`${toolWords} ${alias}`);
|
|
73
|
+
triggers.add(`${alias} ${toolWords}`);
|
|
74
|
+
// Natural phrasings a user would actually type
|
|
75
|
+
triggers.add(`my ${alias}`);
|
|
76
|
+
triggers.add(`check ${alias}`);
|
|
77
|
+
// If the tool name starts with a verb (read/send/list/get/search/create/
|
|
78
|
+
// update/delete/list), pair it with the alias for a clean trigger.
|
|
79
|
+
const firstWord = toolWords.split(/\s+/)[0];
|
|
80
|
+
if (['read', 'send', 'list', 'get', 'search', 'create', 'update', 'delete', 'find', 'show'].includes(firstWord)) {
|
|
81
|
+
triggers.add(`${firstWord} ${alias}`);
|
|
82
|
+
triggers.add(`${firstWord} my ${alias}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// The server name alone is a useful single-word trigger (e.g. "imessage")
|
|
86
|
+
// — specific enough to not overmatch generic tool-list queries.
|
|
87
|
+
for (const alias of serverAliases)
|
|
88
|
+
triggers.add(alias);
|
|
89
|
+
return Array.from(triggers).filter(t => t.length > 0);
|
|
90
|
+
}
|
|
91
|
+
function renderArgsTable(schema) {
|
|
92
|
+
const props = schema.properties ?? {};
|
|
93
|
+
const required = new Set(schema.required ?? []);
|
|
94
|
+
const entries = Object.entries(props);
|
|
95
|
+
if (entries.length === 0)
|
|
96
|
+
return '_No arguments._';
|
|
97
|
+
const rows = entries.map(([name, spec]) => {
|
|
98
|
+
const type = spec.type ?? 'any';
|
|
99
|
+
const req = required.has(name) ? '**required**' : 'optional';
|
|
100
|
+
const enumHint = Array.isArray(spec.enum) && spec.enum.length > 0
|
|
101
|
+
? ` (one of: ${spec.enum.map(v => `\`${v}\``).join(', ')})`
|
|
102
|
+
: '';
|
|
103
|
+
const desc = (spec.description ?? '').replace(/\n/g, ' ').slice(0, 140);
|
|
104
|
+
return `| \`${name}\` | \`${type}\` | ${req} | ${desc}${enumHint} |`;
|
|
105
|
+
});
|
|
106
|
+
return ['| Arg | Type | Required | Description |', '|-----|------|----------|-------------|', ...rows].join('\n');
|
|
107
|
+
}
|
|
108
|
+
function renderExample(schema) {
|
|
109
|
+
const props = schema.properties ?? {};
|
|
110
|
+
const required = new Set(schema.required ?? []);
|
|
111
|
+
const example = {};
|
|
112
|
+
for (const [name, spec] of Object.entries(props)) {
|
|
113
|
+
if (!required.has(name) && Object.keys(example).length >= 2)
|
|
114
|
+
continue;
|
|
115
|
+
switch (spec.type) {
|
|
116
|
+
case 'string':
|
|
117
|
+
example[name] = Array.isArray(spec.enum) && spec.enum.length > 0 ? spec.enum[0] : `<${name}>`;
|
|
118
|
+
break;
|
|
119
|
+
case 'number':
|
|
120
|
+
case 'integer':
|
|
121
|
+
example[name] = 3;
|
|
122
|
+
break;
|
|
123
|
+
case 'boolean':
|
|
124
|
+
example[name] = true;
|
|
125
|
+
break;
|
|
126
|
+
case 'array':
|
|
127
|
+
example[name] = [];
|
|
128
|
+
break;
|
|
129
|
+
default:
|
|
130
|
+
example[name] = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return Object.keys(example).length > 0
|
|
134
|
+
? '```json\n' + JSON.stringify(example, null, 2) + '\n```'
|
|
135
|
+
: '```json\n{}\n```';
|
|
136
|
+
}
|
|
137
|
+
function renderSkillBody(server, tool) {
|
|
138
|
+
const fullName = `mcp__${server}__${tool.name}`;
|
|
139
|
+
const schema = tool.inputSchema ?? { type: 'object', properties: {} };
|
|
140
|
+
return [
|
|
141
|
+
`# ${humanize(server)} — ${humanize(tool.name)}`,
|
|
142
|
+
'',
|
|
143
|
+
tool.description || '_(no description provided by the server)_',
|
|
144
|
+
'',
|
|
145
|
+
'## Tool call',
|
|
146
|
+
'',
|
|
147
|
+
`\`${fullName}\``,
|
|
148
|
+
'',
|
|
149
|
+
'## Arguments',
|
|
150
|
+
'',
|
|
151
|
+
renderArgsTable(schema),
|
|
152
|
+
'',
|
|
153
|
+
'## Minimal example',
|
|
154
|
+
'',
|
|
155
|
+
renderExample(schema),
|
|
156
|
+
'',
|
|
157
|
+
'## Notes',
|
|
158
|
+
'',
|
|
159
|
+
'- The arg names above come directly from the MCP server\'s `tools/list` schema — use them exactly. If a call returns an error like "unknown field", the error text names the allowed args; correct the call and retry.',
|
|
160
|
+
'- Per-call errors (invalid args, auth, rate limits) are not connector failures. Do not declare the connector "broken" or "unavailable" unless the MCP server itself is unreachable.',
|
|
161
|
+
'',
|
|
162
|
+
'---',
|
|
163
|
+
'',
|
|
164
|
+
`*Auto-generated from the MCP server\'s schema. To override, create \`skills/<your-slug>.md\` at the top level with your own triggers — user skills take precedence at retrieval time.*`,
|
|
165
|
+
].join('\n');
|
|
166
|
+
}
|
|
167
|
+
function writeAutoSkill(server, tool) {
|
|
168
|
+
const serverDir = path.join(AUTO_ROOT, sanitizePathSegment(server));
|
|
169
|
+
if (!existsSync(serverDir))
|
|
170
|
+
mkdirSync(serverDir, { recursive: true });
|
|
171
|
+
const filePath = path.join(serverDir, `${sanitizePathSegment(tool.name)}.md`);
|
|
172
|
+
const hash = schemaHash(tool.inputSchema);
|
|
173
|
+
const now = new Date().toISOString();
|
|
174
|
+
const frontmatter = {
|
|
175
|
+
title: `${humanize(server)} — ${humanize(tool.name)}`,
|
|
176
|
+
description: tool.description || `Auto-generated skill for ${tool.name}`,
|
|
177
|
+
triggers: deriveTriggers(server, tool),
|
|
178
|
+
source: 'auto-mcp-schema',
|
|
179
|
+
server,
|
|
180
|
+
tool: `mcp__${server}__${tool.name}`,
|
|
181
|
+
schemaHash: hash,
|
|
182
|
+
generatedAt: now,
|
|
183
|
+
};
|
|
184
|
+
// Skip write if hash matches existing.
|
|
185
|
+
if (existsSync(filePath)) {
|
|
186
|
+
try {
|
|
187
|
+
const existing = matter(readFileSync(filePath, 'utf-8'));
|
|
188
|
+
if (existing.data.schemaHash === hash) {
|
|
189
|
+
return { wrote: false, unchanged: true };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch { /* regen on parse error */ }
|
|
193
|
+
}
|
|
194
|
+
const content = matter.stringify('\n' + renderSkillBody(server, tool) + '\n', frontmatter);
|
|
195
|
+
writeFileSync(filePath, content);
|
|
196
|
+
return { wrote: true, unchanged: false };
|
|
197
|
+
}
|
|
198
|
+
/** Safe path segment — strip anything that isn't alphanum/dash/dot/underscore. */
|
|
199
|
+
function sanitizePathSegment(s) {
|
|
200
|
+
return s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Given the fetched schemas, write one auto-skill per tool. Idempotent —
|
|
204
|
+
* only writes when the schema hash changes or the file is missing.
|
|
205
|
+
* Prunes stale auto-skills for tools the server no longer declares.
|
|
206
|
+
*/
|
|
207
|
+
export function synthesizeSkillsFromSchemas(schemas) {
|
|
208
|
+
let written = 0;
|
|
209
|
+
let unchanged = 0;
|
|
210
|
+
let pruned = 0;
|
|
211
|
+
let toolCount = 0;
|
|
212
|
+
if (!existsSync(AUTO_ROOT))
|
|
213
|
+
mkdirSync(AUTO_ROOT, { recursive: true });
|
|
214
|
+
// Current tools we expect to exist on disk
|
|
215
|
+
const expected = new Set();
|
|
216
|
+
// Phase 1: full-schema skills from stdio probes.
|
|
217
|
+
for (const [server, s] of Object.entries(schemas.servers)) {
|
|
218
|
+
if (!s.tools || s.tools.length === 0)
|
|
219
|
+
continue;
|
|
220
|
+
for (const tool of s.tools) {
|
|
221
|
+
toolCount++;
|
|
222
|
+
const res = writeAutoSkill(server, tool);
|
|
223
|
+
if (res.wrote)
|
|
224
|
+
written++;
|
|
225
|
+
if (res.unchanged)
|
|
226
|
+
unchanged++;
|
|
227
|
+
expected.add(`${sanitizePathSegment(server)}/${sanitizePathSegment(tool.name)}.md`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Phase 2: minimal skills for remote connectors (claude_ai_*, etc.) whose
|
|
231
|
+
// schemas we couldn't fetch directly — we only have the tool name from
|
|
232
|
+
// the SDK inventory. The skill has no args table, but triggers still
|
|
233
|
+
// derive from tool name + server alias so retrieval works. Users can
|
|
234
|
+
// override with a hand-written skill at skills/<name>.md.
|
|
235
|
+
try {
|
|
236
|
+
const inv = loadToolInventory();
|
|
237
|
+
if (inv?.tools) {
|
|
238
|
+
const knownServers = new Set(Object.keys(schemas.servers));
|
|
239
|
+
for (const fullName of inv.tools) {
|
|
240
|
+
const m = fullName.match(/^mcp__([^_]+(?:_[^_]+)*)__(.+)$/);
|
|
241
|
+
if (!m)
|
|
242
|
+
continue;
|
|
243
|
+
const [, server, toolName] = m;
|
|
244
|
+
// Skip if the stdio probe already wrote a full-schema skill.
|
|
245
|
+
if (knownServers.has(server))
|
|
246
|
+
continue;
|
|
247
|
+
// Skip Clementine's own server + plugin tools (both documented elsewhere).
|
|
248
|
+
if (server === 'clementine-tools' || server.startsWith('plugin_'))
|
|
249
|
+
continue;
|
|
250
|
+
const tool = {
|
|
251
|
+
name: toolName,
|
|
252
|
+
description: '',
|
|
253
|
+
inputSchema: { type: 'object', properties: {} },
|
|
254
|
+
};
|
|
255
|
+
const res = writeAutoSkill(server, tool);
|
|
256
|
+
if (res.wrote)
|
|
257
|
+
written++;
|
|
258
|
+
if (res.unchanged)
|
|
259
|
+
unchanged++;
|
|
260
|
+
toolCount++;
|
|
261
|
+
expected.add(`${sanitizePathSegment(server)}/${sanitizePathSegment(toolName)}.md`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
logger.debug({ err }, 'Minimal-skill pass (remote connectors) failed — non-fatal');
|
|
267
|
+
}
|
|
268
|
+
// Phase 3: single prune pass. Walk skills/auto/ and remove any .md whose
|
|
269
|
+
// path isn't in `expected`. We never touch anything outside skills/auto/,
|
|
270
|
+
// so user-authored top-level skills are preserved.
|
|
271
|
+
try {
|
|
272
|
+
if (existsSync(AUTO_ROOT)) {
|
|
273
|
+
for (const serverDir of readdirSync(AUTO_ROOT, { withFileTypes: true })) {
|
|
274
|
+
if (!serverDir.isDirectory())
|
|
275
|
+
continue;
|
|
276
|
+
const dir = path.join(AUTO_ROOT, serverDir.name);
|
|
277
|
+
for (const file of readdirSync(dir)) {
|
|
278
|
+
if (!file.endsWith('.md'))
|
|
279
|
+
continue;
|
|
280
|
+
const rel = `${serverDir.name}/${file}`;
|
|
281
|
+
if (!expected.has(rel)) {
|
|
282
|
+
try {
|
|
283
|
+
rmSync(path.join(dir, file));
|
|
284
|
+
pruned++;
|
|
285
|
+
}
|
|
286
|
+
catch { /* ignore */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
logger.warn({ err }, 'Auto-skill prune pass failed (non-fatal)');
|
|
294
|
+
}
|
|
295
|
+
logger.info({ written, unchanged, pruned, toolCount }, 'Auto-skills synthesized from MCP schemas');
|
|
296
|
+
return { written, unchanged, pruned, toolCount };
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=auto-skills.js.map
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
const ARG_ERROR_RE = /\b(invalid|unknown field|required|missing parameter|schema|unrecognized|unexpected property)\b/i;
|
|
16
16
|
const AUTH_ERROR_RE = /\b(unauthori[sz]ed|401|not authenticated|token expired|token has expired|invalid[_ ]?token|access denied)\b/i;
|
|
17
17
|
/** Regex matching reply phrasings that claim a connector-wide failure. */
|
|
18
|
-
export const CONTRADICTION_RE = /(dead\s*end|doesn'?t exist|not in (the |my )?schema|schema[- ]level|
|
|
18
|
+
export const CONTRADICTION_RE = /(dead\s*end|doesn'?t exist|not in (the |my )?schema|schema[- ]level|aren'?t loading into|(not|isn'?t|aren'?t) (loaded|wired|available|coming through|responding)|connector[^.]{0,40}(dropped|is (a )?dead)|tools? array is empty|MCP server (still connecting|dropped|not responding)|no such tool available|tool doesn'?t exist|both directions are blocked)/i;
|
|
19
19
|
export function classifyResult(content, isError) {
|
|
20
20
|
if (!isError)
|
|
21
21
|
return 'success';
|
|
@@ -101,7 +101,13 @@ export function detectContradiction(reply, calls) {
|
|
|
101
101
|
const match = reply.match(CONTRADICTION_RE);
|
|
102
102
|
if (!match)
|
|
103
103
|
return null;
|
|
104
|
-
|
|
104
|
+
// Cover every connector — claude_ai_* (remote), imessage/figma/hostinger/etc.
|
|
105
|
+
// (Desktop Extensions + stdio servers), everything except Clementine's own
|
|
106
|
+
// tools server and plugins. Earlier versions only filtered claude_ai_*,
|
|
107
|
+
// which let "isn't loaded" replies slip through for iMessage etc.
|
|
108
|
+
const connectorCalls = calls.filter(c => c.name.startsWith('mcp__') &&
|
|
109
|
+
!c.name.startsWith('mcp__clementine-tools__') &&
|
|
110
|
+
!c.name.startsWith('mcp__plugin_'));
|
|
105
111
|
const recoverable = connectorCalls.find(c => c.resultClass === 'success' || c.resultClass === 'arg_error');
|
|
106
112
|
if (!recoverable)
|
|
107
113
|
return null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-server MCP schema fetcher.
|
|
3
|
+
*
|
|
4
|
+
* Spawns each discovered stdio MCP server, issues `initialize` + `tools/list`,
|
|
5
|
+
* captures the full inputSchema per tool, and writes everything to
|
|
6
|
+
* `~/.clementine/.tool-schemas.json`. Per-server 10s timeout so one flaky
|
|
7
|
+
* server doesn't block the rest.
|
|
8
|
+
*
|
|
9
|
+
* Why per-server instead of one big SDK probe: the SDK's init message only
|
|
10
|
+
* returns tool name strings, not schemas. And the SDK probe is flaky under
|
|
11
|
+
* concurrent MCP server spawn — iMessage routinely missed the init window.
|
|
12
|
+
* Direct per-server probes are deterministic and give us canonical schemas.
|
|
13
|
+
*
|
|
14
|
+
* This is the ground-truth source for auto-skill synthesis downstream.
|
|
15
|
+
*/
|
|
16
|
+
export interface ToolSchema {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
inputSchema: unknown;
|
|
20
|
+
}
|
|
21
|
+
export interface ServerSchemas {
|
|
22
|
+
/** How the server was spawned — for regenerate-on-change detection */
|
|
23
|
+
command?: string;
|
|
24
|
+
args?: string[];
|
|
25
|
+
/** ISO timestamp of last successful fetch */
|
|
26
|
+
fetchedAt: string;
|
|
27
|
+
/** Tools the server declared */
|
|
28
|
+
tools: ToolSchema[];
|
|
29
|
+
/** Set when the probe failed; diagnostic only */
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface AllSchemas {
|
|
33
|
+
fetchedAt: string;
|
|
34
|
+
servers: Record<string, ServerSchemas>;
|
|
35
|
+
}
|
|
36
|
+
/** Load cached schemas from disk, or null if not yet fetched. */
|
|
37
|
+
export declare function loadSchemas(): AllSchemas | null;
|
|
38
|
+
/**
|
|
39
|
+
* Fetch schemas from every discovered stdio server in parallel.
|
|
40
|
+
* Merges with any existing cache — servers that errored this round keep
|
|
41
|
+
* their last successful schemas (fail-soft).
|
|
42
|
+
*/
|
|
43
|
+
export declare function fetchAllSchemas(): Promise<AllSchemas>;
|
|
44
|
+
/** Flat list of every tool with its schema and originating server. */
|
|
45
|
+
export declare function flattenSchemas(all: AllSchemas): Array<{
|
|
46
|
+
server: string;
|
|
47
|
+
tool: ToolSchema;
|
|
48
|
+
}>;
|
|
49
|
+
//# sourceMappingURL=mcp-schemas.d.ts.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-server MCP schema fetcher.
|
|
3
|
+
*
|
|
4
|
+
* Spawns each discovered stdio MCP server, issues `initialize` + `tools/list`,
|
|
5
|
+
* captures the full inputSchema per tool, and writes everything to
|
|
6
|
+
* `~/.clementine/.tool-schemas.json`. Per-server 10s timeout so one flaky
|
|
7
|
+
* server doesn't block the rest.
|
|
8
|
+
*
|
|
9
|
+
* Why per-server instead of one big SDK probe: the SDK's init message only
|
|
10
|
+
* returns tool name strings, not schemas. And the SDK probe is flaky under
|
|
11
|
+
* concurrent MCP server spawn — iMessage routinely missed the init window.
|
|
12
|
+
* Direct per-server probes are deterministic and give us canonical schemas.
|
|
13
|
+
*
|
|
14
|
+
* This is the ground-truth source for auto-skill synthesis downstream.
|
|
15
|
+
*/
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
19
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
20
|
+
import { BASE_DIR } from '../config.js';
|
|
21
|
+
import { logger } from '../tools/shared.js';
|
|
22
|
+
import { discoverMcpServers } from './mcp-bridge.js';
|
|
23
|
+
const SCHEMAS_FILE = path.join(BASE_DIR, '.tool-schemas.json');
|
|
24
|
+
const PER_SERVER_TIMEOUT_MS = 10_000;
|
|
25
|
+
/** Load cached schemas from disk, or null if not yet fetched. */
|
|
26
|
+
export function loadSchemas() {
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(SCHEMAS_FILE))
|
|
29
|
+
return null;
|
|
30
|
+
return JSON.parse(readFileSync(SCHEMAS_FILE, 'utf-8'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function saveSchemas(s) {
|
|
37
|
+
try {
|
|
38
|
+
writeFileSync(SCHEMAS_FILE, JSON.stringify(s, null, 2));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
logger.warn({ err }, 'Failed to persist .tool-schemas.json');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Fetch schemas from a single stdio server. Returns null if it failed/timed out. */
|
|
45
|
+
async function fetchOneServer(name, command, args, env) {
|
|
46
|
+
const started = Date.now();
|
|
47
|
+
let client = null;
|
|
48
|
+
let transport = null;
|
|
49
|
+
try {
|
|
50
|
+
transport = new StdioClientTransport({
|
|
51
|
+
command,
|
|
52
|
+
args,
|
|
53
|
+
env: { ...process.env, ...env },
|
|
54
|
+
stderr: 'ignore',
|
|
55
|
+
});
|
|
56
|
+
client = new Client({ name: 'clementine-schema-probe', version: '1.0.0' }, { capabilities: {} });
|
|
57
|
+
// Race the connect+list against a timeout. stdio servers that hang or
|
|
58
|
+
// crash on startup shouldn't block the whole discovery pass.
|
|
59
|
+
const work = (async () => {
|
|
60
|
+
await client.connect(transport);
|
|
61
|
+
const listed = await client.listTools();
|
|
62
|
+
return listed.tools;
|
|
63
|
+
})();
|
|
64
|
+
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error(`timeout after ${PER_SERVER_TIMEOUT_MS}ms`)), PER_SERVER_TIMEOUT_MS));
|
|
65
|
+
const tools = await Promise.race([work, timeout]);
|
|
66
|
+
logger.debug({ server: name, toolCount: tools.length, ms: Date.now() - started }, 'Fetched MCP schemas');
|
|
67
|
+
return {
|
|
68
|
+
command,
|
|
69
|
+
args,
|
|
70
|
+
fetchedAt: new Date().toISOString(),
|
|
71
|
+
tools: tools.map((t) => ({
|
|
72
|
+
name: t.name,
|
|
73
|
+
description: t.description ?? '',
|
|
74
|
+
inputSchema: t.inputSchema ?? { type: 'object', properties: {} },
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
logger.debug({ server: name, err: errMsg, ms: Date.now() - started }, 'MCP schema fetch failed');
|
|
81
|
+
return {
|
|
82
|
+
command,
|
|
83
|
+
args,
|
|
84
|
+
fetchedAt: new Date().toISOString(),
|
|
85
|
+
tools: [],
|
|
86
|
+
error: errMsg,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
try {
|
|
91
|
+
await client?.close();
|
|
92
|
+
}
|
|
93
|
+
catch { /* ignore */ }
|
|
94
|
+
try {
|
|
95
|
+
await transport?.close();
|
|
96
|
+
}
|
|
97
|
+
catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Fetch schemas from every discovered stdio server in parallel.
|
|
102
|
+
* Merges with any existing cache — servers that errored this round keep
|
|
103
|
+
* their last successful schemas (fail-soft).
|
|
104
|
+
*/
|
|
105
|
+
export async function fetchAllSchemas() {
|
|
106
|
+
const existing = loadSchemas();
|
|
107
|
+
const result = {
|
|
108
|
+
fetchedAt: new Date().toISOString(),
|
|
109
|
+
servers: { ...(existing?.servers ?? {}) },
|
|
110
|
+
};
|
|
111
|
+
const servers = discoverMcpServers().filter(s => s.enabled && s.type === 'stdio' && s.command);
|
|
112
|
+
if (servers.length === 0) {
|
|
113
|
+
saveSchemas(result);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
const fetches = servers.map(async (s) => {
|
|
117
|
+
const fetched = await fetchOneServer(s.name, s.command, s.args ?? [], s.env ?? {});
|
|
118
|
+
return { name: s.name, fetched };
|
|
119
|
+
});
|
|
120
|
+
const settled = await Promise.allSettled(fetches);
|
|
121
|
+
let ok = 0, failed = 0;
|
|
122
|
+
for (const r of settled) {
|
|
123
|
+
if (r.status !== 'fulfilled' || !r.value.fetched) {
|
|
124
|
+
failed++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const { name, fetched } = r.value;
|
|
128
|
+
// Only overwrite on success — preserve last-good schemas on transient failure.
|
|
129
|
+
if (fetched.tools.length > 0 || !result.servers[name]) {
|
|
130
|
+
result.servers[name] = fetched;
|
|
131
|
+
}
|
|
132
|
+
if (fetched.error)
|
|
133
|
+
failed++;
|
|
134
|
+
else
|
|
135
|
+
ok++;
|
|
136
|
+
}
|
|
137
|
+
logger.info({ ok, failed, total: servers.length }, 'MCP schema fetch pass complete');
|
|
138
|
+
saveSchemas(result);
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
/** Flat list of every tool with its schema and originating server. */
|
|
142
|
+
export function flattenSchemas(all) {
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const [server, s] of Object.entries(all.servers)) {
|
|
145
|
+
for (const tool of s.tools)
|
|
146
|
+
out.push({ server, tool });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=mcp-schemas.js.map
|
|
@@ -324,6 +324,42 @@ async function mergeSkill(assistant, existing, incoming) {
|
|
|
324
324
|
* edits require a restart, same as the rest of the skill pipeline).
|
|
325
325
|
*/
|
|
326
326
|
const skillEmbeddingCache = new Map();
|
|
327
|
+
/**
|
|
328
|
+
* Recursively list every .md skill file under `dir`. Returns absolute
|
|
329
|
+
* paths, relative paths (for dedupe/naming), and an `isAuto` flag set
|
|
330
|
+
* when the file lives under an `auto/` subtree. Used so auto-generated
|
|
331
|
+
* MCP skills under `skills/auto/<server>/<tool>.md` surface in search
|
|
332
|
+
* while user-authored top-level skills win on score tiebreak.
|
|
333
|
+
*/
|
|
334
|
+
function walkSkillFiles(root) {
|
|
335
|
+
const out = [];
|
|
336
|
+
function walk(dir, rel) {
|
|
337
|
+
let entries;
|
|
338
|
+
try {
|
|
339
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
for (const ent of entries) {
|
|
345
|
+
const name = ent.name;
|
|
346
|
+
// Skip backup files and hidden files
|
|
347
|
+
if (name.endsWith('.bak') || name.startsWith('.'))
|
|
348
|
+
continue;
|
|
349
|
+
const full = path.join(dir, name);
|
|
350
|
+
const nextRel = rel ? path.join(rel, name) : name;
|
|
351
|
+
if (ent.isDirectory()) {
|
|
352
|
+
walk(full, nextRel);
|
|
353
|
+
}
|
|
354
|
+
else if (ent.isFile() && name.endsWith('.md')) {
|
|
355
|
+
const isAuto = nextRel.split(path.sep)[0] === 'auto';
|
|
356
|
+
out.push({ filePath: full, relPath: nextRel, isAuto });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
walk(root, '');
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
327
363
|
function getSkillEmbedding(filePath, triggers, title, description) {
|
|
328
364
|
const cached = skillEmbeddingCache.get(filePath);
|
|
329
365
|
if (cached)
|
|
@@ -359,9 +395,15 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
359
395
|
const useSemantic = embeddingsReady();
|
|
360
396
|
const queryVec = useSemantic ? embedText(query) : null;
|
|
361
397
|
for (const { dir, boost } of dirs) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
398
|
+
// Walk recursively so skills/auto/<server>/<tool>.md gets indexed
|
|
399
|
+
// alongside top-level user-authored skills. Track whether a given
|
|
400
|
+
// file lives under an `auto/` subtree so user-authored wins on
|
|
401
|
+
// tiebreak even when both match the query.
|
|
402
|
+
const files = walkSkillFiles(dir);
|
|
403
|
+
for (const { filePath, relPath, isAuto } of files) {
|
|
404
|
+
// Use relPath (no .md, slashes → dashes) so same-name skills in
|
|
405
|
+
// different subdirs don't collide in the dedupe set.
|
|
406
|
+
const name = relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
|
|
365
407
|
if (seen.has(name))
|
|
366
408
|
continue;
|
|
367
409
|
seen.add(name);
|
|
@@ -369,7 +411,6 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
369
411
|
// negative user feedback (see store.getSkillsToSuppress).
|
|
370
412
|
if (suppressed?.has(name))
|
|
371
413
|
continue;
|
|
372
|
-
const filePath = path.join(dir, file);
|
|
373
414
|
try {
|
|
374
415
|
const raw = readFileSync(filePath, 'utf-8');
|
|
375
416
|
const parsed = matter(raw);
|
|
@@ -384,11 +425,35 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
384
425
|
const triggerLower = triggers
|
|
385
426
|
.filter((t) => typeof t === 'string' && t.length > 0)
|
|
386
427
|
.map(t => t.toLowerCase());
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
428
|
+
const queryLower = query.toLowerCase();
|
|
429
|
+
// Tokenize into whole words so "list" the trigger-word doesn't
|
|
430
|
+
// match every "list X" trigger when a single query word "list"
|
|
431
|
+
// shows up in a totally unrelated query.
|
|
432
|
+
for (const trigger of triggerLower) {
|
|
433
|
+
// Full-phrase substring hit (rare but strongest signal)
|
|
434
|
+
if (trigger.length >= 4 && queryLower.includes(trigger)) {
|
|
435
|
+
score += 5;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
// Word-coverage: fraction of trigger words present in the query
|
|
439
|
+
// (with loose substring match per word to catch plurals etc).
|
|
440
|
+
const tWords = trigger.split(/\s+/).filter(w => w.length > 2);
|
|
441
|
+
if (tWords.length === 0)
|
|
442
|
+
continue;
|
|
443
|
+
let matched = 0;
|
|
444
|
+
for (const tw of tWords) {
|
|
445
|
+
if (queryWords.some(qw => qw.includes(tw) || tw.includes(qw)))
|
|
446
|
+
matched++;
|
|
391
447
|
}
|
|
448
|
+
const coverage = matched / tWords.length;
|
|
449
|
+
// Require ≥50% of the trigger's words to appear. This is what
|
|
450
|
+
// stops "list supabase" from matching "list my imessages" — the
|
|
451
|
+
// word "supabase" isn't in the query so coverage = 0.5, which
|
|
452
|
+
// lands at a tiny score; a fully covered trigger lands strong.
|
|
453
|
+
if (coverage >= 0.5)
|
|
454
|
+
score += coverage * 3;
|
|
455
|
+
}
|
|
456
|
+
for (const word of queryWords) {
|
|
392
457
|
if (title.toLowerCase().includes(word))
|
|
393
458
|
score += 2;
|
|
394
459
|
if (description.toLowerCase().includes(word))
|
|
@@ -408,13 +473,18 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
|
|
|
408
473
|
semanticScore = cos * 4;
|
|
409
474
|
}
|
|
410
475
|
}
|
|
476
|
+
// Auto-skills get a small penalty so user-authored skills win
|
|
477
|
+
// when both match. Still surface auto-skills when nothing else
|
|
478
|
+
// does — they're the only source of canonical MCP tool knowledge
|
|
479
|
+
// for connectors the user hasn't explicitly documented.
|
|
480
|
+
const autoPenalty = isAuto ? -0.5 : 0;
|
|
411
481
|
const totalScore = score + semanticScore;
|
|
412
482
|
if (totalScore > 0) {
|
|
413
483
|
results.push({
|
|
414
484
|
name,
|
|
415
485
|
title,
|
|
416
486
|
content: parsed.content.slice(0, 1500),
|
|
417
|
-
score: totalScore + boost,
|
|
487
|
+
score: totalScore + boost + autoPenalty,
|
|
418
488
|
toolsUsed: parsed.data.toolsUsed ?? [],
|
|
419
489
|
attachments: parsed.data.attachments ?? [],
|
|
420
490
|
skillDir: dir,
|
package/dist/index.js
CHANGED
|
@@ -554,7 +554,7 @@ async function asyncMain() {
|
|
|
554
554
|
// have been taken with a stale probe config (e.g. before we started
|
|
555
555
|
// passing mcpServers to the probe). Re-probe fresh so Extensions and
|
|
556
556
|
// per-query MCP servers are discovered and whitelisted immediately.
|
|
557
|
-
probeAvailableTools(true).then(inv => {
|
|
557
|
+
probeAvailableTools(true).then(async (inv) => {
|
|
558
558
|
const integrations = new Set();
|
|
559
559
|
for (const t of inv.tools) {
|
|
560
560
|
const m = t.match(/^mcp__claude_ai_([^_]+(?:_[^_]+)*)__/);
|
|
@@ -564,6 +564,20 @@ async function asyncMain() {
|
|
|
564
564
|
if (integrations.size > 0) {
|
|
565
565
|
logger.info({ integrations: [...integrations].sort(), toolCount: inv.tools.length }, '🦞 Claude Desktop integrations detected');
|
|
566
566
|
}
|
|
567
|
+
// After inventory is live, fetch canonical schemas from every stdio
|
|
568
|
+
// MCP server we can reach, then synthesize auto-skills for every
|
|
569
|
+
// tool. This is the load-bearing pipeline for "Clementine knows how
|
|
570
|
+
// to call any connector the user has" — no per-tool hardcoding.
|
|
571
|
+
try {
|
|
572
|
+
const { fetchAllSchemas } = await import('./agent/mcp-schemas.js');
|
|
573
|
+
const { synthesizeSkillsFromSchemas } = await import('./agent/auto-skills.js');
|
|
574
|
+
const schemas = await fetchAllSchemas();
|
|
575
|
+
const result = synthesizeSkillsFromSchemas(schemas);
|
|
576
|
+
logger.info(result, '📚 Auto-skills synthesized from MCP schemas');
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
logger.warn({ err }, 'Auto-skill synthesis failed (non-fatal)');
|
|
580
|
+
}
|
|
567
581
|
}).catch(() => { });
|
|
568
582
|
}
|
|
569
583
|
catch { /* non-fatal */ }
|
|
@@ -382,6 +382,23 @@ export function registerAdminTools(server) {
|
|
|
382
382
|
return textResult(`Probe failed: ${String(err).slice(0, 200)}`);
|
|
383
383
|
}
|
|
384
384
|
});
|
|
385
|
+
server.tool('refresh_skills', 'Re-fetch canonical schemas from every MCP server and regenerate auto-skills under ~/.clementine/vault/00-System/skills/auto/. Runs automatically on daemon boot; use this tool mid-session when the owner adds a new connector or updates an MCP server. Owner-DM only. Returns counts of skills written/unchanged/pruned.', {}, async () => {
|
|
386
|
+
const gate = requireOwnerDm();
|
|
387
|
+
if (!gate.ok)
|
|
388
|
+
return textResult(gate.message);
|
|
389
|
+
try {
|
|
390
|
+
const { fetchAllSchemas } = await import('../agent/mcp-schemas.js');
|
|
391
|
+
const { synthesizeSkillsFromSchemas } = await import('../agent/auto-skills.js');
|
|
392
|
+
const schemas = await fetchAllSchemas();
|
|
393
|
+
const result = synthesizeSkillsFromSchemas(schemas);
|
|
394
|
+
const serverLines = Object.entries(schemas.servers).map(([name, s]) => `- **${name}**: ${s.tools.length} tools${s.error ? ` (error: ${s.error.slice(0, 80)})` : ''}`);
|
|
395
|
+
return textResult(`Fetched schemas from ${Object.keys(schemas.servers).length} MCP servers.\n${serverLines.join('\n')}\n\n` +
|
|
396
|
+
`**Skills:** ${result.written} written, ${result.unchanged} unchanged, ${result.pruned} pruned. Total tools indexed: ${result.toolCount}.`);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return textResult(`Skill refresh failed: ${String(err).slice(0, 300)}`);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
385
402
|
server.tool('list_allowed_tools', 'Show the current self-managed allowedTools extras (tools you added via allow_tool on top of the built-in whitelist). Owner-DM only.', {}, async () => {
|
|
386
403
|
const gate = requireOwnerDm();
|
|
387
404
|
if (!gate.ok)
|