clementine-agent 1.18.123 → 1.18.125
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/run-agent-cron.d.ts +58 -0
- package/dist/agent/run-agent-cron.js +113 -8
- package/dist/agent/skill-extractor.d.ts +2 -2
- package/dist/agent/skill-extractor.js +33 -32
- package/dist/agent/skill-store.d.ts +50 -0
- package/dist/agent/skill-store.js +146 -1
- package/dist/cli/dashboard.js +32 -52
- package/dist/cli/index.js +1 -1
- package/dist/gateway/router.js +1 -1
- package/dist/memory/maintenance.js +27 -0
- package/dist/tools/skill-tools.js +25 -53
- package/package.json +1 -1
|
@@ -43,6 +43,42 @@ export declare function computeEffectiveAllowedTools(jobAllow: string[] | undefi
|
|
|
43
43
|
* MCP allowlist set.
|
|
44
44
|
*/
|
|
45
45
|
export declare function applyMcpAllowlist<T>(servers: Record<string, T>, jobAllowedMcpServers: string[] | undefined): Record<string, T>;
|
|
46
|
+
/**
|
|
47
|
+
* Widen the cron's tool allowlist with the union of pinned-skill
|
|
48
|
+
* `clementine.tools.allow` declarations.
|
|
49
|
+
*
|
|
50
|
+
* **Why this exists:** Pinning a skill is a positive user signal — "I want
|
|
51
|
+
* this skill, with the tools it declares it needs." Before 1.18.125 the
|
|
52
|
+
* skill's `tools.allow` was rendered into the prompt as text only; the SDK
|
|
53
|
+
* never saw it, so a skill pinned to a cron with a narrower allowlist would
|
|
54
|
+
* silently fail with tool-not-found errors.
|
|
55
|
+
*
|
|
56
|
+
* **Semantics:**
|
|
57
|
+
* - Cron has no allowlist → return undefined (cron is unrestricted; pinned
|
|
58
|
+
* skill tools flow through the profile/default fallback in `runAgent`).
|
|
59
|
+
* We deliberately don't synthesize an allowlist out of just skill tools
|
|
60
|
+
* here, because that would NARROW an unrestricted cron to "only what the
|
|
61
|
+
* skills declared."
|
|
62
|
+
* - Cron has allowlist + skills declared tools → return the union (skills
|
|
63
|
+
* widen, never narrow, an existing constraint).
|
|
64
|
+
* - Cron has allowlist + no pinned-skill tools → return the cron's allowlist
|
|
65
|
+
* unchanged.
|
|
66
|
+
*/
|
|
67
|
+
export declare function widenAllowlistWithSkillTools(jobAllow: string[] | undefined, pinnedSkillTools: string[] | undefined): string[] | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Extract every distinct `mcp__<server>__<tool>` server name referenced
|
|
70
|
+
* inside the bodies of pinned skills. Empty array when no references found.
|
|
71
|
+
*/
|
|
72
|
+
export declare function extractMcpServersFromSkillBodies(bodies: string[]): string[];
|
|
73
|
+
/**
|
|
74
|
+
* Widen the cron's MCP-server allowlist with servers referenced inside
|
|
75
|
+
* pinned-skill bodies (e.g., a skill that calls `mcp__gmail__send_message`
|
|
76
|
+
* implicitly needs the `gmail` server connected).
|
|
77
|
+
*
|
|
78
|
+
* Same semantics as `widenAllowlistWithSkillTools`: only widens an existing
|
|
79
|
+
* allowlist; doesn't synthesize one when the cron is unrestricted.
|
|
80
|
+
*/
|
|
81
|
+
export declare function widenMcpAllowlistWithSkillRefs(jobMcpAllow: string[] | undefined, skillReferencedServers: string[]): string[] | undefined;
|
|
46
82
|
export interface SkillContextResult {
|
|
47
83
|
/** The rendered "Learned Procedures" block (or empty string when no skills loaded). */
|
|
48
84
|
text: string;
|
|
@@ -54,6 +90,16 @@ export interface SkillContextResult {
|
|
|
54
90
|
}>;
|
|
55
91
|
/** Pinned slugs that didn't resolve (deleted/renamed/suppressed). Logged + surfaced. */
|
|
56
92
|
missing: string[];
|
|
93
|
+
/** Union of every `clementine.tools.allow` entry from pinned skills. Used by
|
|
94
|
+
* `buildCronExecutionPlan` to widen the cron's tool allowlist so a pinned
|
|
95
|
+
* skill's declared tools survive into the SDK call. Empty array when no
|
|
96
|
+
* pinned skill declared a `tools.allow` list. Auto-matched skills do NOT
|
|
97
|
+
* contribute — only explicit pins widen scope. */
|
|
98
|
+
pinnedToolsRequested: string[];
|
|
99
|
+
/** Bodies of pinned skills only — used for `mcp__server__tool` reference
|
|
100
|
+
* extraction so a skill's MCP usage propagates to `allowedMcpServers`.
|
|
101
|
+
* Auto-matched skills excluded for the same reason as above. */
|
|
102
|
+
pinnedBodies: string[];
|
|
57
103
|
}
|
|
58
104
|
/**
|
|
59
105
|
* Build the matched-skills block (procedures learned from prior successful runs).
|
|
@@ -196,6 +242,18 @@ export interface CronExecutionPlan {
|
|
|
196
242
|
* being set inside the skill folder. Deduped + filtered to existing
|
|
197
243
|
* paths. Empty when the trick has no addDirs and no folder-form pins. */
|
|
198
244
|
additionalDirectories: string[];
|
|
245
|
+
/** Diagnostics: which scopes a pinned skill widened on this run. Empty
|
|
246
|
+
* arrays when no widening happened. Surfaced in the Preview UI so users
|
|
247
|
+
* see "this skill brought in `Bash` and `gmail` MCP" without reading
|
|
248
|
+
* source. */
|
|
249
|
+
widenedFromSkills: {
|
|
250
|
+
/** Tool names a pinned skill's `clementine.tools.allow` added on top of
|
|
251
|
+
* the cron's own allowlist. Empty when nothing was widened. */
|
|
252
|
+
tools: string[];
|
|
253
|
+
/** MCP server names a pinned skill's body referenced (`mcp__server__tool`)
|
|
254
|
+
* that the cron's `allowedMcpServers` didn't already include. */
|
|
255
|
+
mcpServers: string[];
|
|
256
|
+
};
|
|
199
257
|
}
|
|
200
258
|
/**
|
|
201
259
|
* Plan a cron run — assemble all context, resolve skills, intersect tool/MCP
|
|
@@ -72,6 +72,70 @@ export function applyMcpAllowlist(servers, jobAllowedMcpServers) {
|
|
|
72
72
|
const allow = new Set(jobAllowedMcpServers);
|
|
73
73
|
return Object.fromEntries(Object.entries(servers).filter(([name]) => allow.has(name)));
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Widen the cron's tool allowlist with the union of pinned-skill
|
|
77
|
+
* `clementine.tools.allow` declarations.
|
|
78
|
+
*
|
|
79
|
+
* **Why this exists:** Pinning a skill is a positive user signal — "I want
|
|
80
|
+
* this skill, with the tools it declares it needs." Before 1.18.125 the
|
|
81
|
+
* skill's `tools.allow` was rendered into the prompt as text only; the SDK
|
|
82
|
+
* never saw it, so a skill pinned to a cron with a narrower allowlist would
|
|
83
|
+
* silently fail with tool-not-found errors.
|
|
84
|
+
*
|
|
85
|
+
* **Semantics:**
|
|
86
|
+
* - Cron has no allowlist → return undefined (cron is unrestricted; pinned
|
|
87
|
+
* skill tools flow through the profile/default fallback in `runAgent`).
|
|
88
|
+
* We deliberately don't synthesize an allowlist out of just skill tools
|
|
89
|
+
* here, because that would NARROW an unrestricted cron to "only what the
|
|
90
|
+
* skills declared."
|
|
91
|
+
* - Cron has allowlist + skills declared tools → return the union (skills
|
|
92
|
+
* widen, never narrow, an existing constraint).
|
|
93
|
+
* - Cron has allowlist + no pinned-skill tools → return the cron's allowlist
|
|
94
|
+
* unchanged.
|
|
95
|
+
*/
|
|
96
|
+
export function widenAllowlistWithSkillTools(jobAllow, pinnedSkillTools) {
|
|
97
|
+
if (!jobAllow?.length)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (!pinnedSkillTools?.length)
|
|
100
|
+
return [...jobAllow];
|
|
101
|
+
return [...new Set([...jobAllow, ...pinnedSkillTools])];
|
|
102
|
+
}
|
|
103
|
+
/** Match `mcp__SERVER__TOOL` references in skill body Markdown.
|
|
104
|
+
* Server names can contain single underscores (`Bright_Data`,
|
|
105
|
+
* `claude_ai_Microsoft_365`) but never `__` (double-underscore is the
|
|
106
|
+
* delimiter). The regex captures the server segment between the leading
|
|
107
|
+
* `mcp__` and the next `__`. Anchored on word boundaries so it doesn't
|
|
108
|
+
* catch substrings of longer identifiers. */
|
|
109
|
+
const MCP_TOOL_REF = /mcp__([A-Za-z0-9-]+(?:_[A-Za-z0-9-]+)*)__/g;
|
|
110
|
+
/**
|
|
111
|
+
* Extract every distinct `mcp__<server>__<tool>` server name referenced
|
|
112
|
+
* inside the bodies of pinned skills. Empty array when no references found.
|
|
113
|
+
*/
|
|
114
|
+
export function extractMcpServersFromSkillBodies(bodies) {
|
|
115
|
+
const found = new Set();
|
|
116
|
+
for (const body of bodies) {
|
|
117
|
+
if (!body)
|
|
118
|
+
continue;
|
|
119
|
+
for (const m of body.matchAll(MCP_TOOL_REF))
|
|
120
|
+
found.add(m[1]);
|
|
121
|
+
}
|
|
122
|
+
return [...found];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Widen the cron's MCP-server allowlist with servers referenced inside
|
|
126
|
+
* pinned-skill bodies (e.g., a skill that calls `mcp__gmail__send_message`
|
|
127
|
+
* implicitly needs the `gmail` server connected).
|
|
128
|
+
*
|
|
129
|
+
* Same semantics as `widenAllowlistWithSkillTools`: only widens an existing
|
|
130
|
+
* allowlist; doesn't synthesize one when the cron is unrestricted.
|
|
131
|
+
*/
|
|
132
|
+
export function widenMcpAllowlistWithSkillRefs(jobMcpAllow, skillReferencedServers) {
|
|
133
|
+
if (!jobMcpAllow?.length)
|
|
134
|
+
return undefined;
|
|
135
|
+
if (!skillReferencedServers.length)
|
|
136
|
+
return [...jobMcpAllow];
|
|
137
|
+
return [...new Set([...jobMcpAllow, ...skillReferencedServers])];
|
|
138
|
+
}
|
|
75
139
|
function capContextItem(s) {
|
|
76
140
|
if (!s)
|
|
77
141
|
return '';
|
|
@@ -235,6 +299,8 @@ function buildCriteriaContext(successCriteria) {
|
|
|
235
299
|
export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSkills, memoryStore, opts) {
|
|
236
300
|
const applied = [];
|
|
237
301
|
const missing = [];
|
|
302
|
+
const pinnedToolsRequested = [];
|
|
303
|
+
const pinnedBodies = [];
|
|
238
304
|
try {
|
|
239
305
|
const { searchSkills, recordSkillUse, loadSkillByName } = await import('./skill-extractor.js');
|
|
240
306
|
const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
|
|
@@ -305,8 +371,23 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
|
|
|
305
371
|
seen.add(m.name);
|
|
306
372
|
}
|
|
307
373
|
}
|
|
374
|
+
// 1.18.125 — collect pinned-skill tool declarations + bodies so the
|
|
375
|
+
// cron planner can widen `allowedTools` / `allowedMcpServers` with what
|
|
376
|
+
// the pinned skills explicitly need. Pins (not auto-matches) widen scope
|
|
377
|
+
// because pinning is the user's explicit signal of intent.
|
|
378
|
+
const pinnedSeen = new Set();
|
|
379
|
+
for (const s of prepared) {
|
|
380
|
+
if (s.source !== 'pinned')
|
|
381
|
+
continue;
|
|
382
|
+
if (pinnedSeen.has(s.name))
|
|
383
|
+
continue;
|
|
384
|
+
pinnedSeen.add(s.name);
|
|
385
|
+
for (const t of s.toolsUsed)
|
|
386
|
+
pinnedToolsRequested.push(t);
|
|
387
|
+
pinnedBodies.push(s.content);
|
|
388
|
+
}
|
|
308
389
|
if (prepared.length === 0)
|
|
309
|
-
return { text: '', applied, missing };
|
|
390
|
+
return { text: '', applied, missing, pinnedToolsRequested: [], pinnedBodies: [] };
|
|
310
391
|
// Folder-form bundled-file budget. Anthropic skill spec says the body
|
|
311
392
|
// should be ≤500 lines; bundled files (templates/, reference docs)
|
|
312
393
|
// load on top. We cap aggregate inlined bundle bytes so a skill with a
|
|
@@ -398,11 +479,11 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
|
|
|
398
479
|
return block;
|
|
399
480
|
});
|
|
400
481
|
const text = `## Learned Procedures (from past successful executions)\nFollow these proven approaches when applicable:\n\n${skillLines.join('\n\n')}\n\n`;
|
|
401
|
-
return { text, applied, missing };
|
|
482
|
+
return { text, applied, missing, pinnedToolsRequested: [...new Set(pinnedToolsRequested)], pinnedBodies };
|
|
402
483
|
}
|
|
403
484
|
catch (err) {
|
|
404
485
|
logger.debug({ err, jobName }, 'buildSkillContext failed (non-fatal)');
|
|
405
|
-
return { text: '', applied, missing };
|
|
486
|
+
return { text: '', applied, missing, pinnedToolsRequested: [], pinnedBodies: [] };
|
|
406
487
|
}
|
|
407
488
|
}
|
|
408
489
|
/**
|
|
@@ -463,15 +544,25 @@ export async function buildCronExecutionPlan(opts) {
|
|
|
463
544
|
].filter(Boolean).join('\n\n'),
|
|
464
545
|
profile: opts.profile,
|
|
465
546
|
});
|
|
547
|
+
// 1.18.125 — pinned-skill scope widening (SDK alignment).
|
|
548
|
+
// A pinned skill that declares `clementine.tools.allow` or references
|
|
549
|
+
// `mcp__server__tool` in its body needs those tools/servers to actually
|
|
550
|
+
// be live for the SDK call — not just rendered into the prompt as text.
|
|
551
|
+
// We widen (never narrow) the cron's allowlists with what the pinned
|
|
552
|
+
// skills declared. Auto-matched skills don't widen scope (only explicit
|
|
553
|
+
// pins do — the user's positive signal).
|
|
554
|
+
const skillReferencedMcpServers = extractMcpServersFromSkillBodies(skillResult.pinnedBodies);
|
|
555
|
+
const widenedJobAllowedTools = widenAllowlistWithSkillTools(opts.allowedTools, skillResult.pinnedToolsRequested);
|
|
556
|
+
const widenedJobMcpAllowlist = widenMcpAllowlistWithSkillRefs(opts.allowedMcpServers, skillReferencedMcpServers);
|
|
466
557
|
// Per-trick MCP allowlist: post-filter on the profile-narrowed map.
|
|
467
|
-
// Effective set = profile ∩ trick.
|
|
468
|
-
const mcpServerMap = applyMcpAllowlist(mcp.servers,
|
|
469
|
-
const allowSet =
|
|
558
|
+
// Effective set = profile ∩ trick (widened).
|
|
559
|
+
const mcpServerMap = applyMcpAllowlist(mcp.servers, widenedJobMcpAllowlist);
|
|
560
|
+
const allowSet = widenedJobMcpAllowlist?.length ? new Set(widenedJobMcpAllowlist) : null;
|
|
470
561
|
const composioConnected = allowSet ? mcp.composioConnected.filter(n => allowSet.has(n)) : mcp.composioConnected;
|
|
471
562
|
const externalConnected = allowSet ? mcp.externalConnected.filter(n => allowSet.has(n)) : mcp.externalConnected;
|
|
472
563
|
const mcpServersApplied = Object.keys(mcpServerMap);
|
|
473
|
-
// Per-trick tool allowlist intersection.
|
|
474
|
-
const effectiveAllowedTools = computeEffectiveAllowedTools(
|
|
564
|
+
// Per-trick tool allowlist intersection (widened by pinned-skill needs).
|
|
565
|
+
const effectiveAllowedTools = computeEffectiveAllowedTools(widenedJobAllowedTools, opts.profile?.team?.allowedTools);
|
|
475
566
|
// Per-tier cap from config (BUDGET.cronT1 / BUDGET.cronT2). 0 = uncapped.
|
|
476
567
|
const configuredCap = tier >= 2 ? BUDGET.cronT2 : BUDGET.cronT1;
|
|
477
568
|
const maxBudget = opts.maxBudgetUsd ?? (configuredCap > 0 ? configuredCap : undefined);
|
|
@@ -516,6 +607,12 @@ export async function buildCronExecutionPlan(opts) {
|
|
|
516
607
|
return false;
|
|
517
608
|
}
|
|
518
609
|
});
|
|
610
|
+
// Diagnostics: what did the pinned skills add on top of what the cron
|
|
611
|
+
// already declared? Renders in the Preview UI as "Skill widened scope: …"
|
|
612
|
+
const baseToolSet = new Set(opts.allowedTools ?? []);
|
|
613
|
+
const widenedToolsFromSkills = skillResult.pinnedToolsRequested.filter(t => !baseToolSet.has(t));
|
|
614
|
+
const baseMcpSet = new Set(opts.allowedMcpServers ?? []);
|
|
615
|
+
const widenedMcpFromSkills = skillReferencedMcpServers.filter(s => !baseMcpSet.has(s));
|
|
519
616
|
return {
|
|
520
617
|
builtPrompt,
|
|
521
618
|
contextBlocks: {
|
|
@@ -537,6 +634,13 @@ export async function buildCronExecutionPlan(opts) {
|
|
|
537
634
|
ownerName,
|
|
538
635
|
predictable,
|
|
539
636
|
additionalDirectories,
|
|
637
|
+
widenedFromSkills: {
|
|
638
|
+
// Only surface widening when the cron actually had a base allowlist —
|
|
639
|
+
// otherwise the cron is unrestricted and "widening" isn't a meaningful
|
|
640
|
+
// concept (the skills' tools were already implicitly allowed).
|
|
641
|
+
tools: opts.allowedTools?.length ? widenedToolsFromSkills : [],
|
|
642
|
+
mcpServers: opts.allowedMcpServers?.length ? widenedMcpFromSkills : [],
|
|
643
|
+
},
|
|
540
644
|
};
|
|
541
645
|
}
|
|
542
646
|
/**
|
|
@@ -565,6 +669,7 @@ export async function runAgentCron(opts) {
|
|
|
565
669
|
skillsMissing: plan.skillsMissing.length,
|
|
566
670
|
trickAllowedTools: effectiveAllowedTools?.length,
|
|
567
671
|
trickAllowedMcp: opts.allowedMcpServers?.length,
|
|
672
|
+
widenedFromSkills: plan.widenedFromSkills,
|
|
568
673
|
}, 'runAgentCron: dispatching to runAgent');
|
|
569
674
|
const startedAt = Date.now();
|
|
570
675
|
const result = await runAgent(builtPrompt, {
|
|
@@ -29,10 +29,10 @@ export declare function extractSkill(assistant: PersonalAssistant, context: {
|
|
|
29
29
|
durationMs: number;
|
|
30
30
|
}): Promise<SkillDocument | null>;
|
|
31
31
|
/** Move a pending skill to the active skills directory. */
|
|
32
|
-
export declare function approvePendingSkill(name: string): {
|
|
32
|
+
export declare function approvePendingSkill(name: string): Promise<{
|
|
33
33
|
ok: boolean;
|
|
34
34
|
message: string;
|
|
35
|
-
}
|
|
35
|
+
}>;
|
|
36
36
|
/** Delete a pending skill (reject it). */
|
|
37
37
|
export declare function rejectPendingSkill(name: string): {
|
|
38
38
|
ok: boolean;
|
|
@@ -121,43 +121,44 @@ function savePendingSkill(skill) {
|
|
|
121
121
|
logger.info({ name: skill.name, source: skill.source }, 'Skill queued for approval');
|
|
122
122
|
}
|
|
123
123
|
/** Save an approved skill as a formatted markdown file. Agent-scoped if agentSlug set. */
|
|
124
|
-
|
|
124
|
+
// 1.18.124 — saveActiveSkill is now a thin wrapper around the shared
|
|
125
|
+
// writeSkill helper. Auto-extracted skills used to land as legacy flat-
|
|
126
|
+
// form (`<name>.md` with top-level triggers/toolsUsed/source) which the
|
|
127
|
+
// Skills page tagged with the orange "LEGACY" badge — confusing on a
|
|
128
|
+
// fresh install. Now they write the same Anthropic-canonical folder
|
|
129
|
+
// form the dashboard + chat paths produce.
|
|
130
|
+
//
|
|
131
|
+
// Also drops the per-overwrite `.md.bak` leak — that was paranoia
|
|
132
|
+
// before the skill catalog had proper provenance. The pending file
|
|
133
|
+
// in PENDING_SKILLS_DIR is the rollback artifact.
|
|
134
|
+
async function saveActiveSkill(skill) {
|
|
125
135
|
ensureDirs();
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
const
|
|
136
|
+
const { writeSkill } = await import('./skill-store.js');
|
|
137
|
+
// Map SkillDocument's source enum (which still has 'unleashed'/'cron'
|
|
138
|
+
// for back-compat with old pending-skill JSONs on disk) to the
|
|
139
|
+
// writeSkill source enum.
|
|
140
|
+
const writeSource = skill.source === 'unleashed' || skill.source === 'cron' ? 'auto'
|
|
141
|
+
: skill.source === 'chat' ? 'chat'
|
|
142
|
+
: 'manual';
|
|
143
|
+
const result = writeSkill({
|
|
144
|
+
name: skill.name,
|
|
131
145
|
title: skill.title,
|
|
132
146
|
description: skill.description,
|
|
147
|
+
// Procedure body only — title/description live in frontmatter
|
|
148
|
+
// under the new schema, no need to repeat them inside the body.
|
|
149
|
+
body: skill.steps,
|
|
150
|
+
tools: skill.toolsUsed,
|
|
133
151
|
triggers: skill.triggers,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
};
|
|
140
|
-
if (skill.sourceJob)
|
|
141
|
-
frontmatter.sourceJob = skill.sourceJob;
|
|
142
|
-
if (skill.agentSlug)
|
|
143
|
-
frontmatter.agentSlug = skill.agentSlug;
|
|
144
|
-
if (skill.lastUsed)
|
|
145
|
-
frontmatter.lastUsed = skill.lastUsed;
|
|
146
|
-
const content = matter.stringify(`\n# ${skill.title}\n\n${skill.description}\n\n## Procedure\n\n${skill.steps}\n`, frontmatter);
|
|
147
|
-
const filePath = path.join(targetDir, `${skill.name}.md`);
|
|
148
|
-
// Backup existing before overwrite
|
|
149
|
-
if (existsSync(filePath)) {
|
|
150
|
-
try {
|
|
151
|
-
copyFileSync(filePath, filePath.replace(/\.md$/, '.md.bak'));
|
|
152
|
-
}
|
|
153
|
-
catch { /* best-effort */ }
|
|
154
|
-
}
|
|
155
|
-
writeFileSync(filePath, content);
|
|
156
|
-
logger.info({ name: skill.name, source: skill.source, agentSlug: skill.agentSlug ?? 'global' }, 'Skill saved');
|
|
152
|
+
agentSlug: skill.agentSlug,
|
|
153
|
+
source: writeSource,
|
|
154
|
+
sourceJob: skill.sourceJob,
|
|
155
|
+
overwrite: true, // approve / merge always replaces in-place
|
|
156
|
+
});
|
|
157
|
+
logger.info({ name: skill.name, source: skill.source, agentSlug: skill.agentSlug ?? 'global', filePath: result.filePath, overwrote: result.overwrote }, 'Skill saved');
|
|
157
158
|
}
|
|
158
159
|
// ── Pending Skill Management ────────────────────────────────────────
|
|
159
160
|
/** Move a pending skill to the active skills directory. */
|
|
160
|
-
export function approvePendingSkill(name) {
|
|
161
|
+
export async function approvePendingSkill(name) {
|
|
161
162
|
ensureDirs();
|
|
162
163
|
const pendingFile = path.join(PENDING_SKILLS_DIR, `${name}.json`);
|
|
163
164
|
if (!existsSync(pendingFile)) {
|
|
@@ -166,7 +167,7 @@ export function approvePendingSkill(name) {
|
|
|
166
167
|
try {
|
|
167
168
|
const skill = JSON.parse(readFileSync(pendingFile, 'utf-8'));
|
|
168
169
|
skill.updatedAt = new Date().toISOString();
|
|
169
|
-
saveActiveSkill(skill);
|
|
170
|
+
await saveActiveSkill(skill);
|
|
170
171
|
unlinkSync(pendingFile);
|
|
171
172
|
logger.info({ name }, 'Pending skill approved and activated');
|
|
172
173
|
return { ok: true, message: `Skill **${skill.title}** is now active${skill.agentSlug ? ` for ${skill.agentSlug}` : ' (global)'}.` };
|
|
@@ -308,7 +309,7 @@ async function mergeSkill(assistant, existing, incoming) {
|
|
|
308
309
|
updatedAt: new Date().toISOString(),
|
|
309
310
|
};
|
|
310
311
|
// Merges go directly to active (existing skill was already approved)
|
|
311
|
-
saveActiveSkill(merged);
|
|
312
|
+
await saveActiveSkill(merged);
|
|
312
313
|
logger.info({ name: merged.name }, 'Skill merged and updated');
|
|
313
314
|
return merged;
|
|
314
315
|
}
|
|
@@ -77,5 +77,55 @@ export declare function migrateAllLegacySkills(): {
|
|
|
77
77
|
migrated: MigrationResult[];
|
|
78
78
|
skipped: MigrationResult[];
|
|
79
79
|
};
|
|
80
|
+
export interface WriteSkillInput {
|
|
81
|
+
/** Slug (lowercase letters/digits/dashes, ≤64 chars, Anthropic regex). */
|
|
82
|
+
name: string;
|
|
83
|
+
/** Human-readable display name. Optional. */
|
|
84
|
+
title?: string;
|
|
85
|
+
/** One-paragraph "what does this do, when should Claude run it" — required by spec. */
|
|
86
|
+
description: string;
|
|
87
|
+
/** Procedure body (Markdown). Required. */
|
|
88
|
+
body: string;
|
|
89
|
+
/** Where the skill came from. Drives lifecycle metadata + dashboard badge. */
|
|
90
|
+
source: 'manual' | 'chat' | 'auto' | 'imported';
|
|
91
|
+
/** Optional tool allowlist — stored under clementine.tools.allow. */
|
|
92
|
+
tools?: string[];
|
|
93
|
+
/** Optional NLP trigger phrases for auto-match — stored under clementine.triggers. */
|
|
94
|
+
triggers?: string[];
|
|
95
|
+
/** Optional agent scope. When set, writes to <agentsDir>/<slug>/skills/
|
|
96
|
+
* instead of the global skills dir. Used by auto-extraction so each
|
|
97
|
+
* hired agent's skills stay isolated by default. */
|
|
98
|
+
agentSlug?: string;
|
|
99
|
+
/** When true, allow overwriting an existing skill (used by update flows). */
|
|
100
|
+
overwrite?: boolean;
|
|
101
|
+
/** Optional source-job tag (auto-extraction provenance). */
|
|
102
|
+
sourceJob?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface WriteSkillResult {
|
|
105
|
+
/** Absolute path to the written SKILL.md. */
|
|
106
|
+
filePath: string;
|
|
107
|
+
/** Slug (matches input name). */
|
|
108
|
+
name: string;
|
|
109
|
+
/** Whether an existing skill was overwritten. */
|
|
110
|
+
overwrote: boolean;
|
|
111
|
+
}
|
|
112
|
+
export declare function writeSkill(input: WriteSkillInput): WriteSkillResult;
|
|
113
|
+
export interface SkillBackupSweepResult {
|
|
114
|
+
/** Files removed this pass. Absolute paths. */
|
|
115
|
+
removed: string[];
|
|
116
|
+
/** Files inspected (matched the pattern). Useful for "nothing to do" telemetry. */
|
|
117
|
+
inspected: number;
|
|
118
|
+
/** Files that matched the pattern but were younger than the cutoff (kept). */
|
|
119
|
+
keptFresh: number;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Sweep `.md.bak` skill backups older than `LEGACY_BAK_AGE_DAYS` from the
|
|
123
|
+
* global skills directory and from every per-agent skills directory under
|
|
124
|
+
* `00-System/agents/<slug>/skills/`. Best-effort: per-file errors are
|
|
125
|
+
* swallowed so a permission glitch on one file doesn't stop the sweep.
|
|
126
|
+
*
|
|
127
|
+
* Idempotent — safe to call repeatedly. Returns counts for logging.
|
|
128
|
+
*/
|
|
129
|
+
export declare function cleanupLegacySkillBackups(): SkillBackupSweepResult;
|
|
80
130
|
export {};
|
|
81
131
|
//# sourceMappingURL=skill-store.d.ts.map
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* runner. Phase C wires runtime invocation. Phase E migrates legacy
|
|
22
22
|
* crons → folder-form skills.
|
|
23
23
|
*/
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import matter from 'gray-matter';
|
|
@@ -647,4 +647,149 @@ export function migrateAllLegacySkills() {
|
|
|
647
647
|
}
|
|
648
648
|
return { migrated, skipped };
|
|
649
649
|
}
|
|
650
|
+
export function writeSkill(input) {
|
|
651
|
+
// Validate name per Anthropic spec — single guard for every caller.
|
|
652
|
+
if (!input.name || !NAME_PATTERN.test(input.name)) {
|
|
653
|
+
throw new Error('writeSkill: name must match ^[a-z0-9][a-z0-9-]{0,63}$');
|
|
654
|
+
}
|
|
655
|
+
if (input.name.length > NAME_MAX_LEN) {
|
|
656
|
+
throw new Error(`writeSkill: name exceeds ${NAME_MAX_LEN} chars`);
|
|
657
|
+
}
|
|
658
|
+
if (RESERVED_NAMES.has(input.name) || /\b(anthropic|claude)\b/i.test(input.name)) {
|
|
659
|
+
throw new Error(`writeSkill: name uses a reserved word`);
|
|
660
|
+
}
|
|
661
|
+
if (!input.description || !input.description.trim()) {
|
|
662
|
+
throw new Error('writeSkill: description is required');
|
|
663
|
+
}
|
|
664
|
+
if (input.description.length > DESCRIPTION_MAX_LEN) {
|
|
665
|
+
throw new Error(`writeSkill: description exceeds ${DESCRIPTION_MAX_LEN} chars`);
|
|
666
|
+
}
|
|
667
|
+
if (!input.body || !input.body.trim()) {
|
|
668
|
+
throw new Error('writeSkill: body is required');
|
|
669
|
+
}
|
|
670
|
+
// Resolve target directory. Agent-scoped writes land under the agent's
|
|
671
|
+
// skills folder so each hired agent's skill set is independent.
|
|
672
|
+
const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
673
|
+
const targetDir = input.agentSlug
|
|
674
|
+
? path.join(base, 'vault', '00-System', 'agents', input.agentSlug, 'skills')
|
|
675
|
+
: globalSkillsDir();
|
|
676
|
+
if (!existsSync(targetDir))
|
|
677
|
+
mkdirSync(targetDir, { recursive: true });
|
|
678
|
+
const folderPath = path.join(targetDir, input.name);
|
|
679
|
+
const entryPath = path.join(folderPath, 'SKILL.md');
|
|
680
|
+
const existed = existsSync(entryPath);
|
|
681
|
+
if (existed && !input.overwrite) {
|
|
682
|
+
throw new Error(`writeSkill: skill "${input.name}" already exists`);
|
|
683
|
+
}
|
|
684
|
+
mkdirSync(folderPath, { recursive: true });
|
|
685
|
+
// Build the frontmatter. Anthropic-canonical fields (name, description)
|
|
686
|
+
// top-level. Everything else under clementine:. The lifecycle metadata
|
|
687
|
+
// (createdAt / updatedAt / version) keeps the Skills page detail pane
|
|
688
|
+
// accurate without authors having to remember to set it by hand.
|
|
689
|
+
const now = new Date().toISOString();
|
|
690
|
+
const fm = {
|
|
691
|
+
name: input.name,
|
|
692
|
+
description: input.description.trim(),
|
|
693
|
+
};
|
|
694
|
+
if (input.title && input.title.trim())
|
|
695
|
+
fm.title = input.title.trim();
|
|
696
|
+
const ext = {
|
|
697
|
+
source: input.source,
|
|
698
|
+
useCount: 0,
|
|
699
|
+
createdAt: now,
|
|
700
|
+
updatedAt: now,
|
|
701
|
+
version: 1,
|
|
702
|
+
};
|
|
703
|
+
if (input.tools && input.tools.length > 0) {
|
|
704
|
+
ext.tools = { allow: input.tools.map(String).map(s => s.trim()).filter(Boolean) };
|
|
705
|
+
}
|
|
706
|
+
if (input.triggers && input.triggers.length > 0) {
|
|
707
|
+
ext.triggers = input.triggers.map(String).map(s => s.trim()).filter(Boolean);
|
|
708
|
+
}
|
|
709
|
+
if (input.sourceJob)
|
|
710
|
+
ext.sourceJob = input.sourceJob;
|
|
711
|
+
fm.clementine = ext;
|
|
712
|
+
const content = matter.stringify(input.body.endsWith('\n') ? input.body : input.body + '\n', fm);
|
|
713
|
+
writeFileSync(entryPath, content);
|
|
714
|
+
return { filePath: entryPath, name: input.name, overwrote: existed };
|
|
715
|
+
}
|
|
716
|
+
// ── Legacy backup janitor (1.18.125) ─────────────────────────────────
|
|
717
|
+
//
|
|
718
|
+
// Pre-1.18.124, `saveActiveSkill` wrote a per-overwrite `.md.bak` next to
|
|
719
|
+
// every skill it updated. The new `writeSkill` path doesn't create these,
|
|
720
|
+
// but the old ones rot in the vault forever unless something sweeps them.
|
|
721
|
+
// `cleanupLegacySkillBackups` finds `.md.bak` files older than the cutoff
|
|
722
|
+
// and removes them. Runs from the periodic memory-maintenance cycle so
|
|
723
|
+
// users don't need to know it exists.
|
|
724
|
+
//
|
|
725
|
+
// Conservative: 30-day age floor + only the slug-named `.md.bak` pattern.
|
|
726
|
+
// Anything mtime-recent stays put in case a user is mid-rollback.
|
|
727
|
+
const LEGACY_BAK_AGE_DAYS = 30;
|
|
728
|
+
const LEGACY_BAK_AGE_MS = LEGACY_BAK_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
729
|
+
/**
|
|
730
|
+
* Sweep `.md.bak` skill backups older than `LEGACY_BAK_AGE_DAYS` from the
|
|
731
|
+
* global skills directory and from every per-agent skills directory under
|
|
732
|
+
* `00-System/agents/<slug>/skills/`. Best-effort: per-file errors are
|
|
733
|
+
* swallowed so a permission glitch on one file doesn't stop the sweep.
|
|
734
|
+
*
|
|
735
|
+
* Idempotent — safe to call repeatedly. Returns counts for logging.
|
|
736
|
+
*/
|
|
737
|
+
export function cleanupLegacySkillBackups() {
|
|
738
|
+
const result = { removed: [], inspected: 0, keptFresh: 0 };
|
|
739
|
+
const cutoff = Date.now() - LEGACY_BAK_AGE_MS;
|
|
740
|
+
const sweepRoots = [globalSkillsDir()];
|
|
741
|
+
// Per-agent skill dirs — discover via the agents/ folder.
|
|
742
|
+
try {
|
|
743
|
+
const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
|
|
744
|
+
const agentsDir = path.join(base, 'vault', '00-System', 'agents');
|
|
745
|
+
if (existsSync(agentsDir)) {
|
|
746
|
+
for (const entry of readdirSync(agentsDir)) {
|
|
747
|
+
if (entry.startsWith('.'))
|
|
748
|
+
continue;
|
|
749
|
+
const agentSkillsDir = path.join(agentsDir, entry, 'skills');
|
|
750
|
+
if (existsSync(agentSkillsDir))
|
|
751
|
+
sweepRoots.push(agentSkillsDir);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch { /* non-fatal — global sweep still runs */ }
|
|
756
|
+
for (const root of sweepRoots) {
|
|
757
|
+
let entries;
|
|
758
|
+
try {
|
|
759
|
+
entries = readdirSync(root);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
for (const entry of entries) {
|
|
765
|
+
// Match exactly the legacy pattern. Don't touch anything else — we
|
|
766
|
+
// never want to nuke `templates/old-draft.md.bak` inside a folder skill,
|
|
767
|
+
// for instance. The legacy writer only ever produced flat
|
|
768
|
+
// `<slug>.md.bak` siblings, so that's all we sweep.
|
|
769
|
+
if (!entry.endsWith('.md.bak'))
|
|
770
|
+
continue;
|
|
771
|
+
const full = path.join(root, entry);
|
|
772
|
+
let st;
|
|
773
|
+
try {
|
|
774
|
+
st = statSync(full);
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (!st.isFile())
|
|
780
|
+
continue;
|
|
781
|
+
result.inspected++;
|
|
782
|
+
if (st.mtimeMs > cutoff) {
|
|
783
|
+
result.keptFresh++;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
try {
|
|
787
|
+
unlinkSync(full);
|
|
788
|
+
result.removed.push(full);
|
|
789
|
+
}
|
|
790
|
+
catch { /* skip — permission or race; next sweep retries */ }
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return result;
|
|
794
|
+
}
|
|
650
795
|
//# sourceMappingURL=skill-store.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -10293,7 +10293,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
10293
10293
|
app.post('/api/skills/pending/:name/approve', async (req, res) => {
|
|
10294
10294
|
try {
|
|
10295
10295
|
const { approvePendingSkill } = await import('../agent/skill-extractor.js');
|
|
10296
|
-
const result = approvePendingSkill(req.params.name);
|
|
10296
|
+
const result = await approvePendingSkill(req.params.name);
|
|
10297
10297
|
if (!result.ok) {
|
|
10298
10298
|
res.status(404).json(result);
|
|
10299
10299
|
return;
|
|
@@ -10318,63 +10318,43 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
|
|
|
10318
10318
|
res.status(500).json({ error: String(err) });
|
|
10319
10319
|
}
|
|
10320
10320
|
});
|
|
10321
|
-
// POST /api/skills — create a new folder-form skill
|
|
10322
|
-
//
|
|
10323
|
-
//
|
|
10324
|
-
//
|
|
10325
|
-
//
|
|
10326
|
-
app.post('/api/skills', (req, res) => {
|
|
10321
|
+
// POST /api/skills — create a new folder-form skill via the shared
|
|
10322
|
+
// writeSkill helper (1.18.124). All three skill-creation paths
|
|
10323
|
+
// (dashboard, MCP create_skill, auto-extraction) flow through the
|
|
10324
|
+
// same write-once-validate-once code; this route is now a thin
|
|
10325
|
+
// adapter over the helper.
|
|
10326
|
+
app.post('/api/skills', async (req, res) => {
|
|
10327
10327
|
try {
|
|
10328
10328
|
const { name, title, description, body, tools } = req.body ?? {};
|
|
10329
|
-
if (
|
|
10330
|
-
res.status(400).json({ error: 'name
|
|
10331
|
-
return;
|
|
10332
|
-
}
|
|
10333
|
-
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
|
10334
|
-
res.status(400).json({ error: 'name must match ^[a-z0-9][a-z0-9-]{0,63}$ (Anthropic spec)' });
|
|
10335
|
-
return;
|
|
10336
|
-
}
|
|
10337
|
-
if (!description || typeof description !== 'string') {
|
|
10338
|
-
res.status(400).json({ error: 'description is required' });
|
|
10339
|
-
return;
|
|
10340
|
-
}
|
|
10341
|
-
if (description.length > 1024) {
|
|
10342
|
-
res.status(400).json({ error: 'description must be ≤ 1024 chars (Anthropic spec)' });
|
|
10329
|
+
if (typeof name !== 'string' || typeof description !== 'string' || typeof body !== 'string') {
|
|
10330
|
+
res.status(400).json({ error: 'name, description, and body are required strings' });
|
|
10343
10331
|
return;
|
|
10344
10332
|
}
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
|
|
10333
|
+
const { writeSkill } = await import('../agent/skill-store.js');
|
|
10334
|
+
try {
|
|
10335
|
+
const result = writeSkill({
|
|
10336
|
+
name,
|
|
10337
|
+
title: typeof title === 'string' ? title : undefined,
|
|
10338
|
+
description,
|
|
10339
|
+
body,
|
|
10340
|
+
tools: Array.isArray(tools) ? tools.map(String) : undefined,
|
|
10341
|
+
source: 'manual',
|
|
10342
|
+
});
|
|
10343
|
+
res.json({ ok: true, name: result.name, layout: 'folder', filePath: result.filePath });
|
|
10348
10344
|
}
|
|
10349
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10355
|
-
|
|
10356
|
-
|
|
10345
|
+
catch (err) {
|
|
10346
|
+
// writeSkill throws synchronously with a readable message for
|
|
10347
|
+
// every validation failure (name regex, description length,
|
|
10348
|
+
// already-exists, etc.). Surface them as 4xx; only unexpected
|
|
10349
|
+
// I/O errors hit the outer 500 path below.
|
|
10350
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
10351
|
+
if (msg.includes('already exists')) {
|
|
10352
|
+
res.status(409).json({ error: msg });
|
|
10353
|
+
}
|
|
10354
|
+
else {
|
|
10355
|
+
res.status(400).json({ error: msg });
|
|
10356
|
+
}
|
|
10357
10357
|
}
|
|
10358
|
-
mkdirSync(folderPath, { recursive: true });
|
|
10359
|
-
const now = new Date().toISOString();
|
|
10360
|
-
const fm = { name, description };
|
|
10361
|
-
if (title && typeof title === 'string' && title.trim())
|
|
10362
|
-
fm.title = title.trim();
|
|
10363
|
-
const allowed = Array.isArray(tools) ? tools.map(String).map(s => s.trim()).filter(Boolean) : [];
|
|
10364
|
-
const clementineExt = {
|
|
10365
|
-
source: 'manual',
|
|
10366
|
-
useCount: 0,
|
|
10367
|
-
createdAt: now,
|
|
10368
|
-
updatedAt: now,
|
|
10369
|
-
version: 1,
|
|
10370
|
-
};
|
|
10371
|
-
if (allowed.length > 0)
|
|
10372
|
-
clementineExt.tools = { allow: allowed };
|
|
10373
|
-
fm.clementine = clementineExt;
|
|
10374
|
-
const matterMod = require('gray-matter');
|
|
10375
|
-
const content = matterMod.stringify(body.endsWith('\n') ? body : body + '\n', fm);
|
|
10376
|
-
writeFileSync(entryPath, content);
|
|
10377
|
-
res.json({ ok: true, name, layout: 'folder', filePath: entryPath });
|
|
10378
10358
|
}
|
|
10379
10359
|
catch (err) {
|
|
10380
10360
|
res.status(500).json({ error: String(err) });
|
package/dist/cli/index.js
CHANGED
|
@@ -2832,7 +2832,7 @@ skillsCmd
|
|
|
2832
2832
|
try {
|
|
2833
2833
|
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
2834
2834
|
const { approvePendingSkill } = await import('../agent/skill-extractor.js');
|
|
2835
|
-
const result = approvePendingSkill(name);
|
|
2835
|
+
const result = await approvePendingSkill(name);
|
|
2836
2836
|
if (result.ok) {
|
|
2837
2837
|
console.log(` ${GREEN}✓${RESET} ${result.message}`);
|
|
2838
2838
|
}
|
package/dist/gateway/router.js
CHANGED
|
@@ -1068,7 +1068,7 @@ export class Gateway {
|
|
|
1068
1068
|
case 'approve': {
|
|
1069
1069
|
if (!args?.name)
|
|
1070
1070
|
return 'Missing skill name.';
|
|
1071
|
-
const result = approvePendingSkill(args.name);
|
|
1071
|
+
const result = await approvePendingSkill(args.name);
|
|
1072
1072
|
return result.message;
|
|
1073
1073
|
}
|
|
1074
1074
|
case 'reject': {
|
|
@@ -146,6 +146,19 @@ export async function runStartupMaintenance(store) {
|
|
|
146
146
|
catch (err) {
|
|
147
147
|
logger.warn({ err }, 'Startup janitor failed');
|
|
148
148
|
}
|
|
149
|
+
// Vault janitor (1.18.125) — sweep legacy `.md.bak` skill backups so the
|
|
150
|
+
// first daemon boot after upgrade clears the existing leak instead of
|
|
151
|
+
// waiting 6h for the periodic cycle.
|
|
152
|
+
try {
|
|
153
|
+
const { cleanupLegacySkillBackups } = await import('../agent/skill-store.js');
|
|
154
|
+
const sweep = cleanupLegacySkillBackups();
|
|
155
|
+
if (sweep.removed.length > 0) {
|
|
156
|
+
logger.info({ removed: sweep.removed.length, inspected: sweep.inspected, keptFresh: sweep.keptFresh }, 'Startup .md.bak sweep');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
logger.warn({ err }, 'Startup .md.bak sweep failed');
|
|
161
|
+
}
|
|
149
162
|
// Embedding warm-up — pre-embed the most-cited chunks in the background so
|
|
150
163
|
// the first retrievals after startup don't pay cold-start latency. Fire
|
|
151
164
|
// and forget; never blocks startup.
|
|
@@ -247,6 +260,20 @@ export async function runPeriodicCycle(store, llmCall) {
|
|
|
247
260
|
catch (err) {
|
|
248
261
|
logger.warn({ err }, 'Periodic janitor failed');
|
|
249
262
|
}
|
|
263
|
+
// 6a. Vault janitor — sweep legacy `.md.bak` skill backups (1.18.125).
|
|
264
|
+
// Pre-1.18.124 saveActiveSkill wrote per-overwrite backups; the new
|
|
265
|
+
// writeSkill path doesn't, so the old ones rot in the vault. Cap age
|
|
266
|
+
// at 30 days to give rollback room. No-op when nothing to sweep.
|
|
267
|
+
try {
|
|
268
|
+
const { cleanupLegacySkillBackups } = await import('../agent/skill-store.js');
|
|
269
|
+
const sweep = cleanupLegacySkillBackups();
|
|
270
|
+
if (sweep.removed.length > 0) {
|
|
271
|
+
logger.info({ removed: sweep.removed.length, inspected: sweep.inspected, keptFresh: sweep.keptFresh }, 'Legacy skill .md.bak sweep');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
logger.warn({ err }, 'Legacy skill .md.bak sweep failed');
|
|
276
|
+
}
|
|
250
277
|
// 6b. Integrity probes — FTS health, orphan derived_from, embedding gaps.
|
|
251
278
|
try {
|
|
252
279
|
const report = runIntegrityProbes(store);
|
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
* description, body presence) is enforced by both the dashboard endpoint
|
|
18
18
|
* and these tools, so you can't smuggle a bad skill through the chat path.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { z } from 'zod';
|
|
23
23
|
import matter from 'gray-matter';
|
|
24
24
|
import { VAULT_DIR, textResult, logger } from './shared.js';
|
|
25
|
-
//
|
|
25
|
+
// 1.18.124 — name regex is the only validator skill-tools still uses
|
|
26
|
+
// directly (for update_skill's pre-flight slug check). All other
|
|
27
|
+
// validations + the file write live in skill-store.writeSkill.
|
|
26
28
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
27
|
-
const NAME_MAX_LEN = 64;
|
|
28
29
|
const DESCRIPTION_MAX_LEN = 1024;
|
|
29
|
-
const RESERVED_NAMES = new Set(['anthropic', 'claude']);
|
|
30
30
|
function globalSkillsDir() {
|
|
31
31
|
return path.join(VAULT_DIR, '00-System', 'skills');
|
|
32
32
|
}
|
|
@@ -50,65 +50,37 @@ export function registerSkillTools(server) {
|
|
|
50
50
|
triggers: z.array(z.string()).optional()
|
|
51
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
52
|
}, async ({ name, title, description, body, tools, triggers }) => {
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
}
|
|
53
|
+
// 1.18.124 — delegate to the shared writeSkill helper. Validation
|
|
54
|
+
// (name regex, length caps, reserved words, already-exists) is
|
|
55
|
+
// now centralized; the same checks run for the dashboard endpoint
|
|
56
|
+
// and the auto-extraction path.
|
|
78
57
|
try {
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
58
|
+
const { writeSkill } = await import('../agent/skill-store.js');
|
|
59
|
+
const result = writeSkill({
|
|
60
|
+
name,
|
|
61
|
+
title,
|
|
62
|
+
description,
|
|
63
|
+
body,
|
|
64
|
+
tools,
|
|
65
|
+
triggers,
|
|
85
66
|
source: 'chat',
|
|
86
|
-
|
|
87
|
-
|
|
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');
|
|
67
|
+
});
|
|
68
|
+
logger.info({ name: result.name, entryPath: result.filePath, source: 'chat' }, 'Skill created via chat');
|
|
101
69
|
const toolsLine = (tools && tools.length > 0) ? `\nAllowed tools: ${tools.slice(0, 5).join(', ')}${tools.length > 5 ? `, +${tools.length - 5} more` : ''}` : '';
|
|
102
70
|
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 ${
|
|
71
|
+
return textResult(`✅ Created skill "${result.name}" at ${result.filePath}\n` +
|
|
104
72
|
`Description: ${description.slice(0, 200)}${description.length > 200 ? '…' : ''}` +
|
|
105
73
|
toolsLine +
|
|
106
74
|
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."`);
|
|
75
|
+
`\n\nThe skill is ready to pin to any task — open the cron editor, go to Tools & MCP, click "+ Add skill" and select "${result.name}". Or invoke it directly in chat: "Run the ${title || result.name} skill."`);
|
|
108
76
|
}
|
|
109
77
|
catch (err) {
|
|
78
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
if (msg.includes('already exists')) {
|
|
80
|
+
return textResult(`❌ Skill "${name}" already exists. Use update_skill instead, or pick a different name.`);
|
|
81
|
+
}
|
|
110
82
|
logger.error({ err, name }, 'create_skill failed');
|
|
111
|
-
return textResult(`❌
|
|
83
|
+
return textResult(`❌ ${msg}`);
|
|
112
84
|
}
|
|
113
85
|
});
|
|
114
86
|
// ── update_skill ────────────────────────────────────────────────────
|