clementine-agent 1.0.58 → 1.0.59

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.
@@ -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
@@ -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
- const files = readdirSync(dir).filter(f => f.endsWith('.md'));
363
- for (const file of files) {
364
- const name = file.replace('.md', '');
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
- for (const word of queryWords) {
388
- for (const trigger of triggerLower) {
389
- if (trigger.includes(word) || word.includes(trigger))
390
- score += 3;
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.58",
3
+ "version": "1.0.59",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",