clementine-agent 1.18.119 → 1.18.120

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.
@@ -30,6 +30,7 @@ import { registerAgentHeartbeatTools } from './agent-heartbeat-tools.js';
30
30
  import { registerBackgroundTaskTools } from './background-task-tools.js';
31
31
  import { registerDecisionReflectionTools } from './decision-reflection-tools.js';
32
32
  import { registerBuilderTools } from './builder-tools.js';
33
+ import { registerSkillTools } from './skill-tools.js';
33
34
  // ── Server ──────────────────────────────────────────────────────────────
34
35
  const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
35
36
  const server = new McpServer({ name: serverName, version: '1.0.0' });
@@ -73,6 +74,7 @@ registerAgentHeartbeatTools(scopedServer);
73
74
  registerBackgroundTaskTools(scopedServer);
74
75
  registerDecisionReflectionTools(scopedServer);
75
76
  registerBuilderTools(scopedServer);
77
+ registerSkillTools(scopedServer);
76
78
  // ── Main ────────────────────────────────────────────────────────────────
77
79
  async function main() {
78
80
  // Initialize memory store and run full sync on startup
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Skill MCP tools (1.18.120)
3
+ *
4
+ * Lets the agent (and therefore the user, via chat in Discord / dashboard /
5
+ * Slack / Telegram) author and update skills in natural language.
6
+ *
7
+ * Why this matters:
8
+ * - Before this, creating a skill required either editing files by hand
9
+ * or clicking through the dashboard's "+ New skill" modal.
10
+ * - With these tools registered, a Discord message like "Hey Clem,
11
+ * create a skill called morning-deal-review that checks the deal
12
+ * pipeline at 8am every weekday" can produce a real `<name>/SKILL.md`
13
+ * folder on disk, ready to pin to a cron.
14
+ *
15
+ * The tools are intentionally thin wrappers around the existing skill-store
16
+ * write path. The Anthropic-spec validation (name regex, ≤1024-char
17
+ * description, body presence) is enforced by both the dashboard endpoint
18
+ * and these tools, so you can't smuggle a bad skill through the chat path.
19
+ */
20
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
+ export declare function registerSkillTools(server: McpServer): void;
22
+ //# sourceMappingURL=skill-tools.d.ts.map
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Skill MCP tools (1.18.120)
3
+ *
4
+ * Lets the agent (and therefore the user, via chat in Discord / dashboard /
5
+ * Slack / Telegram) author and update skills in natural language.
6
+ *
7
+ * Why this matters:
8
+ * - Before this, creating a skill required either editing files by hand
9
+ * or clicking through the dashboard's "+ New skill" modal.
10
+ * - With these tools registered, a Discord message like "Hey Clem,
11
+ * create a skill called morning-deal-review that checks the deal
12
+ * pipeline at 8am every weekday" can produce a real `<name>/SKILL.md`
13
+ * folder on disk, ready to pin to a cron.
14
+ *
15
+ * The tools are intentionally thin wrappers around the existing skill-store
16
+ * write path. The Anthropic-spec validation (name regex, ≤1024-char
17
+ * description, body presence) is enforced by both the dashboard endpoint
18
+ * and these tools, so you can't smuggle a bad skill through the chat path.
19
+ */
20
+ import { writeFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { z } from 'zod';
23
+ import matter from 'gray-matter';
24
+ import { VAULT_DIR, textResult, logger } from './shared.js';
25
+ // Anthropic spec — keep these in sync with skill-store.validateSkill.
26
+ const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
27
+ const NAME_MAX_LEN = 64;
28
+ const DESCRIPTION_MAX_LEN = 1024;
29
+ const RESERVED_NAMES = new Set(['anthropic', 'claude']);
30
+ function globalSkillsDir() {
31
+ return path.join(VAULT_DIR, '00-System', 'skills');
32
+ }
33
+ export function registerSkillTools(server) {
34
+ // ── create_skill ────────────────────────────────────────────────────
35
+ // Writes a folder-form skill to ~/.clementine/vault/00-System/skills/<name>/SKILL.md
36
+ // with Anthropic-canonical frontmatter (name + description top-level)
37
+ // and Clementine extensions (tools.allow, source: chat) under the
38
+ // `clementine` namespace.
39
+ server.tool('create_skill', 'Author a new reusable skill (a recipe Claude follows when invoked). Writes <name>/SKILL.md in the vault. Returns the skill name + path on success. Anthropic spec: name must match ^[a-z0-9][a-z0-9-]{0,63}$ and description ≤1024 chars.', {
40
+ name: z.string()
41
+ .describe('Skill slug — lowercase letters/digits/dashes, max 64 chars, must start with a letter or digit. Example: "morning-deal-review".'),
42
+ title: z.string().optional()
43
+ .describe('Optional friendlier display name. Example: "Morning Deal Review".'),
44
+ description: z.string()
45
+ .describe('One-paragraph summary of what this skill does and when Claude should run it. Used by the runtime auto-matcher AND surfaced as the cron task card preview when the skill is pinned. Max 1024 chars.'),
46
+ body: z.string()
47
+ .describe('The procedure body in Markdown. Use headers (# / ##), numbered lists, code fences. Max 500 lines is best practice. Example: "# Morning Deal Review\\n\\n1. Pull deals updated in the last 24h.\\n2. Surface high-value ones." '),
48
+ tools: z.array(z.string()).optional()
49
+ .describe('Optional allowlist of tool names this skill should restrict itself to (e.g. ["Read", "Bash", "memory_search"]). Stored under clementine.tools.allow. Empty/omitted means inherit the cron task or chat session defaults.'),
50
+ triggers: z.array(z.string()).optional()
51
+ .describe('Optional natural-language phrases that should auto-match this skill at runtime (e.g. ["morning deal review", "check deals today"]). Stored under clementine.triggers. Pinned skills don\'t need triggers — they fire by name.'),
52
+ }, async ({ name, title, description, body, tools, triggers }) => {
53
+ // Validate per Anthropic spec
54
+ if (!NAME_PATTERN.test(name)) {
55
+ return textResult(`❌ Name "${name}" doesn't match the spec. Use lowercase letters, digits, and dashes only — must start with a letter or digit, max 64 chars.`);
56
+ }
57
+ if (name.length > NAME_MAX_LEN) {
58
+ return textResult(`❌ Name is too long (${name.length} chars). Max is ${NAME_MAX_LEN}.`);
59
+ }
60
+ if (RESERVED_NAMES.has(name) || /\b(anthropic|claude)\b/i.test(name)) {
61
+ return textResult(`❌ Name "${name}" uses a reserved word ("anthropic" or "claude"). Pick another.`);
62
+ }
63
+ if (!description || !description.trim()) {
64
+ return textResult('❌ Description is required — Claude uses it to decide when to apply this skill.');
65
+ }
66
+ if (description.length > DESCRIPTION_MAX_LEN) {
67
+ return textResult(`❌ Description is too long (${description.length} chars). Max is ${DESCRIPTION_MAX_LEN}.`);
68
+ }
69
+ if (!body || !body.trim()) {
70
+ return textResult('❌ Procedure body is required — that\'s what Claude actually runs.');
71
+ }
72
+ const skillsDir = globalSkillsDir();
73
+ const folderPath = path.join(skillsDir, name);
74
+ const entryPath = path.join(folderPath, 'SKILL.md');
75
+ if (existsSync(entryPath)) {
76
+ return textResult(`❌ Skill "${name}" already exists at ${entryPath}. Use update_skill instead, or pick a different name.`);
77
+ }
78
+ try {
79
+ mkdirSync(folderPath, { recursive: true });
80
+ const now = new Date().toISOString();
81
+ const fm = { name, description };
82
+ if (title && title.trim())
83
+ fm.title = title.trim();
84
+ const clementineExt = {
85
+ source: 'chat',
86
+ useCount: 0,
87
+ createdAt: now,
88
+ updatedAt: now,
89
+ version: 1,
90
+ };
91
+ if (Array.isArray(tools) && tools.length > 0) {
92
+ clementineExt.tools = { allow: tools.map(String).map(s => s.trim()).filter(Boolean) };
93
+ }
94
+ if (Array.isArray(triggers) && triggers.length > 0) {
95
+ clementineExt.triggers = triggers.map(String).map(s => s.trim()).filter(Boolean);
96
+ }
97
+ fm.clementine = clementineExt;
98
+ const content = matter.stringify(body.endsWith('\n') ? body : body + '\n', fm);
99
+ writeFileSync(entryPath, content);
100
+ logger.info({ name, entryPath, source: 'chat' }, 'Skill created via chat');
101
+ const toolsLine = (tools && tools.length > 0) ? `\nAllowed tools: ${tools.slice(0, 5).join(', ')}${tools.length > 5 ? `, +${tools.length - 5} more` : ''}` : '';
102
+ const triggersLine = (triggers && triggers.length > 0) ? `\nTriggers: ${triggers.slice(0, 4).join(', ')}${triggers.length > 4 ? `, +${triggers.length - 4} more` : ''}` : '';
103
+ return textResult(`✅ Created skill "${name}" at ${entryPath}\n` +
104
+ `Description: ${description.slice(0, 200)}${description.length > 200 ? '…' : ''}` +
105
+ toolsLine +
106
+ triggersLine +
107
+ `\n\nThe skill is ready to pin to any task — open the cron editor, go to Tools & MCP, click "+ Add skill" and select "${name}". Or invoke it directly in chat: "Run the ${title || name} skill."`);
108
+ }
109
+ catch (err) {
110
+ logger.error({ err, name }, 'create_skill failed');
111
+ return textResult(`❌ Failed to write the skill: ${err instanceof Error ? err.message : String(err)}`);
112
+ }
113
+ });
114
+ // ── update_skill ────────────────────────────────────────────────────
115
+ // Edits an existing skill. Preserves frontmatter the caller doesn't
116
+ // touch (useCount, lastUsed, migration provenance, custom fields) so
117
+ // chat edits don't reset the lifecycle metadata.
118
+ server.tool('update_skill', 'Update an existing skill\'s description, body, tools, or triggers. Preserves lifecycle metadata (useCount, createdAt, etc.). Returns the updated path on success.', {
119
+ name: z.string().describe('Slug of the skill to update (e.g. "morning-deal-review").'),
120
+ description: z.string().optional().describe('New description (one paragraph, ≤1024 chars).'),
121
+ body: z.string().optional().describe('New procedure body (Markdown). Replaces the existing body in full.'),
122
+ tools: z.array(z.string()).optional().describe('New allowlist for clementine.tools.allow. Pass [] to clear.'),
123
+ triggers: z.array(z.string()).optional().describe('New trigger phrase list for clementine.triggers. Pass [] to clear.'),
124
+ }, async ({ name, description, body, tools, triggers }) => {
125
+ if (!NAME_PATTERN.test(name)) {
126
+ return textResult(`❌ Name "${name}" is not a valid skill slug.`);
127
+ }
128
+ const skillsDir = globalSkillsDir();
129
+ const folderEntry = path.join(skillsDir, name, 'SKILL.md');
130
+ const flatEntry = path.join(skillsDir, name + '.md');
131
+ const targetPath = existsSync(folderEntry) ? folderEntry : (existsSync(flatEntry) ? flatEntry : null);
132
+ if (!targetPath) {
133
+ return textResult(`❌ Skill "${name}" not found. Use create_skill if you want to author it from scratch.`);
134
+ }
135
+ try {
136
+ const raw = readFileSync(targetPath, 'utf-8');
137
+ const parsed = matter(raw);
138
+ const fm = { ...parsed.data };
139
+ fm.name = name;
140
+ if (description !== undefined) {
141
+ if (description.length > DESCRIPTION_MAX_LEN) {
142
+ return textResult(`❌ Description is too long (${description.length} chars). Max is ${DESCRIPTION_MAX_LEN}.`);
143
+ }
144
+ fm.description = description;
145
+ }
146
+ const ext = (fm.clementine && typeof fm.clementine === 'object') ? fm.clementine : {};
147
+ ext.updatedAt = new Date().toISOString();
148
+ if (tools !== undefined) {
149
+ if (tools.length > 0)
150
+ ext.tools = { ...(ext.tools || {}), allow: tools };
151
+ else if (ext.tools && typeof ext.tools === 'object')
152
+ delete ext.tools.allow;
153
+ }
154
+ if (triggers !== undefined) {
155
+ if (triggers.length > 0)
156
+ ext.triggers = triggers;
157
+ else
158
+ delete ext.triggers;
159
+ }
160
+ fm.clementine = ext;
161
+ const newBody = body !== undefined ? (body.endsWith('\n') ? body : body + '\n') : parsed.content;
162
+ const content = matter.stringify(newBody, fm);
163
+ writeFileSync(targetPath, content);
164
+ logger.info({ name, targetPath, source: 'chat' }, 'Skill updated via chat');
165
+ const changed = [];
166
+ if (description !== undefined)
167
+ changed.push('description');
168
+ if (body !== undefined)
169
+ changed.push('body');
170
+ if (tools !== undefined)
171
+ changed.push('tools');
172
+ if (triggers !== undefined)
173
+ changed.push('triggers');
174
+ return textResult(`✅ Updated skill "${name}" — changed: ${changed.join(', ') || '(no fields specified)'}.\n` +
175
+ `Path: ${targetPath}`);
176
+ }
177
+ catch (err) {
178
+ logger.error({ err, name }, 'update_skill failed');
179
+ return textResult(`❌ Failed to update the skill: ${err instanceof Error ? err.message : String(err)}`);
180
+ }
181
+ });
182
+ // ── list_skills ─────────────────────────────────────────────────────
183
+ // Read-only — lets the agent answer "what skills do I have?" in chat
184
+ // without needing to fall back to file-system tools.
185
+ server.tool('list_skills', 'List every skill currently in the global vault. Returns name, title, description, schema version (anthropic / clementine / legacy), and layout (folder / flat). Useful when the user asks "what skills do you have?" or "show me my skills."', {}, async () => {
186
+ try {
187
+ const skillsDir = globalSkillsDir();
188
+ if (!existsSync(skillsDir))
189
+ return textResult('No skills directory yet. Use create_skill to author your first one.');
190
+ const { readdirSync, statSync } = await import('node:fs');
191
+ const items = readdirSync(skillsDir);
192
+ const skills = [];
193
+ for (const item of items) {
194
+ if (item.startsWith('.'))
195
+ continue;
196
+ const full = path.join(skillsDir, item);
197
+ let st;
198
+ try {
199
+ st = statSync(full);
200
+ }
201
+ catch {
202
+ continue;
203
+ }
204
+ if (st.isDirectory()) {
205
+ const entry = path.join(full, 'SKILL.md');
206
+ if (!existsSync(entry))
207
+ continue;
208
+ try {
209
+ const fm = matter(readFileSync(entry, 'utf-8')).data;
210
+ skills.push({
211
+ name: String(fm.name ?? item),
212
+ title: String(fm.title ?? fm.name ?? item),
213
+ description: String(fm.description ?? ''),
214
+ layout: 'folder',
215
+ });
216
+ }
217
+ catch { /* skip malformed */ }
218
+ }
219
+ else if (st.isFile() && item.endsWith('.md') && !item.endsWith('.bak.md')) {
220
+ try {
221
+ const fm = matter(readFileSync(full, 'utf-8')).data;
222
+ skills.push({
223
+ name: String(fm.name ?? item.replace(/\.md$/, '')),
224
+ title: String(fm.title ?? fm.name ?? item),
225
+ description: String(fm.description ?? ''),
226
+ layout: 'flat',
227
+ });
228
+ }
229
+ catch { /* skip malformed */ }
230
+ }
231
+ }
232
+ if (skills.length === 0)
233
+ return textResult('No skills found yet. Use create_skill to author your first one.');
234
+ skills.sort((a, b) => a.name.localeCompare(b.name));
235
+ const lines = skills.map(s => `• ${s.title} (\`${s.name}\`) — ${s.description.slice(0, 120)}${s.description.length > 120 ? '…' : ''}`);
236
+ return textResult(`${skills.length} skill${skills.length === 1 ? '' : 's'}:\n\n${lines.join('\n')}`);
237
+ }
238
+ catch (err) {
239
+ return textResult(`❌ Failed to list skills: ${err instanceof Error ? err.message : String(err)}`);
240
+ }
241
+ });
242
+ }
243
+ //# sourceMappingURL=skill-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.119",
3
+ "version": "1.18.120",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",