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.
- package/dist/agent/chat-skill-resolver.js +43 -5
- package/dist/agent/run-agent.js +52 -7
- package/dist/agent/run-skill.d.ts +7 -0
- package/dist/agent/run-skill.js +225 -19
- package/dist/agent/tool-call-dedup.d.ts +93 -0
- package/dist/agent/tool-call-dedup.js +168 -0
- package/dist/cli/dashboard.js +109 -19
- package/package.json +1 -1
|
@@ -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
|
|
62
|
-
* legacy `assistant.ts:1492` threshold.
|
|
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
|
-
|
|
232
|
-
|
|
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,
|
package/dist/agent/run-agent.js
CHANGED
|
@@ -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.
|
|
200
|
-
//
|
|
201
|
-
|
|
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.
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
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.
|
package/dist/agent/run-skill.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
|
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
|
|
21645
|
-
|
|
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:
|
|
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">
|
|
21653
|
-
<textarea id="skill-composer-text" rows="
|
|
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">
|
|
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">
|
|
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="
|
|
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">
|
|
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',
|
|
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
|
+ ' · under 1024 chars · no <code style="font-size:10px">< ></code> · '
|
|
30006
|
-
+ '<a href="javascript:void(0)" onclick="
|
|
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'
|
|
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-
|
|
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
|
|
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
|
-
|
|
35408
|
-
if (
|
|
35409
|
-
|
|
35410
|
-
|
|
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">×</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');
|