clementine-agent 1.18.32 → 1.18.33
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/assistant.js +62 -14
- package/dist/cli/dashboard.js +376 -75
- package/dist/gateway/context-hygiene.d.ts +2 -2
- package/dist/gateway/context-hygiene.js +9 -2
- package/dist/gateway/cron-scheduler.js +13 -83
- package/dist/gateway/failure-monitor.js +5 -1
- package/dist/gateway/long-task-preflight.js +6 -1
- package/dist/gateway/router.js +7 -1
- package/package.json +1 -1
package/dist/agent/assistant.js
CHANGED
|
@@ -2436,23 +2436,71 @@ 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
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2439
|
+
// Tool-surface cap. Applies to chat AND to autonomous runs (cron,
|
|
2440
|
+
// unleashed, heartbeat). Without this cap on cron, a single job got
|
|
2441
|
+
// 300+ MCP tool schemas in the system prompt — leaving Sonnet's SDK
|
|
2442
|
+
// autocompact no room to actually compact when tool responses came
|
|
2443
|
+
// back. That manifested as `rapid_refill_breaker` ("context refilled
|
|
2444
|
+
// to the limit within 3 turns"). The SDK's autocompact still works;
|
|
2445
|
+
// we just have to give it room.
|
|
2446
|
+
if (!adminNeeded && allowedTools.length > TOOL_SURFACE_HARD_LIMIT) {
|
|
2443
2447
|
const beforeAllowedToolCount = allowedTools.length;
|
|
2444
2448
|
const coreSdkTools = new Set(['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'WebSearch', 'WebFetch']);
|
|
2445
2449
|
const clementineToolPrefixForCap = `mcp__${TOOLS_SERVER}__`;
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2450
|
+
// Smart fallback: if the route matched specific bundles, keep
|
|
2451
|
+
// those bundles' explicit servers/toolkits and drop everything
|
|
2452
|
+
// else (including the fullSurface=true expansion to "all
|
|
2453
|
+
// connected MCP servers"). Only fall all the way down to
|
|
2454
|
+
// core+Clementine tools when there are no matched bundles to
|
|
2455
|
+
// restrict to.
|
|
2456
|
+
const matchedExternal = Array.isArray(toolRoute.externalMcpServers)
|
|
2457
|
+
? new Set(toolRoute.externalMcpServers)
|
|
2458
|
+
: null;
|
|
2459
|
+
const matchedComposio = Array.isArray(toolRoute.composioToolkits)
|
|
2460
|
+
? new Set(toolRoute.composioToolkits)
|
|
2461
|
+
: null;
|
|
2462
|
+
const hasMatchedBundles = !!matchedExternal && !!matchedComposio
|
|
2463
|
+
&& (matchedExternal.size > 0 || matchedComposio.size > 0);
|
|
2464
|
+
if (hasMatchedBundles) {
|
|
2465
|
+
const keepServers = new Set([
|
|
2466
|
+
TOOLS_SERVER,
|
|
2467
|
+
...(matchedExternal ?? []),
|
|
2468
|
+
...(matchedComposio ?? []),
|
|
2469
|
+
]);
|
|
2470
|
+
allowedTools = allowedTools.filter(tool => {
|
|
2471
|
+
if (coreSdkTools.has(tool))
|
|
2472
|
+
return true;
|
|
2473
|
+
if (!tool.startsWith('mcp__'))
|
|
2474
|
+
return true;
|
|
2475
|
+
const serverName = tool.slice('mcp__'.length).split('__')[0];
|
|
2476
|
+
return keepServers.has(serverName);
|
|
2477
|
+
});
|
|
2478
|
+
externalMcpServers = Object.fromEntries(Object.entries(externalMcpServers).filter(([name]) => matchedExternal.has(name)));
|
|
2479
|
+
composioMcpServers = Object.fromEntries(Object.entries(composioMcpServers).filter(([name]) => matchedComposio.has(name)));
|
|
2480
|
+
logger.warn({
|
|
2481
|
+
sessionKey,
|
|
2482
|
+
beforeAllowedToolCount,
|
|
2483
|
+
afterAllowedToolCount: allowedTools.length,
|
|
2484
|
+
hardLimit: TOOL_SURFACE_HARD_LIMIT,
|
|
2485
|
+
bundles: toolRoute.bundles,
|
|
2486
|
+
keptExternal: [...(matchedExternal ?? [])],
|
|
2487
|
+
keptComposio: [...(matchedComposio ?? [])],
|
|
2488
|
+
autonomous: autonomousToolRun,
|
|
2489
|
+
}, 'Tool surface exceeded hard limit; trimmed to matched bundles');
|
|
2490
|
+
}
|
|
2491
|
+
else {
|
|
2492
|
+
allowedTools = allowedTools.filter(tool => coreSdkTools.has(tool) || tool.startsWith(clementineToolPrefixForCap));
|
|
2493
|
+
externalMcpServers = {};
|
|
2494
|
+
composioMcpServers = {};
|
|
2495
|
+
logger.warn({
|
|
2496
|
+
sessionKey,
|
|
2497
|
+
beforeAllowedToolCount,
|
|
2498
|
+
afterAllowedToolCount: allowedTools.length,
|
|
2499
|
+
hardLimit: TOOL_SURFACE_HARD_LIMIT,
|
|
2500
|
+
bundles: toolRoute.bundles,
|
|
2501
|
+
autonomous: autonomousToolRun,
|
|
2502
|
+
}, 'Tool surface exceeded hard limit with no matched bundles; falling back to core Clementine tools');
|
|
2503
|
+
}
|
|
2456
2504
|
}
|
|
2457
2505
|
}
|
|
2458
2506
|
// Permission mode: always 'bypassPermissions' — this is a daemon/harness with no interactive
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
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 —
|
|
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:
|
|
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)">×</button>
|
|
15313
15484
|
</div>
|
|
15314
|
-
|
|
15315
|
-
|
|
15316
|
-
|
|
15317
|
-
|
|
15318
|
-
|
|
15319
|
-
|
|
15320
|
-
|
|
15321
|
-
|
|
15322
|
-
|
|
15323
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15968
|
-
|
|
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) {
|
|
15971
|
-
|
|
15972
|
-
|
|
15973
|
-
|
|
15974
|
-
|
|
15975
|
-
|
|
15976
|
-
|
|
15977
|
-
|
|
15978
|
-
|
|
15979
|
-
|
|
15980
|
-
|
|
15981
|
-
|
|
15982
|
-
|
|
15983
|
-
|
|
15984
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15999
|
-
|
|
16000
|
-
|
|
16001
|
-
|
|
16002
|
-
|
|
16003
|
-
|
|
16004
|
-
|
|
16005
|
-
|
|
16006
|
-
|
|
16007
|
-
|
|
16008
|
-
|
|
16009
|
-
|
|
16010
|
-
|
|
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 =
|
|
14
|
-
export declare const GATEWAY_CONTEXT_COMPACT_TOKENS =
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/dist/gateway/router.js
CHANGED
|
@@ -1880,7 +1880,13 @@ export class Gateway {
|
|
|
1880
1880
|
const isInteractive = isOwnerDm
|
|
1881
1881
|
|| sessionKey.startsWith('dashboard:')
|
|
1882
1882
|
|| sessionKey.startsWith('cli:');
|
|
1883
|
-
|
|
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,
|