clementine-agent 1.18.119 → 1.18.121
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 +12 -0
- package/dist/agent/run-agent-cron.js +77 -4
- package/dist/agent/run-agent.d.ts +7 -0
- package/dist/agent/run-agent.js +8 -0
- package/dist/agent/skill-extractor.d.ts +0 -9
- package/dist/agent/skill-extractor.js +0 -30
- package/dist/agent/skill-store.d.ts +0 -5
- package/dist/agent/skill-store.js +0 -4
- package/dist/cli/dashboard.js +4 -148
- package/dist/cli/index.js +30 -10
- package/dist/gateway/cron-scheduler.js +2 -2
- package/dist/gateway/router.d.ts +5 -1
- package/dist/gateway/router.js +6 -1
- package/dist/tools/mcp-server.js +2 -0
- package/dist/tools/skill-tools.d.ts +22 -0
- package/dist/tools/skill-tools.js +243 -0
- package/package.json +1 -2
|
@@ -70,6 +70,7 @@ export interface SkillContextResult {
|
|
|
70
70
|
*/
|
|
71
71
|
export declare function buildSkillContext(jobName: string, jobPrompt: string, agentSlug: string | undefined, pinnedSkills: string[] | undefined, memoryStore?: MemoryStore | null, opts?: {
|
|
72
72
|
skipAutoMatch?: boolean;
|
|
73
|
+
projectWorkDir?: string;
|
|
73
74
|
}): Promise<SkillContextResult>;
|
|
74
75
|
/** Minimal interface for the post-task reflection + skill extraction
|
|
75
76
|
* hooks. Lets `runAgentCron` stay decoupled from the full
|
|
@@ -128,6 +129,11 @@ export interface RunAgentCronOptions {
|
|
|
128
129
|
* prior progress. The fix for fire-time memory drift. Undefined =
|
|
129
130
|
* legacy behavior (inject everything). */
|
|
130
131
|
predictable?: boolean;
|
|
132
|
+
/** Extra read+execute scope for the agent's Read/Bash/Glob tools. Maps
|
|
133
|
+
* directly to the CronJobDefinition.addDirs YAML field. Combined with
|
|
134
|
+
* every pinned-skill folder so `Bash python3 scripts/render.py` works
|
|
135
|
+
* from inside a skill bundle without the cwd being set there. */
|
|
136
|
+
addDirs?: string[];
|
|
131
137
|
}
|
|
132
138
|
export interface RunAgentCronResult extends RunAgentResult {
|
|
133
139
|
/** The final prompt that was sent to the agent (after context injection).
|
|
@@ -184,6 +190,12 @@ export interface CronExecutionPlan {
|
|
|
184
190
|
* MEMORY.md / team / delegation / auto-skills were intentionally
|
|
185
191
|
* skipped. Used by the Preview verdict line. */
|
|
186
192
|
predictable: boolean;
|
|
193
|
+
/** Merged list of extra directories the SDK should expose to the agent's
|
|
194
|
+
* Read/Bash/Glob tools. Combines `opts.addDirs` with every pinned-skill
|
|
195
|
+
* folder so a skill's `scripts/render.py` is reachable without the cwd
|
|
196
|
+
* being set inside the skill folder. Deduped + filtered to existing
|
|
197
|
+
* paths. Empty when the trick has no addDirs and no folder-form pins. */
|
|
198
|
+
additionalDirectories: string[];
|
|
187
199
|
}
|
|
188
200
|
/**
|
|
189
201
|
* Plan a cron run — assemble all context, resolve skills, intersect tool/MCP
|
|
@@ -245,14 +245,42 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
|
|
|
245
245
|
: (suppressedNamesRaw ?? undefined);
|
|
246
246
|
const prepared = [];
|
|
247
247
|
const seen = new Set();
|
|
248
|
-
// 1. Load pinned skills first via exact slug lookup.
|
|
248
|
+
// 1. Load pinned skills first via exact slug lookup. When the cron has
|
|
249
|
+
// a workDir set, we ALSO check for a project-scoped skill at
|
|
250
|
+
// <workDir>/.clementine/skills/<name>/SKILL.md before falling back
|
|
251
|
+
// to the global lookup. This closes the SDK-alignment gap from the
|
|
252
|
+
// 1.18.121 audit (project skills were silently unreachable from the
|
|
253
|
+
// cron runtime even though skill-store.getSkill supported them).
|
|
249
254
|
if (pinnedSkills?.length) {
|
|
255
|
+
const projectGetSkill = opts?.projectWorkDir
|
|
256
|
+
? (await import('./skill-store.js')).getSkill
|
|
257
|
+
: null;
|
|
250
258
|
for (const pinName of pinnedSkills) {
|
|
251
259
|
if (seen.has(pinName))
|
|
252
260
|
continue;
|
|
253
261
|
if (prepared.length >= MAX_INJECTED_SKILLS)
|
|
254
262
|
break;
|
|
255
|
-
|
|
263
|
+
// Project-scoped first when a workDir is in scope. The skill-store
|
|
264
|
+
// shape differs from the runtime's SkillMatch — adapt it here so
|
|
265
|
+
// the rest of the pipeline doesn't care which loader returned it.
|
|
266
|
+
let skill = null;
|
|
267
|
+
if (projectGetSkill && opts?.projectWorkDir) {
|
|
268
|
+
const ps = projectGetSkill(pinName, { projectWorkDir: opts.projectWorkDir });
|
|
269
|
+
if (ps && ps.scope === 'project') {
|
|
270
|
+
const ext = (ps.frontmatter.clementine ?? {});
|
|
271
|
+
const tools = ext.tools?.allow ?? [];
|
|
272
|
+
skill = {
|
|
273
|
+
name: ps.frontmatter.name,
|
|
274
|
+
title: String(ps.frontmatter.title ?? ps.frontmatter.name),
|
|
275
|
+
content: ps.body,
|
|
276
|
+
toolsUsed: Array.isArray(tools) ? tools.map(String) : [],
|
|
277
|
+
attachments: [],
|
|
278
|
+
skillDir: path.dirname(path.dirname(ps.filePath)),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (!skill)
|
|
283
|
+
skill = loadSkillByName(pinName, agentSlug, { suppressedNames });
|
|
256
284
|
if (!skill) {
|
|
257
285
|
missing.push(pinName);
|
|
258
286
|
logger.warn({ jobName, pin: pinName, agentSlug }, 'cron: pinned skill not found');
|
|
@@ -403,7 +431,7 @@ export async function buildCronExecutionPlan(opts) {
|
|
|
403
431
|
const delegationContext = predictable ? '' : buildDelegationContext(agentSlug);
|
|
404
432
|
const teamContext = predictable ? '' : buildTeamContext(agentSlug);
|
|
405
433
|
const criteriaContext = buildCriteriaContext(opts.successCriteria);
|
|
406
|
-
const skillResult = await buildSkillContext(opts.jobName, opts.jobPrompt, agentSlug, opts.pinnedSkills, opts.memoryStore, { skipAutoMatch: predictable });
|
|
434
|
+
const skillResult = await buildSkillContext(opts.jobName, opts.jobPrompt, agentSlug, opts.pinnedSkills, opts.memoryStore, { skipAutoMatch: predictable, projectWorkDir: opts.workDir });
|
|
407
435
|
const skillContext = skillResult.text;
|
|
408
436
|
const howToRespond = `## How to respond\n` +
|
|
409
437
|
`You're sending this directly to ${ownerName} as a DM. ` +
|
|
@@ -448,6 +476,46 @@ export async function buildCronExecutionPlan(opts) {
|
|
|
448
476
|
const configuredCap = tier >= 2 ? BUDGET.cronT2 : BUDGET.cronT1;
|
|
449
477
|
const maxBudget = opts.maxBudgetUsd ?? (configuredCap > 0 ? configuredCap : undefined);
|
|
450
478
|
const effort = tier >= 2 ? 'high' : 'medium';
|
|
479
|
+
// 1.18.121 — assemble additionalDirectories. Combines:
|
|
480
|
+
// 1. opts.addDirs (from CronJobDefinition.addDirs YAML field)
|
|
481
|
+
// 2. Every pinned-skill folder so the skill's scripts/ + reference docs
|
|
482
|
+
// are reachable via Read/Bash without the cwd being set inside the
|
|
483
|
+
// skill folder.
|
|
484
|
+
// Deduped via Set; filtered to paths that actually exist on disk so we
|
|
485
|
+
// don't trigger SDK errors on stale references.
|
|
486
|
+
const dirSet = new Set();
|
|
487
|
+
for (const d of opts.addDirs ?? []) {
|
|
488
|
+
if (d && typeof d === 'string')
|
|
489
|
+
dirSet.add(d);
|
|
490
|
+
}
|
|
491
|
+
for (const applied of skillResult.applied) {
|
|
492
|
+
// skillResult.applied lacks the on-disk path; pull it from the prepared
|
|
493
|
+
// skill list (which we have in scope as a closure via the skillContext
|
|
494
|
+
// builder). Cheaper to reconstruct: every pinned-form skill lives at
|
|
495
|
+
// `<skillsRoot>/<name>/SKILL.md` and we want to expose `<skillsRoot>/<name>/`.
|
|
496
|
+
// Walk both global + agent-scoped roots; first hit wins.
|
|
497
|
+
const candidates = [
|
|
498
|
+
path.join(VAULT_DIR, '00-System', 'skills', applied.name),
|
|
499
|
+
];
|
|
500
|
+
if (agentSlug) {
|
|
501
|
+
candidates.unshift(path.join(VAULT_DIR, '00-System', 'agents', agentSlug, 'skills', applied.name));
|
|
502
|
+
}
|
|
503
|
+
for (const candidate of candidates) {
|
|
504
|
+
if (fs.existsSync(path.join(candidate, 'SKILL.md'))) {
|
|
505
|
+
dirSet.add(candidate);
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Final filter: only emit dirs that exist (the SDK errors on missing).
|
|
511
|
+
const additionalDirectories = [...dirSet].filter(d => {
|
|
512
|
+
try {
|
|
513
|
+
return fs.statSync(d).isDirectory();
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
});
|
|
451
519
|
return {
|
|
452
520
|
builtPrompt,
|
|
453
521
|
contextBlocks: {
|
|
@@ -468,6 +536,7 @@ export async function buildCronExecutionPlan(opts) {
|
|
|
468
536
|
agentSlug,
|
|
469
537
|
ownerName,
|
|
470
538
|
predictable,
|
|
539
|
+
additionalDirectories,
|
|
471
540
|
};
|
|
472
541
|
}
|
|
473
542
|
/**
|
|
@@ -483,7 +552,7 @@ export async function buildCronExecutionPlan(opts) {
|
|
|
483
552
|
*/
|
|
484
553
|
export async function runAgentCron(opts) {
|
|
485
554
|
const plan = await buildCronExecutionPlan(opts);
|
|
486
|
-
const { builtPrompt, agentSlug, effort, maxBudgetUsd: maxBudget, effectiveAllowedTools, mcpServerMap, composioConnected, externalConnected, mcpServersApplied, } = plan;
|
|
555
|
+
const { builtPrompt, agentSlug, effort, maxBudgetUsd: maxBudget, effectiveAllowedTools, mcpServerMap, composioConnected, externalConnected, mcpServersApplied, additionalDirectories, } = plan;
|
|
487
556
|
logger.info({
|
|
488
557
|
job: opts.jobName,
|
|
489
558
|
tier: plan.tier,
|
|
@@ -511,6 +580,10 @@ export async function runAgentCron(opts) {
|
|
|
511
580
|
abortSignal: opts.abortSignal,
|
|
512
581
|
...(effectiveAllowedTools ? { allowedTools: effectiveAllowedTools } : {}),
|
|
513
582
|
extraMcpServers: mcpServerMap,
|
|
583
|
+
// 1.18.121 — pipe the merged addDirs+pinned-skill folders to the SDK
|
|
584
|
+
// so a skill's bundled scripts/templates are reachable via Bash/Read
|
|
585
|
+
// without making the cwd the skill folder.
|
|
586
|
+
...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
|
|
514
587
|
});
|
|
515
588
|
// Mirror the run into transcripts so future chat recall can see it.
|
|
516
589
|
// Legacy runCronJob did this with role='cron'; canonical needs the
|
|
@@ -81,6 +81,13 @@ export interface RunAgentOptions {
|
|
|
81
81
|
allowedTools?: string[];
|
|
82
82
|
/** Optional CLAUDE.md / project setting source. Defaults to ['project']. */
|
|
83
83
|
settingSources?: ('project' | 'user' | 'local')[];
|
|
84
|
+
/** Extra directories the SDK should make available to the agent's tools
|
|
85
|
+
* (Read/Bash/Glob/Grep) beyond `cwd`. Maps directly to the SDK's
|
|
86
|
+
* `additionalDirectories` option. The cron runtime uses this to surface
|
|
87
|
+
* pinned-skill folders so the agent can `Bash python3 scripts/render.py`
|
|
88
|
+
* inside a skill bundle without the cwd being set to that folder.
|
|
89
|
+
* Captured in CronJobDefinition.addDirs and piped through buildPrompt. */
|
|
90
|
+
additionalDirectories?: string[];
|
|
84
91
|
/** Additional MCP servers to merge with the always-on clementine-tools
|
|
85
92
|
* server. Use to wire Composio + claude.ai integrations on chat-path
|
|
86
93
|
* invocations that need Outlook/Salesforce/etc. */
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -299,6 +299,14 @@ export async function runAgent(prompt, opts) {
|
|
|
299
299
|
...(opts.model ? { model: opts.model } : {}),
|
|
300
300
|
...(opts.resumeSessionId ? { resume: opts.resumeSessionId } : {}),
|
|
301
301
|
...(sdkAbortController ? { abortController: sdkAbortController } : {}),
|
|
302
|
+
// 1.18.121 — pipe additionalDirectories through to the SDK so agents
|
|
303
|
+
// can Read / Bash inside pinned-skill folders, project-scoped skills,
|
|
304
|
+
// and any cron's add_dirs scope without their cwd being set to those
|
|
305
|
+
// folders. Was captured in CronJobDefinition.addDirs since 1.18.77 but
|
|
306
|
+
// never reached the SDK call site — this closes that gap.
|
|
307
|
+
...(opts.additionalDirectories && opts.additionalDirectories.length > 0
|
|
308
|
+
? { additionalDirectories: opts.additionalDirectories }
|
|
309
|
+
: {}),
|
|
302
310
|
};
|
|
303
311
|
const sdkOptions = normalizeClaudeSdkOptionsForOneMillionContext(sdkOptionsRaw);
|
|
304
312
|
logger.info({
|
|
@@ -79,15 +79,6 @@ export declare function loadSkillByName(name: string, agentSlug?: string, opts?:
|
|
|
79
79
|
* `clementine.useCount` (Anthropic-canonical frontmatter keeps top-level
|
|
80
80
|
* reserved for `name`/`description`). */
|
|
81
81
|
export declare function recordSkillUse(skillName: string, agentSlug?: string): void;
|
|
82
|
-
/** List all active skills (global + all agent-scoped). */
|
|
83
|
-
export declare function listSkills(agentSlug?: string): Array<{
|
|
84
|
-
name: string;
|
|
85
|
-
title: string;
|
|
86
|
-
source: string;
|
|
87
|
-
useCount: number;
|
|
88
|
-
updatedAt: string;
|
|
89
|
-
agentSlug?: string;
|
|
90
|
-
}>;
|
|
91
82
|
/**
|
|
92
83
|
* Move skills that were never used (useCount=0, no usage telemetry rows) and
|
|
93
84
|
* are older than `olderThanDays` to the `.archive/` subdirectory inside their
|
|
@@ -667,36 +667,6 @@ export function recordSkillUse(skillName, agentSlug) {
|
|
|
667
667
|
}
|
|
668
668
|
catch { /* non-fatal */ }
|
|
669
669
|
}
|
|
670
|
-
/** List all active skills (global + all agent-scoped). */
|
|
671
|
-
export function listSkills(agentSlug) {
|
|
672
|
-
const results = [];
|
|
673
|
-
const dirs = [];
|
|
674
|
-
if (agentSlug) {
|
|
675
|
-
dirs.push({ dir: agentSkillsDir(agentSlug), slug: agentSlug });
|
|
676
|
-
}
|
|
677
|
-
else {
|
|
678
|
-
dirs.push({ dir: GLOBAL_SKILLS_DIR });
|
|
679
|
-
}
|
|
680
|
-
for (const { dir, slug } of dirs) {
|
|
681
|
-
if (!existsSync(dir))
|
|
682
|
-
continue;
|
|
683
|
-
for (const f of readdirSync(dir).filter(f => f.endsWith('.md'))) {
|
|
684
|
-
try {
|
|
685
|
-
const parsed = matter(readFileSync(path.join(dir, f), 'utf-8'));
|
|
686
|
-
results.push({
|
|
687
|
-
name: f.replace('.md', ''),
|
|
688
|
-
title: parsed.data.title ?? f,
|
|
689
|
-
source: parsed.data.source ?? 'unknown',
|
|
690
|
-
useCount: parsed.data.useCount ?? 0,
|
|
691
|
-
updatedAt: parsed.data.updatedAt ?? '',
|
|
692
|
-
agentSlug: slug,
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
catch { /* skip */ }
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
return results;
|
|
699
|
-
}
|
|
700
670
|
// ── Stale skill archival ────────────────────────────────────────────
|
|
701
671
|
/**
|
|
702
672
|
* Move skills that were never used (useCount=0, no usage telemetry rows) and
|
|
@@ -77,10 +77,5 @@ export declare function migrateAllLegacySkills(): {
|
|
|
77
77
|
migrated: MigrationResult[];
|
|
78
78
|
skipped: MigrationResult[];
|
|
79
79
|
};
|
|
80
|
-
/** Diagnostics for the dashboard — expose where the loader looked. */
|
|
81
|
-
export declare function _skillDirsForDiagnostics(workDir?: string): {
|
|
82
|
-
global: string;
|
|
83
|
-
project: string | null;
|
|
84
|
-
};
|
|
85
80
|
export {};
|
|
86
81
|
//# sourceMappingURL=skill-store.d.ts.map
|
|
@@ -647,8 +647,4 @@ export function migrateAllLegacySkills() {
|
|
|
647
647
|
}
|
|
648
648
|
return { migrated, skipped };
|
|
649
649
|
}
|
|
650
|
-
/** Diagnostics for the dashboard — expose where the loader looked. */
|
|
651
|
-
export function _skillDirsForDiagnostics(workDir) {
|
|
652
|
-
return { global: globalSkillsDir(), project: projectSkillsDir(workDir) ?? null };
|
|
653
|
-
}
|
|
654
650
|
//# sourceMappingURL=skill-store.js.map
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -32709,7 +32709,7 @@ async function deleteSkillFromStudio(name) {
|
|
|
32709
32709
|
await apiDelete('/api/skills/' + encodeURIComponent(name));
|
|
32710
32710
|
toast('Skill deleted', 'success');
|
|
32711
32711
|
refreshBuilderSkills();
|
|
32712
|
-
|
|
32712
|
+
refreshSkillsPage(); // also refresh the automations tab list
|
|
32713
32713
|
} catch(e) { toast('Error: ' + e, 'error'); }
|
|
32714
32714
|
}
|
|
32715
32715
|
|
|
@@ -33093,7 +33093,7 @@ async function saveBuilderArtifact() {
|
|
|
33093
33093
|
msgs.innerHTML += '<div style="text-align:center;padding:12px;color:var(--green);font-size:13px;font-weight:600">\\u2714 ' + esc(r.message || 'Saved') + '</div>';
|
|
33094
33094
|
msgs.scrollTop = msgs.scrollHeight;
|
|
33095
33095
|
document.getElementById('builder-save-btn').style.display = 'none';
|
|
33096
|
-
if (type === 'skill') {
|
|
33096
|
+
if (type === 'skill') { refreshSkillsPage(); refreshBuilderSkills(); }
|
|
33097
33097
|
if (type === 'cron') refreshCron();
|
|
33098
33098
|
if (type === 'agent') refreshTeamNav();
|
|
33099
33099
|
if (type === 'workflow') refreshWorkflows();
|
|
@@ -37299,7 +37299,7 @@ async function saveSkill() {
|
|
|
37299
37299
|
document.getElementById('skill-description').value = '';
|
|
37300
37300
|
document.getElementById('skill-triggers').value = '';
|
|
37301
37301
|
document.getElementById('skill-steps').value = '';
|
|
37302
|
-
|
|
37302
|
+
refreshSkillsPage();
|
|
37303
37303
|
} else {
|
|
37304
37304
|
toast('Failed: ' + (r.error || 'unknown'), 'error');
|
|
37305
37305
|
}
|
|
@@ -37311,7 +37311,7 @@ async function deleteSkill(name) {
|
|
|
37311
37311
|
try {
|
|
37312
37312
|
await apiDelete('/api/skills/' + encodeURIComponent(name));
|
|
37313
37313
|
toast('Skill deleted', 'success');
|
|
37314
|
-
|
|
37314
|
+
refreshSkillsPage();
|
|
37315
37315
|
} catch(e) { toast('Error: ' + e, 'error'); }
|
|
37316
37316
|
}
|
|
37317
37317
|
|
|
@@ -37643,152 +37643,8 @@ async function refreshBrokenJobs() {
|
|
|
37643
37643
|
}
|
|
37644
37644
|
}
|
|
37645
37645
|
|
|
37646
|
-
async function refreshPendingSkills() {
|
|
37647
|
-
try {
|
|
37648
|
-
var r = await apiFetch('/api/skills/pending');
|
|
37649
|
-
var d = await r.json();
|
|
37650
|
-
var pending = d.skills || [];
|
|
37651
|
-
var tabBadge = document.getElementById('tab-pending-skill-count');
|
|
37652
|
-
if (tabBadge) {
|
|
37653
|
-
tabBadge.textContent = String(pending.length);
|
|
37654
|
-
tabBadge.style.display = pending.length > 0 ? '' : 'none';
|
|
37655
|
-
}
|
|
37656
|
-
var card = document.getElementById('pending-skills-card');
|
|
37657
|
-
var countBadge = document.getElementById('pending-skills-count-badge');
|
|
37658
|
-
var container = document.getElementById('panel-pending-skills');
|
|
37659
|
-
if (!container) return;
|
|
37660
|
-
if (pending.length === 0) {
|
|
37661
|
-
if (card) card.style.display = 'none';
|
|
37662
|
-
container.innerHTML = '';
|
|
37663
|
-
return;
|
|
37664
|
-
}
|
|
37665
|
-
if (card) card.style.display = '';
|
|
37666
|
-
if (countBadge) countBadge.textContent = pending.length + ' pending';
|
|
37667
37646
|
|
|
37668
|
-
var html = '<div style="display:flex;flex-direction:column;gap:10px">';
|
|
37669
|
-
for (var s of pending) {
|
|
37670
|
-
var sourceTag = s.source === 'cron' ? '<span class="badge badge-green" style="font-size:10px">cron</span>'
|
|
37671
|
-
: s.source === 'unleashed' ? '<span class="badge badge-purple" style="font-size:10px">unleashed</span>'
|
|
37672
|
-
: s.source === 'chat' ? '<span class="badge badge-blue" style="font-size:10px">chat</span>'
|
|
37673
|
-
: '<span class="badge badge-gray" style="font-size:10px">' + esc(s.source || 'unknown') + '</span>';
|
|
37674
|
-
var age = s.createdAt ? timeAgo(s.createdAt) : '';
|
|
37675
|
-
var scopeTag = s.agentSlug
|
|
37676
|
-
? '<span style="font-size:10px;color:var(--text-muted)">for ' + esc(s.agentSlug) + '</span>'
|
|
37677
|
-
: '<span style="font-size:10px;color:var(--text-muted)">global</span>';
|
|
37678
|
-
html += '<div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">'
|
|
37679
|
-
+ '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
|
|
37680
|
-
+ '<strong>' + esc(s.title) + '</strong> ' + sourceTag + ' ' + scopeTag
|
|
37681
|
-
+ (age ? ' <span style="font-size:10px;color:var(--text-muted)">\\u00b7 learned ' + age + '</span>' : '')
|
|
37682
|
-
+ '<span style="margin-left:auto;display:flex;gap:6px">'
|
|
37683
|
-
+ '<button onclick="approvePendingSkill(\\x27' + esc(s.name) + '\\x27)" style="background:var(--accent);border:1px solid var(--accent);border-radius:4px;padding:3px 10px;font-size:11px;color:white;cursor:pointer">Approve</button>'
|
|
37684
|
-
+ '<button onclick="rejectPendingSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:3px 10px;font-size:11px;color:var(--red);cursor:pointer">Reject</button>'
|
|
37685
|
-
+ '</span>'
|
|
37686
|
-
+ '</div>'
|
|
37687
|
-
+ '<div style="font-size:12px;color:var(--text-secondary)">' + esc(s.description || '') + '</div>'
|
|
37688
|
-
+ '</div>';
|
|
37689
|
-
}
|
|
37690
|
-
html += '</div>';
|
|
37691
|
-
container.innerHTML = html;
|
|
37692
|
-
} catch(e) { /* non-fatal */ }
|
|
37693
|
-
}
|
|
37694
|
-
|
|
37695
|
-
async function approvePendingSkill(name) {
|
|
37696
|
-
try {
|
|
37697
|
-
var r = await apiJson('POST', '/api/skills/pending/' + encodeURIComponent(name) + '/approve', {});
|
|
37698
|
-
if (r && r.ok) {
|
|
37699
|
-
toast(r.message || 'Skill approved', 'success');
|
|
37700
|
-
refreshPendingSkills();
|
|
37701
|
-
refreshSkills();
|
|
37702
|
-
} else {
|
|
37703
|
-
toast((r && r.message) || 'Failed to approve', 'error');
|
|
37704
|
-
}
|
|
37705
|
-
} catch(e) { toast('Failed to approve skill', 'error'); }
|
|
37706
|
-
}
|
|
37707
37647
|
|
|
37708
|
-
async function rejectPendingSkill(name) {
|
|
37709
|
-
if (!confirm('Reject this pending skill? It will be deleted.')) return;
|
|
37710
|
-
try {
|
|
37711
|
-
var r = await apiJson('POST', '/api/skills/pending/' + encodeURIComponent(name) + '/reject', {});
|
|
37712
|
-
if (r && r.ok) {
|
|
37713
|
-
toast(r.message || 'Skill rejected', 'success');
|
|
37714
|
-
refreshPendingSkills();
|
|
37715
|
-
} else {
|
|
37716
|
-
toast((r && r.message) || 'Failed to reject', 'error');
|
|
37717
|
-
}
|
|
37718
|
-
} catch(e) { toast('Failed to reject skill', 'error'); }
|
|
37719
|
-
}
|
|
37720
|
-
|
|
37721
|
-
async function refreshSkills() {
|
|
37722
|
-
refreshPendingSkills();
|
|
37723
|
-
try {
|
|
37724
|
-
var r = await apiFetch('/api/skills');
|
|
37725
|
-
var d = await r.json();
|
|
37726
|
-
var skills = d.skills || [];
|
|
37727
|
-
var badge = document.getElementById('tab-skill-count');
|
|
37728
|
-
var countBadge = document.getElementById('skill-count-badge');
|
|
37729
|
-
if (badge) { badge.textContent = skills.length || ''; badge.style.display = skills.length > 0 ? '' : 'none'; }
|
|
37730
|
-
if (countBadge) countBadge.textContent = skills.length + ' skill' + (skills.length !== 1 ? 's' : '');
|
|
37731
|
-
var navSkillBadge = document.getElementById('nav-skill-count');
|
|
37732
|
-
if (navSkillBadge) { navSkillBadge.textContent = skills.length || ''; navSkillBadge.style.display = skills.length > 0 ? '' : 'none'; }
|
|
37733
|
-
var container = document.getElementById('panel-skills');
|
|
37734
|
-
if (!container) return;
|
|
37735
|
-
if (skills.length === 0) {
|
|
37736
|
-
container.innerHTML = '<div class="empty-state">No skills learned yet. Skills are auto-extracted from successful tasks or taught manually above.</div>';
|
|
37737
|
-
return;
|
|
37738
|
-
}
|
|
37739
|
-
var html = '<div style="display:flex;flex-direction:column;gap:12px">';
|
|
37740
|
-
for (var s of skills) {
|
|
37741
|
-
var sourceTag = s.source === 'manual' ? '<span class="badge badge-blue" style="font-size:10px">taught</span>'
|
|
37742
|
-
: s.source === 'cron' ? '<span class="badge badge-green" style="font-size:10px">cron</span>'
|
|
37743
|
-
: s.source === 'unleashed' ? '<span class="badge badge-purple" style="font-size:10px">unleashed</span>'
|
|
37744
|
-
: '<span class="badge badge-gray" style="font-size:10px">' + esc(s.source) + '</span>';
|
|
37745
|
-
var allTriggers = s.triggers || [];
|
|
37746
|
-
var shownTriggers = allTriggers.slice(0, 5).map(function(t) { return '<code style="font-size:11px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px">' + esc(t) + '</code>'; }).join(' ');
|
|
37747
|
-
var triggers = shownTriggers + (allTriggers.length > 5 ? ' <span style="font-size:11px;color:var(--text-muted)">+' + (allTriggers.length - 5) + ' more</span>' : '');
|
|
37748
|
-
var age = s.updatedAt ? timeAgo(s.updatedAt) : '';
|
|
37749
|
-
// Source context for auto-extracted skills
|
|
37750
|
-
var sourceCtx = '';
|
|
37751
|
-
if (s.sourceJob) sourceCtx += '<span style="font-size:10px;color:var(--text-muted)">from ' + esc(s.sourceJob) + '</span>';
|
|
37752
|
-
if (s.createdAt && (s.source === 'cron' || s.source === 'unleashed')) {
|
|
37753
|
-
sourceCtx += '<span style="font-size:10px;color:var(--text-muted)">' + (sourceCtx ? ' \\u00b7 ' : '') + 'learned ' + timeAgo(s.createdAt) + '</span>';
|
|
37754
|
-
}
|
|
37755
|
-
// Tools used pills
|
|
37756
|
-
var toolsPills = '';
|
|
37757
|
-
if (s.toolsUsed && s.toolsUsed.length > 0) {
|
|
37758
|
-
var shownTools = s.toolsUsed.slice(0, 4).map(function(t) {
|
|
37759
|
-
return '<span style="font-size:10px;background:var(--accent)15;color:var(--accent);padding:1px 6px;border-radius:3px;border:1px solid var(--accent)30">' + esc(t) + '</span>';
|
|
37760
|
-
}).join(' ');
|
|
37761
|
-
toolsPills = '<div style="display:flex;gap:3px;flex-wrap:wrap;margin-top:4px">'
|
|
37762
|
-
+ shownTools
|
|
37763
|
-
+ (s.toolsUsed.length > 4 ? ' <span style="font-size:10px;color:var(--text-muted)">+' + (s.toolsUsed.length - 4) + '</span>' : '')
|
|
37764
|
-
+ '</div>';
|
|
37765
|
-
}
|
|
37766
|
-
var retrieval7d = (typeof s.retrievals7d === 'number' && s.retrievals7d > 0)
|
|
37767
|
-
? ' \\u00b7 ' + s.retrievals7d + ' retrievals (7d)'
|
|
37768
|
-
: '';
|
|
37769
|
-
html += '<div id="skill-card-' + esc(s.name) + '" style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">'
|
|
37770
|
-
+ '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">'
|
|
37771
|
-
+ '<strong style="cursor:pointer" onclick="expandSkill(\\x27' + esc(s.name) + '\\x27)">' + esc(s.title) + '</strong> ' + sourceTag
|
|
37772
|
-
+ (sourceCtx ? ' ' + sourceCtx : '')
|
|
37773
|
-
+ '<span style="margin-left:auto;display:flex;align-items:center;gap:8px">'
|
|
37774
|
-
+ '<span style="font-size:11px;color:var(--text-muted)">used ' + s.useCount + 'x' + (age ? ' \\u00b7 ' + age : '') + retrieval7d + '</span>'
|
|
37775
|
-
+ '<button onclick="expandSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--text-secondary);cursor:pointer">View</button>'
|
|
37776
|
-
+ '<button onclick="editSkillInBuilder(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--accent);cursor:pointer">Edit</button>'
|
|
37777
|
-
+ '<button onclick="deleteSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--red);cursor:pointer">Delete</button>'
|
|
37778
|
-
+ '</span>'
|
|
37779
|
-
+ '</div>'
|
|
37780
|
-
+ '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px">' + esc(s.description) + '</div>'
|
|
37781
|
-
+ (triggers ? '<div style="display:flex;gap:4px;flex-wrap:wrap">' + triggers + '</div>' : '')
|
|
37782
|
-
+ toolsPills
|
|
37783
|
-
+ '</div>';
|
|
37784
|
-
}
|
|
37785
|
-
html += '</div>';
|
|
37786
|
-
container.innerHTML = html;
|
|
37787
|
-
} catch(e) {
|
|
37788
|
-
var c = document.getElementById('panel-skills');
|
|
37789
|
-
if (c) c.innerHTML = '<div class="empty-state" style="color:var(--red)">Failed to load skills</div>';
|
|
37790
|
-
}
|
|
37791
|
-
}
|
|
37792
37648
|
|
|
37793
37649
|
// ── Agent-scoped Skills (Training tab) ──
|
|
37794
37650
|
function toggleAgentTeachSkill() {
|
package/dist/cli/index.js
CHANGED
|
@@ -2728,27 +2728,47 @@ skillsCmd
|
|
|
2728
2728
|
const RESET = '\x1b[0m';
|
|
2729
2729
|
try {
|
|
2730
2730
|
process.env.CLEMENTINE_HOME = BASE_DIR;
|
|
2731
|
-
|
|
2732
|
-
|
|
2731
|
+
// 1.18.121 — switched from skill-extractor (flat-only loader, broken
|
|
2732
|
+
// since the 1.18.110 folder-form migration so this command was hiding
|
|
2733
|
+
// every migrated skill) to skill-store, which walks both layouts and
|
|
2734
|
+
// reads the canonical Anthropic frontmatter.
|
|
2735
|
+
const { listSkills } = await import('../agent/skill-store.js');
|
|
2736
|
+
const skills = listSkills();
|
|
2737
|
+
if (opts.agent) {
|
|
2738
|
+
console.error(` ${DIM}Note: --agent filter is informational only since the 1.19 catalog. Showing all skills.${RESET}`);
|
|
2739
|
+
}
|
|
2733
2740
|
if (opts.json) {
|
|
2734
|
-
console.log(JSON.stringify(skills
|
|
2741
|
+
console.log(JSON.stringify(skills.map(s => ({
|
|
2742
|
+
name: s.frontmatter.name,
|
|
2743
|
+
title: s.frontmatter.title ?? s.frontmatter.name,
|
|
2744
|
+
description: s.frontmatter.description ?? '',
|
|
2745
|
+
layout: s.layout,
|
|
2746
|
+
schemaVersion: s.schemaVersion,
|
|
2747
|
+
scope: s.scope,
|
|
2748
|
+
useCount: (s.frontmatter.clementine?.useCount ?? s.frontmatter.useCount ?? 0),
|
|
2749
|
+
updatedAt: (s.frontmatter.clementine?.updatedAt ?? ''),
|
|
2750
|
+
filePath: s.filePath,
|
|
2751
|
+
})), null, 2));
|
|
2735
2752
|
return;
|
|
2736
2753
|
}
|
|
2737
2754
|
if (skills.length === 0) {
|
|
2738
2755
|
console.log();
|
|
2739
|
-
console.log(` ${DIM}No
|
|
2740
|
-
console.log(`
|
|
2741
|
-
console.log(`
|
|
2756
|
+
console.log(` ${DIM}No skills found.${RESET}`);
|
|
2757
|
+
console.log(` Author one in chat ("Hey Clemmy, create a skill called X that…")`);
|
|
2758
|
+
console.log(` or in the dashboard's Skills page (${BOLD}clementine dashboard${RESET}).`);
|
|
2742
2759
|
console.log();
|
|
2743
2760
|
return;
|
|
2744
2761
|
}
|
|
2745
2762
|
console.log();
|
|
2746
|
-
console.log(` ${BOLD}${'NAME'.padEnd(
|
|
2763
|
+
console.log(` ${BOLD}${'NAME'.padEnd(40)}${'LAYOUT'.padEnd(8)}${'USES'.padEnd(8)}${'UPDATED'}${RESET}`);
|
|
2747
2764
|
console.log(` ${DIM}${'─'.repeat(80)}${RESET}`);
|
|
2748
2765
|
for (const s of skills) {
|
|
2749
|
-
const
|
|
2750
|
-
const
|
|
2751
|
-
|
|
2766
|
+
const ext = s.frontmatter.clementine ?? {};
|
|
2767
|
+
const useCount = ext.useCount ?? s.frontmatter.useCount ?? 0;
|
|
2768
|
+
const rawUpdated = ext.updatedAt ?? '';
|
|
2769
|
+
const updated = String(rawUpdated).slice(0, 10);
|
|
2770
|
+
const layoutLabel = s.layout === 'folder' ? 'folder' : 'flat';
|
|
2771
|
+
console.log(` ${s.frontmatter.name.slice(0, 38).padEnd(40)}${CYAN}${layoutLabel.padEnd(8)}${RESET}${String(useCount).padEnd(8)}${DIM}${updated}${RESET}`);
|
|
2752
2772
|
}
|
|
2753
2773
|
console.log();
|
|
2754
2774
|
console.log(` ${DIM}Total: ${skills.length} skill${skills.length === 1 ? '' : 's'}.${RESET}`);
|
|
@@ -1185,12 +1185,12 @@ export class CronScheduler {
|
|
|
1185
1185
|
const startedAt = new Date();
|
|
1186
1186
|
try {
|
|
1187
1187
|
// Standard cron jobs get a timeout via SDK AbortController (advisor may override)
|
|
1188
|
-
let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers, job.predictable);
|
|
1188
|
+
let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers, job.predictable, job.addDirs);
|
|
1189
1189
|
// alwaysDeliver: retry once if the response is empty/noise
|
|
1190
1190
|
if (job.alwaysDeliver && (!response || CronScheduler.isCronNoise(response))) {
|
|
1191
1191
|
logger.info({ job: job.name }, 'alwaysDeliver: empty/noise response — retrying once');
|
|
1192
1192
|
try {
|
|
1193
|
-
const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers, job.predictable);
|
|
1193
|
+
const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers, job.predictable, job.addDirs);
|
|
1194
1194
|
if (retryResponse && !CronScheduler.isCronNoise(retryResponse)) {
|
|
1195
1195
|
response = retryResponse;
|
|
1196
1196
|
}
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -182,7 +182,11 @@ export declare class Gateway {
|
|
|
182
182
|
* identically. Affects only UI display + budget heuristics elsewhere. */
|
|
183
183
|
_mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string, pinnedSkills?: string[], allowedTools?: string[], allowedMcpServers?: string[],
|
|
184
184
|
/** Predictable (contract) mode — runner skips memory/team/auto-skills. */
|
|
185
|
-
predictable?: boolean
|
|
185
|
+
predictable?: boolean,
|
|
186
|
+
/** Extra read+exec scope for the SDK's Read/Bash/Glob tools. From the
|
|
187
|
+
* CronJobDefinition.addDirs YAML field. Combined inside runAgentCron
|
|
188
|
+
* with each pinned-skill folder. (1.18.121) */
|
|
189
|
+
addDirs?: string[]): Promise<string>;
|
|
186
190
|
/**
|
|
187
191
|
* PRD §10 / 1.18.91 — cancel an in-flight cron run by name. Returns true if
|
|
188
192
|
* an AbortController was found and abort() was called, false if nothing was
|
package/dist/gateway/router.js
CHANGED
|
@@ -1976,7 +1976,11 @@ export class Gateway {
|
|
|
1976
1976
|
// ── Trick capabilities (optional; preserve today's behavior when omitted) ─
|
|
1977
1977
|
pinnedSkills, allowedTools, allowedMcpServers,
|
|
1978
1978
|
/** Predictable (contract) mode — runner skips memory/team/auto-skills. */
|
|
1979
|
-
predictable
|
|
1979
|
+
predictable,
|
|
1980
|
+
/** Extra read+exec scope for the SDK's Read/Bash/Glob tools. From the
|
|
1981
|
+
* CronJobDefinition.addDirs YAML field. Combined inside runAgentCron
|
|
1982
|
+
* with each pinned-skill folder. (1.18.121) */
|
|
1983
|
+
addDirs) {
|
|
1980
1984
|
const releaseLane = await lanes.acquire('cron');
|
|
1981
1985
|
// Build a wall-clock abort timer from maxHours / timeoutMs.
|
|
1982
1986
|
// Whichever is shorter wins. Defaults to 1h if neither is set.
|
|
@@ -2022,6 +2026,7 @@ export class Gateway {
|
|
|
2022
2026
|
allowedTools,
|
|
2023
2027
|
allowedMcpServers,
|
|
2024
2028
|
predictable,
|
|
2029
|
+
addDirs,
|
|
2025
2030
|
});
|
|
2026
2031
|
scanner.refreshIntegrity();
|
|
2027
2032
|
// Stash trick-capability metadata for the scheduler to read when
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.18.121",
|
|
4
4
|
"description": "Clementine — Personal AI Assistant (TypeScript)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -53,7 +53,6 @@
|
|
|
53
53
|
"node-cron": "^3.0.3",
|
|
54
54
|
"pdf-parse": "^1.1.1",
|
|
55
55
|
"pino": "^9.6.0",
|
|
56
|
-
"twilio": "^5.5.0",
|
|
57
56
|
"ws": "^8.19.0",
|
|
58
57
|
"zod": "^4.3.6"
|
|
59
58
|
},
|