clementine-agent 1.18.32 → 1.18.34

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.
@@ -15,7 +15,7 @@ import { query as rawQuery, listSubagents, getSubagentMessages, SYSTEM_PROMPT_DY
15
15
  import pino from 'pino';
16
16
  import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, normalizeClaudeSdkOptionsForOneMillionContext, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, usesOneMillionContext, envSnapshot, } from '../config.js';
17
17
  import { summarizeIntegrationStatus } from '../config/integrations-registry.js';
18
- import { loadToolPreferences, computeAvailability, buildPromptInstruction, buildComposioStatusBlock, } from '../integrations/tool-preferences.js';
18
+ import { loadToolPreferences, computeAvailability, buildPromptInstruction, buildComposioStatusBlock, KNOWN_SERVICES, } from '../integrations/tool-preferences.js';
19
19
  import { loadClaudeIntegrations } from './mcp-bridge.js';
20
20
  import { detectFrustrationSignals, detectRepeatedTopics } from './insight-engine.js';
21
21
  import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
@@ -32,7 +32,7 @@ import { PromptCache } from './prompt-cache.js';
32
32
  import { searchSkills as searchSkillsSync } from './skill-extractor.js';
33
33
  import { classifyIntent, getStrategyGuidance } from './intent-classifier.js';
34
34
  import { getEventLog } from './session-event-log.js';
35
- import { routeToolSurface, TOOL_SURFACE_HARD_LIMIT, TOOL_SURFACE_WARN_THRESHOLD } from './tool-router.js';
35
+ import { applyServiceDedup, routeToolSurface, TOOL_SURFACE_HARD_LIMIT, TOOL_SURFACE_WARN_THRESHOLD } from './tool-router.js';
36
36
  import { isRestrictedToolset, toolsetAllowsLocalWrites } from './toolsets.js';
37
37
  import { looksLikeApprovalPrompt } from './local-turn.js';
38
38
  import { decideTurn } from './turn-policy.js';
@@ -2436,23 +2436,130 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2436
2436
  whitelist.add(mcpTool('goal_work'));
2437
2437
  allowedTools = allowedTools.filter(t => whitelist.has(t));
2438
2438
  }
2439
- if (!toolRoute.fullSurface
2440
- && !adminNeeded
2441
- && !autonomousToolRun
2442
- && allowedTools.length > TOOL_SURFACE_HARD_LIMIT) {
2439
+ // ── Per-service dedup (intelligent routing) ───────────────────
2440
+ // When a service has BOTH Composio + Claude Desktop sources
2441
+ // connected (e.g. Composio outlook + claude.ai Microsoft 365),
2442
+ // bundles in tool-router list both so either path can route to
2443
+ // whichever is connected. But if BOTH are connected, today's
2444
+ // behavior loaded both — and worse, claude.ai's auto-attach
2445
+ // would pull in every other connector the user authorized
2446
+ // (Drive, Gmail, Calendar, Slack…) via the env path. ~300+ tool
2447
+ // schemas leak in this way and leave Sonnet's autocompact no
2448
+ // room to work.
2449
+ //
2450
+ // Dedup walks each (Composio↔claude.ai) pair, picks ONE per
2451
+ // user preference (default Composio), drops the loser from
2452
+ // mcpServers + allowedTools, and turns inheritFullClaudeEnv off
2453
+ // when no claude.ai service survived (so SAFE_ENV is used and
2454
+ // the SDK can't auto-attach the other connectors).
2455
+ if (!toolsDisabledForCall && !isPlanStep && !toolRoute.fullSurface) {
2456
+ const composioConnected = new Set(Object.keys(composioMcpServers));
2457
+ const cdIntegrationsForDedup = loadClaudeIntegrations();
2458
+ const claudeDesktopActive = new Set(Object.values(cdIntegrationsForDedup).filter(i => i.connected).map(i => i.name));
2459
+ const prefs = loadToolPreferences();
2460
+ const dedupResult = applyServiceDedup(toolRoute, {
2461
+ composioConnected,
2462
+ claudeDesktopActive,
2463
+ preferences: prefs.preferences,
2464
+ knownServices: KNOWN_SERVICES,
2465
+ });
2466
+ if (dedupResult.droppedClaudeAi.length > 0 || dedupResult.droppedComposio.length > 0) {
2467
+ const beforeAllowed = allowedTools.length;
2468
+ const beforeInherit = toolRoute.inheritFullClaudeEnv;
2469
+ toolRoute = dedupResult.route;
2470
+ for (const name of dedupResult.droppedClaudeAi) {
2471
+ delete externalMcpServers[name];
2472
+ }
2473
+ for (const slug of dedupResult.droppedComposio) {
2474
+ delete composioMcpServers[slug];
2475
+ }
2476
+ const droppedServers = new Set([
2477
+ ...dedupResult.droppedClaudeAi,
2478
+ ...dedupResult.droppedComposio,
2479
+ ]);
2480
+ allowedTools = allowedTools.filter(tool => {
2481
+ if (!tool.startsWith('mcp__'))
2482
+ return true;
2483
+ const serverName = tool.slice('mcp__'.length).split('__')[0];
2484
+ return !droppedServers.has(serverName);
2485
+ });
2486
+ logger.info({
2487
+ sessionKey,
2488
+ droppedClaudeAi: dedupResult.droppedClaudeAi,
2489
+ droppedComposio: dedupResult.droppedComposio,
2490
+ anyClaudeDesktopKept: dedupResult.anyClaudeDesktopKept,
2491
+ inheritFullClaudeEnvBefore: beforeInherit,
2492
+ inheritFullClaudeEnvAfter: toolRoute.inheritFullClaudeEnv,
2493
+ allowedToolCountBefore: beforeAllowed,
2494
+ allowedToolCountAfter: allowedTools.length,
2495
+ }, 'Tool route deduped per user tool-preferences');
2496
+ }
2497
+ }
2498
+ // Tool-surface cap. Applies to chat AND to autonomous runs (cron,
2499
+ // unleashed, heartbeat). Without this cap on cron, a single job got
2500
+ // 300+ MCP tool schemas in the system prompt — leaving Sonnet's SDK
2501
+ // autocompact no room to actually compact when tool responses came
2502
+ // back. That manifested as `rapid_refill_breaker` ("context refilled
2503
+ // to the limit within 3 turns"). The SDK's autocompact still works;
2504
+ // we just have to give it room.
2505
+ if (!adminNeeded && allowedTools.length > TOOL_SURFACE_HARD_LIMIT) {
2443
2506
  const beforeAllowedToolCount = allowedTools.length;
2444
2507
  const coreSdkTools = new Set(['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch']);
2445
2508
  const clementineToolPrefixForCap = `mcp__${TOOLS_SERVER}__`;
2446
- allowedTools = allowedTools.filter(tool => coreSdkTools.has(tool) || tool.startsWith(clementineToolPrefixForCap));
2447
- externalMcpServers = {};
2448
- composioMcpServers = {};
2449
- logger.warn({
2450
- sessionKey,
2451
- beforeAllowedToolCount,
2452
- afterAllowedToolCount: allowedTools.length,
2453
- hardLimit: TOOL_SURFACE_HARD_LIMIT,
2454
- bundles: toolRoute.bundles,
2455
- }, 'SDK allowed tool surface exceeded hard limit; falling back to core Clementine tools for this interactive turn');
2509
+ // Smart fallback: if the route matched specific bundles, keep
2510
+ // those bundles' explicit servers/toolkits and drop everything
2511
+ // else (including the fullSurface=true expansion to "all
2512
+ // connected MCP servers"). Only fall all the way down to
2513
+ // core+Clementine tools when there are no matched bundles to
2514
+ // restrict to.
2515
+ const matchedExternal = Array.isArray(toolRoute.externalMcpServers)
2516
+ ? new Set(toolRoute.externalMcpServers)
2517
+ : null;
2518
+ const matchedComposio = Array.isArray(toolRoute.composioToolkits)
2519
+ ? new Set(toolRoute.composioToolkits)
2520
+ : null;
2521
+ const hasMatchedBundles = !!matchedExternal && !!matchedComposio
2522
+ && (matchedExternal.size > 0 || matchedComposio.size > 0);
2523
+ if (hasMatchedBundles) {
2524
+ const keepServers = new Set([
2525
+ TOOLS_SERVER,
2526
+ ...(matchedExternal ?? []),
2527
+ ...(matchedComposio ?? []),
2528
+ ]);
2529
+ allowedTools = allowedTools.filter(tool => {
2530
+ if (coreSdkTools.has(tool))
2531
+ return true;
2532
+ if (!tool.startsWith('mcp__'))
2533
+ return true;
2534
+ const serverName = tool.slice('mcp__'.length).split('__')[0];
2535
+ return keepServers.has(serverName);
2536
+ });
2537
+ externalMcpServers = Object.fromEntries(Object.entries(externalMcpServers).filter(([name]) => matchedExternal.has(name)));
2538
+ composioMcpServers = Object.fromEntries(Object.entries(composioMcpServers).filter(([name]) => matchedComposio.has(name)));
2539
+ logger.warn({
2540
+ sessionKey,
2541
+ beforeAllowedToolCount,
2542
+ afterAllowedToolCount: allowedTools.length,
2543
+ hardLimit: TOOL_SURFACE_HARD_LIMIT,
2544
+ bundles: toolRoute.bundles,
2545
+ keptExternal: [...(matchedExternal ?? [])],
2546
+ keptComposio: [...(matchedComposio ?? [])],
2547
+ autonomous: autonomousToolRun,
2548
+ }, 'Tool surface exceeded hard limit; trimmed to matched bundles');
2549
+ }
2550
+ else {
2551
+ allowedTools = allowedTools.filter(tool => coreSdkTools.has(tool) || tool.startsWith(clementineToolPrefixForCap));
2552
+ externalMcpServers = {};
2553
+ composioMcpServers = {};
2554
+ logger.warn({
2555
+ sessionKey,
2556
+ beforeAllowedToolCount,
2557
+ afterAllowedToolCount: allowedTools.length,
2558
+ hardLimit: TOOL_SURFACE_HARD_LIMIT,
2559
+ bundles: toolRoute.bundles,
2560
+ autonomous: autonomousToolRun,
2561
+ }, 'Tool surface exceeded hard limit with no matched bundles; falling back to core Clementine tools');
2562
+ }
2456
2563
  }
2457
2564
  }
2458
2565
  // Permission mode: always 'bypassPermissions' — this is a daemon/harness with no interactive
@@ -31,5 +31,32 @@ export declare const TOOL_SURFACE_WARN_THRESHOLD = 150;
31
31
  export declare const TOOL_SURFACE_HARD_LIMIT = 220;
32
32
  export declare const TOOL_BUNDLES: readonly ToolBundleDefinition[];
33
33
  export declare function routeToolSurface(text: string | undefined): ToolRouteDecision;
34
+ import type { ServiceDefinition, ToolSource } from '../integrations/tool-preferences.js';
35
+ export interface ServiceDedupOptions {
36
+ /** Composio toolkit slugs the user has actually connected. */
37
+ composioConnected: Set<string>;
38
+ /** Claude Desktop integration names the user has actually connected. */
39
+ claudeDesktopActive: Set<string>;
40
+ /** User-selected source per service id (from tool-preferences.json). */
41
+ preferences: Record<string, ToolSource>;
42
+ /** The KNOWN_SERVICES registry. Passed in so this module stays
43
+ * decoupled from tool-preferences (and tests can stub the table). */
44
+ knownServices: readonly ServiceDefinition[];
45
+ }
46
+ export interface ServiceDedupResult {
47
+ /** The route with losing sources removed from external + composio sets. */
48
+ route: ToolRouteDecision;
49
+ /** Claude Desktop integration names that were dropped. Used by the
50
+ * caller to add disallowedTools and decide whether the SDK subprocess
51
+ * needs claude.ai env inheritance at all. */
52
+ droppedClaudeAi: string[];
53
+ /** Composio toolkit slugs that were dropped. Mirror of the above. */
54
+ droppedComposio: string[];
55
+ /** True if any claude.ai integration survived dedup. When false, the
56
+ * caller can drop CLAUDE_CODE_OAUTH_TOKEN from the subprocess env so
57
+ * Claude Code doesn't auto-attach claude.ai connectors. */
58
+ anyClaudeDesktopKept: boolean;
59
+ }
60
+ export declare function applyServiceDedup(route: ToolRouteDecision, opts: ServiceDedupOptions): ServiceDedupResult;
34
61
  export {};
35
62
  //# sourceMappingURL=tool-router.d.ts.map
@@ -193,4 +193,89 @@ export function routeToolSurface(text) {
193
193
  reason: bundles.size > 0 || external.size > 0 || composio.size > 0 ? 'matched' : 'empty',
194
194
  };
195
195
  }
196
+ export function applyServiceDedup(route, opts) {
197
+ const droppedClaudeAi = [];
198
+ const droppedComposio = [];
199
+ // fullSurface routes intentionally load everything — admin/debug paths.
200
+ // Skip dedup so behavior matches the user's explicit "all tools" intent.
201
+ if (route.fullSurface) {
202
+ return {
203
+ route,
204
+ droppedClaudeAi,
205
+ droppedComposio,
206
+ anyClaudeDesktopKept: true,
207
+ };
208
+ }
209
+ const externalSet = new Set(route.externalMcpServers ?? []);
210
+ const composioSet = new Set(route.composioToolkits ?? []);
211
+ for (const service of opts.knownServices) {
212
+ const cdName = service.claudeDesktopName;
213
+ const composioSlug = service.composioSlug;
214
+ if (!cdName || !composioSlug)
215
+ continue;
216
+ const routeHasCd = externalSet.has(cdName);
217
+ const routeHasComposio = composioSet.has(composioSlug);
218
+ if (!routeHasCd || !routeHasComposio)
219
+ continue;
220
+ // Both sources are in the route. Resolve based on availability + pref.
221
+ const cdAvailable = opts.claudeDesktopActive.has(cdName);
222
+ const composioAvailable = opts.composioConnected.has(composioSlug);
223
+ if (!cdAvailable && !composioAvailable) {
224
+ // Neither connected — drop both (the route lists them, but they'll
225
+ // fail at attach time). Cleaner to remove now.
226
+ externalSet.delete(cdName);
227
+ composioSet.delete(composioSlug);
228
+ droppedClaudeAi.push(cdName);
229
+ droppedComposio.push(composioSlug);
230
+ continue;
231
+ }
232
+ if (!cdAvailable) {
233
+ // Only Composio connected — drop the claude.ai entry.
234
+ externalSet.delete(cdName);
235
+ droppedClaudeAi.push(cdName);
236
+ continue;
237
+ }
238
+ if (!composioAvailable) {
239
+ composioSet.delete(composioSlug);
240
+ droppedComposio.push(composioSlug);
241
+ continue;
242
+ }
243
+ // Conflict: both connected. Pick per user preference, default Composio.
244
+ const userPref = opts.preferences[service.id];
245
+ const effective = userPref === 'off'
246
+ ? 'off'
247
+ : userPref ?? 'composio';
248
+ if (effective === 'off') {
249
+ externalSet.delete(cdName);
250
+ composioSet.delete(composioSlug);
251
+ droppedClaudeAi.push(cdName);
252
+ droppedComposio.push(composioSlug);
253
+ }
254
+ else if (effective === 'composio') {
255
+ externalSet.delete(cdName);
256
+ droppedClaudeAi.push(cdName);
257
+ }
258
+ else if (effective === 'claude-desktop') {
259
+ composioSet.delete(composioSlug);
260
+ droppedComposio.push(composioSlug);
261
+ }
262
+ }
263
+ // After dedup, the SDK subprocess needs claude.ai env inheritance ONLY
264
+ // if some claude.ai integration is still in the route. If everything
265
+ // routed to Composio, force inheritFullClaudeEnv off so Claude Code
266
+ // can't auto-attach the rest of the user's authorized integrations.
267
+ const anyClaudeDesktopKept = externalSet.size > 0
268
+ && [...externalSet].some(name => opts.claudeDesktopActive.has(name));
269
+ return {
270
+ route: {
271
+ ...route,
272
+ externalMcpServers: [...externalSet],
273
+ composioToolkits: [...composioSet],
274
+ inheritFullClaudeEnv: route.inheritFullClaudeEnv && anyClaudeDesktopKept,
275
+ },
276
+ droppedClaudeAi,
277
+ droppedComposio,
278
+ anyClaudeDesktopKept,
279
+ };
280
+ }
196
281
  //# sourceMappingURL=tool-router.js.map
@@ -2342,7 +2342,12 @@ export async function cmdDashboard(opts) {
2342
2342
  }
2343
2343
  // Response timeout — prevent hung handlers from blocking the connection pool.
2344
2344
  // Brain routes drive LLM calls + multi-file writes and need a longer budget.
2345
- const isLongRunning = req.path.startsWith('/brain/');
2345
+ // SSE streaming endpoints (path ends in `/stream`) and the chat endpoints
2346
+ // also drive LLM calls and would otherwise be killed mid-stream.
2347
+ const isLongRunning = req.path.startsWith('/brain/')
2348
+ || req.path.endsWith('/stream')
2349
+ || req.path === '/chat'
2350
+ || req.path === '/builder/chat';
2346
2351
  const timeoutMs = isLongRunning ? 10 * 60 * 1000 : 8000;
2347
2352
  const timeout = setTimeout(() => {
2348
2353
  if (!res.headersSent) {
@@ -7159,9 +7164,12 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7159
7164
  'Connection': 'keep-alive',
7160
7165
  });
7161
7166
  let closed = false;
7162
- req.on('close', () => { closed = true; });
7167
+ // res.on('close') fires only on actual client disconnect or response
7168
+ // teardown. req.on('close') fires once the request body finishes
7169
+ // parsing, which would silently drop every event after the first.
7170
+ res.on('close', () => { closed = true; });
7163
7171
  const writeEvent = (type, data = {}) => {
7164
- if (closed)
7172
+ if (closed || res.writableEnded)
7165
7173
  return;
7166
7174
  try {
7167
7175
  res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
@@ -8019,15 +8027,22 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
8019
8027
  `Help the user think about what makes a good agent: clear role, specific tools, focused personality. Keep it conversational — one question at a time.\n` +
8020
8028
  `When the user says "save" or approves, output the final artifact block.]\n\n`
8021
8029
  : type === 'workflow'
8022
- ? `[BUILDER MODE: You are helping the user build a "trick" — a (possibly multi-step) thing Clementine can do on a schedule or on demand. As you develop it, output the current state as a JSON block:\n` +
8030
+ ? `[BUILDER MODE: You are helping the user DRAFT a "trick" — a (possibly multi-step) thing Clementine can do on a schedule or on demand. You are NOT executing the trick. You are not running anything in the background. You are only authoring a spec the user will save, then run later from the dashboard.\n` +
8031
+ `\n` +
8032
+ `Hard rules:\n` +
8033
+ ` - NEVER say "on it", "running in the background", "I'll follow up", "working on it now", or anything else that implies you're executing the user's request. You are drafting a spec.\n` +
8034
+ ` - Stay strictly conversational. One short question per turn. Update the artifact block on every turn.\n` +
8035
+ ` - If the user describes "real work" (multi-step actions, scrapers, enrichments, reports), still just draft it — don't dispatch.\n` +
8036
+ `\n` +
8037
+ `As you develop the trick, output the current state as a JSON block:\n` +
8023
8038
  '```json-artifact\n{"type":"workflow","name":"...","description":"...","schedule":"","model":"","steps":"step1:\\n prompt: ...\\nstep2:\\n prompt: ...\\n dependsOn: step1"}\n```\n' +
8024
- `Update this block in EVERY response. Keep it conversational — one question at a time. Ask about (in roughly this order):\n` +
8025
- ` 1. The goal (one sentence is fine).\n` +
8039
+ `Ask about (in roughly this order, one at a time):\n` +
8040
+ ` 1. The goal (one sentence is fine — confirm it back).\n` +
8026
8041
  ` 2. When it should run — natural language is fine ("every weekday at 9"); convert to a cron expression in the schedule field. Empty schedule = manual.\n` +
8027
8042
  ` 3. Which tools, projects, or channels she'll need (MCP servers, local CLIs like sf/gh/gcloud, Slack/Discord targets).\n` +
8028
8043
  ` 4. Which model — claude-opus-4-7 (most capable), claude-sonnet-4-6 (balanced), or claude-haiku-4-5-20251001 (fastest). Leave model empty if the user doesn't care.\n` +
8029
8044
  `Most tricks need only one prompt step. Add steps only when the user explicitly wants a multi-step pipeline.\n` +
8030
- `When the user says "save" or approves, output the final artifact block.]\n\n`
8045
+ `When the user says "save" or approves, output the final artifact block — don't try to save it yourself, the dashboard handles persistence.]\n\n`
8031
8046
  : `[BUILDER MODE: You are helping configure an artifact. Output structured JSON blocks as you build.]\n\n`;
8032
8047
  enrichedMessage = builderPrefix + fileContext + toolContext + artifactContext + message;
8033
8048
  builderSessionInited.add(sessionKey);
@@ -8056,6 +8071,148 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
8056
8071
  res.status(500).json({ error: String(err) });
8057
8072
  }
8058
8073
  });
8074
+ // Streaming variant of /api/builder/chat. Same enrichment, SSE response.
8075
+ // Emits `text` events for token chunks and a final `done` event with the
8076
+ // cleaned response and parsed artifact. Mirrors /api/chat/stream's shape.
8077
+ app.post('/api/builder/chat/stream', async (req, res) => {
8078
+ const { message, artifactType, agentSlug, currentArtifact, attachments, linkedTools } = req.body;
8079
+ if (!message || typeof message !== 'string') {
8080
+ res.status(400).json({ error: 'message is required' });
8081
+ return;
8082
+ }
8083
+ res.writeHead(200, {
8084
+ 'Content-Type': 'text/event-stream',
8085
+ 'Cache-Control': 'no-cache',
8086
+ 'Connection': 'keep-alive',
8087
+ });
8088
+ let closed = false;
8089
+ // Use res.on('close') for client-disconnect detection. The req-level
8090
+ // close event in Express fires once the request body has been read, even
8091
+ // while the response is still open — using it to gate writes silently
8092
+ // drops every event after the first.
8093
+ res.on('close', () => { closed = true; });
8094
+ const writeEvent = (eventType, data = {}) => {
8095
+ if (closed || res.writableEnded)
8096
+ return;
8097
+ try {
8098
+ res.write(`data: ${JSON.stringify({ type: eventType, ...data })}\n\n`);
8099
+ }
8100
+ catch {
8101
+ closed = true;
8102
+ }
8103
+ };
8104
+ // Flush headers immediately so the client sees the connection open even
8105
+ // before the gateway warms up (otherwise some HTTP intermediaries hold
8106
+ // the response until first body byte).
8107
+ writeEvent('progress', { status: 'connecting…' });
8108
+ // ── Same enrichment as /api/builder/chat (system prefix on first turn,
8109
+ // artifact + files + tools on every turn). Inlined to keep the diff
8110
+ // contained; refactor into a helper if a third endpoint shows up.
8111
+ const type = artifactType || 'skill';
8112
+ const sessionKey = `dashboard:builder:${type}:${agentSlug || 'clementine'}`;
8113
+ const isFirstMessage = !builderSessionInited.has(sessionKey);
8114
+ const artifactContext = currentArtifact
8115
+ ? `\n[CURRENT ARTIFACT STATE]\n\`\`\`json-artifact\n${JSON.stringify(currentArtifact)}\n\`\`\`\n`
8116
+ : '';
8117
+ let fileContext = '';
8118
+ if (Array.isArray(attachments) && attachments.length > 0) {
8119
+ const fileParts = [];
8120
+ for (const att of attachments) {
8121
+ if (att.filename && att.content) {
8122
+ try {
8123
+ const decoded = Buffer.from(att.content, 'base64').toString('utf-8');
8124
+ const trimmed = decoded.length > 4000 ? decoded.slice(0, 4000) + '\n... (truncated)' : decoded;
8125
+ fileParts.push(`### ${att.filename}\n\`\`\`\n${trimmed}\n\`\`\``);
8126
+ }
8127
+ catch { /* skip binary files */ }
8128
+ }
8129
+ }
8130
+ if (fileParts.length > 0) {
8131
+ fileContext = `\n[REFERENCE FILES — the user attached these for context]\n${fileParts.join('\n\n')}\n`;
8132
+ }
8133
+ }
8134
+ let toolContext = '';
8135
+ if (Array.isArray(linkedTools) && linkedTools.length > 0) {
8136
+ toolContext = `\n[LINKED TOOLS — this skill should use these tools: ${linkedTools.join(', ')}]\n`;
8137
+ }
8138
+ let enrichedMessage;
8139
+ if (isFirstMessage) {
8140
+ const agentContext = agentSlug ? `You are building this for the agent "${agentSlug}". The skill/cron will be scoped to this agent specifically.\n` : '';
8141
+ const builderPrefix = type === 'skill'
8142
+ ? `[BUILDER MODE: You are helping build a reusable skill. ${agentContext}As you develop the procedure, output the current state as a JSON block:\n` +
8143
+ '```json-artifact\n{"type":"skill","title":"...","description":"...","triggers":["..."],"steps":"markdown procedure","toolsUsed":["tool1","tool2"]}\n```\n' +
8144
+ `Update this block in EVERY response as the skill evolves. If the user has linked tools, include them in the toolsUsed array. Ask clarifying questions to refine the procedure. Keep it conversational — one question at a time. ` +
8145
+ `When the user says "save" or approves, output the final artifact block.]\n\n`
8146
+ : type === 'cron'
8147
+ ? `[BUILDER MODE: You are helping build a scheduled cron job. As you develop the job, output the current state as a JSON block:\n` +
8148
+ '```json-artifact\n{"type":"cron","name":"...","schedule":"cron expression","tier":1,"prompt":"the full job prompt","mode":"standard","enabled":true}\n```\n' +
8149
+ `Update this block in EVERY response as the job evolves. Ask about schedule, what it should do, which tools/APIs it needs, what tier (1=read-only, 2=read-write), and whether it should run in unleashed mode.\n` +
8150
+ `When the user says "save" or approves, output the final artifact block.]\n\n`
8151
+ : type === 'agent'
8152
+ ? `[BUILDER MODE: You are helping create a new AI agent team member. As you develop the agent config, output the current state as a JSON block:\n` +
8153
+ '```json-artifact\n{"type":"agent","name":"...","description":"role description","model":"sonnet","personality":"system prompt / onboarding brief","tools":["tool1","tool2"],"channel":"","tier":2}\n```\n' +
8154
+ `Update this block in EVERY response as the agent evolves. Ask about: the agent's role, what tools it needs, what model to use, its personality/system prompt, which channel it should operate in, and its security tier.\n` +
8155
+ `Keep it conversational — one question at a time. When the user says "save" or approves, output the final artifact block.]\n\n`
8156
+ : type === 'workflow'
8157
+ ? `[BUILDER MODE: You are helping the user DRAFT a "trick" — a (possibly multi-step) thing Clementine can do on a schedule or on demand. You are NOT executing the trick. You are not running anything in the background. You are only authoring a spec the user will save, then run later from the dashboard.\n` +
8158
+ `\n` +
8159
+ `Hard rules:\n` +
8160
+ ` - NEVER say "on it", "running in the background", "I'll follow up", "working on it now", or anything else that implies you're executing the user's request. You are drafting a spec.\n` +
8161
+ ` - Stay strictly conversational. One short question per turn. Update the artifact block on every turn.\n` +
8162
+ ` - If the user describes "real work" (multi-step actions, scrapers, enrichments, reports), still just draft it — don't dispatch.\n` +
8163
+ `\n` +
8164
+ `As you develop the trick, output the current state as a JSON block:\n` +
8165
+ '```json-artifact\n{"type":"workflow","name":"...","description":"...","schedule":"","model":"","steps":"step1:\\n prompt: ...\\nstep2:\\n prompt: ...\\n dependsOn: step1"}\n```\n' +
8166
+ `Ask about (in roughly this order, one at a time):\n` +
8167
+ ` 1. The goal (one sentence is fine — confirm it back).\n` +
8168
+ ` 2. When it should run — natural language is fine ("every weekday at 9"); convert to a cron expression in the schedule field. Empty schedule = manual.\n` +
8169
+ ` 3. Which tools, projects, or channels she'll need (MCP servers, local CLIs like sf/gh/gcloud, Slack/Discord targets).\n` +
8170
+ ` 4. Which model — claude-opus-4-7 (most capable), claude-sonnet-4-6 (balanced), or claude-haiku-4-5-20251001 (fastest). Leave model empty if the user doesn't care.\n` +
8171
+ `Most tricks need only one prompt step. Add steps only when the user explicitly wants a multi-step pipeline.\n` +
8172
+ `When the user says "save" or approves, output the final artifact block — don't try to save it yourself, the dashboard handles persistence.]\n\n`
8173
+ : `[BUILDER MODE: You are helping configure an artifact. Output structured JSON blocks as you build.]\n\n`;
8174
+ enrichedMessage = builderPrefix + fileContext + toolContext + artifactContext + message;
8175
+ builderSessionInited.add(sessionKey);
8176
+ }
8177
+ else {
8178
+ enrichedMessage = fileContext + toolContext + artifactContext + message;
8179
+ }
8180
+ try {
8181
+ writeEvent('progress', { status: 'thinking…' });
8182
+ const gateway = await getGateway();
8183
+ let lastText = '';
8184
+ const response = await gateway.handleMessage(sessionKey, enrichedMessage, async (text) => {
8185
+ lastText = text ?? '';
8186
+ // Strip any in-progress json-artifact fence from the streamed token
8187
+ // chunk so users don't see raw JSON scrolling past in the UI. The
8188
+ // final artifact arrives in the `done` event.
8189
+ const visible = lastText.replace(/```json-artifact[\s\S]*?(```|$)/g, '');
8190
+ writeEvent('text', { text: visible });
8191
+ }, undefined, undefined, async (toolName) => {
8192
+ writeEvent('tool', { name: toolName });
8193
+ }, async (status) => {
8194
+ writeEvent('progress', { status });
8195
+ });
8196
+ const finalText = response ?? lastText ?? '';
8197
+ let artifact = null;
8198
+ const artifactMatch = finalText.match(/```json-artifact\s*\n([\s\S]*?)```/);
8199
+ if (artifactMatch) {
8200
+ try {
8201
+ artifact = JSON.parse(artifactMatch[1]);
8202
+ }
8203
+ catch { /* malformed */ }
8204
+ }
8205
+ const cleanResponse = finalText.replace(/```json-artifact\s*\n[\s\S]*?```/g, '').trim();
8206
+ writeEvent('done', { response: cleanResponse, artifact });
8207
+ if (!closed)
8208
+ res.end();
8209
+ }
8210
+ catch (err) {
8211
+ writeEvent('error', { error: String(err) });
8212
+ if (!closed)
8213
+ res.end();
8214
+ }
8215
+ });
8059
8216
  // Reset builder session when user clicks "New"
8060
8217
  app.post('/api/builder/reset', (_req, res) => {
8061
8218
  const { artifactType, agentSlug } = _req.body;
@@ -15303,29 +15460,45 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15303
15460
  </div>
15304
15461
  </div>
15305
15462
  </div>
15306
- <!-- Chat-first builder modal — multi-turn conversation that drafts a trick spec live -->
15463
+ <!-- Chat-first builder modal — two-pane: chat on the left, live spec preview on the right -->
15464
+ <style>
15465
+ .trick-chat-body { display:flex; flex-direction:row; flex:1; min-height:0; }
15466
+ .trick-chat-pane { width:420px; flex-shrink:0; display:flex; flex-direction:column; min-height:0; }
15467
+ .trick-spec-pane { flex:1; min-width:0; min-height:0; overflow-y:auto; padding:18px 20px; background:var(--bg-tertiary); border-left:1px solid var(--border); }
15468
+ .trick-spec-card { background:var(--bg-secondary); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; margin-bottom:8px; }
15469
+ .trick-spec-skeleton-row { height:14px; background:var(--bg-secondary); border-radius:4px; margin:8px 0; opacity:0.6; }
15470
+ @keyframes trickShimmer { 0% { opacity:0.45 } 50% { opacity:0.85 } 100% { opacity:0.45 } }
15471
+ .trick-spec-streaming .trick-spec-card { animation:trickShimmer 1.4s ease-in-out infinite; }
15472
+ @media (max-width: 900px) {
15473
+ .trick-chat-body { flex-direction:column; }
15474
+ .trick-chat-pane { width:100%; max-height:50vh; }
15475
+ .trick-spec-pane { border-left:none; border-top:1px solid var(--border); }
15476
+ }
15477
+ </style>
15307
15478
  <div id="routines-chat-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
15308
- <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);width:760px;max-width:96vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden">
15309
- <div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px">
15479
+ <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);width:1100px;max-width:96vw;height:88vh;max-height:88vh;display:flex;flex-direction:column;overflow:hidden">
15480
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
15310
15481
  <h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Build a trick with Clementine</h3>
15311
- <span style="flex:1"></span>
15482
+ <span id="routines-chat-status" style="color:var(--text-muted);flex:1;font-size:11px;min-height:14px"></span>
15312
15483
  <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeChat()" style="padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">&times;</button>
15313
15484
  </div>
15314
- <!-- Live spec preview -->
15315
- <div id="routines-chat-preview" style="display:none;padding:10px 18px;border-bottom:1px solid var(--border);background:var(--bg-tertiary);font-size:12px;color:var(--text-secondary)"></div>
15316
- <!-- Messages -->
15317
- <div id="routines-chat-messages" style="flex:1;min-height:240px;overflow-y:auto;padding:14px 18px;display:flex;flex-direction:column;gap:10px"></div>
15318
- <!-- Composer -->
15319
- <div style="padding:12px 18px;border-top:1px solid var(--border);background:var(--bg-secondary)">
15320
- <div style="display:flex;gap:8px;align-items:flex-end">
15321
- <textarea id="routines-chat-input" rows="2" placeholder="Tell Clementine what you want her to do…" style="flex:1;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:inherit;resize:vertical;box-sizing:border-box" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();window.RoutinesUI&&RoutinesUI.sendChat();}"></textarea>
15322
- <button id="routines-chat-send" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.sendChat()" style="padding:8px 16px;align-self:flex-end">Send</button>
15323
- </div>
15324
- <div style="display:flex;align-items:center;gap:10px;margin-top:8px;font-size:11px">
15325
- <span id="routines-chat-status" style="color:var(--text-muted);flex:1;min-height:14px"></span>
15326
- <button id="routines-chat-save" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.saveChatDraft()" style="display:none;padding:5px 12px;font-size:11px">Save trick</button>
15327
- <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeChat(true);RoutinesUI.openCreate();" style="padding:4px 10px;background:transparent;border:none;color:var(--text-muted);font-size:11px;cursor:pointer;text-decoration:underline">Build manually instead</button>
15485
+ <div class="trick-chat-body">
15486
+ <!-- Left: chat pane -->
15487
+ <div class="trick-chat-pane">
15488
+ <div id="routines-chat-messages" style="flex:1;min-height:0;overflow-y:auto;padding:14px 18px;display:flex;flex-direction:column;gap:10px"></div>
15489
+ <div style="padding:12px 18px;border-top:1px solid var(--border);background:var(--bg-secondary);flex-shrink:0">
15490
+ <div style="display:flex;gap:8px;align-items:flex-end">
15491
+ <textarea id="routines-chat-input" rows="2" placeholder="Tell Clementine what you want her to do…" style="flex:1;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:inherit;resize:vertical;box-sizing:border-box" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();window.RoutinesUI&&RoutinesUI.sendChat();}"></textarea>
15492
+ <button id="routines-chat-send" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.sendChat()" style="padding:8px 16px;align-self:flex-end">Send</button>
15493
+ </div>
15494
+ </div>
15328
15495
  </div>
15496
+ <!-- Right: live spec pane -->
15497
+ <div class="trick-spec-pane" id="routines-chat-spec"></div>
15498
+ </div>
15499
+ <div style="padding:10px 18px;border-top:1px solid var(--border);background:var(--bg-secondary);display:flex;align-items:center;gap:10px;flex-shrink:0">
15500
+ <span style="flex:1"></span>
15501
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeChat(true);RoutinesUI.openCreate();" style="padding:4px 10px;background:transparent;border:none;color:var(--text-muted);font-size:11px;cursor:pointer;text-decoration:underline">Build manually instead</button>
15329
15502
  </div>
15330
15503
  </div>
15331
15504
  </div>
@@ -15929,9 +16102,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15929
16102
  R.state.chatMessages = [];
15930
16103
  R.state.chatArtifact = null;
15931
16104
  R.state.chatBusy = false;
16105
+ R.state.chatStreaming = false;
15932
16106
  document.getElementById('routines-chat-input').value = '';
15933
16107
  document.getElementById('routines-chat-status').textContent = '';
15934
- document.getElementById('routines-chat-save').style.display = 'none';
15935
16108
  // Reset the builder session so the prior conversation doesn't leak in.
15936
16109
  apiFetch('/api/builder/reset', {
15937
16110
  method: 'POST',
@@ -15939,7 +16112,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15939
16112
  body: JSON.stringify({ artifactType: 'workflow' })
15940
16113
  }).catch(function(){ /* non-fatal */ });
15941
16114
  R.renderChatMessages();
15942
- R.renderChatPreview();
16115
+ R.renderChatSpec();
15943
16116
  // Seed with a greeting from the assistant so the panel isn't empty.
15944
16117
  R.appendChatMessage('assistant', 'Hi! Tell me what you want Clementine to do — a sentence is fine. I\\x27ll ask a couple of follow-ups (when it should run, which tools she needs, which model) and draft a trick you can save.');
15945
16118
  m.style.display = 'flex';
@@ -15955,35 +16128,133 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15955
16128
  },
15956
16129
  renderChatMessages: function() {
15957
16130
  var box = document.getElementById('routines-chat-messages'); if (!box) return;
15958
- box.innerHTML = (R.state.chatMessages || []).map(function(m){
16131
+ var streaming = R.state.chatStreaming;
16132
+ var msgs = R.state.chatMessages || [];
16133
+ box.innerHTML = msgs.map(function(m, i){
15959
16134
  var isUser = m.role === 'user';
15960
16135
  var bg = isUser ? 'var(--clementine,#ff8c21)' : 'var(--bg-tertiary)';
15961
16136
  var color = isUser ? '#fff' : 'var(--text-primary)';
15962
16137
  var align = isUser ? 'flex-end' : 'flex-start';
15963
- return '<div style="display:flex;justify-content:' + align + '"><div style="max-width:78%;padding:8px 12px;border-radius:10px;background:' + bg + ';color:' + color + ';font-size:13px;line-height:1.5;white-space:pre-wrap">' + R.esc(m.text) + '</div></div>';
16138
+ var isLastAssistant = !isUser && i === msgs.length - 1 && streaming;
16139
+ var caret = isLastAssistant ? '<span style="display:inline-block;width:6px;height:14px;margin-left:2px;background:var(--text-primary);opacity:0.55;animation:trickShimmer 1s infinite"></span>' : '';
16140
+ var bodyText = m.text || (isLastAssistant ? '' : '(thinking…)');
16141
+ return '<div style="display:flex;justify-content:' + align + '"><div style="max-width:82%;padding:8px 12px;border-radius:10px;background:' + bg + ';color:' + color + ';font-size:13px;line-height:1.5;white-space:pre-wrap">' + R.esc(bodyText) + caret + '</div></div>';
15964
16142
  }).join('');
15965
16143
  box.scrollTop = box.scrollHeight;
15966
16144
  },
15967
- renderChatPreview: function() {
15968
- var pv = document.getElementById('routines-chat-preview'); if (!pv) return;
16145
+ // Renders the right-hand spec pane. Skeleton state when the agent
16146
+ // hasn't drafted anything yet; populated card view once it has a
16147
+ // name + at least one step. Save button lives at the bottom.
16148
+ renderChatSpec: function() {
16149
+ var pane = document.getElementById('routines-chat-spec'); if (!pane) return;
16150
+ pane.classList.toggle('trick-spec-streaming', !!R.state.chatStreaming);
15969
16151
  var a = R.state.chatArtifact;
15970
- if (!a || !a.name) { pv.style.display = 'none'; return; }
15971
- pv.style.display = 'block';
15972
- var schedule = a.schedule ? '<code style="font-family:\\x27JetBrains Mono\\x27,monospace;font-size:11px">' + R.esc(a.schedule) + '</code>' : '<em style="color:var(--text-muted)">manual</em>';
15973
- var stepCount = (a.steps && typeof a.steps === 'string')
15974
- ? (a.steps.match(/^[a-zA-Z0-9_-]+:/gm) || []).length
15975
- : (Array.isArray(a.steps) ? a.steps.length : 0);
15976
- pv.innerHTML = '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><strong style="color:var(--text-primary)">' + R.esc(a.name) + '</strong>'
15977
- + '<span style="color:var(--text-muted)">·</span><span>schedule ' + schedule + '</span>'
15978
- + '<span style="color:var(--text-muted)">·</span><span>' + stepCount + ' step' + (stepCount === 1 ? '' : 's') + '</span>'
15979
- + (a.model ? '<span style="color:var(--text-muted)">·</span><span>model <code>' + R.esc(a.model) + '</code></span>' : '')
15980
- + '</div>'
15981
- + (a.description ? '<div style="margin-top:4px;color:var(--text-muted);font-size:11px">' + R.esc(a.description) + '</div>' : '');
15982
- // Save button shows once we have a name + at least one step.
15983
- var saveBtn = document.getElementById('routines-chat-save');
15984
- if (saveBtn) saveBtn.style.display = (a.name && stepCount > 0) ? '' : 'none';
16152
+ if (!a || !a.name) {
16153
+ pane.innerHTML = '<div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:10px">Trick spec</div>'
16154
+ + '<div class="trick-spec-card" style="opacity:0.7"><div class="trick-spec-skeleton-row" style="width:60%"></div><div class="trick-spec-skeleton-row" style="width:90%"></div><div class="trick-spec-skeleton-row" style="width:40%"></div></div>'
16155
+ + '<div style="font-size:11px;color:var(--text-muted);line-height:1.5;margin-top:14px;font-style:italic">I\\x27ll fill this in as we chat. Each answer you give populates a field on the right — name, schedule, model, steps. When it\\x27s ready you\\x27ll see a Save trick button.</div>';
16156
+ return;
16157
+ }
16158
+ // Parse the YAML-ish steps string into displayable cards.
16159
+ var steps = R.parseStepsForSpec(a.steps);
16160
+ var stepCount = steps.length;
16161
+ var schedule = a.schedule ? R.humanizeCron(a.schedule) : 'manual';
16162
+ var modelLabel = a.model ? R.modelLabel(a.model) : 'inherit';
16163
+ var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em">Trick spec</div><span style="flex:1"></span>'
16164
+ + (R.state.chatStreaming ? '<span style="font-size:11px;color:var(--clementine)">drafting…</span>' : '')
16165
+ + '</div>';
16166
+ var meta = '<div class="trick-spec-card">'
16167
+ + '<div style="font-size:15px;font-weight:600;color:var(--text-primary);margin-bottom:4px">' + R.esc(a.name) + '</div>'
16168
+ + (a.description ? '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">' + R.esc(a.description) + '</div>' : '')
16169
+ + '<div style="display:flex;flex-wrap:wrap;gap:14px;font-size:11px;color:var(--text-muted)">'
16170
+ + '<div><span style="text-transform:uppercase;letter-spacing:0.04em">Schedule</span><div style="margin-top:2px;color:var(--text-primary);font-size:12px;font-weight:500">' + R.esc(schedule) + (a.schedule ? ' <code style="font-family:\\x27JetBrains Mono\\x27,monospace;font-size:10px;color:var(--text-muted)" title="' + R.esc(a.schedule) + '">' + R.esc(a.schedule) + '</code>' : '') + '</div></div>'
16171
+ + '<div><span style="text-transform:uppercase;letter-spacing:0.04em">Model</span><div style="margin-top:2px;color:var(--text-primary);font-size:12px;font-weight:500">' + R.esc(modelLabel) + '</div></div>'
16172
+ + '<div><span style="text-transform:uppercase;letter-spacing:0.04em">Steps</span><div style="margin-top:2px;color:var(--text-primary);font-size:12px;font-weight:500">' + stepCount + '</div></div>'
16173
+ + '</div></div>';
16174
+ var stepsHtml = '';
16175
+ if (steps.length > 0) {
16176
+ stepsHtml = '<div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin:18px 0 8px">Steps</div>'
16177
+ + steps.map(function(s, i){
16178
+ var kind = s.kind || 'prompt';
16179
+ var kindColor = { prompt: '#5e72e4', mcp: '#2dce89', cli: '#fb6340', conditional: '#f5365c', channel: '#11cdef', transform: '#ffd600', loop: '#8965e0' }[kind] || '#888';
16180
+ var badge = '<span style="display:inline-block;background:' + kindColor + '22;color:' + kindColor + ';padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em">' + kind + '</span>';
16181
+ return '<div class="trick-spec-card" style="border-left:3px solid ' + kindColor + '">'
16182
+ + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="font-size:11px;color:var(--text-muted);font-weight:600">#' + (i + 1) + '</span>' + badge + '<code style="font-family:\\x27JetBrains Mono\\x27,monospace;font-size:11px;color:var(--text-secondary)">' + R.esc(s.id) + '</code></div>'
16183
+ + (s.preview ? '<div style="font-size:12px;color:var(--text-secondary);line-height:1.45">' + R.esc(s.preview) + '</div>' : '')
16184
+ + '</div>';
16185
+ }).join('');
16186
+ }
16187
+ var ready = a.name && stepCount > 0;
16188
+ var saveRow = ready
16189
+ ? '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px"><span style="font-size:12px;color:var(--green,#22c55e);font-weight:500">✓ Ready to save</span><span style="flex:1"></span><button class="btn-sm btn-primary" onclick="window.RoutinesUI&&RoutinesUI.saveChatDraft()" style="padding:6px 18px">Save trick</button></div>'
16190
+ : '<div style="margin-top:18px;font-size:11px;color:var(--text-muted);font-style:italic">A few more details and I\\x27ll let you save.</div>';
16191
+ pane.innerHTML = head + meta + stepsHtml + saveRow;
15985
16192
  },
15986
- sendChat: function() {
16193
+ // Best-effort parser for the agent's YAML-ish steps string. Handles
16194
+ // flat top-level "id:" keys with indented "prompt:", "kind:",
16195
+ // "dependsOn:" children. Falls back gracefully on weird shapes.
16196
+ parseStepsForSpec: function(stepsStr) {
16197
+ if (!stepsStr) return [];
16198
+ if (Array.isArray(stepsStr)) {
16199
+ return stepsStr.map(function(s){ return { id: String(s.id || ''), kind: s.kind || 'prompt', preview: String(s.prompt || '').slice(0, 160) }; }).filter(function(s){ return s.id; });
16200
+ }
16201
+ if (typeof stepsStr !== 'string') return [];
16202
+ var lines = stepsStr.split(/\\r?\\n/);
16203
+ var steps = [];
16204
+ var current = null;
16205
+ lines.forEach(function(line){
16206
+ var topMatch = line.match(/^([A-Za-z0-9_-]+)\\s*:\\s*$/);
16207
+ if (topMatch && !line.startsWith(' ') && !line.startsWith('\\t')) {
16208
+ if (current) steps.push(current);
16209
+ current = { id: topMatch[1], kind: 'prompt', promptParts: [] };
16210
+ return;
16211
+ }
16212
+ if (!current) return;
16213
+ var promptM = line.match(/^\\s+prompt\\s*:\\s*(.*)$/);
16214
+ if (promptM) { current.promptParts.push(promptM[1]); return; }
16215
+ var kindM = line.match(/^\\s+kind\\s*:\\s*(\\w+)/);
16216
+ if (kindM) { current.kind = kindM[1]; return; }
16217
+ if (/^\\s+/.test(line) && line.trim().length) {
16218
+ // Continuation of multi-line prompt or other field — capture for preview
16219
+ current.promptParts.push(line.trim());
16220
+ }
16221
+ });
16222
+ if (current) steps.push(current);
16223
+ return steps.map(function(s){
16224
+ return { id: s.id, kind: s.kind, preview: s.promptParts.join(' ').slice(0, 160) };
16225
+ });
16226
+ },
16227
+ // Friendly cron string for the spec pane (and reusable in the list).
16228
+ humanizeCron: function(expr) {
16229
+ if (!expr) return 'manual';
16230
+ var parts = String(expr).trim().split(/\\s+/);
16231
+ if (parts.length !== 5) return expr;
16232
+ var min = parts[0], hour = parts[1], dom = parts[2], mon = parts[3], dow = parts[4];
16233
+ var dows = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
16234
+ var pad2 = function(n){ n = String(n); return n.length < 2 ? '0' + n : n; };
16235
+ var fmt = function(h, m){
16236
+ var hn = parseInt(h, 10), mn = parseInt(m, 10);
16237
+ if (isNaN(hn) || isNaN(mn)) return '';
16238
+ var ampm = hn >= 12 ? 'pm' : 'am';
16239
+ var h12 = ((hn + 11) % 12) + 1;
16240
+ return mn === 0 ? h12 + ampm : h12 + ':' + pad2(mn) + ampm;
16241
+ };
16242
+ if (min === '*' && hour === '*' && dom === '*' && mon === '*' && dow === '*') return 'every minute';
16243
+ if (/^\\*\\/\\d+$/.test(min) && hour === '*' && dom === '*' && mon === '*' && dow === '*') return 'every ' + min.slice(2) + ' min';
16244
+ if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') return 'hourly';
16245
+ if (/^\\d+$/.test(min) && hour === '*' && dom === '*' && mon === '*' && dow === '*') return 'every hour at :' + pad2(min);
16246
+ if (min === '0' && /^\\*\\/\\d+$/.test(hour) && dom === '*' && mon === '*' && dow === '*') return 'every ' + hour.slice(2) + ' hours';
16247
+ if (/^\\d+$/.test(min) && /^\\d+$/.test(hour) && dom === '*' && mon === '*' && dow === '*') return 'daily at ' + fmt(hour, min);
16248
+ if (/^\\d+$/.test(min) && /^\\d+$/.test(hour) && dom === '*' && mon === '*' && dow === '1-5') return 'weekdays at ' + fmt(hour, min);
16249
+ if (/^\\d+$/.test(min) && /^\\d+$/.test(hour) && dom === '*' && mon === '*' && /^\\d$/.test(dow)) return 'every ' + dows[+dow] + ' at ' + fmt(hour, min);
16250
+ if (/^\\d+$/.test(min) && /^\\d+$/.test(hour) && /^\\d+$/.test(dom) && mon === '*' && dow === '*') return 'monthly on day ' + dom + ' at ' + fmt(hour, min);
16251
+ return expr;
16252
+ },
16253
+ modelLabel: function(id) {
16254
+ var found = (R.MODEL_OPTS || []).find(function(o){ return o.id === id; });
16255
+ return found ? found.label.split(' — ')[0] : id;
16256
+ },
16257
+ sendChat: async function() {
15987
16258
  if (R.state.chatBusy) return;
15988
16259
  var input = document.getElementById('routines-chat-input');
15989
16260
  var text = input.value.trim();
@@ -15991,39 +16262,69 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15991
16262
  input.value = '';
15992
16263
  R.appendChatMessage('user', text);
15993
16264
  R.state.chatBusy = true;
16265
+ R.state.chatStreaming = true;
15994
16266
  var sendBtn = document.getElementById('routines-chat-send');
15995
16267
  var status = document.getElementById('routines-chat-status');
15996
16268
  if (sendBtn) { sendBtn.textContent = 'Thinking…'; sendBtn.disabled = true; }
15997
16269
  if (status) status.textContent = 'Clementine is drafting…';
15998
- apiFetch('/api/builder/chat', {
15999
- method: 'POST',
16000
- headers: { 'Content-Type': 'application/json' },
16001
- body: JSON.stringify({
16002
- message: text,
16003
- artifactType: 'workflow',
16004
- currentArtifact: R.state.chatArtifact || undefined,
16005
- })
16006
- }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
16007
- .then(function(res){
16008
- R.state.chatBusy = false;
16009
- if (sendBtn) { sendBtn.textContent = 'Send'; sendBtn.disabled = false; }
16010
- if (!res.ok) {
16011
- if (status) status.textContent = 'Error: ' + (res.body && res.body.error || 'unknown');
16012
- return;
16013
- }
16014
- // Endpoint returns { ok, response, artifact } — server already
16015
- // strips the json-artifact fence and parses the JSON for us.
16016
- var reply = (res.body && res.body.response) || '(no reply)';
16017
- if (res.body && res.body.artifact) R.state.chatArtifact = res.body.artifact;
16018
- R.appendChatMessage('assistant', reply);
16019
- R.renderChatPreview();
16020
- if (status) status.textContent = (res.body && res.body.artifact) ? 'Draft updated.' : '';
16021
- })
16022
- .catch(function(err){
16023
- R.state.chatBusy = false;
16024
- if (sendBtn) { sendBtn.textContent = 'Send'; sendBtn.disabled = false; }
16025
- if (status) status.textContent = 'Chat error: ' + err;
16270
+ // Push a placeholder assistant bubble that we'll fill from the stream.
16271
+ R.state.chatMessages.push({ role: 'assistant', text: '' });
16272
+ R.renderChatMessages();
16273
+ R.renderChatSpec();
16274
+ try {
16275
+ var resp = await apiFetch('/api/builder/chat/stream', {
16276
+ method: 'POST',
16277
+ headers: { 'Content-Type': 'application/json' },
16278
+ body: JSON.stringify({
16279
+ message: text,
16280
+ artifactType: 'workflow',
16281
+ currentArtifact: R.state.chatArtifact || undefined,
16282
+ })
16026
16283
  });
16284
+ if (!resp.ok || !resp.body) throw new Error('HTTP ' + resp.status);
16285
+ var reader = resp.body.getReader();
16286
+ var decoder = new TextDecoder();
16287
+ var buf = '';
16288
+ while (true) {
16289
+ var chunk = await reader.read();
16290
+ if (chunk.done) break;
16291
+ buf += decoder.decode(chunk.value, { stream: true });
16292
+ var idx;
16293
+ while ((idx = buf.indexOf('\\n\\n')) >= 0) {
16294
+ var raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
16295
+ if (!raw.startsWith('data:')) continue;
16296
+ var json = raw.replace(/^data:\\s*/, '').trim();
16297
+ var evt = null;
16298
+ try { evt = JSON.parse(json); } catch (e) { continue; }
16299
+ var lastIdx = R.state.chatMessages.length - 1;
16300
+ if (evt.type === 'text') {
16301
+ if (lastIdx >= 0 && R.state.chatMessages[lastIdx].role === 'assistant') {
16302
+ R.state.chatMessages[lastIdx].text = evt.text || '';
16303
+ R.renderChatMessages();
16304
+ }
16305
+ } else if (evt.type === 'done') {
16306
+ if (lastIdx >= 0 && R.state.chatMessages[lastIdx].role === 'assistant') {
16307
+ R.state.chatMessages[lastIdx].text = evt.response || R.state.chatMessages[lastIdx].text || '(no reply)';
16308
+ }
16309
+ if (evt.artifact) R.state.chatArtifact = evt.artifact;
16310
+ R.state.chatStreaming = false;
16311
+ R.renderChatMessages();
16312
+ R.renderChatSpec();
16313
+ if (status) status.textContent = evt.artifact ? 'Draft updated.' : '';
16314
+ } else if (evt.type === 'error') {
16315
+ if (status) status.textContent = 'Error: ' + (evt.error || 'unknown');
16316
+ }
16317
+ }
16318
+ }
16319
+ } catch (err) {
16320
+ if (status) status.textContent = 'Chat error: ' + err;
16321
+ } finally {
16322
+ R.state.chatBusy = false;
16323
+ R.state.chatStreaming = false;
16324
+ if (sendBtn) { sendBtn.textContent = 'Send'; sendBtn.disabled = false; }
16325
+ R.renderChatMessages();
16326
+ R.renderChatSpec();
16327
+ }
16027
16328
  },
16028
16329
  // Persist the current draft as a real trick. The artifact's steps
16029
16330
  // field can come back as a YAML-ish string (per the agent's prompt
@@ -10,8 +10,8 @@ export interface GatewayContextHygieneDecision {
10
10
  reason: string;
11
11
  estimatedTokens: number;
12
12
  }
13
- export declare const GATEWAY_CONTEXT_COMPACT_EXCHANGES = 30;
14
- export declare const GATEWAY_CONTEXT_COMPACT_TOKENS = 90000;
13
+ export declare const GATEWAY_CONTEXT_COMPACT_EXCHANGES = 100;
14
+ export declare const GATEWAY_CONTEXT_COMPACT_TOKENS = 180000;
15
15
  export declare function assessGatewayContextHygiene(snapshot: GatewayContextSnapshot): GatewayContextHygieneDecision;
16
16
  export declare function formatGatewayHygieneAnnotation(decision: GatewayContextHygieneDecision): string;
17
17
  //# sourceMappingURL=context-hygiene.d.ts.map
@@ -1,6 +1,13 @@
1
1
  import { estimateTokensApprox } from './turn-ledger.js';
2
- export const GATEWAY_CONTEXT_COMPACT_EXCHANGES = 30;
3
- export const GATEWAY_CONTEXT_COMPACT_TOKENS = 90_000;
2
+ // Session-state pruning ceiling. Independent of the SDK's own autocompact —
3
+ // this trims OUR in-memory record of the conversation so it doesn't grow
4
+ // unbounded over a long-lived chat session. The SDK owns the actual
5
+ // context-window dance; we just want a hard ceiling on session bookkeeping.
6
+ // Thresholds were tightened earlier (30 / 90K) to compensate for autocompact
7
+ // thrash, but the real cause was the over-broad tool surface, not session
8
+ // state. With that fixed, this only needs to fire as a safety net.
9
+ export const GATEWAY_CONTEXT_COMPACT_EXCHANGES = 100;
10
+ export const GATEWAY_CONTEXT_COMPACT_TOKENS = 180_000;
4
11
  export function assessGatewayContextHygiene(snapshot) {
5
12
  const totalChars = snapshot.textChars + (snapshot.pendingContextChars ?? 0) + (snapshot.recentTranscriptChars ?? 0);
6
13
  const estimatedTokens = estimateTokensApprox('x'.repeat(Math.min(totalChars, 400_000)))
@@ -1053,19 +1053,18 @@ export class CronScheduler {
1053
1053
  }
1054
1054
  catch { /* non-fatal */ }
1055
1055
  }
1056
+ // Long-task preflight is ADVISORY ONLY. We log the risk + inject a
1057
+ // checkpoint-discipline prompt prefix so the agent paces itself, but
1058
+ // we do NOT auto-override the model/mode, never DM the owner asking
1059
+ // to approve an Opus 1M upgrade, and never pre-block the run.
1060
+ //
1061
+ // Sonnet runs every job by default. Opus 1M is opt-in: set
1062
+ // `model: claude-opus-4-7[1m]` in CRON.md per-job, or flip
1063
+ // CLEMENTINE_1M_CONTEXT_MODE=on for global enable.
1056
1064
  let longTaskPreflight;
1057
1065
  const preflight = analyzeLongTaskPreflight(job, jobPrompt, this.runLog.readRecent(job.name, 5));
1058
1066
  if (preflight.risk !== 'normal') {
1059
- job = { ...job };
1060
- if (preflight.modelOverride)
1061
- job.model = preflight.modelOverride;
1062
- if (preflight.modeOverride)
1063
- job.mode = preflight.modeOverride;
1064
- if (preflight.maxHoursOverride)
1065
- job.maxHours = preflight.maxHoursOverride;
1066
1067
  longTaskPreflight = compactLongTaskPreflight(preflight);
1067
- let promptPreflight = preflight;
1068
- let approvalPromptPrefix;
1069
1068
  logger.warn({
1070
1069
  job: job.name,
1071
1070
  risk: preflight.risk,
@@ -1075,7 +1074,8 @@ export class CronScheduler {
1075
1074
  model: job.model,
1076
1075
  mode: job.mode,
1077
1076
  reasons: preflight.reasons,
1078
- }, 'Long-task preflight routed cron job');
1077
+ advisory: true,
1078
+ }, 'Long-task preflight (advisory) flagged cron job');
1079
1079
  this.logAutonomy('long_task_preflight', job, {
1080
1080
  risk: preflight.risk,
1081
1081
  route: preflight.route,
@@ -1083,81 +1083,11 @@ export class CronScheduler {
1083
1083
  projectedContextTokens: preflight.projectedContextTokens,
1084
1084
  model: job.model,
1085
1085
  mode: job.mode,
1086
- requiresUserRefinement: preflight.requiresUserRefinement,
1086
+ requiresUserRefinement: false,
1087
+ advisory: true,
1087
1088
  });
1088
- if (preflight.shouldSkipBeforeRun) {
1089
- let approvedLongContext = false;
1090
- if (preflight.approvalModelOverride) {
1091
- const approvalId = `long-task-preflight-${job.name.replace(/[^a-zA-Z0-9_-]/g, '_')}-${Date.now()}`;
1092
- const approvalMessage = [
1093
- `**Long task needs approval:** \`${job.name}\``,
1094
- '',
1095
- `Preflight estimates ${preflight.estimatedInputTokens.toLocaleString()} initial tokens and ${Math.round(preflight.projectedContextTokens).toLocaleString()} working-context tokens.`,
1096
- `Current route would exceed the 200K-safe path, but Clementine can try a one-time run on \`${preflight.approvalModelOverride}\`.`,
1097
- '',
1098
- `Reply \`yes\` or \`go\` to approve this one run, or \`no\` to skip and refine/split the task.`,
1099
- `Reason: ${preflight.approvalReason}`,
1100
- ].join('\n');
1101
- await this.dispatcher.send(approvalMessage, {})
1102
- .catch(err => logger.debug({ err, job: job.name }, 'Failed to send long-task approval request'));
1103
- const approvalResult = await this.gateway.requestApproval(`Run ${job.name} on ${preflight.approvalModelOverride}?`, approvalId)
1104
- .catch(() => false);
1105
- const normalizedApproval = String(approvalResult).trim().toLowerCase();
1106
- approvedLongContext = approvalResult === true
1107
- || ['yes', 'y', 'go', 'approve', 'approved', 'true'].includes(normalizedApproval);
1108
- if (approvedLongContext) {
1109
- job.model = preflight.approvalModelOverride;
1110
- job.mode = 'unleashed';
1111
- job.maxHours = preflight.maxHoursOverride ?? job.maxHours ?? 2;
1112
- longTaskPreflight = {
1113
- ...longTaskPreflight,
1114
- route: 'opus_1m',
1115
- contextWindowTokens: 1_000_000,
1116
- modelAfter: preflight.approvalModelOverride,
1117
- modeAfter: 'unleashed',
1118
- requiresUserRefinement: false,
1119
- canProceedWithApproval: false,
1120
- approvalReason: 'Owner approved one-time long-context execution.',
1121
- };
1122
- promptPreflight = {
1123
- ...preflight,
1124
- route: 'opus_1m',
1125
- contextWindowTokens: 1_000_000,
1126
- modelAfter: preflight.approvalModelOverride,
1127
- modeAfter: 'unleashed',
1128
- requiresUserRefinement: false,
1129
- canProceedWithApproval: false,
1130
- approvalReason: 'Owner approved one-time long-context execution.',
1131
- approvalModel: preflight.approvalModelOverride,
1132
- approvalModelOverride: undefined,
1133
- shouldSkipBeforeRun: false,
1134
- };
1135
- approvalPromptPrefix = `## Long Task Approval\nOwner approved this one-time run on ${preflight.approvalModelOverride}. Continue with strict checkpoints and bounded tool output.`;
1136
- logger.warn({ job: job.name, model: job.model }, 'Long-task preflight approved for one-time long-context run');
1137
- }
1138
- }
1139
- if (!approvedLongContext) {
1140
- const now = new Date().toISOString();
1141
- const message = (`Long-task preflight blocked ${job.name}: estimated ${preflight.estimatedInputTokens.toLocaleString()} input tokens ` +
1142
- `on a ${preflight.contextWindowTokens.toLocaleString()} token route. ` +
1143
- `${preflight.recommendations[0]}`);
1144
- this._logRun({
1145
- jobName: job.name,
1146
- startedAt: now,
1147
- finishedAt: now,
1148
- status: 'skipped',
1149
- durationMs: 0,
1150
- attempt: 0,
1151
- outputPreview: message,
1152
- longTaskPreflight,
1153
- });
1154
- await this.dispatcher.send(message, { agentSlug: job.agentSlug });
1155
- return;
1156
- }
1157
- }
1158
1089
  jobPrompt = [
1159
- approvalPromptPrefix,
1160
- formatLongTaskPromptPrefix(promptPreflight),
1090
+ formatLongTaskPromptPrefix(preflight),
1161
1091
  jobPrompt,
1162
1092
  ].filter(Boolean).join('\n\n');
1163
1093
  }
@@ -150,10 +150,14 @@ function isSemanticFailure(entry) {
150
150
  const previewLower = preview.toLowerCase();
151
151
  // Match on word boundaries so "BLOCKED" matches "Result: BLOCKED" but
152
152
  // "blockedBy" in a stray JSON fragment doesn't.
153
+ //
154
+ // __NOTHING__ is the explicit "nothing to report" sentinel from the cron
155
+ // prompt (assistant.ts runCronJob). It's a successful empty-result, not a
156
+ // failure — flagging it here made every quiet inbox check look broken to
157
+ // the proactive insight engine.
153
158
  const markerRegexes = [
154
159
  /\b(blocked|task_blocked|task_incomplete)\b/,
155
160
  /\b(failed|could not|unable to|no local bash|permission denied)\b/,
156
- /__nothing__/,
157
161
  ];
158
162
  for (const re of markerRegexes) {
159
163
  if (re.test(previewLower))
@@ -32,9 +32,14 @@ function broadTaskSignals(text) {
32
32
  ];
33
33
  return signals.filter(([re]) => re.test(lower)).map(([, reason]) => reason);
34
34
  }
35
- function recentContextFailures(recentRuns) {
35
+ const RECENT_CONTEXT_FAILURE_WINDOW_MS = 48 * 60 * 60 * 1000;
36
+ function recentContextFailures(recentRuns, now = Date.now()) {
36
37
  const reasons = [];
38
+ const cutoff = now - RECENT_CONTEXT_FAILURE_WINDOW_MS;
37
39
  for (const run of recentRuns.slice(0, 5)) {
40
+ const startedMs = Date.parse(run.startedAt);
41
+ if (Number.isFinite(startedMs) && startedMs < cutoff)
42
+ continue;
38
43
  const health = classifyRunHealth(run);
39
44
  if (health.status === 'context_overflow')
40
45
  reasons.push('recent run hit context overflow');
@@ -1880,7 +1880,13 @@ export class Gateway {
1880
1880
  const isInteractive = isOwnerDm
1881
1881
  || sessionKey.startsWith('dashboard:')
1882
1882
  || sessionKey.startsWith('cli:');
1883
- if (isInteractive && !isInternalMsg && !recentContext?.suppressDeepMode && !text.startsWith('!') && !sess?.deepTask) {
1883
+ // Builder sessions (dashboard chat-first trick builder) are
1884
+ // conversational by contract — they author specs, they don't
1885
+ // run them. Skip the deep-mode classifier so a "build a thing
1886
+ // that does X and Y" prompt doesn't get hijacked into an async
1887
+ // background task.
1888
+ const isBuilderSession = sessionKey.startsWith('dashboard:builder:');
1889
+ if (isInteractive && !isBuilderSession && !isInternalMsg && !recentContext?.suppressDeepMode && !text.startsWith('!') && !sess?.deepTask) {
1884
1890
  try {
1885
1891
  const turnDecision = decideTurn({
1886
1892
  text,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.32",
3
+ "version": "1.18.34",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",