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.
@@ -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
@@ -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.2.3",
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