clementine-agent 1.18.124 → 1.18.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,6 +43,42 @@ export declare function computeEffectiveAllowedTools(jobAllow: string[] | undefi
43
43
  * MCP allowlist set.
44
44
  */
45
45
  export declare function applyMcpAllowlist<T>(servers: Record<string, T>, jobAllowedMcpServers: string[] | undefined): Record<string, T>;
46
+ /**
47
+ * Widen the cron's tool allowlist with the union of pinned-skill
48
+ * `clementine.tools.allow` declarations.
49
+ *
50
+ * **Why this exists:** Pinning a skill is a positive user signal — "I want
51
+ * this skill, with the tools it declares it needs." Before 1.18.125 the
52
+ * skill's `tools.allow` was rendered into the prompt as text only; the SDK
53
+ * never saw it, so a skill pinned to a cron with a narrower allowlist would
54
+ * silently fail with tool-not-found errors.
55
+ *
56
+ * **Semantics:**
57
+ * - Cron has no allowlist → return undefined (cron is unrestricted; pinned
58
+ * skill tools flow through the profile/default fallback in `runAgent`).
59
+ * We deliberately don't synthesize an allowlist out of just skill tools
60
+ * here, because that would NARROW an unrestricted cron to "only what the
61
+ * skills declared."
62
+ * - Cron has allowlist + skills declared tools → return the union (skills
63
+ * widen, never narrow, an existing constraint).
64
+ * - Cron has allowlist + no pinned-skill tools → return the cron's allowlist
65
+ * unchanged.
66
+ */
67
+ export declare function widenAllowlistWithSkillTools(jobAllow: string[] | undefined, pinnedSkillTools: string[] | undefined): string[] | undefined;
68
+ /**
69
+ * Extract every distinct `mcp__<server>__<tool>` server name referenced
70
+ * inside the bodies of pinned skills. Empty array when no references found.
71
+ */
72
+ export declare function extractMcpServersFromSkillBodies(bodies: string[]): string[];
73
+ /**
74
+ * Widen the cron's MCP-server allowlist with servers referenced inside
75
+ * pinned-skill bodies (e.g., a skill that calls `mcp__gmail__send_message`
76
+ * implicitly needs the `gmail` server connected).
77
+ *
78
+ * Same semantics as `widenAllowlistWithSkillTools`: only widens an existing
79
+ * allowlist; doesn't synthesize one when the cron is unrestricted.
80
+ */
81
+ export declare function widenMcpAllowlistWithSkillRefs(jobMcpAllow: string[] | undefined, skillReferencedServers: string[]): string[] | undefined;
46
82
  export interface SkillContextResult {
47
83
  /** The rendered "Learned Procedures" block (or empty string when no skills loaded). */
48
84
  text: string;
@@ -54,6 +90,16 @@ export interface SkillContextResult {
54
90
  }>;
55
91
  /** Pinned slugs that didn't resolve (deleted/renamed/suppressed). Logged + surfaced. */
56
92
  missing: string[];
93
+ /** Union of every `clementine.tools.allow` entry from pinned skills. Used by
94
+ * `buildCronExecutionPlan` to widen the cron's tool allowlist so a pinned
95
+ * skill's declared tools survive into the SDK call. Empty array when no
96
+ * pinned skill declared a `tools.allow` list. Auto-matched skills do NOT
97
+ * contribute — only explicit pins widen scope. */
98
+ pinnedToolsRequested: string[];
99
+ /** Bodies of pinned skills only — used for `mcp__server__tool` reference
100
+ * extraction so a skill's MCP usage propagates to `allowedMcpServers`.
101
+ * Auto-matched skills excluded for the same reason as above. */
102
+ pinnedBodies: string[];
57
103
  }
58
104
  /**
59
105
  * Build the matched-skills block (procedures learned from prior successful runs).
@@ -196,6 +242,18 @@ export interface CronExecutionPlan {
196
242
  * being set inside the skill folder. Deduped + filtered to existing
197
243
  * paths. Empty when the trick has no addDirs and no folder-form pins. */
198
244
  additionalDirectories: string[];
245
+ /** Diagnostics: which scopes a pinned skill widened on this run. Empty
246
+ * arrays when no widening happened. Surfaced in the Preview UI so users
247
+ * see "this skill brought in `Bash` and `gmail` MCP" without reading
248
+ * source. */
249
+ widenedFromSkills: {
250
+ /** Tool names a pinned skill's `clementine.tools.allow` added on top of
251
+ * the cron's own allowlist. Empty when nothing was widened. */
252
+ tools: string[];
253
+ /** MCP server names a pinned skill's body referenced (`mcp__server__tool`)
254
+ * that the cron's `allowedMcpServers` didn't already include. */
255
+ mcpServers: string[];
256
+ };
199
257
  }
200
258
  /**
201
259
  * Plan a cron run — assemble all context, resolve skills, intersect tool/MCP
@@ -72,6 +72,70 @@ export function applyMcpAllowlist(servers, jobAllowedMcpServers) {
72
72
  const allow = new Set(jobAllowedMcpServers);
73
73
  return Object.fromEntries(Object.entries(servers).filter(([name]) => allow.has(name)));
74
74
  }
75
+ /**
76
+ * Widen the cron's tool allowlist with the union of pinned-skill
77
+ * `clementine.tools.allow` declarations.
78
+ *
79
+ * **Why this exists:** Pinning a skill is a positive user signal — "I want
80
+ * this skill, with the tools it declares it needs." Before 1.18.125 the
81
+ * skill's `tools.allow` was rendered into the prompt as text only; the SDK
82
+ * never saw it, so a skill pinned to a cron with a narrower allowlist would
83
+ * silently fail with tool-not-found errors.
84
+ *
85
+ * **Semantics:**
86
+ * - Cron has no allowlist → return undefined (cron is unrestricted; pinned
87
+ * skill tools flow through the profile/default fallback in `runAgent`).
88
+ * We deliberately don't synthesize an allowlist out of just skill tools
89
+ * here, because that would NARROW an unrestricted cron to "only what the
90
+ * skills declared."
91
+ * - Cron has allowlist + skills declared tools → return the union (skills
92
+ * widen, never narrow, an existing constraint).
93
+ * - Cron has allowlist + no pinned-skill tools → return the cron's allowlist
94
+ * unchanged.
95
+ */
96
+ export function widenAllowlistWithSkillTools(jobAllow, pinnedSkillTools) {
97
+ if (!jobAllow?.length)
98
+ return undefined;
99
+ if (!pinnedSkillTools?.length)
100
+ return [...jobAllow];
101
+ return [...new Set([...jobAllow, ...pinnedSkillTools])];
102
+ }
103
+ /** Match `mcp__SERVER__TOOL` references in skill body Markdown.
104
+ * Server names can contain single underscores (`Bright_Data`,
105
+ * `claude_ai_Microsoft_365`) but never `__` (double-underscore is the
106
+ * delimiter). The regex captures the server segment between the leading
107
+ * `mcp__` and the next `__`. Anchored on word boundaries so it doesn't
108
+ * catch substrings of longer identifiers. */
109
+ const MCP_TOOL_REF = /mcp__([A-Za-z0-9-]+(?:_[A-Za-z0-9-]+)*)__/g;
110
+ /**
111
+ * Extract every distinct `mcp__<server>__<tool>` server name referenced
112
+ * inside the bodies of pinned skills. Empty array when no references found.
113
+ */
114
+ export function extractMcpServersFromSkillBodies(bodies) {
115
+ const found = new Set();
116
+ for (const body of bodies) {
117
+ if (!body)
118
+ continue;
119
+ for (const m of body.matchAll(MCP_TOOL_REF))
120
+ found.add(m[1]);
121
+ }
122
+ return [...found];
123
+ }
124
+ /**
125
+ * Widen the cron's MCP-server allowlist with servers referenced inside
126
+ * pinned-skill bodies (e.g., a skill that calls `mcp__gmail__send_message`
127
+ * implicitly needs the `gmail` server connected).
128
+ *
129
+ * Same semantics as `widenAllowlistWithSkillTools`: only widens an existing
130
+ * allowlist; doesn't synthesize one when the cron is unrestricted.
131
+ */
132
+ export function widenMcpAllowlistWithSkillRefs(jobMcpAllow, skillReferencedServers) {
133
+ if (!jobMcpAllow?.length)
134
+ return undefined;
135
+ if (!skillReferencedServers.length)
136
+ return [...jobMcpAllow];
137
+ return [...new Set([...jobMcpAllow, ...skillReferencedServers])];
138
+ }
75
139
  function capContextItem(s) {
76
140
  if (!s)
77
141
  return '';
@@ -235,6 +299,8 @@ function buildCriteriaContext(successCriteria) {
235
299
  export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSkills, memoryStore, opts) {
236
300
  const applied = [];
237
301
  const missing = [];
302
+ const pinnedToolsRequested = [];
303
+ const pinnedBodies = [];
238
304
  try {
239
305
  const { searchSkills, recordSkillUse, loadSkillByName } = await import('./skill-extractor.js');
240
306
  const skillQuery = jobName + ' ' + jobPrompt.slice(0, 200);
@@ -305,8 +371,23 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
305
371
  seen.add(m.name);
306
372
  }
307
373
  }
374
+ // 1.18.125 — collect pinned-skill tool declarations + bodies so the
375
+ // cron planner can widen `allowedTools` / `allowedMcpServers` with what
376
+ // the pinned skills explicitly need. Pins (not auto-matches) widen scope
377
+ // because pinning is the user's explicit signal of intent.
378
+ const pinnedSeen = new Set();
379
+ for (const s of prepared) {
380
+ if (s.source !== 'pinned')
381
+ continue;
382
+ if (pinnedSeen.has(s.name))
383
+ continue;
384
+ pinnedSeen.add(s.name);
385
+ for (const t of s.toolsUsed)
386
+ pinnedToolsRequested.push(t);
387
+ pinnedBodies.push(s.content);
388
+ }
308
389
  if (prepared.length === 0)
309
- return { text: '', applied, missing };
390
+ return { text: '', applied, missing, pinnedToolsRequested: [], pinnedBodies: [] };
310
391
  // Folder-form bundled-file budget. Anthropic skill spec says the body
311
392
  // should be ≤500 lines; bundled files (templates/, reference docs)
312
393
  // load on top. We cap aggregate inlined bundle bytes so a skill with a
@@ -398,11 +479,11 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
398
479
  return block;
399
480
  });
400
481
  const text = `## Learned Procedures (from past successful executions)\nFollow these proven approaches when applicable:\n\n${skillLines.join('\n\n')}\n\n`;
401
- return { text, applied, missing };
482
+ return { text, applied, missing, pinnedToolsRequested: [...new Set(pinnedToolsRequested)], pinnedBodies };
402
483
  }
403
484
  catch (err) {
404
485
  logger.debug({ err, jobName }, 'buildSkillContext failed (non-fatal)');
405
- return { text: '', applied, missing };
486
+ return { text: '', applied, missing, pinnedToolsRequested: [], pinnedBodies: [] };
406
487
  }
407
488
  }
408
489
  /**
@@ -463,15 +544,25 @@ export async function buildCronExecutionPlan(opts) {
463
544
  ].filter(Boolean).join('\n\n'),
464
545
  profile: opts.profile,
465
546
  });
547
+ // 1.18.125 — pinned-skill scope widening (SDK alignment).
548
+ // A pinned skill that declares `clementine.tools.allow` or references
549
+ // `mcp__server__tool` in its body needs those tools/servers to actually
550
+ // be live for the SDK call — not just rendered into the prompt as text.
551
+ // We widen (never narrow) the cron's allowlists with what the pinned
552
+ // skills declared. Auto-matched skills don't widen scope (only explicit
553
+ // pins do — the user's positive signal).
554
+ const skillReferencedMcpServers = extractMcpServersFromSkillBodies(skillResult.pinnedBodies);
555
+ const widenedJobAllowedTools = widenAllowlistWithSkillTools(opts.allowedTools, skillResult.pinnedToolsRequested);
556
+ const widenedJobMcpAllowlist = widenMcpAllowlistWithSkillRefs(opts.allowedMcpServers, skillReferencedMcpServers);
466
557
  // Per-trick MCP allowlist: post-filter on the profile-narrowed map.
467
- // Effective set = profile ∩ trick.
468
- const mcpServerMap = applyMcpAllowlist(mcp.servers, opts.allowedMcpServers);
469
- const allowSet = opts.allowedMcpServers?.length ? new Set(opts.allowedMcpServers) : null;
558
+ // Effective set = profile ∩ trick (widened).
559
+ const mcpServerMap = applyMcpAllowlist(mcp.servers, widenedJobMcpAllowlist);
560
+ const allowSet = widenedJobMcpAllowlist?.length ? new Set(widenedJobMcpAllowlist) : null;
470
561
  const composioConnected = allowSet ? mcp.composioConnected.filter(n => allowSet.has(n)) : mcp.composioConnected;
471
562
  const externalConnected = allowSet ? mcp.externalConnected.filter(n => allowSet.has(n)) : mcp.externalConnected;
472
563
  const mcpServersApplied = Object.keys(mcpServerMap);
473
- // Per-trick tool allowlist intersection.
474
- const effectiveAllowedTools = computeEffectiveAllowedTools(opts.allowedTools, opts.profile?.team?.allowedTools);
564
+ // Per-trick tool allowlist intersection (widened by pinned-skill needs).
565
+ const effectiveAllowedTools = computeEffectiveAllowedTools(widenedJobAllowedTools, opts.profile?.team?.allowedTools);
475
566
  // Per-tier cap from config (BUDGET.cronT1 / BUDGET.cronT2). 0 = uncapped.
476
567
  const configuredCap = tier >= 2 ? BUDGET.cronT2 : BUDGET.cronT1;
477
568
  const maxBudget = opts.maxBudgetUsd ?? (configuredCap > 0 ? configuredCap : undefined);
@@ -516,6 +607,12 @@ export async function buildCronExecutionPlan(opts) {
516
607
  return false;
517
608
  }
518
609
  });
610
+ // Diagnostics: what did the pinned skills add on top of what the cron
611
+ // already declared? Renders in the Preview UI as "Skill widened scope: …"
612
+ const baseToolSet = new Set(opts.allowedTools ?? []);
613
+ const widenedToolsFromSkills = skillResult.pinnedToolsRequested.filter(t => !baseToolSet.has(t));
614
+ const baseMcpSet = new Set(opts.allowedMcpServers ?? []);
615
+ const widenedMcpFromSkills = skillReferencedMcpServers.filter(s => !baseMcpSet.has(s));
519
616
  return {
520
617
  builtPrompt,
521
618
  contextBlocks: {
@@ -537,6 +634,13 @@ export async function buildCronExecutionPlan(opts) {
537
634
  ownerName,
538
635
  predictable,
539
636
  additionalDirectories,
637
+ widenedFromSkills: {
638
+ // Only surface widening when the cron actually had a base allowlist —
639
+ // otherwise the cron is unrestricted and "widening" isn't a meaningful
640
+ // concept (the skills' tools were already implicitly allowed).
641
+ tools: opts.allowedTools?.length ? widenedToolsFromSkills : [],
642
+ mcpServers: opts.allowedMcpServers?.length ? widenedMcpFromSkills : [],
643
+ },
540
644
  };
541
645
  }
542
646
  /**
@@ -565,6 +669,7 @@ export async function runAgentCron(opts) {
565
669
  skillsMissing: plan.skillsMissing.length,
566
670
  trickAllowedTools: effectiveAllowedTools?.length,
567
671
  trickAllowedMcp: opts.allowedMcpServers?.length,
672
+ widenedFromSkills: plan.widenedFromSkills,
568
673
  }, 'runAgentCron: dispatching to runAgent');
569
674
  const startedAt = Date.now();
570
675
  const result = await runAgent(builtPrompt, {
@@ -110,5 +110,22 @@ export interface WriteSkillResult {
110
110
  overwrote: boolean;
111
111
  }
112
112
  export declare function writeSkill(input: WriteSkillInput): WriteSkillResult;
113
+ export interface SkillBackupSweepResult {
114
+ /** Files removed this pass. Absolute paths. */
115
+ removed: string[];
116
+ /** Files inspected (matched the pattern). Useful for "nothing to do" telemetry. */
117
+ inspected: number;
118
+ /** Files that matched the pattern but were younger than the cutoff (kept). */
119
+ keptFresh: number;
120
+ }
121
+ /**
122
+ * Sweep `.md.bak` skill backups older than `LEGACY_BAK_AGE_DAYS` from the
123
+ * global skills directory and from every per-agent skills directory under
124
+ * `00-System/agents/<slug>/skills/`. Best-effort: per-file errors are
125
+ * swallowed so a permission glitch on one file doesn't stop the sweep.
126
+ *
127
+ * Idempotent — safe to call repeatedly. Returns counts for logging.
128
+ */
129
+ export declare function cleanupLegacySkillBackups(): SkillBackupSweepResult;
113
130
  export {};
114
131
  //# sourceMappingURL=skill-store.d.ts.map
@@ -21,7 +21,7 @@
21
21
  * runner. Phase C wires runtime invocation. Phase E migrates legacy
22
22
  * crons → folder-form skills.
23
23
  */
24
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
25
25
  import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import matter from 'gray-matter';
@@ -713,4 +713,83 @@ export function writeSkill(input) {
713
713
  writeFileSync(entryPath, content);
714
714
  return { filePath: entryPath, name: input.name, overwrote: existed };
715
715
  }
716
+ // ── Legacy backup janitor (1.18.125) ─────────────────────────────────
717
+ //
718
+ // Pre-1.18.124, `saveActiveSkill` wrote a per-overwrite `.md.bak` next to
719
+ // every skill it updated. The new `writeSkill` path doesn't create these,
720
+ // but the old ones rot in the vault forever unless something sweeps them.
721
+ // `cleanupLegacySkillBackups` finds `.md.bak` files older than the cutoff
722
+ // and removes them. Runs from the periodic memory-maintenance cycle so
723
+ // users don't need to know it exists.
724
+ //
725
+ // Conservative: 30-day age floor + only the slug-named `.md.bak` pattern.
726
+ // Anything mtime-recent stays put in case a user is mid-rollback.
727
+ const LEGACY_BAK_AGE_DAYS = 30;
728
+ const LEGACY_BAK_AGE_MS = LEGACY_BAK_AGE_DAYS * 24 * 60 * 60 * 1000;
729
+ /**
730
+ * Sweep `.md.bak` skill backups older than `LEGACY_BAK_AGE_DAYS` from the
731
+ * global skills directory and from every per-agent skills directory under
732
+ * `00-System/agents/<slug>/skills/`. Best-effort: per-file errors are
733
+ * swallowed so a permission glitch on one file doesn't stop the sweep.
734
+ *
735
+ * Idempotent — safe to call repeatedly. Returns counts for logging.
736
+ */
737
+ export function cleanupLegacySkillBackups() {
738
+ const result = { removed: [], inspected: 0, keptFresh: 0 };
739
+ const cutoff = Date.now() - LEGACY_BAK_AGE_MS;
740
+ const sweepRoots = [globalSkillsDir()];
741
+ // Per-agent skill dirs — discover via the agents/ folder.
742
+ try {
743
+ const base = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
744
+ const agentsDir = path.join(base, 'vault', '00-System', 'agents');
745
+ if (existsSync(agentsDir)) {
746
+ for (const entry of readdirSync(agentsDir)) {
747
+ if (entry.startsWith('.'))
748
+ continue;
749
+ const agentSkillsDir = path.join(agentsDir, entry, 'skills');
750
+ if (existsSync(agentSkillsDir))
751
+ sweepRoots.push(agentSkillsDir);
752
+ }
753
+ }
754
+ }
755
+ catch { /* non-fatal — global sweep still runs */ }
756
+ for (const root of sweepRoots) {
757
+ let entries;
758
+ try {
759
+ entries = readdirSync(root);
760
+ }
761
+ catch {
762
+ continue;
763
+ }
764
+ for (const entry of entries) {
765
+ // Match exactly the legacy pattern. Don't touch anything else — we
766
+ // never want to nuke `templates/old-draft.md.bak` inside a folder skill,
767
+ // for instance. The legacy writer only ever produced flat
768
+ // `<slug>.md.bak` siblings, so that's all we sweep.
769
+ if (!entry.endsWith('.md.bak'))
770
+ continue;
771
+ const full = path.join(root, entry);
772
+ let st;
773
+ try {
774
+ st = statSync(full);
775
+ }
776
+ catch {
777
+ continue;
778
+ }
779
+ if (!st.isFile())
780
+ continue;
781
+ result.inspected++;
782
+ if (st.mtimeMs > cutoff) {
783
+ result.keptFresh++;
784
+ continue;
785
+ }
786
+ try {
787
+ unlinkSync(full);
788
+ result.removed.push(full);
789
+ }
790
+ catch { /* skip — permission or race; next sweep retries */ }
791
+ }
792
+ }
793
+ return result;
794
+ }
716
795
  //# sourceMappingURL=skill-store.js.map
@@ -146,6 +146,19 @@ export async function runStartupMaintenance(store) {
146
146
  catch (err) {
147
147
  logger.warn({ err }, 'Startup janitor failed');
148
148
  }
149
+ // Vault janitor (1.18.125) — sweep legacy `.md.bak` skill backups so the
150
+ // first daemon boot after upgrade clears the existing leak instead of
151
+ // waiting 6h for the periodic cycle.
152
+ try {
153
+ const { cleanupLegacySkillBackups } = await import('../agent/skill-store.js');
154
+ const sweep = cleanupLegacySkillBackups();
155
+ if (sweep.removed.length > 0) {
156
+ logger.info({ removed: sweep.removed.length, inspected: sweep.inspected, keptFresh: sweep.keptFresh }, 'Startup .md.bak sweep');
157
+ }
158
+ }
159
+ catch (err) {
160
+ logger.warn({ err }, 'Startup .md.bak sweep failed');
161
+ }
149
162
  // Embedding warm-up — pre-embed the most-cited chunks in the background so
150
163
  // the first retrievals after startup don't pay cold-start latency. Fire
151
164
  // and forget; never blocks startup.
@@ -247,6 +260,20 @@ export async function runPeriodicCycle(store, llmCall) {
247
260
  catch (err) {
248
261
  logger.warn({ err }, 'Periodic janitor failed');
249
262
  }
263
+ // 6a. Vault janitor — sweep legacy `.md.bak` skill backups (1.18.125).
264
+ // Pre-1.18.124 saveActiveSkill wrote per-overwrite backups; the new
265
+ // writeSkill path doesn't, so the old ones rot in the vault. Cap age
266
+ // at 30 days to give rollback room. No-op when nothing to sweep.
267
+ try {
268
+ const { cleanupLegacySkillBackups } = await import('../agent/skill-store.js');
269
+ const sweep = cleanupLegacySkillBackups();
270
+ if (sweep.removed.length > 0) {
271
+ logger.info({ removed: sweep.removed.length, inspected: sweep.inspected, keptFresh: sweep.keptFresh }, 'Legacy skill .md.bak sweep');
272
+ }
273
+ }
274
+ catch (err) {
275
+ logger.warn({ err }, 'Legacy skill .md.bak sweep failed');
276
+ }
250
277
  // 6b. Integrity probes — FTS health, orphan derived_from, embedding gaps.
251
278
  try {
252
279
  const report = runIntegrityProbes(store);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.124",
3
+ "version": "1.18.125",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",