clementine-agent 1.18.161 → 1.18.162

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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * runSkill — the canonical Skill execution primitive (1.18.162).
3
+ *
4
+ * Closes Skills Runtime C-2 from the Skills-First redesign.
5
+ *
6
+ * Today (pre-1.18.162) a pinned skill is fed into a cron prompt as a
7
+ * markdown context block and its `clementine.tools.allow` list is UNIONED
8
+ * into the cron's allowedTools (1.18.121 widening). That is permissive,
9
+ * not enforced — a skill that says "I only use Bash + WebFetch" can still
10
+ * call any tool the surrounding cron allows.
11
+ *
12
+ * `runSkill(name, options)` is the alternative path: a sub-call where the
13
+ * skill's `tools.allow` is a HARD allowlist (only those tools, plus a
14
+ * minimal core set), `{{var}}` placeholders in the body are substituted
15
+ * from `options.inputs`, and `clementine.success.schema` is ajv-validated
16
+ * post-run.
17
+ *
18
+ * Why a separate primitive (and not a flag on the existing widening path):
19
+ * - Caller intent is different. Pinned-skills-as-context is "give the LLM
20
+ * reference material"; runSkill is "do this specific procedure now."
21
+ * - Hard enforcement requires constructing the SDK call ourselves, not
22
+ * reusing a cron-job's effective allowlist.
23
+ * - Inputs/success are skill-call concepts, not cron concepts.
24
+ *
25
+ * Surfaced as the MCP tool `run_skill(name, inputs?)` so chat + cron +
26
+ * sub-agents converge on one primitive.
27
+ */
28
+ import type { Skill } from '../types.js';
29
+ export interface RunSkillOptions {
30
+ /** Mustache-style `{{var}}` substitutions for the skill body. */
31
+ inputs?: Record<string, string | number | boolean>;
32
+ /** Optional caller context appended after the skill body
33
+ * (e.g. the user's request, the cron firing context). */
34
+ context?: string;
35
+ /** Stable session key for transcript mirroring. Defaults to a synthesized
36
+ * key derived from the skill name + timestamp. */
37
+ sessionKey?: string;
38
+ /** Source classification for telemetry. Defaults to 'skill'. */
39
+ source?: string;
40
+ /** Optional model override. */
41
+ model?: string;
42
+ /** Hard turn cap. Falls back to `clementine.limits.maxTurns` if set. */
43
+ maxTurns?: number;
44
+ /** Hard budget cap (USD). Falls back to `clementine.limits.maxBudgetUsd`. */
45
+ maxBudgetUsd?: number;
46
+ /** Project work dir for per-project skill precedence (mirrors getSkill's
47
+ * `projectWorkDir` parameter — when set, project-scoped skills shadow
48
+ * global ones with the same name). */
49
+ projectWorkDir?: string;
50
+ /** Skip success.schema validation even if the skill declares one. */
51
+ skipValidation?: boolean;
52
+ /** Streaming callback for partial assistant text. */
53
+ onText?: (chunk: string) => void | Promise<void>;
54
+ /** Abort signal — cancels the SDK stream when triggered. */
55
+ abortSignal?: AbortSignal;
56
+ }
57
+ export interface RunSkillResult {
58
+ ok: boolean;
59
+ /** Final text response from the SDK. */
60
+ output: string;
61
+ /** Cost in USD. */
62
+ cost?: number;
63
+ /** Number of agentic turns. */
64
+ turns?: number;
65
+ /** SDK session id — capture for resume. */
66
+ sessionId?: string;
67
+ /** SDK runId — joins to the Event store. */
68
+ runId?: string;
69
+ /** Schema validation result when the skill declared `clementine.success.schema`. */
70
+ validation?: {
71
+ /** True when validation actually ran (schema present + JSON extractable). */
72
+ tried: boolean;
73
+ /** True when the response validated against the schema. */
74
+ pass: boolean;
75
+ /** First few ajv error messages. */
76
+ errors: string[];
77
+ };
78
+ /** The hard allowlist that was passed to the SDK. */
79
+ effectiveTools?: string[];
80
+ /** Failure reason when ok=false. */
81
+ error?: string;
82
+ }
83
+ /**
84
+ * Substitute `{{var}}` placeholders in `body` from `inputs`. Missing
85
+ * keys are left as-is (so the LLM still sees the placeholder and can
86
+ * complain) rather than silently dropped — a missing input is more
87
+ * recoverable as visible text than as a stripped string.
88
+ */
89
+ export declare function applyMustache(body: string, inputs: Record<string, string | number | boolean> | undefined): string;
90
+ /**
91
+ * Compute the HARD allowlist for a skill call.
92
+ *
93
+ * Combines, in order:
94
+ * 1. The skill's `clementine.tools.allow` list (or [] if absent)
95
+ * 2. Tool names auto-extracted from the skill body matching `mcp__*__*`
96
+ * 3. SKILL_BASELINE_TOOLS so the SDK can read files / dispatch subagents
97
+ *
98
+ * Then subtracts anything in `clementine.tools.deny` (deny wins).
99
+ *
100
+ * Returns a deduped array. Empty input = empty output (which the SDK
101
+ * treats as "deny everything"); callers are expected to set a sensible
102
+ * `tools.allow` on the skill.
103
+ */
104
+ export declare function computeSkillAllowlist(skill: Skill): string[];
105
+ /**
106
+ * Build the prompt the SDK actually executes for a skill call.
107
+ *
108
+ * Format:
109
+ * <skill body, with mustache substitutions applied>
110
+ *
111
+ * ## Caller context
112
+ * <options.context> ← when provided
113
+ *
114
+ * The skill body itself becomes the procedure; the optional context is
115
+ * the immediate "what triggered this call" frame. Bundled files (other
116
+ * .md siblings under the skill folder) are NOT inlined — the SDK can
117
+ * read them via `Read` if listed under tools.allow.
118
+ */
119
+ export declare function buildSkillPrompt(skill: Skill, inputs: Record<string, string | number | boolean> | undefined, context: string | undefined): string;
120
+ /**
121
+ * Run a skill as a hard-allowlisted sub-call. Returns a structured result.
122
+ *
123
+ * The skill is loaded via `getSkill()` (project-precedence honored when
124
+ * `projectDir` + `agentSlug` are passed). Its body is mustache-rendered
125
+ * with `inputs`, then sent to the SDK with an allowlist computed from
126
+ * `clementine.tools.allow` + auto-extracted MCP refs + a small baseline.
127
+ * After the SDK returns, `clementine.success.schema` (when set) is
128
+ * ajv-validated against the response.
129
+ *
130
+ * This function never throws — failures (skill not found, SDK error,
131
+ * timeout) are returned as `{ ok: false, error }`. The caller (chat,
132
+ * cron, sub-agent, MCP tool) decides how to surface that.
133
+ */
134
+ export declare function runSkill(name: string, options?: RunSkillOptions): Promise<RunSkillResult>;
135
+ //# sourceMappingURL=run-skill.d.ts.map
@@ -0,0 +1,267 @@
1
+ /**
2
+ * runSkill — the canonical Skill execution primitive (1.18.162).
3
+ *
4
+ * Closes Skills Runtime C-2 from the Skills-First redesign.
5
+ *
6
+ * Today (pre-1.18.162) a pinned skill is fed into a cron prompt as a
7
+ * markdown context block and its `clementine.tools.allow` list is UNIONED
8
+ * into the cron's allowedTools (1.18.121 widening). That is permissive,
9
+ * not enforced — a skill that says "I only use Bash + WebFetch" can still
10
+ * call any tool the surrounding cron allows.
11
+ *
12
+ * `runSkill(name, options)` is the alternative path: a sub-call where the
13
+ * skill's `tools.allow` is a HARD allowlist (only those tools, plus a
14
+ * minimal core set), `{{var}}` placeholders in the body are substituted
15
+ * from `options.inputs`, and `clementine.success.schema` is ajv-validated
16
+ * post-run.
17
+ *
18
+ * Why a separate primitive (and not a flag on the existing widening path):
19
+ * - Caller intent is different. Pinned-skills-as-context is "give the LLM
20
+ * reference material"; runSkill is "do this specific procedure now."
21
+ * - Hard enforcement requires constructing the SDK call ourselves, not
22
+ * reusing a cron-job's effective allowlist.
23
+ * - Inputs/success are skill-call concepts, not cron concepts.
24
+ *
25
+ * Surfaced as the MCP tool `run_skill(name, inputs?)` so chat + cron +
26
+ * sub-agents converge on one primitive.
27
+ */
28
+ import path from 'node:path';
29
+ import pino from 'pino';
30
+ import { getSkill } from './skill-store.js';
31
+ import { runAgent } from './run-agent.js';
32
+ const logger = pino({ name: 'clementine.run-skill' });
33
+ // ── Mustache substitution ─────────────────────────────────────────────
34
+ /** Matches `{{var_name}}` with optional whitespace. var_name is
35
+ * `[a-zA-Z_][a-zA-Z0-9_-]*` — the same identifier shape used in YAML
36
+ * frontmatter `inputs:` keys. */
37
+ const MUSTACHE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_-]*)\s*\}\}/g;
38
+ /**
39
+ * Substitute `{{var}}` placeholders in `body` from `inputs`. Missing
40
+ * keys are left as-is (so the LLM still sees the placeholder and can
41
+ * complain) rather than silently dropped — a missing input is more
42
+ * recoverable as visible text than as a stripped string.
43
+ */
44
+ export function applyMustache(body, inputs) {
45
+ if (!inputs || Object.keys(inputs).length === 0)
46
+ return body;
47
+ return body.replace(MUSTACHE, (match, key) => {
48
+ if (Object.prototype.hasOwnProperty.call(inputs, key)) {
49
+ return String(inputs[key]);
50
+ }
51
+ return match;
52
+ });
53
+ }
54
+ // ── Allowlist computation ─────────────────────────────────────────────
55
+ /** Tools every skill needs as a baseline regardless of its `tools.allow`.
56
+ * Without these the SDK can't navigate the project at all. Read/Glob/Grep
57
+ * are non-mutating; Agent is required so the SDK can dispatch its own
58
+ * internal subagents. */
59
+ const SKILL_BASELINE_TOOLS = ['Agent', 'Read', 'Glob', 'Grep'];
60
+ /** Matches `mcp__<server>__<tool>` references in skill bodies. Used to
61
+ * auto-include MCP tool names the skill *clearly* intends to call but
62
+ * which the author forgot to list under `tools.allow`. Same pattern as
63
+ * run-agent-cron.ts:150. */
64
+ const MCP_TOOL_REF = /mcp__([A-Za-z0-9-]+(?:_[A-Za-z0-9-]+)*)__[A-Za-z0-9_-]+/g;
65
+ /**
66
+ * Compute the HARD allowlist for a skill call.
67
+ *
68
+ * Combines, in order:
69
+ * 1. The skill's `clementine.tools.allow` list (or [] if absent)
70
+ * 2. Tool names auto-extracted from the skill body matching `mcp__*__*`
71
+ * 3. SKILL_BASELINE_TOOLS so the SDK can read files / dispatch subagents
72
+ *
73
+ * Then subtracts anything in `clementine.tools.deny` (deny wins).
74
+ *
75
+ * Returns a deduped array. Empty input = empty output (which the SDK
76
+ * treats as "deny everything"); callers are expected to set a sensible
77
+ * `tools.allow` on the skill.
78
+ */
79
+ export function computeSkillAllowlist(skill) {
80
+ const tools = skill.frontmatter?.clementine?.tools;
81
+ const declared = Array.isArray(tools?.allow) ? tools.allow : [];
82
+ const denied = new Set(Array.isArray(tools?.deny) ? tools.deny : []);
83
+ const fromBody = new Set();
84
+ let m;
85
+ // exec() with /g shares state per-regex; reset before each pass.
86
+ MCP_TOOL_REF.lastIndex = 0;
87
+ while ((m = MCP_TOOL_REF.exec(skill.body)) !== null) {
88
+ // m[0] is the full mcp__<server>__<tool> match
89
+ fromBody.add(m[0]);
90
+ }
91
+ const merged = new Set([
92
+ ...declared,
93
+ ...fromBody,
94
+ ...SKILL_BASELINE_TOOLS,
95
+ ]);
96
+ for (const d of denied)
97
+ merged.delete(d);
98
+ return [...merged];
99
+ }
100
+ // ── Prompt builder ────────────────────────────────────────────────────
101
+ /**
102
+ * Build the prompt the SDK actually executes for a skill call.
103
+ *
104
+ * Format:
105
+ * <skill body, with mustache substitutions applied>
106
+ *
107
+ * ## Caller context
108
+ * <options.context> ← when provided
109
+ *
110
+ * The skill body itself becomes the procedure; the optional context is
111
+ * the immediate "what triggered this call" frame. Bundled files (other
112
+ * .md siblings under the skill folder) are NOT inlined — the SDK can
113
+ * read them via `Read` if listed under tools.allow.
114
+ */
115
+ export function buildSkillPrompt(skill, inputs, context) {
116
+ const substitutedBody = applyMustache(skill.body, inputs);
117
+ if (!context || !context.trim())
118
+ return substitutedBody;
119
+ return `${substitutedBody}\n\n## Caller context\n\n${context.trim()}\n`;
120
+ }
121
+ // ── Schema validation ─────────────────────────────────────────────────
122
+ /** Best-effort JSON extraction: try whole text, then fenced ```json
123
+ * block, then the largest {…} substring. Mirrors goal-evaluator.ts so
124
+ * skill authors get the same forgiving behavior as goalCheck. */
125
+ function extractJson(text) {
126
+ if (!text)
127
+ return null;
128
+ const trimmed = text.trim();
129
+ try {
130
+ return JSON.parse(trimmed);
131
+ }
132
+ catch { /* fall through */ }
133
+ const fenced = /```json\s*([\s\S]*?)```/i.exec(text);
134
+ if (fenced?.[1]) {
135
+ try {
136
+ return JSON.parse(fenced[1]);
137
+ }
138
+ catch { /* fall through */ }
139
+ }
140
+ const start = text.indexOf('{');
141
+ const end = text.lastIndexOf('}');
142
+ if (start !== -1 && end > start) {
143
+ try {
144
+ return JSON.parse(text.slice(start, end + 1));
145
+ }
146
+ catch { /* fall through */ }
147
+ }
148
+ return null;
149
+ }
150
+ async function validateSkillOutput(output, schema) {
151
+ const json = extractJson(output);
152
+ if (json === null)
153
+ return { tried: false, pass: false, errors: [] };
154
+ try {
155
+ // Lazy import: ajv pulls in ~150KB and most callers won't have a schema.
156
+ // Default-export interop matches goal-evaluator.ts:75 — ajv@8 is CJS
157
+ // and the ESM bridge sometimes lands the constructor on .default.
158
+ const ajvMod = await import('ajv');
159
+ const AjvCtor = ajvMod.default ?? ajvMod;
160
+ const ajv = new AjvCtor({ allErrors: true, strict: false });
161
+ const validate = ajv.compile(schema);
162
+ const valid = validate(json);
163
+ const rawErrors = validate.errors ?? ajv.errors ?? [];
164
+ return {
165
+ tried: true,
166
+ pass: !!valid,
167
+ errors: rawErrors.slice(0, 5).map(e => {
168
+ const p = e.instancePath || '';
169
+ const m = e.message || 'invalid';
170
+ return p ? `${p} ${m}` : m;
171
+ }),
172
+ };
173
+ }
174
+ catch (err) {
175
+ return { tried: true, pass: false, errors: [`schema compile error: ${err}`] };
176
+ }
177
+ }
178
+ // ── The primitive ─────────────────────────────────────────────────────
179
+ /**
180
+ * Run a skill as a hard-allowlisted sub-call. Returns a structured result.
181
+ *
182
+ * The skill is loaded via `getSkill()` (project-precedence honored when
183
+ * `projectDir` + `agentSlug` are passed). Its body is mustache-rendered
184
+ * with `inputs`, then sent to the SDK with an allowlist computed from
185
+ * `clementine.tools.allow` + auto-extracted MCP refs + a small baseline.
186
+ * After the SDK returns, `clementine.success.schema` (when set) is
187
+ * ajv-validated against the response.
188
+ *
189
+ * This function never throws — failures (skill not found, SDK error,
190
+ * timeout) are returned as `{ ok: false, error }`. The caller (chat,
191
+ * cron, sub-agent, MCP tool) decides how to surface that.
192
+ */
193
+ export async function runSkill(name, options = {}) {
194
+ const skill = getSkill(name, {
195
+ ...(options.projectWorkDir ? { projectWorkDir: options.projectWorkDir } : {}),
196
+ });
197
+ if (!skill) {
198
+ return {
199
+ ok: false,
200
+ output: '',
201
+ error: `Skill not found: ${name}`,
202
+ };
203
+ }
204
+ const effectiveTools = computeSkillAllowlist(skill);
205
+ const prompt = buildSkillPrompt(skill, options.inputs, options.context);
206
+ const limits = skill.frontmatter?.clementine?.limits;
207
+ const maxTurns = options.maxTurns ?? limits?.maxTurns;
208
+ const maxBudgetUsd = options.maxBudgetUsd ?? limits?.maxBudgetUsd;
209
+ const sessionKey = options.sessionKey
210
+ ?? `skill:${name}:${Date.now().toString(36)}`;
211
+ // Surface the skill folder to the SDK via additionalDirectories so
212
+ // bundled scripts (skill/scripts/*.py) are reachable for `Bash` calls.
213
+ // Folder-form skills only — flat skills have no siblings worth surfacing.
214
+ const additionalDirectories = skill.layout === 'folder' ? [path.dirname(skill.filePath)] : undefined;
215
+ logger.info({
216
+ skill: name,
217
+ tools: effectiveTools,
218
+ maxTurns,
219
+ maxBudgetUsd,
220
+ inputKeys: Object.keys(options.inputs ?? {}),
221
+ hasContext: !!options.context,
222
+ }, 'runSkill: invoking');
223
+ let runResult;
224
+ try {
225
+ const sdkOpts = {
226
+ sessionKey,
227
+ source: options.source ?? 'skill',
228
+ allowedTools: effectiveTools,
229
+ ...(options.model ? { model: options.model } : {}),
230
+ ...(typeof maxTurns === 'number' ? { maxTurns } : {}),
231
+ ...(typeof maxBudgetUsd === 'number' ? { maxBudgetUsd } : {}),
232
+ ...(additionalDirectories ? { additionalDirectories } : {}),
233
+ ...(options.onText ? { onText: options.onText } : {}),
234
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
235
+ };
236
+ runResult = await runAgent(prompt, sdkOpts);
237
+ }
238
+ catch (err) {
239
+ logger.error({ err, skill: name }, 'runSkill: SDK call failed');
240
+ return {
241
+ ok: false,
242
+ output: '',
243
+ effectiveTools,
244
+ error: `SDK error: ${err}`,
245
+ };
246
+ }
247
+ // Schema validation — only when the skill declared one and the caller
248
+ // didn't opt out. We do not flip ok=false on schema fail; we surface
249
+ // the result so the caller can decide. (A cron may want to retry; a
250
+ // chat user just sees a "schema mismatch" badge.)
251
+ let validation;
252
+ const successSchema = skill.frontmatter?.clementine?.success?.schema;
253
+ if (!options.skipValidation && successSchema) {
254
+ validation = await validateSkillOutput(runResult.text, successSchema);
255
+ }
256
+ return {
257
+ ok: true,
258
+ output: runResult.text,
259
+ cost: runResult.totalCostUsd,
260
+ turns: runResult.numTurns,
261
+ sessionId: runResult.sessionId,
262
+ runId: runResult.runId,
263
+ effectiveTools,
264
+ ...(validation ? { validation } : {}),
265
+ };
266
+ }
267
+ //# sourceMappingURL=run-skill.js.map
@@ -214,5 +214,43 @@ export function registerSkillTools(server) {
214
214
  return textResult(`❌ Failed to list skills: ${err instanceof Error ? err.message : String(err)}`);
215
215
  }
216
216
  });
217
+ // ── run_skill (1.18.162) ────────────────────────────────────────────
218
+ // Invoke a skill as a hard-allowlisted sub-call. Mustache substitutes
219
+ // `{{var}}` placeholders in the skill body from `inputs`, runs through
220
+ // the SDK with ONLY the skill's clementine.tools.allow + a baseline
221
+ // (Agent/Read/Glob/Grep) + auto-extracted mcp__*__* refs from the body,
222
+ // and validates against clementine.success.schema if declared.
223
+ //
224
+ // Use this when a chat or another skill needs to *execute* a procedure
225
+ // (not just reference it). Pinned-skills-as-context (the existing 1.18.121
226
+ // widening path) is for the cron prompt; this is for callable execution.
227
+ server.tool('run_skill', 'Execute a named skill as a sub-call with a HARD tool allowlist. The skill body is rendered with optional {{var}} substitutions from `inputs`, then run with only the tools the skill declared under clementine.tools.allow (plus a small baseline). Returns the skill output + cost + schema validation result when applicable. Use when chat says "run my morning-briefing skill" or when one skill needs to invoke another.', {
228
+ name: z.string().regex(NAME_PATTERN).describe('Skill slug (e.g. "morning-briefing"). Must match an existing skill in the vault.'),
229
+ inputs: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
230
+ .describe('Optional key→value map substituted into {{var}} placeholders in the skill body. Missing placeholders are left as-is so the LLM can complain.'),
231
+ context: z.string().optional()
232
+ .describe('Optional caller context appended after the skill body (e.g. "user said: do X right now"). Surfaced under a "## Caller context" heading.'),
233
+ }, async ({ name, inputs, context }) => {
234
+ try {
235
+ // Lazy import — runSkill pulls in run-agent + the SDK; only load on
236
+ // demand so `list_skills` etc stay fast and the MCP server boots
237
+ // without warming the whole agent path.
238
+ const { runSkill } = await import('../agent/run-skill.js');
239
+ const result = await runSkill(name, { inputs, context, source: 'mcp:run_skill' });
240
+ if (!result.ok) {
241
+ return textResult(`❌ run_skill(${name}) failed: ${result.error ?? 'unknown error'}`);
242
+ }
243
+ const validationLine = result.validation
244
+ ? `\n\n**Schema:** ${result.validation.tried ? (result.validation.pass ? '✅ pass' : `❌ fail — ${result.validation.errors.slice(0, 2).join('; ')}`) : '(skipped — no JSON in output)'}`
245
+ : '';
246
+ const meta = `\n\n_${result.turns ?? 0} turns · $${(result.cost ?? 0).toFixed(4)} · ${result.effectiveTools?.length ?? 0} tools allowed_${validationLine}`;
247
+ return textResult(`${result.output}${meta}`);
248
+ }
249
+ catch (err) {
250
+ const msg = err instanceof Error ? err.message : String(err);
251
+ logger.error({ err, skill: name }, 'run_skill failed');
252
+ return textResult(`❌ run_skill(${name}) failed: ${msg}`);
253
+ }
254
+ });
217
255
  }
218
256
  //# sourceMappingURL=skill-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.161",
3
+ "version": "1.18.162",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",