clementine-agent 1.18.65 → 1.18.66

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.
@@ -18,6 +18,53 @@ import type { AgentProfile } from '../types.js';
18
18
  import type { AgentManager } from './agent-manager.js';
19
19
  import type { MemoryStore } from '../memory/store.js';
20
20
  import { type RunAgentResult } from './run-agent.js';
21
+ /**
22
+ * Compute the effective tool allowlist for a cron run.
23
+ *
24
+ * Semantics:
25
+ * - Both undefined / empty → return undefined. Caller (`runAgent`)
26
+ * will fall back to `profile.team.allowedTools` then
27
+ * `CORE_TOOLS_FOR_AGENT_PARENT` — preserving today's behavior for
28
+ * legacy CRON.md entries with no `allowed_tools` field.
29
+ * - Job allowlist only → return `['Agent', ...job]` (deduped).
30
+ * Bare tricks (no agentSlug) hit this path too.
31
+ * - Profile allowlist only → return undefined (let runAgent thread it
32
+ * through its own profile path; no need to duplicate work here).
33
+ * - Both → intersection ∪ {Agent}. When intersection is empty the
34
+ * result is just `['Agent']` — degenerate but valid (subagent
35
+ * delegation is the one thing every cron must always be able to do).
36
+ */
37
+ export declare function computeEffectiveAllowedTools(jobAllow: string[] | undefined, profileAllow: string[] | undefined): string[] | undefined;
38
+ /**
39
+ * Compute the effective MCP server map for a cron run by intersecting the
40
+ * trick's `allowedMcpServers` with the already-resolved server map from
41
+ * `buildExtraMcpForRunAgent` (which has already applied the profile
42
+ * allowlist). Returns the unchanged input map when the trick has no
43
+ * MCP allowlist set.
44
+ */
45
+ export declare function applyMcpAllowlist<T>(servers: Record<string, T>, jobAllowedMcpServers: string[] | undefined): Record<string, T>;
46
+ export interface SkillContextResult {
47
+ /** The rendered "Learned Procedures" block (or empty string when no skills loaded). */
48
+ text: string;
49
+ /** Skills actually injected — the dashboard records this on the run log. */
50
+ applied: Array<{
51
+ name: string;
52
+ source: 'pinned' | 'auto';
53
+ score?: number;
54
+ }>;
55
+ /** Pinned slugs that didn't resolve (deleted/renamed/suppressed). Logged + surfaced. */
56
+ missing: string[];
57
+ }
58
+ /**
59
+ * Build the matched-skills block (procedures learned from prior successful runs).
60
+ * Pinned skills load first via exact-slug lookup; remaining slots fill from
61
+ * the keyword/semantic auto-match. Total cap = `MAX_INJECTED_SKILLS`. Pins
62
+ * that don't resolve are surfaced via `missing[]` (warned, never fatal) so
63
+ * the dashboard can flag broken references.
64
+ *
65
+ * Exported only for testability — the production caller is `runAgentCron`.
66
+ */
67
+ export declare function buildSkillContext(jobName: string, jobPrompt: string, agentSlug: string | undefined, pinnedSkills: string[] | undefined, memoryStore?: MemoryStore | null): Promise<SkillContextResult>;
21
68
  /** Minimal interface for the post-task reflection + skill extraction
22
69
  * hooks. Lets `runAgentCron` stay decoupled from the full
23
70
  * PersonalAssistant import while still benefiting from the existing
@@ -56,6 +103,18 @@ export interface RunAgentCronOptions {
56
103
  * PersonalAssistant — it implements both members. Optional so the
57
104
  * helper still works in tests without the full assistant graph. */
58
105
  postTaskHooks?: CronPostTaskHooks | null;
106
+ /** Pinned skill slugs from the trick definition. Loaded before the
107
+ * auto-match search; total skills injected is capped at
108
+ * `MAX_INJECTED_SKILLS`. */
109
+ pinnedSkills?: string[];
110
+ /** Per-trick tool whitelist. Intersected with `profile.team.allowedTools`
111
+ * when both are present. 'Agent' is always force-included. Undefined
112
+ * preserves today's behavior (falls back to profile or default). */
113
+ allowedTools?: string[];
114
+ /** Per-trick MCP server whitelist (server names from `discoverMcpServers`).
115
+ * Applied after `buildExtraMcpForRunAgent` runs, so the effective set
116
+ * is `profile ∩ trick`. */
117
+ allowedMcpServers?: string[];
59
118
  }
60
119
  export interface RunAgentCronResult extends RunAgentResult {
61
120
  /** The final prompt that was sent to the agent (after context injection).
@@ -64,7 +123,59 @@ export interface RunAgentCronResult extends RunAgentResult {
64
123
  /** Diagnostics: which Composio + external servers were live for this run. */
65
124
  composioConnected: string[];
66
125
  externalConnected: string[];
126
+ /** Skills actually injected (pinned + auto). Surfaced on the run log so the
127
+ * dashboard can render a "ran with: …" line. */
128
+ skillsApplied: Array<{
129
+ name: string;
130
+ source: 'pinned' | 'auto';
131
+ score?: number;
132
+ }>;
133
+ /** Pinned skills that didn't resolve (bad slug / suppressed). Empty array
134
+ * is fine; only populated when the trick had pins that failed to load. */
135
+ skillsMissing: string[];
136
+ /** Effective tool allowlist passed to runAgent (post-intersection). Undefined
137
+ * means the trick didn't override — runAgent fell through to profile/default. */
138
+ allowedToolsApplied?: string[];
139
+ /** MCP servers live for this run after profile + trick intersection. */
140
+ mcpServersApplied: string[];
67
141
  }
142
+ /** Plan output from `buildCronExecutionPlan` — everything the runner needs
143
+ * to dispatch, plus the broken-down context blocks so a preview UI can
144
+ * show "what came from where" without re-running the build. */
145
+ export interface CronExecutionPlan {
146
+ builtPrompt: string;
147
+ contextBlocks: {
148
+ memoryContext: string;
149
+ progressContext: string;
150
+ goalContext: string;
151
+ delegationContext: string;
152
+ teamContext: string;
153
+ criteriaContext: string;
154
+ skillContext: string;
155
+ jobPrompt: string;
156
+ howToRespond: string;
157
+ };
158
+ skillsApplied: SkillContextResult['applied'];
159
+ skillsMissing: string[];
160
+ effectiveAllowedTools: string[] | undefined;
161
+ mcpServerMap: Record<string, unknown>;
162
+ mcpServersApplied: string[];
163
+ composioConnected: string[];
164
+ externalConnected: string[];
165
+ tier: number;
166
+ effort: 'low' | 'medium' | 'high';
167
+ maxBudgetUsd: number | undefined;
168
+ agentSlug: string | undefined;
169
+ ownerName: string;
170
+ }
171
+ /**
172
+ * Plan a cron run — assemble all context, resolve skills, intersect tool/MCP
173
+ * allowlists — without dispatching to the agent. Used by `runAgentCron` for
174
+ * the actual run, and by the dashboard's `GET /api/cron/:name/preview`
175
+ * endpoint so users can see *exactly* what the trick will send to the agent
176
+ * before the next fire.
177
+ */
178
+ export declare function buildCronExecutionPlan(opts: RunAgentCronOptions): Promise<CronExecutionPlan>;
68
179
  /**
69
180
  * Run a cron job via the canonical SDK runAgent path.
70
181
  *
@@ -26,6 +26,52 @@ const CRON_PROGRESS_PENDING_MAX_ITEMS = 20;
26
26
  const CRON_PROGRESS_NOTES_MAX_CHARS = 2000;
27
27
  const logger = pino({ name: 'clementine.run-agent-cron' });
28
28
  const CRON_CONTEXT_ITEM_MAX = 80;
29
+ /** Total number of skill blocks injected into a cron prompt — pinned + auto. */
30
+ const MAX_INJECTED_SKILLS = 4;
31
+ /**
32
+ * Compute the effective tool allowlist for a cron run.
33
+ *
34
+ * Semantics:
35
+ * - Both undefined / empty → return undefined. Caller (`runAgent`)
36
+ * will fall back to `profile.team.allowedTools` then
37
+ * `CORE_TOOLS_FOR_AGENT_PARENT` — preserving today's behavior for
38
+ * legacy CRON.md entries with no `allowed_tools` field.
39
+ * - Job allowlist only → return `['Agent', ...job]` (deduped).
40
+ * Bare tricks (no agentSlug) hit this path too.
41
+ * - Profile allowlist only → return undefined (let runAgent thread it
42
+ * through its own profile path; no need to duplicate work here).
43
+ * - Both → intersection ∪ {Agent}. When intersection is empty the
44
+ * result is just `['Agent']` — degenerate but valid (subagent
45
+ * delegation is the one thing every cron must always be able to do).
46
+ */
47
+ export function computeEffectiveAllowedTools(jobAllow, profileAllow) {
48
+ if (!jobAllow?.length)
49
+ return undefined;
50
+ let result;
51
+ if (profileAllow?.length) {
52
+ const jobSet = new Set(jobAllow);
53
+ result = profileAllow.filter(t => jobSet.has(t));
54
+ }
55
+ else {
56
+ result = [...jobAllow];
57
+ }
58
+ if (!result.includes('Agent'))
59
+ result.unshift('Agent');
60
+ return Array.from(new Set(result));
61
+ }
62
+ /**
63
+ * Compute the effective MCP server map for a cron run by intersecting the
64
+ * trick's `allowedMcpServers` with the already-resolved server map from
65
+ * `buildExtraMcpForRunAgent` (which has already applied the profile
66
+ * allowlist). Returns the unchanged input map when the trick has no
67
+ * MCP allowlist set.
68
+ */
69
+ export function applyMcpAllowlist(servers, jobAllowedMcpServers) {
70
+ if (!jobAllowedMcpServers?.length)
71
+ return servers;
72
+ const allow = new Set(jobAllowedMcpServers);
73
+ return Object.fromEntries(Object.entries(servers).filter(([name]) => allow.has(name)));
74
+ }
29
75
  function capContextItem(s) {
30
76
  if (!s)
31
77
  return '';
@@ -173,29 +219,71 @@ function buildCriteriaContext(successCriteria) {
173
219
  return `## Success Criteria\nYour output will be verified against these criteria:\n` +
174
220
  successCriteria.map(c => `- ${c}`).join('\n') + '\n\n';
175
221
  }
176
- /** Build the matched-skills block (procedures learned from prior successful runs). */
177
- async function buildSkillContext(jobName, jobPrompt, agentSlug, memoryStore) {
222
+ /**
223
+ * Build the matched-skills block (procedures learned from prior successful runs).
224
+ * Pinned skills load first via exact-slug lookup; remaining slots fill from
225
+ * the keyword/semantic auto-match. Total cap = `MAX_INJECTED_SKILLS`. Pins
226
+ * that don't resolve are surfaced via `missing[]` (warned, never fatal) so
227
+ * the dashboard can flag broken references.
228
+ *
229
+ * Exported only for testability — the production caller is `runAgentCron`.
230
+ */
231
+ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSkills, memoryStore) {
232
+ const applied = [];
233
+ const missing = [];
178
234
  try {
179
- const { searchSkills, recordSkillUse } = await import('./skill-extractor.js');
235
+ const { searchSkills, recordSkillUse, loadSkillByName } = await import('./skill-extractor.js');
180
236
  const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
181
237
  const suppressedNamesRaw = memoryStore
182
238
  ?.getSkillsToSuppress?.(agentSlug);
183
239
  const suppressedNames = Array.isArray(suppressedNamesRaw)
184
240
  ? new Set(suppressedNamesRaw)
185
241
  : (suppressedNamesRaw ?? undefined);
186
- const matchedSkills = searchSkills(skillQuery, 2, agentSlug, { suppressedNames });
187
- if (matchedSkills.length === 0)
188
- return '';
189
- const skillLines = matchedSkills.map(s => {
242
+ const prepared = [];
243
+ const seen = new Set();
244
+ // 1. Load pinned skills first via exact slug lookup.
245
+ if (pinnedSkills?.length) {
246
+ for (const pinName of pinnedSkills) {
247
+ if (seen.has(pinName))
248
+ continue;
249
+ if (prepared.length >= MAX_INJECTED_SKILLS)
250
+ break;
251
+ const skill = loadSkillByName(pinName, agentSlug, { suppressedNames });
252
+ if (!skill) {
253
+ missing.push(pinName);
254
+ logger.warn({ jobName, pin: pinName, agentSlug }, 'cron: pinned skill not found');
255
+ continue;
256
+ }
257
+ prepared.push({ ...skill, source: 'pinned' });
258
+ seen.add(pinName);
259
+ }
260
+ }
261
+ // 2. Auto-match fills the remainder, deduped against pins.
262
+ const remaining = MAX_INJECTED_SKILLS - prepared.length;
263
+ if (remaining > 0) {
264
+ const matched = searchSkills(skillQuery, remaining + (pinnedSkills?.length ?? 0), agentSlug, { suppressedNames });
265
+ for (const m of matched) {
266
+ if (prepared.length >= MAX_INJECTED_SKILLS)
267
+ break;
268
+ if (seen.has(m.name))
269
+ continue;
270
+ prepared.push({ ...m, source: 'auto' });
271
+ seen.add(m.name);
272
+ }
273
+ }
274
+ if (prepared.length === 0)
275
+ return { text: '', applied, missing };
276
+ const skillLines = prepared.map(s => {
190
277
  recordSkillUse(s.name);
191
278
  memoryStore?.logSkillUse?.({
192
279
  skillName: s.name,
193
280
  sessionKey: `cron:${agentSlug ?? 'clementine'}:${jobName}`,
194
281
  queryText: skillQuery,
195
- score: s.score,
282
+ score: s.score ?? 0,
196
283
  agentSlug: agentSlug ?? null,
197
284
  });
198
- let block = `### ${s.title}\n${s.content}`;
285
+ applied.push({ name: s.name, source: s.source, score: s.score });
286
+ let block = `### ${s.title}${s.source === 'pinned' ? ' _(pinned)_' : ''}\n${s.content}`;
199
287
  if (s.toolsUsed.length > 0)
200
288
  block += `\n**Tools:** ${s.toolsUsed.join(', ')}`;
201
289
  if (s.attachments.length > 0) {
@@ -213,53 +301,34 @@ async function buildSkillContext(jobName, jobPrompt, agentSlug, memoryStore) {
213
301
  }
214
302
  return block;
215
303
  });
216
- return `## Learned Procedures (from past successful executions)\nFollow these proven approaches when applicable:\n\n${skillLines.join('\n\n')}\n\n`;
304
+ const text = `## Learned Procedures (from past successful executions)\nFollow these proven approaches when applicable:\n\n${skillLines.join('\n\n')}\n\n`;
305
+ return { text, applied, missing };
217
306
  }
218
- catch {
219
- return '';
307
+ catch (err) {
308
+ logger.debug({ err, jobName }, 'buildSkillContext failed (non-fatal)');
309
+ return { text: '', applied, missing };
220
310
  }
221
311
  }
222
312
  /**
223
- * Run a cron job via the canonical SDK runAgent path.
224
- *
225
- * Composes the same context blocks the legacy runCronJob injects
226
- * (progress, goals, delegation, team, criteria, skills, fanout
227
- * directive), wires Composio + external MCP via the dedup-aware
228
- * helper, then calls runAgent.
229
- *
230
- * The SDK handles the loop, compaction, subagent fanout, prompt
231
- * caching, retries — none of which we wrap manually anymore.
313
+ * Plan a cron run assemble all context, resolve skills, intersect tool/MCP
314
+ * allowlists — without dispatching to the agent. Used by `runAgentCron` for
315
+ * the actual run, and by the dashboard's `GET /api/cron/:name/preview`
316
+ * endpoint so users can see *exactly* what the trick will send to the agent
317
+ * before the next fire.
232
318
  */
233
- export async function runAgentCron(opts) {
319
+ export async function buildCronExecutionPlan(opts) {
234
320
  const tier = opts.tier ?? 1;
235
321
  const agentSlug = opts.profile?.slug;
236
322
  const ownerName = process.env.OWNER_NAME ?? 'the user';
237
- // ── Compose context blocks (mirrors legacy runCronJob) ─────────────
238
- // Memory block goes first so the agent reads its long-term context
239
- // before the run-specific progress/goals/etc. For a hired agent
240
- // (Ross/Sasha) this is their own MEMORY.md, not Clementine's global.
241
323
  const memoryContext = buildAutonomousMemoryContext(opts.profile);
242
324
  const progressContext = buildProgressContext(opts.jobName);
243
325
  const goalContext = buildGoalContext(opts.jobName);
244
326
  const delegationContext = buildDelegationContext(agentSlug);
245
327
  const teamContext = buildTeamContext(agentSlug);
246
328
  const criteriaContext = buildCriteriaContext(opts.successCriteria);
247
- const skillContext = await buildSkillContext(opts.jobName, opts.jobPrompt, agentSlug, opts.memoryStore);
248
- // Sub-agent routing is handled by the SDK's `agents` map
249
- // (see agent-definitions.ts). The planner + researcher + cron-fixer
250
- // descriptions auto-match per-item / multi-step work, so the SDK
251
- // spawns isolated sub-agents without a hand-rolled prompt directive.
252
- // Final prompt
253
- const builtPrompt = `[Scheduled task: ${opts.jobName}]\n\n` +
254
- memoryContext +
255
- progressContext +
256
- goalContext +
257
- skillContext +
258
- delegationContext +
259
- teamContext +
260
- criteriaContext +
261
- `${opts.jobPrompt}\n\n` +
262
- `## How to respond\n` +
329
+ const skillResult = await buildSkillContext(opts.jobName, opts.jobPrompt, agentSlug, opts.pinnedSkills, opts.memoryStore);
330
+ const skillContext = skillResult.text;
331
+ const howToRespond = `## How to respond\n` +
263
332
  `You're sending this directly to ${ownerName} as a DM. ` +
264
333
  `Write like you're texting a friend — casual, warm, concise. ` +
265
334
  `Use their name naturally. No headers, bullet lists, or formal structure unless the content genuinely needs it. ` +
@@ -270,7 +339,16 @@ export async function runAgentCron(opts) {
270
339
  `"Quiet morning, inbox is clean" beats __NOTHING__ if you did check things.\n\n` +
271
340
  `After finishing your work, you MUST write a final text response with your findings — ` +
272
341
  `only that final message gets delivered.`;
273
- // ── Wire Composio + external MCP servers (same dedup as legacy) ───
342
+ const builtPrompt = `[Scheduled task: ${opts.jobName}]\n\n` +
343
+ memoryContext +
344
+ progressContext +
345
+ goalContext +
346
+ skillContext +
347
+ delegationContext +
348
+ teamContext +
349
+ criteriaContext +
350
+ `${opts.jobPrompt}\n\n` +
351
+ howToRespond;
274
352
  const mcp = await buildExtraMcpForRunAgent({
275
353
  scopeText: [
276
354
  opts.jobName,
@@ -280,23 +358,66 @@ export async function runAgentCron(opts) {
280
358
  ].filter(Boolean).join('\n\n'),
281
359
  profile: opts.profile,
282
360
  });
283
- // ── Run via canonical runAgent ────────────────────────────────────
284
- // Per-tier cap from config (BUDGET.cronT1 / BUDGET.cronT2). Sourced
285
- // from env / clementine.json / dashboard writes. 0 means uncapped —
286
- // we pass undefined so runAgent omits the SDK option entirely.
287
- // Caller can still override via opts.maxBudgetUsd.
361
+ // Per-trick MCP allowlist: post-filter on the profile-narrowed map.
362
+ // Effective set = profile trick.
363
+ const mcpServerMap = applyMcpAllowlist(mcp.servers, opts.allowedMcpServers);
364
+ const allowSet = opts.allowedMcpServers?.length ? new Set(opts.allowedMcpServers) : null;
365
+ const composioConnected = allowSet ? mcp.composioConnected.filter(n => allowSet.has(n)) : mcp.composioConnected;
366
+ const externalConnected = allowSet ? mcp.externalConnected.filter(n => allowSet.has(n)) : mcp.externalConnected;
367
+ const mcpServersApplied = Object.keys(mcpServerMap);
368
+ // Per-trick tool allowlist intersection.
369
+ const effectiveAllowedTools = computeEffectiveAllowedTools(opts.allowedTools, opts.profile?.team?.allowedTools);
370
+ // Per-tier cap from config (BUDGET.cronT1 / BUDGET.cronT2). 0 = uncapped.
288
371
  const configuredCap = tier >= 2 ? BUDGET.cronT2 : BUDGET.cronT1;
289
372
  const maxBudget = opts.maxBudgetUsd ?? (configuredCap > 0 ? configuredCap : undefined);
290
373
  const effort = tier >= 2 ? 'high' : 'medium';
374
+ return {
375
+ builtPrompt,
376
+ contextBlocks: {
377
+ memoryContext, progressContext, goalContext, delegationContext,
378
+ teamContext, criteriaContext, skillContext,
379
+ jobPrompt: opts.jobPrompt, howToRespond,
380
+ },
381
+ skillsApplied: skillResult.applied,
382
+ skillsMissing: skillResult.missing,
383
+ effectiveAllowedTools,
384
+ mcpServerMap,
385
+ mcpServersApplied,
386
+ composioConnected,
387
+ externalConnected,
388
+ tier,
389
+ effort,
390
+ maxBudgetUsd: maxBudget,
391
+ agentSlug,
392
+ ownerName,
393
+ };
394
+ }
395
+ /**
396
+ * Run a cron job via the canonical SDK runAgent path.
397
+ *
398
+ * Composes the same context blocks the legacy runCronJob injects
399
+ * (progress, goals, delegation, team, criteria, skills, fanout
400
+ * directive), wires Composio + external MCP via the dedup-aware
401
+ * helper, then calls runAgent.
402
+ *
403
+ * The SDK handles the loop, compaction, subagent fanout, prompt
404
+ * caching, retries — none of which we wrap manually anymore.
405
+ */
406
+ export async function runAgentCron(opts) {
407
+ const plan = await buildCronExecutionPlan(opts);
408
+ const { builtPrompt, agentSlug, effort, maxBudgetUsd: maxBudget, effectiveAllowedTools, mcpServerMap, composioConnected, externalConnected, mcpServersApplied, } = plan;
291
409
  logger.info({
292
410
  job: opts.jobName,
293
- tier,
411
+ tier: plan.tier,
294
412
  profile: agentSlug,
295
- composioConnected: mcp.composioConnected,
296
- externalConnected: mcp.externalConnected,
297
- droppedClaudeAi: mcp.droppedClaudeAi,
298
- droppedComposio: mcp.droppedComposio,
413
+ composioConnected,
414
+ externalConnected,
299
415
  promptChars: builtPrompt.length,
416
+ pinnedSkills: opts.pinnedSkills?.length ?? 0,
417
+ skillsApplied: plan.skillsApplied.length,
418
+ skillsMissing: plan.skillsMissing.length,
419
+ trickAllowedTools: effectiveAllowedTools?.length,
420
+ trickAllowedMcp: opts.allowedMcpServers?.length,
300
421
  }, 'runAgentCron: dispatching to runAgent');
301
422
  const startedAt = Date.now();
302
423
  const result = await runAgent(builtPrompt, {
@@ -310,7 +431,8 @@ export async function runAgentCron(opts) {
310
431
  ...(maxBudget !== undefined ? { maxBudgetUsd: maxBudget } : {}),
311
432
  maxTurns: opts.maxTurns,
312
433
  abortSignal: opts.abortSignal,
313
- extraMcpServers: mcp.servers,
434
+ ...(effectiveAllowedTools ? { allowedTools: effectiveAllowedTools } : {}),
435
+ extraMcpServers: mcpServerMap,
314
436
  });
315
437
  // Mirror the run into transcripts so future chat recall can see it.
316
438
  // Legacy runCronJob did this with role='cron'; canonical needs the
@@ -346,8 +468,12 @@ export async function runAgentCron(opts) {
346
468
  return {
347
469
  ...result,
348
470
  builtPrompt,
349
- composioConnected: mcp.composioConnected,
350
- externalConnected: mcp.externalConnected,
471
+ composioConnected,
472
+ externalConnected,
473
+ skillsApplied: plan.skillsApplied,
474
+ skillsMissing: plan.skillsMissing,
475
+ allowedToolsApplied: effectiveAllowedTools,
476
+ mcpServersApplied,
351
477
  };
352
478
  }
353
479
  //# sourceMappingURL=run-agent-cron.js.map
@@ -60,6 +60,20 @@ export interface SkillMatch {
60
60
  export declare function searchSkills(query: string, limit?: number, agentSlug?: string, opts?: {
61
61
  suppressedNames?: Set<string>;
62
62
  }): SkillMatch[];
63
+ /**
64
+ * Load a single skill by its flattened slug (filename minus `.md`,
65
+ * with directory separators replaced by dashes — e.g.
66
+ * `auto/discord/send.md` → `auto-discord-send`). Walks the same
67
+ * agent-scoped + global directory list as `searchSkills`, with
68
+ * agent-scoped winning on collision. Honors the same suppression set.
69
+ *
70
+ * Returns a `SkillMatch` with `score = 0` so callers can mix pinned
71
+ * skills into the same render path as auto-matched ones, or `null`
72
+ * when the slug doesn't resolve (caller should warn, not fail).
73
+ */
74
+ export declare function loadSkillByName(name: string, agentSlug?: string, opts?: {
75
+ suppressedNames?: Set<string>;
76
+ }): SkillMatch | null;
63
77
  /** Record that a skill was used (bump use count). */
64
78
  export declare function recordSkillUse(skillName: string, agentSlug?: string): void;
65
79
  /** List all active skills (global + all agent-scoped). */
@@ -496,6 +496,56 @@ export function searchSkills(query, limit = 3, agentSlug, opts) {
496
496
  }
497
497
  return results.sort((a, b) => b.score - a.score).slice(0, limit);
498
498
  }
499
+ /**
500
+ * Load a single skill by its flattened slug (filename minus `.md`,
501
+ * with directory separators replaced by dashes — e.g.
502
+ * `auto/discord/send.md` → `auto-discord-send`). Walks the same
503
+ * agent-scoped + global directory list as `searchSkills`, with
504
+ * agent-scoped winning on collision. Honors the same suppression set.
505
+ *
506
+ * Returns a `SkillMatch` with `score = 0` so callers can mix pinned
507
+ * skills into the same render path as auto-matched ones, or `null`
508
+ * when the slug doesn't resolve (caller should warn, not fail).
509
+ */
510
+ export function loadSkillByName(name, agentSlug, opts) {
511
+ const dirs = [];
512
+ if (agentSlug) {
513
+ const agentDir = agentSkillsDir(agentSlug);
514
+ if (existsSync(agentDir))
515
+ dirs.push(agentDir);
516
+ }
517
+ if (existsSync(GLOBAL_SKILLS_DIR))
518
+ dirs.push(GLOBAL_SKILLS_DIR);
519
+ if (dirs.length === 0)
520
+ return null;
521
+ if (opts?.suppressedNames?.has(name))
522
+ return null;
523
+ for (const dir of dirs) {
524
+ const files = walkSkillFiles(dir);
525
+ for (const { filePath, relPath } of files) {
526
+ const slug = relPath.replace(/\.md$/, '').replace(/[\\/]/g, '-');
527
+ if (slug !== name)
528
+ continue;
529
+ try {
530
+ const raw = readFileSync(filePath, 'utf-8');
531
+ const parsed = matter(raw);
532
+ return {
533
+ name: slug,
534
+ title: parsed.data.title ?? slug,
535
+ content: parsed.content.slice(0, 1500),
536
+ score: 0,
537
+ toolsUsed: parsed.data.toolsUsed ?? [],
538
+ attachments: parsed.data.attachments ?? [],
539
+ skillDir: dir,
540
+ };
541
+ }
542
+ catch {
543
+ return null;
544
+ }
545
+ }
546
+ }
547
+ return null;
548
+ }
499
549
  /** Record that a skill was used (bump use count). */
500
550
  export function recordSkillUse(skillName, agentSlug) {
501
551
  try {