clementine-agent 1.18.171 → 1.18.173

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.
@@ -58,10 +58,21 @@ import pino from 'pino';
58
58
  import { searchSkills } from './skill-extractor.js';
59
59
  const logger = pino({ name: 'clementine.chat-skill-resolver' });
60
60
  // ── Tunables ──────────────────────────────────────────────────────────
61
- /** Default minimum score to consider a skill match real. Mirrors the
62
- * legacy `assistant.ts:1492` threshold. Skill auto-match is heuristic;
63
- * this filter keeps weak matches from injecting unrelated tooling. */
61
+ /** Default minimum score for user-authored skill matches. Mirrors the
62
+ * legacy `assistant.ts:1492` threshold. */
64
63
  const DEFAULT_MIN_SCORE = 4;
64
+ /** Higher threshold applied when ALL matches are auto-generated MCP-derived
65
+ * skills (no user-authored signal). 1.18.171 hotfix: a vague chat message
66
+ * ("did our changes break it?") matched three unrelated auto-skills
67
+ * (ElevenLabs + apify) at score 5.5 each because semantic-only matching
68
+ * drifted toward whatever embeddings were closest. Bumping the bar for
69
+ * auto-only match-sets keeps that noise out of the system prompt. */
70
+ const AUTO_ONLY_MIN_SCORE = 8;
71
+ /** When ALL matches are auto-generated AND they reference this many or
72
+ * more distinct servers, the cluster is treated as semantic-noise and
73
+ * the injection is skipped entirely. Three different services have no
74
+ * business being "all relevant" to a single user message. */
75
+ const AUTO_ONLY_SERVER_NOISE_THRESHOLD = 3;
65
76
  /** Default top-K matches to aggregate. Single-tool requests usually
66
77
  * return one strong match; category requests ("salesforce") return
67
78
  * several similarly-scored auto-skills. Top-3 covers both. Raising
@@ -228,9 +239,36 @@ export function resolveSkillsForChat(userMessage, opts = {}) {
228
239
  logger.debug({ err }, 'chat-skill-resolver: searchSkills failed (non-fatal)');
229
240
  return empty;
230
241
  }
231
- const matches = candidates
232
- .filter((m) => m.score >= minScore)
242
+ // 1.18.171 hotfix: detect auto-only match-sets and apply the higher
243
+ // threshold + noise-cluster filter so vague chat messages don't surface
244
+ // unrelated MCP context. See the comment block on AUTO_ONLY_MIN_SCORE.
245
+ const isAutoMatch = (m) => m.name.startsWith('auto-');
246
+ const candidatesAllAuto = candidates.length > 0 && candidates.every(isAutoMatch);
247
+ const effectiveMinScore = candidatesAllAuto
248
+ ? Math.max(minScore, AUTO_ONLY_MIN_SCORE)
249
+ : minScore;
250
+ let matches = candidates
251
+ .filter((m) => m.score >= effectiveMinScore)
233
252
  .slice(0, limit);
253
+ // Auto-only noise cluster filter: when every survivor is auto AND they
254
+ // collectively reference too many distinct servers (no semantic
255
+ // clustering on a single service), treat as drift and drop.
256
+ if (matches.length >= 2 && matches.every(isAutoMatch)) {
257
+ const seenServers = new Set();
258
+ for (const m of matches) {
259
+ for (const s of extractMcpServersFromMatch(m))
260
+ seenServers.add(s);
261
+ }
262
+ if (seenServers.size >= AUTO_ONLY_SERVER_NOISE_THRESHOLD) {
263
+ logger.info({
264
+ droppedMatches: matches.map(m => ({ name: m.name, score: Number(m.score.toFixed(2)) })),
265
+ distinctServers: [...seenServers],
266
+ reason: 'auto_only_server_cluster_too_wide',
267
+ queryChars,
268
+ }, 'chat-skill-resolver: dropped match-set (semantic noise)');
269
+ matches = [];
270
+ }
271
+ }
234
272
  if (matches.length === 0) {
235
273
  return {
236
274
  ...empty,
@@ -86,6 +86,7 @@ export function invalidateMcpStatusEntry(name) {
86
86
  }
87
87
  import { BASE_DIR, PKG_DIR, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, normalizeClaudeSdkOptionsForOneMillionContext, TOOL_OUTPUT_GUARD, } from '../config.js';
88
88
  import { buildGuardHooks } from './tool-output-guard.js';
89
+ import { buildDedupHook } from './tool-call-dedup.js';
89
90
  import { buildAgentMap } from './agent-definitions.js';
90
91
  import { buildExecutionToolPolicy, } from './execution-policy.js';
91
92
  const MCP_SERVER_SCRIPT = path.join(PKG_DIR, 'dist', 'tools', 'mcp-server.js');
@@ -196,13 +197,20 @@ export async function runAgent(prompt, opts) {
196
197
  ? requestedBudget
197
198
  : undefined;
198
199
  const startedAt = Date.now();
199
- // Build the AgentDefinition map. Caller can override; otherwise we
200
- // use the standard system subagents + hired-agent profiles.
201
- const agents = opts.agents ?? buildAgentMap({
200
+ // Build the AgentDefinition map.
201
+ // - Default: planner/researcher/cron-fixer + hired-agent profiles.
202
+ // - Caller-supplied agents (opts.agents) MERGE over the defaults rather
203
+ // than REPLACE them (1.18.173). `runSkill`'s auto-delegation path
204
+ // needs to inject a per-run `skill-worker` definition while keeping
205
+ // the planner/researcher/etc. available for deeper delegation.
206
+ // Tests that want a fully isolated map pass an explicit override
207
+ // via the `replaceAgents` option below.
208
+ const defaultAgents = buildAgentMap({
202
209
  profileManager: opts.agentManager ?? undefined,
203
210
  isAutonomous: source === 'cron' || source === 'heartbeat',
204
211
  activeAgentSlug: opts.profile?.slug,
205
212
  });
213
+ const agents = opts.agents ? { ...defaultAgents, ...opts.agents } : defaultAgents;
206
214
  // Wrap prompt to direct Claude to a specific subagent when caller asks.
207
215
  // Per SDK docs: explicit invocation = "Use the X agent to..."
208
216
  const effectivePrompt = opts.forceSubagent && agents[opts.forceSubagent]
@@ -341,6 +349,34 @@ export async function runAgent(prompt, opts) {
341
349
  },
342
350
  })
343
351
  : { hooks: {}, stats: { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0 } };
352
+ // ── Tool-call dedup hook (1.18.173) ─────────────────────────────────
353
+ // Breaks the "re-fetch after compaction" loop that crashed the
354
+ // imessage-triage cron on 2026-05-11 (4× identical tool calls →
355
+ // SDK autocompact-thrashing abort). PreToolUse hook detects same
356
+ // (toolName, inputHash) within 60s: 2nd call gets a soft hint, 3rd+
357
+ // is denied so the model can't burn turns re-calling the same data.
358
+ // Defense-in-depth — the cleaner fix (delegating to a subagent so the
359
+ // parent never re-fetches in the first place) lives in run-skill.ts.
360
+ const dedup = buildDedupHook({
361
+ runId,
362
+ onDecision: (info) => {
363
+ if (info.decision === 'allow')
364
+ return;
365
+ writeEvent({
366
+ kind: 'error',
367
+ ts: new Date().toISOString(),
368
+ sessionId,
369
+ toolError: `_clementine_dedup:${info.decision} ${info.toolName} call#${info.callCount} @${info.sinceFirstMs}ms`,
370
+ });
371
+ },
372
+ });
373
+ // Merge hook maps from the two modules. SDK accepts arrays of
374
+ // HookCallbackMatcher per event; we concatenate.
375
+ const mergedHooks = { ...guard.hooks };
376
+ for (const [evt, matchers] of Object.entries(dedup.hooks)) {
377
+ const existing = mergedHooks[evt] ?? [];
378
+ mergedHooks[evt] = [...existing, ...matchers];
379
+ }
344
380
  // Apply 1M-context env normalization (existing infra)
345
381
  const sdkOptionsRaw = {
346
382
  systemPrompt: profileAppend
@@ -380,10 +416,11 @@ export async function runAgent(prompt, opts) {
380
416
  ...(opts.additionalDirectories && opts.additionalDirectories.length > 0
381
417
  ? { additionalDirectories: opts.additionalDirectories }
382
418
  : {}),
383
- // 1.18.169 — install the tool-output guard hooks. SDK types accept
384
- // `hooks` keyed by HookEvent; the empty object is a no-op when the
385
- // guard is disabled.
386
- ...(Object.keys(guard.hooks).length > 0 ? { hooks: guard.hooks } : {}),
419
+ // 1.18.169 — install the tool-output guard hooks.
420
+ // 1.18.173 merged with the tool-call dedup hooks (PreToolUse).
421
+ // SDK types accept `hooks` keyed by HookEvent; the empty object is
422
+ // a no-op when both guards are disabled.
423
+ ...(Object.keys(mergedHooks).length > 0 ? { hooks: mergedHooks } : {}),
387
424
  };
388
425
  const sdkOptions = normalizeClaudeSdkOptionsForOneMillionContext(sdkOptionsRaw);
389
426
  logger.info({
@@ -640,6 +677,14 @@ export async function runAgent(prompt, opts) {
640
677
  compactions: guard.stats.compactions,
641
678
  ceilingHits: guard.stats.ceilingHits,
642
679
  } : undefined,
680
+ // 1.18.173 — tool-call dedup summary. Non-zero warned/blocked means
681
+ // the model tried to re-fetch identical data (typically a
682
+ // post-compaction refetch loop).
683
+ dedup: dedup.stats.inspected > 0 ? {
684
+ inspected: dedup.stats.inspected,
685
+ warned: dedup.stats.warned,
686
+ blocked: dedup.stats.blocked,
687
+ } : undefined,
643
688
  }, 'runAgent: query complete');
644
689
  // PRD §6 Phase 4e: subagent transcript backfill (Path C). The SDK persists
645
690
  // every subagent's full message stream to ~/.claude/projects/<encoded-cwd>/
@@ -145,6 +145,13 @@ export declare function buildSkillPrompt(skill: Skill, inputs: Record<string, st
145
145
  * After the SDK returns, `clementine.success.schema` (when set) is
146
146
  * ajv-validated against the response.
147
147
  *
148
+ * **Autonomous runs (1.18.173)**: When `source` is one of
149
+ * AUTONOMOUS_SOURCES, the skill runs through the auto-delegating
150
+ * wrapper: a thin parent dispatches to a `skill-worker` subagent which
151
+ * does all the work in its own context. Closes the
152
+ * "refetch-after-compaction" loop class permanently. Skills can opt out
153
+ * via frontmatter `clementine.execution.inline: true`.
154
+ *
148
155
  * This function never throws — failures (skill not found, SDK error,
149
156
  * timeout) are returned as `{ ok: false, error }`. The caller (chat,
150
157
  * cron, sub-agent, MCP tool) decides how to surface that.
@@ -28,6 +28,7 @@ import path from 'node:path';
28
28
  import pino from 'pino';
29
29
  import { getSkill } from './skill-store.js';
30
30
  import { runAgent } from './run-agent.js';
31
+ import { MODELS } from '../config.js';
31
32
  const logger = pino({ name: 'clementine.run-skill' });
32
33
  // ── Mustache substitution ─────────────────────────────────────────────
33
34
  /** Matches `{{var_name}}` with optional whitespace. var_name is
@@ -183,6 +184,133 @@ async function validateSkillOutput(output, schema) {
183
184
  return { tried: true, pass: false, errors: [`schema compile error: ${err}`] };
184
185
  }
185
186
  }
187
+ // ── Autonomous delegation (1.18.173) ──────────────────────────────────
188
+ /**
189
+ * Sources whose runs should default to the auto-delegating wrapper.
190
+ * In autonomous mode the parent agent immediately dispatches the entire
191
+ * skill body to a `skill-worker` subagent via the Agent tool. That keeps
192
+ * the parent's context tiny (no tool results ever land in it) so the SDK
193
+ * never has to compact mid-run, and post-compaction "refetch loops"
194
+ * become impossible — the parent never had the data to lose.
195
+ *
196
+ * Interactive sources ('chat', 'skill' invoked directly by a chat user)
197
+ * stay on the inline path: the user is waiting on output and the extra
198
+ * subagent dispatch latency is a worse UX tradeoff than the small
199
+ * compaction risk on a single conversational turn.
200
+ */
201
+ const AUTONOMOUS_SOURCES = new Set([
202
+ 'cron',
203
+ 'scheduled-skill',
204
+ 'heartbeat',
205
+ 'team-task',
206
+ ]);
207
+ /**
208
+ * Decide whether a runSkill call should use the auto-delegating
209
+ * (subagent) wrapper. Skills can opt out via frontmatter
210
+ * `clementine.execution.inline: true` for procedures the author has
211
+ * verified fit cleanly in one context (e.g., a 2-line script call).
212
+ */
213
+ function shouldAutoDelegate(skill, source) {
214
+ if (!AUTONOMOUS_SOURCES.has(source))
215
+ return false;
216
+ const execMode = skill.frontmatter?.clementine?.execution?.inline;
217
+ if (execMode === true)
218
+ return false;
219
+ return true;
220
+ }
221
+ /**
222
+ * Resolve the model string to use for an autonomous run. The 1M-context
223
+ * variant gives the worker subagent 5× the room of the standard 200K
224
+ * window — enough headroom that compaction is rare and the
225
+ * "refetch-after-compact" loop pattern (seen in the 2026-05-11
226
+ * imessage-triage failures) never occurs in practice.
227
+ *
228
+ * The actual 1M routing is gated by the user's plan (see
229
+ * config.ts:usesOneMillionContext) and the model family — Haiku doesn't
230
+ * support 1M, and Sonnet 1M needs the [1m] suffix. We return the full
231
+ * Sonnet model ID with [1m] appended; downstream
232
+ * normalizeClaudeSdkOptionsForOneMillionContext strips it back off when
233
+ * the plan doesn't support it.
234
+ */
235
+ function resolveAutonomousModel(explicitModel, skillModel) {
236
+ // Caller's explicit model wins.
237
+ if (explicitModel)
238
+ return explicitModel;
239
+ // Skill-declared model wins next.
240
+ if (skillModel)
241
+ return skillModel;
242
+ // Default: Sonnet [1m]. The normalizer will strip [1m] if the user's
243
+ // plan doesn't include it, falling back to standard Sonnet — still
244
+ // works, just with less headroom.
245
+ const base = MODELS.sonnet;
246
+ if (!base)
247
+ return undefined;
248
+ if (/\[1m\]/i.test(base))
249
+ return base;
250
+ return `${base}[1m]`;
251
+ }
252
+ /**
253
+ * Build the AgentDefinition for the `skill-worker` subagent that
254
+ * executes this skill in an isolated context. The subagent's system
255
+ * prompt is the skill body; its tools are the skill's computed
256
+ * allowlist; its model is the same 1M-context model the parent uses
257
+ * (the worker is where the real data flows — the parent stays tiny).
258
+ *
259
+ * `description` is what the SDK shows the parent for routing decisions.
260
+ * Since the parent is `forceSubagent`'d to this worker, the description
261
+ * mostly serves as transcript context.
262
+ */
263
+ function buildSkillWorkerAgent(skill, effectiveTools, model, workerMaxTurns) {
264
+ const def = {
265
+ description: `Executes the "${skill.frontmatter.name}" scheduled skill end-to-end in an isolated context window. ` +
266
+ `Reads any data the skill needs, processes it, performs the skill's described delivery action ` +
267
+ `(e.g., sends a Discord/Slack notification), and returns a concise summary to the orchestrator.`,
268
+ prompt: `You are the worker subagent for the "${skill.frontmatter.name}" scheduled skill.\n\n` +
269
+ `Your job is to execute the procedure below from start to finish in a single subagent run. ` +
270
+ `You have your own isolated context window — do NOT save state for a parent agent; if the ` +
271
+ `procedure calls for sending a notification, YOU send it (you have the relevant tools).\n\n` +
272
+ `Return a single concise final response describing what happened (e.g., "Sent Discord DM about ` +
273
+ `2 actionable items, ignored 8 spam"). Do not return raw tool output; do not narrate every step. ` +
274
+ `If nothing actionable was found and the procedure says exit silently, return "No action needed."\n\n` +
275
+ `## Procedure\n\n${skill.body}`,
276
+ tools: effectiveTools,
277
+ // SDK accepts 'sonnet' / 'opus' / 'haiku' tier aliases OR full model
278
+ // IDs. We pass the full ID with [1m] when present; the SDK strips
279
+ // [1m] internally for plans that don't support it.
280
+ ...(model ? { model } : {}),
281
+ effort: 'medium',
282
+ maxTurns: workerMaxTurns,
283
+ };
284
+ return def;
285
+ }
286
+ /**
287
+ * Build the parent orchestrator's prompt. The parent has exactly one
288
+ * job: dispatch to `skill-worker` via the Agent tool and relay its
289
+ * return. Keeping this prompt under ~600 bytes is important — the
290
+ * parent's context grows by the parent prompt + the worker's final
291
+ * return text (typically <2KB). Total parent context per run: ~3KB.
292
+ * Well below any compaction threshold even on a 200K-window model.
293
+ */
294
+ function buildOrchestratorPrompt(skill, callerContext) {
295
+ const parts = [
296
+ `## Scheduled Skill Execution`,
297
+ ``,
298
+ `Dispatch the "${skill.frontmatter.name}" skill to the \`skill-worker\` subagent via the Agent tool.`,
299
+ `The worker has the skill body as its system prompt and the tools required to perform the procedure end-to-end (including any notification delivery).`,
300
+ ``,
301
+ `## Your job`,
302
+ ``,
303
+ `1. Call the Agent tool ONCE, dispatching to "skill-worker" with this brief: "Execute the ${skill.frontmatter.name} procedure now."`,
304
+ `2. Wait for its return.`,
305
+ `3. Relay its summary as your final response — do not add commentary, do not re-do its work.`,
306
+ ``,
307
+ `Do NOT call any other tools directly. The worker handles all data access and delivery.`,
308
+ ];
309
+ if (callerContext && callerContext.trim()) {
310
+ parts.push('', '## Caller context (forward this to the worker if relevant)', '', callerContext.trim());
311
+ }
312
+ return parts.join('\n');
313
+ }
186
314
  // ── The primitive ─────────────────────────────────────────────────────
187
315
  /**
188
316
  * Run a skill as a hard-allowlisted sub-call. Returns a structured result.
@@ -194,6 +322,13 @@ async function validateSkillOutput(output, schema) {
194
322
  * After the SDK returns, `clementine.success.schema` (when set) is
195
323
  * ajv-validated against the response.
196
324
  *
325
+ * **Autonomous runs (1.18.173)**: When `source` is one of
326
+ * AUTONOMOUS_SOURCES, the skill runs through the auto-delegating
327
+ * wrapper: a thin parent dispatches to a `skill-worker` subagent which
328
+ * does all the work in its own context. Closes the
329
+ * "refetch-after-compaction" loop class permanently. Skills can opt out
330
+ * via frontmatter `clementine.execution.inline: true`.
331
+ *
197
332
  * This function never throws — failures (skill not found, SDK error,
198
333
  * timeout) are returned as `{ ok: false, error }`. The caller (chat,
199
334
  * cron, sub-agent, MCP tool) decides how to surface that.
@@ -212,7 +347,17 @@ export async function runSkill(name, options = {}) {
212
347
  }
213
348
  const effectiveTools = computeSkillAllowlist(skill);
214
349
  const hasExplicitToolScope = skillHasExplicitToolScope(skill);
215
- const prompt = buildSkillPrompt(skill, options.inputs, options.context);
350
+ const source = options.source ?? 'skill';
351
+ // 1.18.173: autonomous runs (cron, scheduled-skill, heartbeat,
352
+ // team-task) wrap the skill in a thin orchestrator that dispatches
353
+ // the entire procedure to a `skill-worker` subagent. The parent's
354
+ // context never grows past ~3KB regardless of how much data the
355
+ // skill reads, so post-compaction refetch loops are structurally
356
+ // impossible. See shouldAutoDelegate / buildSkillWorkerAgent above.
357
+ const autoDelegate = shouldAutoDelegate(skill, source);
358
+ const prompt = autoDelegate
359
+ ? buildOrchestratorPrompt(skill, options.context)
360
+ : buildSkillPrompt(skill, options.inputs, options.context);
216
361
  const limits = skill.frontmatter?.clementine?.limits;
217
362
  const maxTurns = options.maxTurns ?? limits?.maxTurns;
218
363
  const maxBudgetUsd = options.maxBudgetUsd ?? limits?.maxBudgetUsd;
@@ -225,6 +370,14 @@ export async function runSkill(name, options = {}) {
225
370
  ...(skill.layout === 'folder' ? [path.dirname(skill.filePath)] : []),
226
371
  ];
227
372
  const mutatingSkill = effectiveTools.some((t) => t === 'Write' || t === 'Edit' || t === 'Bash' || /__(write|edit|update|create|delete|send|post|patch|set)/i.test(t));
373
+ // 1.18.173: resolve the effective model. Autonomous runs default to
374
+ // Sonnet [1m] (1M context window) so the worker subagent has 5× the
375
+ // room of a standard 200K-window model. resolveAutonomousModel honors
376
+ // explicit overrides + skill-declared limits.model first.
377
+ const skillModel = skill.frontmatter?.clementine?.limits?.model;
378
+ const effectiveModel = autoDelegate
379
+ ? resolveAutonomousModel(options.model, skillModel)
380
+ : (options.model ?? skillModel);
228
381
  logger.info({
229
382
  skill: name,
230
383
  tools: effectiveTools,
@@ -232,6 +385,9 @@ export async function runSkill(name, options = {}) {
232
385
  maxBudgetUsd,
233
386
  inputKeys: Object.keys(options.inputs ?? {}),
234
387
  hasContext: !!options.context,
388
+ autoDelegate,
389
+ model: effectiveModel,
390
+ source,
235
391
  }, 'runSkill: invoking');
236
392
  let runResult;
237
393
  try {
@@ -245,24 +401,74 @@ export async function runSkill(name, options = {}) {
245
401
  ].filter(Boolean).join('\n\n'),
246
402
  profile: options.profile,
247
403
  });
248
- const allowedToolsForRun = hasExplicitToolScope ? effectiveTools : undefined;
249
- const sdkOpts = {
250
- sessionKey,
251
- source: options.source ?? 'skill',
252
- ...(allowedToolsForRun ? { allowedTools: allowedToolsForRun } : {}),
253
- profile: options.profile,
254
- agentManager: options.agentManager,
255
- memoryStore: options.memoryStore,
256
- cwd: options.projectWorkDir,
257
- extraMcpServers: mcp.servers,
258
- enableFileCheckpointing: mutatingSkill || Boolean(options.projectWorkDir),
259
- ...(options.model ? { model: options.model } : {}),
260
- ...(typeof maxTurns === 'number' ? { maxTurns } : {}),
261
- ...(typeof maxBudgetUsd === 'number' ? { maxBudgetUsd } : {}),
262
- ...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
263
- ...(options.onText ? { onText: options.onText } : {}),
264
- ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
265
- };
404
+ // ── Autonomous-delegation branch (1.18.173) ──────────────────────
405
+ // Parent: minimal allowedTools (Agent only) + forceSubagent to
406
+ // skill-worker. Worker: full tool surface + skill body as system
407
+ // prompt. Worker is the SDK AgentDefinition; the SDK wires its
408
+ // tools/model/prompt at query time.
409
+ let sdkOpts;
410
+ if (autoDelegate) {
411
+ // Worker gets enough turns to complete bulk work (skill author's
412
+ // maxTurns cap, or 30 as a safe default for triage-class work).
413
+ const workerMaxTurns = (typeof maxTurns === 'number' && maxTurns > 0) ? maxTurns : 30;
414
+ const workerDef = buildSkillWorkerAgent(skill, effectiveTools, effectiveModel, workerMaxTurns);
415
+ sdkOpts = {
416
+ sessionKey,
417
+ source,
418
+ // Parent's allowedTools: ONLY Agent (delegate-or-fail). Keeps
419
+ // the parent's context shape predictable and prevents it from
420
+ // doing data-heavy work itself even if the LLM disagrees.
421
+ allowedTools: ['Agent'],
422
+ // Force-routing: SDK wraps the prompt with "Use the skill-worker
423
+ // agent to handle this request" so dispatch is the natural
424
+ // first action.
425
+ forceSubagent: 'skill-worker',
426
+ // Inject the skill-worker into the agents map. runAgent merges
427
+ // its `buildAgentMap()` defaults with whatever's passed via
428
+ // opts.agents — see run-agent.ts:362.
429
+ agents: { 'skill-worker': workerDef },
430
+ profile: options.profile,
431
+ agentManager: options.agentManager,
432
+ memoryStore: options.memoryStore,
433
+ cwd: options.projectWorkDir,
434
+ extraMcpServers: mcp.servers,
435
+ enableFileCheckpointing: mutatingSkill || Boolean(options.projectWorkDir),
436
+ // Parent uses the same model family so MCP server reuse is clean
437
+ // (the SDK keys some cache state by model). Parent turns are
438
+ // tightly capped: it should dispatch and relay in ≤3 turns.
439
+ ...(effectiveModel ? { model: effectiveModel } : {}),
440
+ maxTurns: 5,
441
+ ...(typeof maxBudgetUsd === 'number' ? { maxBudgetUsd } : {}),
442
+ ...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
443
+ ...(options.onText ? { onText: options.onText } : {}),
444
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
445
+ };
446
+ }
447
+ else {
448
+ // ── Inline branch (interactive / opt-out skills) ────────────────
449
+ // Original 1.18.162 behavior — the SDK call runs the skill body
450
+ // directly as the main-agent prompt. Used for chat-invoked skills
451
+ // where the latency of a subagent dispatch is worse UX than the
452
+ // small compaction risk.
453
+ const allowedToolsForRun = hasExplicitToolScope ? effectiveTools : undefined;
454
+ sdkOpts = {
455
+ sessionKey,
456
+ source,
457
+ ...(allowedToolsForRun ? { allowedTools: allowedToolsForRun } : {}),
458
+ profile: options.profile,
459
+ agentManager: options.agentManager,
460
+ memoryStore: options.memoryStore,
461
+ cwd: options.projectWorkDir,
462
+ extraMcpServers: mcp.servers,
463
+ enableFileCheckpointing: mutatingSkill || Boolean(options.projectWorkDir),
464
+ ...(effectiveModel ? { model: effectiveModel } : {}),
465
+ ...(typeof maxTurns === 'number' ? { maxTurns } : {}),
466
+ ...(typeof maxBudgetUsd === 'number' ? { maxBudgetUsd } : {}),
467
+ ...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
468
+ ...(options.onText ? { onText: options.onText } : {}),
469
+ ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
470
+ };
471
+ }
266
472
  runResult = await runAgent(prompt, sdkOpts);
267
473
  }
268
474
  catch (err) {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * tool-call-dedup — PreToolUse hook that detects same-call loops and
3
+ * nudges the model to stop re-fetching identical data.
4
+ *
5
+ * Why this exists (1.18.173)
6
+ * ──────────────────────────
7
+ * The Anthropic SDK's auto-compactor summarizes prior turns when context
8
+ * approaches the model's window. If the working data lived in those
9
+ * earlier turns, compaction loses it — and the model often responds by
10
+ * RE-CALLING the same tool with the same arguments to "re-load" the
11
+ * data. That refill triggers the next compaction, which loses the
12
+ * re-loaded data, which triggers another re-call, … and the SDK's
13
+ * thrashing detector aborts the run after 3 consecutive cycles.
14
+ *
15
+ * Real-world example (2026-05-11 imessage-triage 08:00 UTC, run
16
+ * 839a7d1a-…): four IDENTICAL calls to `get_unread_imessages({limit:20})`
17
+ * in 115 seconds, one after each compaction. The tool-output-guard from
18
+ * 1.18.169 didn't fire because each individual response was under the
19
+ * 30KB cap; the loop was structural, not size-based.
20
+ *
21
+ * What this hook does
22
+ * ───────────────────
23
+ * On every PreToolUse, hash `(toolName, JSON.stringify(input))` and look
24
+ * it up in a per-run cache (60s TTL by default).
25
+ * • count = 1 (first call): let it through, record.
26
+ * • count = 2 (second call within TTL): inject an `additionalContext`
27
+ * hint into the next turn saying "you already called this; the
28
+ * result hasn't changed; reuse it or change the inputs." Tool still
29
+ * executes (the model might have legitimate reasons to re-poll).
30
+ * • count = 3+ (third+ identical call): `permissionDecision: 'deny'`
31
+ * with a reason that directs the model to either change inputs or
32
+ * stop the loop. The model receives a denial result instead of new
33
+ * tool data — breaks the refetch-after-compact cycle.
34
+ *
35
+ * Aligned with Anthropic SDK best practices: PreToolUse + permission
36
+ * decisions are the documented mechanism for controlling tool execution
37
+ * mid-run. `sdk.d.ts:2002-2008` — `PreToolUseHookSpecificOutput` carries
38
+ * `permissionDecision` ('allow'/'deny'/'ask'/'defer') + reason +
39
+ * additionalContext for exactly this case.
40
+ *
41
+ * Failure mode
42
+ * ────────────
43
+ * Never throws. Hash errors, cache errors, anything — degrades to
44
+ * letting the call through. Telemetry must never block execution.
45
+ */
46
+ import type { HookCallbackMatcher, HookEvent } from '@anthropic-ai/claude-agent-sdk';
47
+ export interface DedupHookOptions {
48
+ /** Stable run identifier — used to scope the cache per run. */
49
+ runId: string;
50
+ /** How long an identical call is considered "the same" (ms). */
51
+ ttlMs?: number;
52
+ /** Override the soft-warn threshold (default 2nd call). */
53
+ softWarnAt?: number;
54
+ /** Override the hard-block threshold (default 3rd call). */
55
+ hardBlockAt?: number;
56
+ /** Optional callback fired on every dedup decision. */
57
+ onDecision?: (info: {
58
+ toolName: string;
59
+ inputHash: string;
60
+ callCount: number;
61
+ decision: 'allow' | 'warn' | 'block';
62
+ sinceFirstMs: number;
63
+ }) => void;
64
+ }
65
+ export interface DedupRunStats {
66
+ /** Total PreToolUse invocations inspected. */
67
+ inspected: number;
68
+ /** Calls that were warned (let through with hint). */
69
+ warned: number;
70
+ /** Calls that were blocked outright. */
71
+ blocked: number;
72
+ }
73
+ export interface DedupHookHandles {
74
+ /** Hook map suitable for SDK `query({ options: { hooks } })`. */
75
+ hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
76
+ /** Aggregated telemetry — read after the run completes. */
77
+ stats: DedupRunStats;
78
+ }
79
+ /**
80
+ * Compute a stable hash of a tool call's input shape. JSON.stringify
81
+ * with a sorted-keys replacer so `{a:1,b:2}` and `{b:2,a:1}` collide
82
+ * (same semantic call); other minor differences (object key order) don't
83
+ * spuriously evade the dedup.
84
+ */
85
+ export declare function hashToolInput(input: unknown): string;
86
+ /**
87
+ * Build a PreToolUse dedup hook for a single runAgent invocation.
88
+ * Per-run cache (no cross-run state) — short-lived agentic runs don't
89
+ * need persistence and we don't want stale cache to deny legitimate
90
+ * post-restart re-polls.
91
+ */
92
+ export declare function buildDedupHook(opts: DedupHookOptions): DedupHookHandles;
93
+ //# sourceMappingURL=tool-call-dedup.d.ts.map
@@ -0,0 +1,168 @@
1
+ /**
2
+ * tool-call-dedup — PreToolUse hook that detects same-call loops and
3
+ * nudges the model to stop re-fetching identical data.
4
+ *
5
+ * Why this exists (1.18.173)
6
+ * ──────────────────────────
7
+ * The Anthropic SDK's auto-compactor summarizes prior turns when context
8
+ * approaches the model's window. If the working data lived in those
9
+ * earlier turns, compaction loses it — and the model often responds by
10
+ * RE-CALLING the same tool with the same arguments to "re-load" the
11
+ * data. That refill triggers the next compaction, which loses the
12
+ * re-loaded data, which triggers another re-call, … and the SDK's
13
+ * thrashing detector aborts the run after 3 consecutive cycles.
14
+ *
15
+ * Real-world example (2026-05-11 imessage-triage 08:00 UTC, run
16
+ * 839a7d1a-…): four IDENTICAL calls to `get_unread_imessages({limit:20})`
17
+ * in 115 seconds, one after each compaction. The tool-output-guard from
18
+ * 1.18.169 didn't fire because each individual response was under the
19
+ * 30KB cap; the loop was structural, not size-based.
20
+ *
21
+ * What this hook does
22
+ * ───────────────────
23
+ * On every PreToolUse, hash `(toolName, JSON.stringify(input))` and look
24
+ * it up in a per-run cache (60s TTL by default).
25
+ * • count = 1 (first call): let it through, record.
26
+ * • count = 2 (second call within TTL): inject an `additionalContext`
27
+ * hint into the next turn saying "you already called this; the
28
+ * result hasn't changed; reuse it or change the inputs." Tool still
29
+ * executes (the model might have legitimate reasons to re-poll).
30
+ * • count = 3+ (third+ identical call): `permissionDecision: 'deny'`
31
+ * with a reason that directs the model to either change inputs or
32
+ * stop the loop. The model receives a denial result instead of new
33
+ * tool data — breaks the refetch-after-compact cycle.
34
+ *
35
+ * Aligned with Anthropic SDK best practices: PreToolUse + permission
36
+ * decisions are the documented mechanism for controlling tool execution
37
+ * mid-run. `sdk.d.ts:2002-2008` — `PreToolUseHookSpecificOutput` carries
38
+ * `permissionDecision` ('allow'/'deny'/'ask'/'defer') + reason +
39
+ * additionalContext for exactly this case.
40
+ *
41
+ * Failure mode
42
+ * ────────────
43
+ * Never throws. Hash errors, cache errors, anything — degrades to
44
+ * letting the call through. Telemetry must never block execution.
45
+ */
46
+ import { createHash } from 'node:crypto';
47
+ import pino from 'pino';
48
+ const logger = pino({ name: 'clementine.tool-call-dedup' });
49
+ // ── Tunables ──────────────────────────────────────────────────────────
50
+ /** Within this window (ms), identical calls are considered "the same". */
51
+ const DEFAULT_TTL_MS = 60_000;
52
+ /** Second identical call within TTL → soft warn (let it through with a hint). */
53
+ const SOFT_WARN_AT = 2;
54
+ /** Third+ identical call within TTL → hard block (deny). */
55
+ const HARD_BLOCK_AT = 3;
56
+ // ── Hashing ───────────────────────────────────────────────────────────
57
+ /**
58
+ * Compute a stable hash of a tool call's input shape. JSON.stringify
59
+ * with a sorted-keys replacer so `{a:1,b:2}` and `{b:2,a:1}` collide
60
+ * (same semantic call); other minor differences (object key order) don't
61
+ * spuriously evade the dedup.
62
+ */
63
+ export function hashToolInput(input) {
64
+ try {
65
+ const stable = JSON.stringify(input, replaceForStableHash);
66
+ return createHash('sha256').update(stable).digest('hex').slice(0, 16);
67
+ }
68
+ catch {
69
+ return 'unhashable';
70
+ }
71
+ }
72
+ function replaceForStableHash(_key, value) {
73
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
74
+ const sorted = {};
75
+ const keys = Object.keys(value).sort();
76
+ for (const k of keys)
77
+ sorted[k] = value[k];
78
+ return sorted;
79
+ }
80
+ return value;
81
+ }
82
+ // ── Hook builder ──────────────────────────────────────────────────────
83
+ /**
84
+ * Build a PreToolUse dedup hook for a single runAgent invocation.
85
+ * Per-run cache (no cross-run state) — short-lived agentic runs don't
86
+ * need persistence and we don't want stale cache to deny legitimate
87
+ * post-restart re-polls.
88
+ */
89
+ export function buildDedupHook(opts) {
90
+ const cache = new Map();
91
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
92
+ const softAt = opts.softWarnAt ?? SOFT_WARN_AT;
93
+ const hardAt = opts.hardBlockAt ?? HARD_BLOCK_AT;
94
+ const stats = { inspected: 0, warned: 0, blocked: 0 };
95
+ const preToolUse = async (input) => {
96
+ if (input.hook_event_name !== 'PreToolUse')
97
+ return {};
98
+ const evt = input;
99
+ const toolName = String(evt.tool_name ?? 'unknown');
100
+ const inputHash = hashToolInput(evt.tool_input);
101
+ const key = `${toolName}:${inputHash}`;
102
+ const now = Date.now();
103
+ stats.inspected += 1;
104
+ let entry = cache.get(key);
105
+ // Treat expired entries as fresh — drop and restart the count.
106
+ if (entry && now - entry.lastSeen > ttl) {
107
+ cache.delete(key);
108
+ entry = undefined;
109
+ }
110
+ if (!entry) {
111
+ cache.set(key, { count: 1, firstSeen: now, lastSeen: now });
112
+ opts.onDecision?.({ toolName, inputHash, callCount: 1, decision: 'allow', sinceFirstMs: 0 });
113
+ return {};
114
+ }
115
+ entry.count += 1;
116
+ entry.lastSeen = now;
117
+ const sinceFirstMs = now - entry.firstSeen;
118
+ if (entry.count >= hardAt) {
119
+ stats.blocked += 1;
120
+ logger.warn({
121
+ toolName,
122
+ inputHash,
123
+ callCount: entry.count,
124
+ sinceFirstMs,
125
+ runId: opts.runId,
126
+ }, 'tool-call-dedup: hard-blocking identical call');
127
+ opts.onDecision?.({ toolName, inputHash, callCount: entry.count, decision: 'block', sinceFirstMs });
128
+ return {
129
+ hookSpecificOutput: {
130
+ hookEventName: 'PreToolUse',
131
+ permissionDecision: 'deny',
132
+ permissionDecisionReason: `Tool \`${toolName}\` was already called with these exact arguments ${entry.count - 1} time(s) in the last ${Math.floor(sinceFirstMs / 1000)}s. ` +
133
+ `The result has not changed. STOP re-calling — use the result from your earlier context, ` +
134
+ `change the arguments to fetch different data, or finish the task with what you already know. ` +
135
+ `If you genuinely need fresh data, wait at least ${Math.ceil(ttl / 1000)}s and try again.`,
136
+ },
137
+ };
138
+ }
139
+ if (entry.count >= softAt) {
140
+ stats.warned += 1;
141
+ logger.info({
142
+ toolName,
143
+ inputHash,
144
+ callCount: entry.count,
145
+ sinceFirstMs,
146
+ runId: opts.runId,
147
+ }, 'tool-call-dedup: warning on repeat call');
148
+ opts.onDecision?.({ toolName, inputHash, callCount: entry.count, decision: 'warn', sinceFirstMs });
149
+ return {
150
+ hookSpecificOutput: {
151
+ hookEventName: 'PreToolUse',
152
+ additionalContext: `Note: you've already called \`${toolName}\` with these exact arguments ${entry.count - 1} time(s) in the last ${Math.floor(sinceFirstMs / 1000)}s. ` +
153
+ `The result will be identical. Consider re-using the prior result rather than letting this call burn turns/budget. ` +
154
+ `One more identical re-call will be blocked.`,
155
+ },
156
+ };
157
+ }
158
+ opts.onDecision?.({ toolName, inputHash, callCount: entry.count, decision: 'allow', sinceFirstMs });
159
+ return {};
160
+ };
161
+ return {
162
+ hooks: {
163
+ PreToolUse: [{ hooks: [preToolUse] }],
164
+ },
165
+ stats,
166
+ };
167
+ }
168
+ //# sourceMappingURL=tool-call-dedup.js.map
@@ -21638,25 +21638,34 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
21638
21638
  </div>
21639
21639
  </div>
21640
21640
  <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
21641
- <button class="btn-secondary" onclick="openSkillStudio()" style="font-size:13px;padding:8px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px" title="Open the conversational Skill Studio">
21641
+ <button class="btn-secondary" onclick="openSkillStudio()" style="font-size:13px;padding:8px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px" title="Open the natural-language Skill Studio">
21642
21642
  Open Studio
21643
21643
  </button>
21644
- <button class="btn-primary" onclick="openCreateSkillModalFromComposer()" style="font-size:13px;padding:8px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px" title="Open a clean blank skill editor">
21645
- Blank skill
21644
+ <button class="btn-primary" onclick="openCreateSkillModalFromComposer()" style="font-size:13px;padding:8px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:6px" title="Open a blank SKILL.md editor">
21645
+ Manual editor
21646
21646
  </button>
21647
21647
  </div>
21648
21648
  </div>
21649
+ <div id="skill-composer-home">
21649
21650
  <div id="skill-composer" style="margin:0 0 16px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);padding:14px 16px">
21650
- <div style="display:grid;grid-template-columns:minmax(280px,1.2fr) minmax(260px,0.8fr);gap:14px;align-items:start">
21651
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:12px">
21652
+ <div style="min-width:0">
21653
+ <div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:3px">Skill Studio</div>
21654
+ <div style="font-size:12px;color:var(--text-muted);line-height:1.45">Describe reusable work in plain language. Optional starting points only seed the draft; nothing runs until you save or test it.</div>
21655
+ </div>
21656
+ <div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;white-space:nowrap;margin-top:2px">Natural language first</div>
21657
+ </div>
21658
+ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;align-items:start">
21651
21659
  <div>
21652
- <label for="skill-composer-text" style="display:block;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px">Describe the skill</label>
21653
- <textarea id="skill-composer-text" rows="4" oninput="updateSkillComposerDraftState()" placeholder="Review my Asana tasks, update a Google Sheet, verify four source systems, then report back in Asana." style="width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:13px;line-height:1.45;resize:vertical;min-height:92px"></textarea>
21660
+ <label for="skill-composer-text" style="display:block;font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px">What should Clementine learn?</label>
21661
+ <textarea id="skill-composer-text" rows="5" oninput="updateSkillComposerDraftState()" placeholder="Find Salesforce contacts I have not touched in 15 days, enrich the accounts with DataForSEO signals, draft cold prospecting emails, then report the drafts back for review." style="width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:13px;line-height:1.45;resize:vertical;min-height:112px"></textarea>
21662
+ <div style="margin-top:8px;font-size:11px;color:var(--text-muted);line-height:1.45">Good skills name the repeatable outcome, required tools or data, approval boundaries, and what counts as done.</div>
21654
21663
  </div>
21655
21664
  <div>
21656
- <div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px">Starting point</div>
21665
+ <div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px">Optional starting points</div>
21657
21666
  <div id="skill-composer-modes" style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;margin-bottom:10px">
21658
21667
  <button type="button" class="skill-composer-mode" data-kind="outcome" onclick="setSkillComposerMode('outcome')" style="padding:7px 6px;border:1px solid var(--accent);border-radius:6px;background:rgba(255,141,0,0.10);color:var(--accent);font-size:11px;font-weight:600;cursor:pointer">Outcome</button>
21659
- <button type="button" class="skill-composer-mode" data-kind="tool" onclick="setSkillComposerMode('tool')" style="padding:7px 6px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-secondary);font-size:11px;font-weight:600;cursor:pointer">Tool/MCP</button>
21668
+ <button type="button" class="skill-composer-mode" data-kind="tool" onclick="setSkillComposerMode('tool')" style="padding:7px 6px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-secondary);font-size:11px;font-weight:600;cursor:pointer">MCP/API</button>
21660
21669
  <button type="button" class="skill-composer-mode" data-kind="cli" onclick="setSkillComposerMode('cli')" style="padding:7px 6px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-secondary);font-size:11px;font-weight:600;cursor:pointer">CLI</button>
21661
21670
  <button type="button" class="skill-composer-mode" data-kind="project" onclick="setSkillComposerMode('project')" style="padding:7px 6px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-secondary);font-size:11px;font-weight:600;cursor:pointer">Project</button>
21662
21671
  <button type="button" class="skill-composer-mode" data-kind="memory" onclick="setSkillComposerMode('memory')" style="padding:7px 6px;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);color:var(--text-secondary);font-size:11px;font-weight:600;cursor:pointer">Memory</button>
@@ -21670,12 +21679,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
21670
21679
  <div id="skill-composer-anchor-summary" style="margin-top:8px;min-height:22px;font-size:11px;color:var(--text-muted);line-height:1.45">No starting point selected.</div>
21671
21680
  <div id="skill-composer-preview" style="margin-top:10px;max-height:180px;overflow:auto;border:1px solid var(--border);border-radius:6px;background:var(--bg-primary);padding:10px 12px;font-size:11px;line-height:1.45;color:var(--text-secondary)"></div>
21672
21681
  <div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:12px">
21673
- <button type="button" class="btn-secondary" onclick="openSkillStudio()" style="font-size:12px;padding:7px 12px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-primary);cursor:pointer">Open Studio</button>
21674
- <button type="button" class="btn-primary" id="skill-composer-draft-btn" onclick="startSkillComposerDraft()" disabled style="font-size:12px;padding:7px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:600;cursor:pointer;opacity:0.55">Draft skill</button>
21682
+ <button type="button" class="btn-secondary" onclick="startSkillComposerChat()" style="font-size:12px;padding:7px 12px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-primary);cursor:pointer">Build in chat</button>
21683
+ <button type="button" class="btn-primary" id="skill-composer-draft-btn" onclick="startSkillComposerDraft()" disabled style="font-size:12px;padding:7px 14px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:600;cursor:pointer;opacity:0.55">Review draft</button>
21675
21684
  </div>
21676
21685
  </div>
21677
21686
  </div>
21678
21687
  </div>
21688
+ </div>
21679
21689
  <div style="display:grid;grid-template-columns:380px 1fr;gap:18px;height:calc(100vh - 360px);min-height:440px">
21680
21690
  <div id="skills-list-pane" style="overflow-y:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">
21681
21691
  <div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px">
@@ -23906,7 +23916,7 @@ function openCommandK() {
23906
23916
  { kw: 'home activity', page: 'home', tab: 'activity', label: 'Home · Activity' },
23907
23917
  { kw: 'build workflows workflow builder', page: 'build', tab: 'workflows', label: 'Build · Workflow Builder' },
23908
23918
  { kw: 'build crons schedules scheduled tasks operations automation', page: 'build', tab: 'crons', label: 'Build · Schedules' },
23909
- { kw: 'build skills', page: 'build', tab: 'skills', label: 'Build · Skills' },
23919
+ { kw: 'build skills skill studio create skill', page: 'skills', tab: '', label: 'Skills · Skill Studio' },
23910
23920
  { kw: 'build templates', page: 'build', tab: 'templates', label: 'Build · Templates' },
23911
23921
  { kw: 'team roster', page: 'team', tab: 'roster', label: 'Team · Roster' },
23912
23922
  { kw: 'team activity', page: 'team', tab: 'activity', label: 'Team · Activity' },
@@ -29214,6 +29224,17 @@ function startSkillComposerDraft() {
29214
29224
  openCreateSkillModalFromComposer({ draft: true });
29215
29225
  }
29216
29226
 
29227
+ function startSkillComposerChat() {
29228
+ var prompt = buildSkillComposerPrompt();
29229
+ if (typeof askClementineWith !== 'function') {
29230
+ toast('Chat is not ready yet. Try again after the dashboard finishes loading.', 'error');
29231
+ return;
29232
+ }
29233
+ closeSkillStudio({ silent: true });
29234
+ askClementineWith(prompt, { autoSend: false });
29235
+ toast('Skill-creator prompt loaded in chat. Press send when you are ready.', 'info');
29236
+ }
29237
+
29217
29238
  function openCreateSkillModalFromComposer(opts) {
29218
29239
  opts = opts || {};
29219
29240
  var text = ((document.getElementById('skill-composer-text') || {}).value || '').trim();
@@ -29223,6 +29244,7 @@ function openCreateSkillModalFromComposer(opts) {
29223
29244
  return;
29224
29245
  }
29225
29246
  var seed = buildSkillComposerDraftSeed();
29247
+ closeSkillStudio({ silent: true });
29226
29248
  _openSkillModal({ mode: 'create', prefill: seed });
29227
29249
  if (seed.note && typeof toast === 'function') toast(seed.note, 'success');
29228
29250
  }
@@ -29948,6 +29970,30 @@ async function sbRunSkillTest() {
29948
29970
  }
29949
29971
  }
29950
29972
 
29973
+ function askSkillCreatorForDescription() {
29974
+ var name = (document.getElementById('skill-modal-name')?.value || '').trim();
29975
+ var title = (document.getElementById('skill-modal-title')?.value || '').trim();
29976
+ var desc = (document.getElementById('skill-modal-desc')?.value || '').trim();
29977
+ var body = (document.getElementById('skill-modal-body')?.value || '').trim();
29978
+ var prompt = [
29979
+ 'Use skill-creator principles to help write the frontmatter description for this Clementine skill.',
29980
+ '',
29981
+ 'Skill name: ' + (name || '(not set yet)'),
29982
+ 'Title: ' + (title || '(not set yet)'),
29983
+ 'Current description: ' + (desc || '(empty)'),
29984
+ 'Procedure preview:',
29985
+ body ? body.slice(0, 1600) : '(empty)',
29986
+ '',
29987
+ 'Return one concise description under 1024 characters. It must say what the skill does, when to use it, and trigger phrases. Do not rewrite the whole skill unless I ask.'
29988
+ ].join('\\n');
29989
+ if (typeof askClementineWith !== 'function') {
29990
+ toast('Chat is not ready yet. Try again after the dashboard finishes loading.', 'error');
29991
+ return;
29992
+ }
29993
+ askClementineWith(prompt, { autoSend: false });
29994
+ toast('Description prompt loaded in chat. Press send when ready.', 'info');
29995
+ }
29996
+
29951
29997
  async function _openSkillModal(opts) {
29952
29998
  opts = opts || {};
29953
29999
  var prefill = opts.mode === 'create' && opts.prefill ? opts.prefill : {};
@@ -30003,7 +30049,7 @@ async function _openSkillModal(opts) {
30003
30049
  + '<strong style="color:var(--text-secondary);font-weight:600">Format:</strong> '
30004
30050
  + '<code style="font-size:10px;background:var(--bg-secondary);padding:1px 4px;border-radius:3px">[WHAT it does] + [WHEN to use it] + [trigger phrases]</code>'
30005
30051
  + ' &nbsp;·&nbsp; under 1024 chars · no <code style="font-size:10px">&lt; &gt;</code> · '
30006
- + '<a href="javascript:void(0)" onclick="askClementineWith(\\x27Use skill-creator to help me write a great Anthropic-canonical description for the skill I am building.\\x27)" style="color:var(--accent);text-decoration:none">use skill-creator</a>'
30052
+ + '<a href="javascript:void(0)" onclick="askSkillCreatorForDescription()" style="color:var(--accent);text-decoration:none">use skill-creator</a>'
30007
30053
  + '</div>'
30008
30054
  + '<textarea id="skill-modal-desc" rows="2" oninput="updateSkillModalCounters()" placeholder="Example: Analyzes Outlook emails and drafts triage replies. Use when user asks to triage email or mentions inbox cleanup." style="width:100%;padding:8px 10px;font-size:13px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);margin-bottom:12px;font-family:inherit;resize:vertical"></textarea>'
30009
30055
  + '<label style="display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500">Allowed tools <span style="color:var(--text-muted)">(comma-separated, leave blank for default)</span></label>'
@@ -30023,7 +30069,9 @@ async function _openSkillModal(opts) {
30023
30069
  + '</div>';
30024
30070
  document.body.appendChild(modal);
30025
30071
  }
30026
- document.getElementById('skill-modal-heading').textContent = opts.mode === 'edit' ? 'Edit skill: ' + nameVal : 'New skill';
30072
+ document.getElementById('skill-modal-heading').textContent = opts.mode === 'edit'
30073
+ ? 'Edit skill: ' + nameVal
30074
+ : (prefill.note ? 'Review skill draft' : 'Manual skill editor');
30027
30075
  document.getElementById('skill-modal-original-name').value = opts.mode === 'edit' ? nameVal : '';
30028
30076
  document.getElementById('skill-modal-name').value = nameVal;
30029
30077
  document.getElementById('skill-modal-name').disabled = opts.mode === 'edit';
@@ -30044,7 +30092,8 @@ async function _openSkillModal(opts) {
30044
30092
  var errEl = document.getElementById('skill-modal-error');
30045
30093
  if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
30046
30094
  modal.style.display = 'flex';
30047
- document.getElementById('skill-modal-name').focus();
30095
+ var initialFocus = prefill.note ? document.getElementById('skill-modal-desc') : document.getElementById('skill-modal-name');
30096
+ if (initialFocus) initialFocus.focus();
30048
30097
  if (typeof updateSkillModalCounters === 'function') updateSkillModalCounters();
30049
30098
  if (typeof renderSkillModalToolsPreview === 'function') renderSkillModalToolsPreview();
30050
30099
  // 1.18.168 — render the compact optional template seed in create mode only.
@@ -35396,7 +35445,7 @@ document.addEventListener('click', function(e) {
35396
35445
  // Back-compat shim — older call sites still reference loadProfiles().
35397
35446
  function loadProfiles() { return refreshChatAgentPicker(); }
35398
35447
 
35399
- // ── Skill Studio — opens the Skills page composer ──────────
35448
+ // ── Skill Studio — opens the natural-language composer as a real modal ──────────
35400
35449
 
35401
35450
  function openSkillStudio() {
35402
35451
  navigateTo('skills');
@@ -35404,15 +35453,56 @@ function openSkillStudio() {
35404
35453
  try { initSkillComposer(); } catch (_) { /* non-fatal */ }
35405
35454
  var composer = document.getElementById('skill-composer');
35406
35455
  var input = document.getElementById('skill-composer-text');
35407
- if (composer && composer.scrollIntoView) composer.scrollIntoView({ behavior: 'smooth', block: 'start' });
35408
- if (composer) {
35409
- composer.style.boxShadow = '0 0 0 2px rgba(255,141,0,0.35)';
35410
- setTimeout(function() { composer.style.boxShadow = ''; }, 1400);
35456
+ var modal = document.getElementById('skill-studio-modal');
35457
+ if (!modal) {
35458
+ modal = document.createElement('div');
35459
+ modal.id = 'skill-studio-modal';
35460
+ modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.42);z-index:1050;display:none;align-items:center;justify-content:center;padding:20px';
35461
+ modal.innerHTML =
35462
+ '<div style="width:min(1040px,96vw);max-height:92vh;background:var(--bg-primary);border:1px solid var(--border);border-radius:10px;box-shadow:0 18px 56px rgba(0,0,0,0.35);display:flex;flex-direction:column;overflow:hidden">'
35463
+ + '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:16px 20px;border-bottom:1px solid var(--border);background:var(--bg-secondary)">'
35464
+ + '<div style="min-width:0">'
35465
+ + '<div style="font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:3px">Skill Studio</div>'
35466
+ + '<div style="font-size:12px;color:var(--text-muted);line-height:1.45">Start with the outcome. Add MCP, CLI, project, or memory anchors only when they are real dependencies. Review the generated SKILL.md before saving.</div>'
35467
+ + '</div>'
35468
+ + '<button type="button" onclick="closeSkillStudio()" title="Close Skill Studio" style="background:none;border:none;font-size:20px;color:var(--text-muted);cursor:pointer;padding:0 4px;line-height:1">&times;</button>'
35469
+ + '</div>'
35470
+ + '<div id="skill-studio-modal-body" style="padding:18px 20px;overflow:auto;flex:1;min-height:0"></div>'
35471
+ + '<div style="display:flex;align-items:center;gap:8px;justify-content:flex-end;padding:14px 20px;border-top:1px solid var(--border);background:var(--bg-secondary)">'
35472
+ + '<button type="button" onclick="closeSkillStudio()" style="font-size:13px;padding:7px 14px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-primary);cursor:pointer">Keep on page</button>'
35473
+ + '<button type="button" onclick="startSkillComposerChat()" style="font-size:13px;padding:7px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);cursor:pointer">Build in chat</button>'
35474
+ + '<button type="button" onclick="startSkillComposerDraft()" class="btn-primary" style="font-size:13px;padding:7px 16px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-weight:600;cursor:pointer">Review draft</button>'
35475
+ + '</div>'
35476
+ + '</div>';
35477
+ document.body.appendChild(modal);
35411
35478
  }
35479
+ var body = document.getElementById('skill-studio-modal-body');
35480
+ if (composer && body && composer.parentElement !== body) {
35481
+ composer.dataset.originalMargin = composer.style.margin || '';
35482
+ body.appendChild(composer);
35483
+ composer.style.margin = '0';
35484
+ }
35485
+ modal.style.display = 'flex';
35486
+ updateSkillComposerDraftState();
35412
35487
  if (input) input.focus();
35413
35488
  }, 80);
35414
35489
  }
35415
35490
 
35491
+ function closeSkillStudio(opts) {
35492
+ opts = opts || {};
35493
+ var modal = document.getElementById('skill-studio-modal');
35494
+ var composer = document.getElementById('skill-composer');
35495
+ var home = document.getElementById('skill-composer-home');
35496
+ if (composer && home && composer.parentElement !== home) {
35497
+ home.appendChild(composer);
35498
+ composer.style.margin = composer.dataset.originalMargin || '0 0 16px';
35499
+ }
35500
+ if (modal) modal.style.display = 'none';
35501
+ if (!opts.silent && composer && composer.scrollIntoView) {
35502
+ composer.scrollIntoView({ behavior: 'smooth', block: 'start' });
35503
+ }
35504
+ }
35505
+
35416
35506
  function updateBuilderMode() {
35417
35507
  var type = (document.getElementById('builder-type') || {}).value || 'skill';
35418
35508
  var title = document.getElementById('builder-page-title');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.171",
3
+ "version": "1.18.173",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",