clementine-agent 1.2.3 → 1.3.0
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/cli/dashboard.js +2501 -681
- package/dist/cli/static/LICENSE-NOTICES.md +12 -0
- package/dist/cli/static/drawflow.min.css +1 -0
- package/dist/cli/static/drawflow.min.js +1 -0
- package/dist/dashboard/builder/dry-run.d.ts +31 -0
- package/dist/dashboard/builder/dry-run.js +138 -0
- package/dist/dashboard/builder/events.d.ts +23 -0
- package/dist/dashboard/builder/events.js +28 -0
- package/dist/dashboard/builder/mcp-invoke.d.ts +25 -0
- package/dist/dashboard/builder/mcp-invoke.js +143 -0
- package/dist/dashboard/builder/runner.d.ts +68 -0
- package/dist/dashboard/builder/runner.js +418 -0
- package/dist/dashboard/builder/serializer.d.ts +79 -0
- package/dist/dashboard/builder/serializer.js +547 -0
- package/dist/dashboard/builder/snapshots.d.ts +32 -0
- package/dist/dashboard/builder/snapshots.js +138 -0
- package/dist/dashboard/builder/validation.d.ts +26 -0
- package/dist/dashboard/builder/validation.js +183 -0
- package/dist/gateway/router.js +31 -2
- package/dist/index.js +18 -0
- package/dist/tools/builder-tools.d.ts +13 -0
- package/dist/tools/builder-tools.js +437 -0
- package/dist/tools/mcp-server.js +2 -0
- package/dist/types.d.ts +46 -0
- package/package.json +2 -2
- package/vault/00-System/skills/builder-canvas.md +126 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine — Builder MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Agent-facing surface for managing workflows + crons on the visual
|
|
5
|
+
* canvas. Read, edit, validate, and dry-run. Actual execution lives
|
|
6
|
+
* separately in the runner (Phase 2+).
|
|
7
|
+
*
|
|
8
|
+
* Outputs are terse plain text by default for prompt efficiency; pass
|
|
9
|
+
* `verbose: true` to get the underlying JSON for debugging.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { textResult } from './shared.js';
|
|
13
|
+
import { listAllForBuilder, readWorkflow, saveWorkflow, cronId, workflowId, parseBuilderId, isCronShape, sourceFileForId, } from '../dashboard/builder/serializer.js';
|
|
14
|
+
import { validateWorkflow } from '../dashboard/builder/validation.js';
|
|
15
|
+
import { dryRunWorkflow } from '../dashboard/builder/dry-run.js';
|
|
16
|
+
import { emitBuilderEvent } from '../dashboard/builder/events.js';
|
|
17
|
+
import { listSnapshots, restoreSnapshot } from '../dashboard/builder/snapshots.js';
|
|
18
|
+
import { discoverMcpServers, loadToolInventory } from '../agent/mcp-bridge.js';
|
|
19
|
+
const STEP_KINDS = ['prompt', 'mcp', 'channel', 'transform', 'conditional', 'loop'];
|
|
20
|
+
const stepShape = z.object({
|
|
21
|
+
id: z.string().describe('Step id (unique within workflow)'),
|
|
22
|
+
prompt: z.string().describe('Prompt for the agent (required for kind=prompt; can be a description for other kinds)'),
|
|
23
|
+
dependsOn: z.array(z.string()).default([]).describe('Step ids this step depends on'),
|
|
24
|
+
tier: z.number().min(1).max(5).default(1),
|
|
25
|
+
maxTurns: z.number().min(1).default(15),
|
|
26
|
+
model: z.string().optional(),
|
|
27
|
+
workDir: z.string().optional(),
|
|
28
|
+
kind: z.enum(STEP_KINDS).optional().describe('Default: prompt'),
|
|
29
|
+
mcp: z.object({ server: z.string(), tool: z.string(), inputs: z.record(z.string(), z.unknown()).optional() }).optional(),
|
|
30
|
+
channel: z.object({ channel: z.enum(['discord', 'slack', 'telegram', 'whatsapp', 'email', 'webhook']), target: z.string(), content: z.string() }).optional(),
|
|
31
|
+
transform: z.object({ expression: z.string() }).optional(),
|
|
32
|
+
conditional: z.object({ condition: z.string(), trueNext: z.array(z.string()).optional(), falseNext: z.array(z.string()).optional() }).optional(),
|
|
33
|
+
loop: z.object({ items: z.string(), bodyStepIds: z.array(z.string()) }).optional(),
|
|
34
|
+
});
|
|
35
|
+
export function registerBuilderTools(server) {
|
|
36
|
+
// ── Discovery ──────────────────────────────────────────────────────────
|
|
37
|
+
server.tool('workflow_list', 'List all workflows and crons visible in the Builder. Returns one per line: id|name|origin|enabled|schedule|stepCount.', {
|
|
38
|
+
enabledOnly: z.boolean().optional().describe('If true, return only enabled workflows'),
|
|
39
|
+
verbose: z.boolean().optional(),
|
|
40
|
+
}, async ({ enabledOnly, verbose }) => {
|
|
41
|
+
const items = listAllForBuilder().filter(i => !enabledOnly || i.enabled);
|
|
42
|
+
if (verbose)
|
|
43
|
+
return textResult(JSON.stringify(items, null, 2));
|
|
44
|
+
if (items.length === 0)
|
|
45
|
+
return textResult('(no workflows or crons found)');
|
|
46
|
+
return textResult(items.map(i => `${i.id}|${i.name}|${i.origin}|${i.enabled ? 'on' : 'off'}|${i.schedule ?? '-'}|${i.stepCount}step${i.stepCount === 1 ? '' : 's'}`).join('\n'));
|
|
47
|
+
});
|
|
48
|
+
server.tool('workflow_read', 'Read a workflow as canonical JSON. Use this before editing — patches reference current step ids.', {
|
|
49
|
+
id: z.string().describe('Builder id (e.g., cron:morning-briefing or workflow:daily-digest)'),
|
|
50
|
+
}, async ({ id }) => {
|
|
51
|
+
const wf = readWorkflow(id);
|
|
52
|
+
if (!wf)
|
|
53
|
+
return textResult(`Not found: ${id}`);
|
|
54
|
+
return textResult(JSON.stringify(wf, null, 2));
|
|
55
|
+
});
|
|
56
|
+
server.tool('workflow_search', 'Search workflows + crons by name or content (substring, case-insensitive).', { query: z.string() }, async ({ query }) => {
|
|
57
|
+
const q = query.toLowerCase();
|
|
58
|
+
const items = listAllForBuilder();
|
|
59
|
+
const matches = [];
|
|
60
|
+
for (const i of items) {
|
|
61
|
+
if (i.name.toLowerCase().includes(q) || (i.description ?? '').toLowerCase().includes(q)) {
|
|
62
|
+
matches.push(`${i.id}|${i.name}|${i.origin}`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const wf = readWorkflow(i.id);
|
|
66
|
+
if (wf && wf.steps.some(s => s.prompt.toLowerCase().includes(q))) {
|
|
67
|
+
matches.push(`${i.id}|${i.name}|${i.origin} (matched in step prompt)`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return textResult(matches.length === 0 ? `(no matches for "${query}")` : matches.join('\n'));
|
|
71
|
+
});
|
|
72
|
+
server.tool('workflow_list_mcp_tools', 'List MCP servers and their tools. Use to fill in mcp-step configs (server + tool name).', {}, async () => {
|
|
73
|
+
const servers = discoverMcpServers();
|
|
74
|
+
const inv = loadToolInventory();
|
|
75
|
+
const lines = [];
|
|
76
|
+
for (const s of servers) {
|
|
77
|
+
const enabled = s.enabled ? 'on' : 'off';
|
|
78
|
+
const toolNames = inv?.tools?.filter(t => t.startsWith(`mcp__${s.name}__`)).map(t => t.split('__')[2]) ?? [];
|
|
79
|
+
const toolList = toolNames.length ? toolNames.join(', ') : '(no tools cached)';
|
|
80
|
+
lines.push(`${s.name} [${enabled}]: ${toolList}`);
|
|
81
|
+
}
|
|
82
|
+
return textResult(lines.length === 0 ? '(no MCP servers configured)' : lines.join('\n'));
|
|
83
|
+
});
|
|
84
|
+
server.tool('workflow_list_channels', 'List configured channels (Discord/Slack/Telegram/WhatsApp/Email/Webhook). Tells you which channel kinds are wired up.', {}, async () => {
|
|
85
|
+
const channels = ['discord', 'slack', 'telegram', 'whatsapp', 'email', 'webhook'];
|
|
86
|
+
const lines = channels.map(c => `${c}: ${channelHint(c)}`);
|
|
87
|
+
return textResult(lines.join('\n'));
|
|
88
|
+
});
|
|
89
|
+
// ── Validation ─────────────────────────────────────────────────────────
|
|
90
|
+
server.tool('workflow_validate', 'Run static validation on a workflow. Returns errors + warnings. Cheap — no execution.', { id: z.string() }, async ({ id }) => {
|
|
91
|
+
const wf = readWorkflow(id);
|
|
92
|
+
if (!wf)
|
|
93
|
+
return textResult(`Not found: ${id}`);
|
|
94
|
+
const result = validateWorkflow(wf);
|
|
95
|
+
if (result.issues.length === 0)
|
|
96
|
+
return textResult('OK — no issues');
|
|
97
|
+
return textResult(formatIssues(result.issues));
|
|
98
|
+
});
|
|
99
|
+
server.tool('workflow_dry_run', 'Walk a workflow without executing. Shows what each step would do, in topological order, with rough cost estimate. Use for long-running jobs to preview safely.', { id: z.string() }, async ({ id }) => {
|
|
100
|
+
const wf = readWorkflow(id);
|
|
101
|
+
if (!wf)
|
|
102
|
+
return textResult(`Not found: ${id}`);
|
|
103
|
+
const r = dryRunWorkflow(wf);
|
|
104
|
+
const lines = [];
|
|
105
|
+
lines.push(r.ok ? `DRY RUN: ${wf.name}` : `DRY RUN (validation failed): ${wf.name}`);
|
|
106
|
+
if (r.validationIssues.length)
|
|
107
|
+
lines.push(formatIssues(r.validationIssues));
|
|
108
|
+
for (const s of r.steps) {
|
|
109
|
+
lines.push(`[wave ${s.wave}] ${s.description}`);
|
|
110
|
+
for (const w of s.warnings)
|
|
111
|
+
lines.push(` ⚠ ${w}`);
|
|
112
|
+
}
|
|
113
|
+
if (r.estimatedTokens) {
|
|
114
|
+
lines.push(`Rough estimate: ~${r.estimatedTokens.total.toLocaleString()} tokens across ${r.estimatedTokens.promptSteps} prompt step(s).`);
|
|
115
|
+
}
|
|
116
|
+
for (const note of r.notes)
|
|
117
|
+
lines.push(note);
|
|
118
|
+
return textResult(lines.join('\n'));
|
|
119
|
+
});
|
|
120
|
+
// ── Mutations ──────────────────────────────────────────────────────────
|
|
121
|
+
server.tool('workflow_save', 'Save a workflow (full replace). Validates before writing — rejects on errors unless `force: true`. Use this when you need to change many fields atomically; for small edits prefer the targeted tools (workflow_add_node etc.).', {
|
|
122
|
+
id: z.string(),
|
|
123
|
+
workflow: z.object({
|
|
124
|
+
name: z.string(),
|
|
125
|
+
description: z.string().default(''),
|
|
126
|
+
enabled: z.boolean().default(true),
|
|
127
|
+
trigger: z.object({ schedule: z.string().optional(), manual: z.boolean().optional() }).default({ manual: true }),
|
|
128
|
+
inputs: z.record(z.string(), z.object({ type: z.enum(['string', 'number']), default: z.string().optional(), description: z.string().optional() })).default({}),
|
|
129
|
+
steps: z.array(stepShape),
|
|
130
|
+
synthesis: z.object({ prompt: z.string() }).optional(),
|
|
131
|
+
agentSlug: z.string().optional(),
|
|
132
|
+
}),
|
|
133
|
+
force: z.boolean().optional().describe('Bypass validation errors (warnings always ignored)'),
|
|
134
|
+
}, async ({ id, workflow, force }) => {
|
|
135
|
+
const existing = readWorkflow(id);
|
|
136
|
+
if (!existing)
|
|
137
|
+
return textResult(`Not found: ${id}`);
|
|
138
|
+
const next = {
|
|
139
|
+
...workflow,
|
|
140
|
+
steps: workflow.steps.map(s => normalizeStep(s)),
|
|
141
|
+
sourceFile: existing.sourceFile,
|
|
142
|
+
};
|
|
143
|
+
const v = validateWorkflow(next);
|
|
144
|
+
if (!v.ok && !force) {
|
|
145
|
+
return textResult('Save rejected — validation errors:\n' + formatIssues(v.issues) + '\nPass force: true to override.');
|
|
146
|
+
}
|
|
147
|
+
const result = saveWorkflow(id, next);
|
|
148
|
+
if (!result.ok)
|
|
149
|
+
return textResult('Save failed: ' + result.error);
|
|
150
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: next } });
|
|
151
|
+
return textResult(`Saved ${id}.${v.issues.length ? ' Warnings:\n' + formatIssues(v.issues.filter(i => i.severity === 'warning')) : ''}`);
|
|
152
|
+
});
|
|
153
|
+
server.tool('workflow_create', 'Create a new workflow file (multi-step) under vault/00-System/workflows/. Returns its builder id.', {
|
|
154
|
+
name: z.string().describe('Workflow name (also derived as the file slug)'),
|
|
155
|
+
description: z.string().default(''),
|
|
156
|
+
schedule: z.string().optional().describe('Cron expression (omit for manual-only)'),
|
|
157
|
+
initialPrompt: z.string().optional().describe('First-step prompt; defaults to a placeholder'),
|
|
158
|
+
}, async ({ name, description, schedule, initialPrompt }) => {
|
|
159
|
+
const slug = slugify(name);
|
|
160
|
+
const wf = {
|
|
161
|
+
name,
|
|
162
|
+
description,
|
|
163
|
+
enabled: true,
|
|
164
|
+
trigger: { schedule, manual: !schedule },
|
|
165
|
+
inputs: {},
|
|
166
|
+
steps: [{
|
|
167
|
+
id: 's1',
|
|
168
|
+
prompt: initialPrompt ?? 'Describe what this workflow should do.',
|
|
169
|
+
dependsOn: [],
|
|
170
|
+
tier: 1,
|
|
171
|
+
maxTurns: 15,
|
|
172
|
+
}],
|
|
173
|
+
sourceFile: '',
|
|
174
|
+
};
|
|
175
|
+
const id = workflowId(slug);
|
|
176
|
+
const result = saveWorkflow(id, wf);
|
|
177
|
+
if (!result.ok)
|
|
178
|
+
return textResult('Create failed: ' + result.error);
|
|
179
|
+
emitBuilderEvent({ type: 'workflow:created', workflowId: id, payload: { workflow: wf } });
|
|
180
|
+
return textResult(`Created ${id}.`);
|
|
181
|
+
});
|
|
182
|
+
server.tool('workflow_set_enabled', 'Enable or disable a workflow/cron without other changes.', { id: z.string(), enabled: z.boolean() }, async ({ id, enabled }) => {
|
|
183
|
+
const wf = readWorkflow(id);
|
|
184
|
+
if (!wf)
|
|
185
|
+
return textResult(`Not found: ${id}`);
|
|
186
|
+
if (wf.enabled === enabled)
|
|
187
|
+
return textResult(`Already ${enabled ? 'enabled' : 'disabled'}.`);
|
|
188
|
+
wf.enabled = enabled;
|
|
189
|
+
const result = saveWorkflow(id, wf);
|
|
190
|
+
if (!result.ok)
|
|
191
|
+
return textResult('Save failed: ' + result.error);
|
|
192
|
+
emitBuilderEvent({ type: 'workflow:enabled-changed', workflowId: id, payload: { enabled } });
|
|
193
|
+
return textResult(`${id} → ${enabled ? 'enabled' : 'disabled'}`);
|
|
194
|
+
});
|
|
195
|
+
server.tool('workflow_set_schedule', 'Change a workflow/cron schedule. Pass schedule=null to make it manual-only.', { id: z.string(), schedule: z.string().nullable() }, async ({ id, schedule }) => {
|
|
196
|
+
const wf = readWorkflow(id);
|
|
197
|
+
if (!wf)
|
|
198
|
+
return textResult(`Not found: ${id}`);
|
|
199
|
+
wf.trigger = schedule ? { schedule, manual: false } : { manual: true };
|
|
200
|
+
const v = validateWorkflow(wf);
|
|
201
|
+
if (!v.ok)
|
|
202
|
+
return textResult('Schedule change rejected: ' + formatIssues(v.issues.filter(i => i.severity === 'error')));
|
|
203
|
+
const result = saveWorkflow(id, wf);
|
|
204
|
+
if (!result.ok)
|
|
205
|
+
return textResult('Save failed: ' + result.error);
|
|
206
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: wf } });
|
|
207
|
+
return textResult(`Schedule for ${id} → ${schedule ?? 'manual'}`);
|
|
208
|
+
});
|
|
209
|
+
server.tool('workflow_add_node', 'Append a new step to a workflow.', {
|
|
210
|
+
id: z.string(),
|
|
211
|
+
step: stepShape,
|
|
212
|
+
}, async ({ id, step }) => {
|
|
213
|
+
const wf = readWorkflow(id);
|
|
214
|
+
if (!wf)
|
|
215
|
+
return textResult(`Not found: ${id}`);
|
|
216
|
+
if (wf.steps.some(s => s.id === step.id))
|
|
217
|
+
return textResult(`Step id "${step.id}" already exists in ${id}`);
|
|
218
|
+
wf.steps.push(normalizeStep(step));
|
|
219
|
+
if (isCronShape(wf) === false && parseBuilderId(id)?.origin === 'cron') {
|
|
220
|
+
return textResult('Cron entries must remain single-step. Use workflow_create to make a multi-step workflow instead.');
|
|
221
|
+
}
|
|
222
|
+
const v = validateWorkflow(wf);
|
|
223
|
+
if (!v.ok)
|
|
224
|
+
return textResult('Add rejected: ' + formatIssues(v.issues.filter(i => i.severity === 'error')));
|
|
225
|
+
const result = saveWorkflow(id, wf);
|
|
226
|
+
if (!result.ok)
|
|
227
|
+
return textResult('Save failed: ' + result.error);
|
|
228
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: wf } });
|
|
229
|
+
return textResult(`Added step "${step.id}" to ${id}`);
|
|
230
|
+
});
|
|
231
|
+
server.tool('workflow_update_node', 'Update an existing step in place (partial fields allowed).', {
|
|
232
|
+
id: z.string(),
|
|
233
|
+
stepId: z.string(),
|
|
234
|
+
patch: stepShape.partial(),
|
|
235
|
+
}, async ({ id, stepId, patch }) => {
|
|
236
|
+
const wf = readWorkflow(id);
|
|
237
|
+
if (!wf)
|
|
238
|
+
return textResult(`Not found: ${id}`);
|
|
239
|
+
const idx = wf.steps.findIndex(s => s.id === stepId);
|
|
240
|
+
if (idx === -1)
|
|
241
|
+
return textResult(`Step "${stepId}" not found in ${id}`);
|
|
242
|
+
const next = normalizeStep({ ...wf.steps[idx], ...patch, id: patch.id ?? stepId });
|
|
243
|
+
wf.steps[idx] = next;
|
|
244
|
+
const v = validateWorkflow(wf);
|
|
245
|
+
if (!v.ok)
|
|
246
|
+
return textResult('Update rejected: ' + formatIssues(v.issues.filter(i => i.severity === 'error')));
|
|
247
|
+
const result = saveWorkflow(id, wf);
|
|
248
|
+
if (!result.ok)
|
|
249
|
+
return textResult('Save failed: ' + result.error);
|
|
250
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: wf } });
|
|
251
|
+
return textResult(`Updated step "${stepId}" in ${id}`);
|
|
252
|
+
});
|
|
253
|
+
server.tool('workflow_remove_node', 'Remove a step + any edges referencing it.', { id: z.string(), stepId: z.string() }, async ({ id, stepId }) => {
|
|
254
|
+
const wf = readWorkflow(id);
|
|
255
|
+
if (!wf)
|
|
256
|
+
return textResult(`Not found: ${id}`);
|
|
257
|
+
if (!wf.steps.some(s => s.id === stepId))
|
|
258
|
+
return textResult(`Step "${stepId}" not found in ${id}`);
|
|
259
|
+
if (parseBuilderId(id)?.origin === 'cron')
|
|
260
|
+
return textResult('Cron entries must remain single-step; cannot remove the only step.');
|
|
261
|
+
wf.steps = wf.steps.filter(s => s.id !== stepId).map(s => ({ ...s, dependsOn: s.dependsOn.filter(d => d !== stepId) }));
|
|
262
|
+
const v = validateWorkflow(wf);
|
|
263
|
+
if (!v.ok)
|
|
264
|
+
return textResult('Remove rejected: ' + formatIssues(v.issues.filter(i => i.severity === 'error')));
|
|
265
|
+
const result = saveWorkflow(id, wf);
|
|
266
|
+
if (!result.ok)
|
|
267
|
+
return textResult('Save failed: ' + result.error);
|
|
268
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: wf } });
|
|
269
|
+
return textResult(`Removed step "${stepId}" from ${id}`);
|
|
270
|
+
});
|
|
271
|
+
server.tool('workflow_connect', 'Add an edge from one step to another (sets `to.dependsOn += [from]`).', { id: z.string(), from: z.string(), to: z.string() }, async ({ id, from, to }) => {
|
|
272
|
+
const wf = readWorkflow(id);
|
|
273
|
+
if (!wf)
|
|
274
|
+
return textResult(`Not found: ${id}`);
|
|
275
|
+
if (from === to)
|
|
276
|
+
return textResult('Cannot connect a step to itself');
|
|
277
|
+
const fromStep = wf.steps.find(s => s.id === from);
|
|
278
|
+
const toStep = wf.steps.find(s => s.id === to);
|
|
279
|
+
if (!fromStep || !toStep)
|
|
280
|
+
return textResult(`Both steps must exist (from=${from}, to=${to})`);
|
|
281
|
+
if (toStep.dependsOn.includes(from))
|
|
282
|
+
return textResult(`Edge ${from} → ${to} already exists`);
|
|
283
|
+
toStep.dependsOn.push(from);
|
|
284
|
+
const v = validateWorkflow(wf);
|
|
285
|
+
if (!v.ok) {
|
|
286
|
+
// roll back
|
|
287
|
+
toStep.dependsOn = toStep.dependsOn.filter(d => d !== from);
|
|
288
|
+
return textResult('Connect rejected (would introduce cycle or other error): ' + formatIssues(v.issues.filter(i => i.severity === 'error')));
|
|
289
|
+
}
|
|
290
|
+
const result = saveWorkflow(id, wf);
|
|
291
|
+
if (!result.ok)
|
|
292
|
+
return textResult('Save failed: ' + result.error);
|
|
293
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: wf } });
|
|
294
|
+
return textResult(`Connected ${from} → ${to}`);
|
|
295
|
+
});
|
|
296
|
+
server.tool('workflow_disconnect', 'Remove an edge between two steps.', { id: z.string(), from: z.string(), to: z.string() }, async ({ id, from, to }) => {
|
|
297
|
+
const wf = readWorkflow(id);
|
|
298
|
+
if (!wf)
|
|
299
|
+
return textResult(`Not found: ${id}`);
|
|
300
|
+
const toStep = wf.steps.find(s => s.id === to);
|
|
301
|
+
if (!toStep)
|
|
302
|
+
return textResult(`Step ${to} not found`);
|
|
303
|
+
if (!toStep.dependsOn.includes(from))
|
|
304
|
+
return textResult(`No edge ${from} → ${to}`);
|
|
305
|
+
toStep.dependsOn = toStep.dependsOn.filter(d => d !== from);
|
|
306
|
+
const result = saveWorkflow(id, wf);
|
|
307
|
+
if (!result.ok)
|
|
308
|
+
return textResult('Save failed: ' + result.error);
|
|
309
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: wf } });
|
|
310
|
+
return textResult(`Disconnected ${from} → ${to}`);
|
|
311
|
+
});
|
|
312
|
+
server.tool('workflow_rename', 'Rename a workflow. Workflow files are renamed on disk; cron entries keep the same file but update the name field.', { id: z.string(), newName: z.string() }, async ({ id, newName }) => {
|
|
313
|
+
const wf = readWorkflow(id);
|
|
314
|
+
if (!wf)
|
|
315
|
+
return textResult(`Not found: ${id}`);
|
|
316
|
+
const origin = parseBuilderId(id)?.origin;
|
|
317
|
+
if (origin === 'cron') {
|
|
318
|
+
wf.name = newName;
|
|
319
|
+
const result = saveWorkflow(id, wf);
|
|
320
|
+
if (!result.ok)
|
|
321
|
+
return textResult('Rename failed: ' + result.error);
|
|
322
|
+
const newId = cronId(newName);
|
|
323
|
+
emitBuilderEvent({ type: 'workflow:renamed', workflowId: id, payload: { newId } });
|
|
324
|
+
return textResult(`Renamed (cron): ${id} → ${newId}`);
|
|
325
|
+
}
|
|
326
|
+
// Workflow file rename: write new file, delete old.
|
|
327
|
+
const newId = workflowId(slugify(newName));
|
|
328
|
+
if (newId === id) {
|
|
329
|
+
wf.name = newName;
|
|
330
|
+
const result = saveWorkflow(id, wf);
|
|
331
|
+
return textResult(result.ok ? `Renamed (label only): ${id}` : `Failed: ${result.error}`);
|
|
332
|
+
}
|
|
333
|
+
wf.name = newName;
|
|
334
|
+
const newSave = saveWorkflow(newId, wf);
|
|
335
|
+
if (!newSave.ok)
|
|
336
|
+
return textResult('Rename failed (writing new file): ' + newSave.error);
|
|
337
|
+
// Delete old file
|
|
338
|
+
try {
|
|
339
|
+
const { unlinkSync, existsSync } = await import('node:fs');
|
|
340
|
+
if (existsSync(wf.sourceFile))
|
|
341
|
+
unlinkSync(wf.sourceFile);
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
return textResult(`New file written but old file delete failed: ${err.message}`);
|
|
345
|
+
}
|
|
346
|
+
emitBuilderEvent({ type: 'workflow:renamed', workflowId: id, payload: { newId } });
|
|
347
|
+
return textResult(`Renamed: ${id} → ${newId}`);
|
|
348
|
+
});
|
|
349
|
+
server.tool('workflow_duplicate', 'Create a copy of a workflow with a new name.', { id: z.string(), newName: z.string() }, async ({ id, newName }) => {
|
|
350
|
+
const wf = readWorkflow(id);
|
|
351
|
+
if (!wf)
|
|
352
|
+
return textResult(`Not found: ${id}`);
|
|
353
|
+
const slug = slugify(newName);
|
|
354
|
+
const newId = workflowId(slug);
|
|
355
|
+
const copy = { ...wf, name: newName, sourceFile: '' };
|
|
356
|
+
const result = saveWorkflow(newId, copy);
|
|
357
|
+
if (!result.ok)
|
|
358
|
+
return textResult('Duplicate failed: ' + result.error);
|
|
359
|
+
emitBuilderEvent({ type: 'workflow:created', workflowId: newId, payload: { workflow: copy } });
|
|
360
|
+
return textResult(`Duplicated ${id} → ${newId}`);
|
|
361
|
+
});
|
|
362
|
+
server.tool('workflow_history', 'List recent saved snapshots of a workflow (newest first). Last 20 are kept. Use to find a point to restore.', { id: z.string() }, async ({ id }) => {
|
|
363
|
+
const list = listSnapshots(id);
|
|
364
|
+
if (list.length === 0)
|
|
365
|
+
return textResult('(no snapshots yet)');
|
|
366
|
+
return textResult(list.map(s => `${s.filename}|${s.ts}|${s.size}b`).join('\n'));
|
|
367
|
+
});
|
|
368
|
+
server.tool('workflow_restore', 'Restore a workflow from a snapshot. The current state is itself snapshotted before overwrite, so the restore is reversible. Use workflow_history to find the snapshot filename first.', { id: z.string(), snapshotFilename: z.string() }, async ({ id, snapshotFilename }) => {
|
|
369
|
+
const sourceFile = sourceFileForId(id);
|
|
370
|
+
if (!sourceFile)
|
|
371
|
+
return textResult(`Unknown id: ${id}`);
|
|
372
|
+
const result = restoreSnapshot(id, snapshotFilename, sourceFile);
|
|
373
|
+
if (!result.ok)
|
|
374
|
+
return textResult('Restore failed: ' + (result.error ?? 'unknown'));
|
|
375
|
+
emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { restoredFrom: snapshotFilename } });
|
|
376
|
+
return textResult(`Restored ${id} from ${snapshotFilename}`);
|
|
377
|
+
});
|
|
378
|
+
server.tool('workflow_delete', 'Delete a workflow file (multi-step workflows only). Cron entries are removed via workflow_set_enabled or by editing CRON.md directly.', { id: z.string() }, async ({ id }) => {
|
|
379
|
+
const parsed = parseBuilderId(id);
|
|
380
|
+
if (!parsed)
|
|
381
|
+
return textResult(`Bad id: ${id}`);
|
|
382
|
+
if (parsed.origin === 'cron')
|
|
383
|
+
return textResult('Use workflow_set_enabled false to disable a cron, or delete the CRON.md entry manually.');
|
|
384
|
+
const wf = readWorkflow(id);
|
|
385
|
+
if (!wf)
|
|
386
|
+
return textResult(`Not found: ${id}`);
|
|
387
|
+
try {
|
|
388
|
+
const { unlinkSync, existsSync } = await import('node:fs');
|
|
389
|
+
if (existsSync(wf.sourceFile))
|
|
390
|
+
unlinkSync(wf.sourceFile);
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
return textResult(`Delete failed: ${err.message}`);
|
|
394
|
+
}
|
|
395
|
+
emitBuilderEvent({ type: 'workflow:deleted', workflowId: id });
|
|
396
|
+
return textResult(`Deleted ${id}`);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// ── helpers ────────────────────────────────────────────────────────────
|
|
400
|
+
function normalizeStep(s) {
|
|
401
|
+
const step = {
|
|
402
|
+
id: s.id,
|
|
403
|
+
prompt: s.prompt,
|
|
404
|
+
dependsOn: s.dependsOn ?? [],
|
|
405
|
+
tier: s.tier ?? 1,
|
|
406
|
+
maxTurns: s.maxTurns ?? 15,
|
|
407
|
+
model: s.model,
|
|
408
|
+
workDir: s.workDir,
|
|
409
|
+
kind: s.kind,
|
|
410
|
+
mcp: s.mcp,
|
|
411
|
+
channel: s.channel,
|
|
412
|
+
transform: s.transform,
|
|
413
|
+
conditional: s.conditional,
|
|
414
|
+
loop: s.loop,
|
|
415
|
+
};
|
|
416
|
+
return step;
|
|
417
|
+
}
|
|
418
|
+
function formatIssues(issues) {
|
|
419
|
+
return issues.map(i => `${i.severity.toUpperCase()} [${i.code}]${i.stepId ? ' (' + i.stepId + ')' : ''}: ${i.message}`).join('\n');
|
|
420
|
+
}
|
|
421
|
+
function slugify(name) {
|
|
422
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'workflow';
|
|
423
|
+
}
|
|
424
|
+
function channelHint(channel) {
|
|
425
|
+
// Quick description of where each channel lives. Real introspection of token presence
|
|
426
|
+
// happens in the dashboard Settings → Channels tab; here we just describe shape.
|
|
427
|
+
switch (channel) {
|
|
428
|
+
case 'discord': return 'guild + user channels via DISCORD_BOT_TOKEN; target = channel id or user id';
|
|
429
|
+
case 'slack': return 'workspace via SLACK_BOT_TOKEN; target = #channel or @user';
|
|
430
|
+
case 'telegram': return 'bot via TELEGRAM_BOT_TOKEN; target = chat id';
|
|
431
|
+
case 'whatsapp': return 'Twilio WhatsApp; target = E.164 phone number';
|
|
432
|
+
case 'email': return 'SMTP/Outlook; target = email address';
|
|
433
|
+
case 'webhook': return 'arbitrary HTTP POST; target = URL';
|
|
434
|
+
default: return '';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
//# sourceMappingURL=builder-tools.js.map
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { registerBrainTools } from './brain-tools.js';
|
|
|
29
29
|
import { registerAgentHeartbeatTools } from './agent-heartbeat-tools.js';
|
|
30
30
|
import { registerBackgroundTaskTools } from './background-task-tools.js';
|
|
31
31
|
import { registerDecisionReflectionTools } from './decision-reflection-tools.js';
|
|
32
|
+
import { registerBuilderTools } from './builder-tools.js';
|
|
32
33
|
// ── Server ──────────────────────────────────────────────────────────────
|
|
33
34
|
const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
|
|
34
35
|
const server = new McpServer({ name: serverName, version: '1.0.0' });
|
|
@@ -45,6 +46,7 @@ registerBrainTools(server);
|
|
|
45
46
|
registerAgentHeartbeatTools(server);
|
|
46
47
|
registerBackgroundTaskTools(server);
|
|
47
48
|
registerDecisionReflectionTools(server);
|
|
49
|
+
registerBuilderTools(server);
|
|
48
50
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
49
51
|
async function main() {
|
|
50
52
|
// Initialize memory store and run full sync on startup
|
package/dist/types.d.ts
CHANGED
|
@@ -454,6 +454,33 @@ export interface WorkflowInput {
|
|
|
454
454
|
default?: string;
|
|
455
455
|
description?: string;
|
|
456
456
|
}
|
|
457
|
+
export type WorkflowStepKind = 'prompt' | 'mcp' | 'channel' | 'transform' | 'conditional' | 'loop';
|
|
458
|
+
export interface WorkflowStepMcpConfig {
|
|
459
|
+
server: string;
|
|
460
|
+
tool: string;
|
|
461
|
+
inputs?: Record<string, unknown>;
|
|
462
|
+
}
|
|
463
|
+
export interface WorkflowStepChannelConfig {
|
|
464
|
+
channel: 'discord' | 'slack' | 'telegram' | 'whatsapp' | 'email' | 'webhook';
|
|
465
|
+
target: string;
|
|
466
|
+
content: string;
|
|
467
|
+
}
|
|
468
|
+
export interface WorkflowStepTransformConfig {
|
|
469
|
+
expression: string;
|
|
470
|
+
}
|
|
471
|
+
export interface WorkflowStepConditionalConfig {
|
|
472
|
+
condition: string;
|
|
473
|
+
trueNext?: string[];
|
|
474
|
+
falseNext?: string[];
|
|
475
|
+
}
|
|
476
|
+
export interface WorkflowStepLoopConfig {
|
|
477
|
+
items: string;
|
|
478
|
+
bodyStepIds: string[];
|
|
479
|
+
}
|
|
480
|
+
export interface WorkflowStepCanvas {
|
|
481
|
+
x: number;
|
|
482
|
+
y: number;
|
|
483
|
+
}
|
|
457
484
|
export interface WorkflowStep {
|
|
458
485
|
id: string;
|
|
459
486
|
prompt: string;
|
|
@@ -462,6 +489,13 @@ export interface WorkflowStep {
|
|
|
462
489
|
tier: number;
|
|
463
490
|
maxTurns: number;
|
|
464
491
|
workDir?: string;
|
|
492
|
+
kind?: WorkflowStepKind;
|
|
493
|
+
mcp?: WorkflowStepMcpConfig;
|
|
494
|
+
channel?: WorkflowStepChannelConfig;
|
|
495
|
+
transform?: WorkflowStepTransformConfig;
|
|
496
|
+
conditional?: WorkflowStepConditionalConfig;
|
|
497
|
+
loop?: WorkflowStepLoopConfig;
|
|
498
|
+
canvas?: WorkflowStepCanvas;
|
|
465
499
|
}
|
|
466
500
|
export interface WorkflowDefinition {
|
|
467
501
|
name: string;
|
|
@@ -479,6 +513,18 @@ export interface WorkflowDefinition {
|
|
|
479
513
|
sourceFile: string;
|
|
480
514
|
agentSlug?: string;
|
|
481
515
|
}
|
|
516
|
+
export type WorkflowOriginKind = 'workflow' | 'cron';
|
|
517
|
+
export interface BuilderWorkflowSummary {
|
|
518
|
+
id: string;
|
|
519
|
+
origin: WorkflowOriginKind;
|
|
520
|
+
name: string;
|
|
521
|
+
description: string;
|
|
522
|
+
enabled: boolean;
|
|
523
|
+
schedule?: string;
|
|
524
|
+
stepCount: number;
|
|
525
|
+
sourceFile: string;
|
|
526
|
+
agentSlug?: string;
|
|
527
|
+
}
|
|
482
528
|
export interface WorkflowRunEntry {
|
|
483
529
|
workflowName: string;
|
|
484
530
|
runId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clementine-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Clementine — Personal AI Assistant (TypeScript)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"clementine": "dist/cli/index.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"build:assets": "mkdir -p dist/agent/advisor-rules/builtin && (cp src/agent/advisor-rules/builtin/*.yaml dist/agent/advisor-rules/builtin/ 2>/dev/null || true)",
|
|
11
|
+
"build:assets": "mkdir -p dist/agent/advisor-rules/builtin && (cp src/agent/advisor-rules/builtin/*.yaml dist/agent/advisor-rules/builtin/ 2>/dev/null || true) && mkdir -p dist/cli/static && (cp src/cli/static/* dist/cli/static/ 2>/dev/null || true)",
|
|
12
12
|
"build": "rm -rf dist.tmp 2>/dev/null; tsc --outDir dist.tmp && rm -rf dist && mv dist.tmp dist && chmod +x dist/cli/index.js && npm run build:assets",
|
|
13
13
|
"prepublishOnly": "npm run build && find dist -name '*.map' -delete",
|
|
14
14
|
"dev": "tsx src/index.ts",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Visual Builder canvas — workflow + cron editing
|
|
3
|
+
description: >-
|
|
4
|
+
When the user is on the dashboard's Builder page with a workflow or cron
|
|
5
|
+
open in the visual canvas, use the workflow_* MCP tools to read, edit,
|
|
6
|
+
validate, and dry-run the workflow. Edits land live on the user's canvas
|
|
7
|
+
via SSE.
|
|
8
|
+
triggers:
|
|
9
|
+
- edit this workflow
|
|
10
|
+
- add a step
|
|
11
|
+
- add a node
|
|
12
|
+
- remove a step
|
|
13
|
+
- connect these steps
|
|
14
|
+
- change the schedule
|
|
15
|
+
- disable this cron
|
|
16
|
+
- validate the workflow
|
|
17
|
+
- dry run this workflow
|
|
18
|
+
- dry-run
|
|
19
|
+
- show what would happen
|
|
20
|
+
- test the workflow safely
|
|
21
|
+
- rename this workflow
|
|
22
|
+
- duplicate this workflow
|
|
23
|
+
- turn this on
|
|
24
|
+
- turn this off
|
|
25
|
+
source: manual
|
|
26
|
+
loaded: auto
|
|
27
|
+
placement: system
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Visual Builder canvas — workflow + cron editing
|
|
31
|
+
|
|
32
|
+
## When this applies
|
|
33
|
+
|
|
34
|
+
The user is working in the dashboard's **Builder** page with a workflow
|
|
35
|
+
or cron open in the visual canvas. Their chat messages are about editing
|
|
36
|
+
that workflow: adding/removing steps, changing the schedule, validating,
|
|
37
|
+
etc. The canvas updates live via Server-Sent Events as you make edits, so
|
|
38
|
+
the user *sees your changes appear* on screen.
|
|
39
|
+
|
|
40
|
+
## Tools you have
|
|
41
|
+
|
|
42
|
+
Discovery and read:
|
|
43
|
+
- `workflow_list` — list all workflows + crons (one per line, terse)
|
|
44
|
+
- `workflow_read` — read full workflow as JSON before editing
|
|
45
|
+
- `workflow_search` — find by name or step content
|
|
46
|
+
- `workflow_list_mcp_tools` — discover available MCP servers/tools (use to fill in `mcp` step config)
|
|
47
|
+
- `workflow_list_channels` — discover channel kinds for `channel` step config
|
|
48
|
+
|
|
49
|
+
Validation (cheap, no execution):
|
|
50
|
+
- `workflow_validate` — static checks: cycles, missing deps, missing fields per kind
|
|
51
|
+
- `workflow_dry_run` — describe what each step would do, in topological order, with rough token estimate. **Use this for long-running jobs to preview safely before scheduling.**
|
|
52
|
+
|
|
53
|
+
Mutations (always emit a live update to the canvas):
|
|
54
|
+
- `workflow_add_node` — append a new step
|
|
55
|
+
- `workflow_update_node` — change an existing step's fields (partial patch)
|
|
56
|
+
- `workflow_remove_node` — delete a step + edges referencing it
|
|
57
|
+
- `workflow_connect` — add edge `from → to` (sets `to.dependsOn += [from]`)
|
|
58
|
+
- `workflow_disconnect` — remove edge
|
|
59
|
+
- `workflow_set_enabled` — toggle on/off
|
|
60
|
+
- `workflow_set_schedule` — change cron schedule (or pass `null` for manual-only)
|
|
61
|
+
- `workflow_rename`, `workflow_duplicate`, `workflow_delete`
|
|
62
|
+
- `workflow_save` — full replace (use for atomic multi-field changes; otherwise prefer the targeted tools)
|
|
63
|
+
- `workflow_create` — new workflow file
|
|
64
|
+
|
|
65
|
+
## Workflow shape
|
|
66
|
+
|
|
67
|
+
Every workflow is a step DAG. A step has:
|
|
68
|
+
- `id` (unique within workflow)
|
|
69
|
+
- `prompt` (string — required for `kind: prompt`, descriptive for others)
|
|
70
|
+
- `dependsOn[]` (step ids this step depends on)
|
|
71
|
+
- `tier`, `maxTurns`, `model`, `workDir`
|
|
72
|
+
- `kind` — one of: `prompt` (default), `mcp`, `channel`, `transform`, `conditional`, `loop`
|
|
73
|
+
- Plus a kind-specific config (`mcp`, `channel`, `transform`, `conditional`, `loop`)
|
|
74
|
+
|
|
75
|
+
A **cron** is a single-step workflow with a cron schedule trigger. You
|
|
76
|
+
**cannot** add a second step to a cron — cron entries must remain
|
|
77
|
+
single-step. To make a multi-step automation that runs on a schedule,
|
|
78
|
+
use `workflow_create` with a schedule.
|
|
79
|
+
|
|
80
|
+
## How to work
|
|
81
|
+
|
|
82
|
+
**Always read first.** Before editing, call `workflow_read` to get the
|
|
83
|
+
current step ids and structure. Patches reference step ids by exact
|
|
84
|
+
match.
|
|
85
|
+
|
|
86
|
+
**Validate after edits.** Save tools auto-validate and reject errors;
|
|
87
|
+
warnings still pass through. Run `workflow_validate` if the user asks
|
|
88
|
+
"is this right?" or before recommending they enable a workflow.
|
|
89
|
+
|
|
90
|
+
**Dry-run before scheduling.** When the user is about to enable a
|
|
91
|
+
long-running workflow (multi-hour job, batch outreach, large ingest),
|
|
92
|
+
offer `workflow_dry_run` first. It walks the DAG and describes what each
|
|
93
|
+
step *would* do — no execution, no side effects.
|
|
94
|
+
|
|
95
|
+
**One mutation per turn for big changes.** The canvas updates live, so
|
|
96
|
+
small targeted edits (`workflow_add_node`, `workflow_connect`) feel
|
|
97
|
+
better than wholesale `workflow_save` rewrites. The user can see
|
|
98
|
+
intermediate states.
|
|
99
|
+
|
|
100
|
+
**Step ids should be short and descriptive.** `s1`, `s2` is fine;
|
|
101
|
+
`fetch_emails`, `summarize`, `send_to_slack` is better. Don't change
|
|
102
|
+
existing step ids unless the user explicitly asks — other steps depend
|
|
103
|
+
on them.
|
|
104
|
+
|
|
105
|
+
**Channel and MCP steps need real config.** Use `workflow_list_mcp_tools`
|
|
106
|
+
to find a real `server.tool` pair before adding an MCP step. Use
|
|
107
|
+
`workflow_list_channels` to confirm a channel kind is wired up before
|
|
108
|
+
adding a channel step.
|
|
109
|
+
|
|
110
|
+
## Common patterns
|
|
111
|
+
|
|
112
|
+
User: "Add a slack send step at the end."
|
|
113
|
+
1. `workflow_read` to get current step ids
|
|
114
|
+
2. Identify the leaf step(s) (no other step depends on them)
|
|
115
|
+
3. `workflow_add_node` with `kind: 'channel'`, `channel: { channel: 'slack', target: '#me', content: '{{<leaf>.output}}' }`, `dependsOn: ['<leaf>']`
|
|
116
|
+
|
|
117
|
+
User: "Make this run every weekday at 9am."
|
|
118
|
+
1. `workflow_set_schedule` with `'0 9 * * 1-5'`
|
|
119
|
+
|
|
120
|
+
User: "Will this work? Don't run it yet."
|
|
121
|
+
1. `workflow_validate` for static checks
|
|
122
|
+
2. `workflow_dry_run` to walk through what it would do
|
|
123
|
+
|
|
124
|
+
User: "Skip if there are no unread emails."
|
|
125
|
+
1. `workflow_add_node` with `kind: 'conditional'`, condition referencing the email-list step's output count
|
|
126
|
+
2. `workflow_connect` to wire it
|