clementine-agent 1.2.2 → 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.
Files changed (41) hide show
  1. package/dist/agent/assistant.js +12 -0
  2. package/dist/cli/dashboard.js +3034 -734
  3. package/dist/cli/static/LICENSE-NOTICES.md +12 -0
  4. package/dist/cli/static/drawflow.min.css +1 -0
  5. package/dist/cli/static/drawflow.min.js +1 -0
  6. package/dist/config.d.ts +11 -0
  7. package/dist/config.js +16 -0
  8. package/dist/dashboard/builder/dry-run.d.ts +31 -0
  9. package/dist/dashboard/builder/dry-run.js +138 -0
  10. package/dist/dashboard/builder/events.d.ts +23 -0
  11. package/dist/dashboard/builder/events.js +28 -0
  12. package/dist/dashboard/builder/mcp-invoke.d.ts +25 -0
  13. package/dist/dashboard/builder/mcp-invoke.js +143 -0
  14. package/dist/dashboard/builder/runner.d.ts +68 -0
  15. package/dist/dashboard/builder/runner.js +418 -0
  16. package/dist/dashboard/builder/serializer.d.ts +79 -0
  17. package/dist/dashboard/builder/serializer.js +547 -0
  18. package/dist/dashboard/builder/snapshots.d.ts +32 -0
  19. package/dist/dashboard/builder/snapshots.js +138 -0
  20. package/dist/dashboard/builder/validation.d.ts +26 -0
  21. package/dist/dashboard/builder/validation.js +183 -0
  22. package/dist/gateway/router.js +31 -2
  23. package/dist/index.js +38 -0
  24. package/dist/memory/chunker.js +13 -2
  25. package/dist/memory/hot-cache.d.ts +38 -0
  26. package/dist/memory/hot-cache.js +73 -0
  27. package/dist/memory/integrity.d.ts +28 -0
  28. package/dist/memory/integrity.js +119 -0
  29. package/dist/memory/maintenance.d.ts +23 -2
  30. package/dist/memory/maintenance.js +140 -3
  31. package/dist/memory/store.d.ts +259 -2
  32. package/dist/memory/store.js +751 -21
  33. package/dist/memory/write-queue.d.ts +96 -0
  34. package/dist/memory/write-queue.js +165 -0
  35. package/dist/tools/builder-tools.d.ts +13 -0
  36. package/dist/tools/builder-tools.js +437 -0
  37. package/dist/tools/mcp-server.js +2 -0
  38. package/dist/tools/memory-tools.js +38 -1
  39. package/dist/types.d.ts +56 -2
  40. package/package.json +2 -2
  41. 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
@@ -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
@@ -362,11 +362,48 @@ export function registerMemoryTools(server) {
362
362
  }
363
363
  return textResult(`Unknown action: ${action}`);
364
364
  });
365
+ // ── 2b. memory_record_procedure ────────────────────────────────────────
366
+ server.tool('memory_record_procedure', getToolDescription('memory_record_procedure') ?? 'Record a learned workflow as a durable procedure. Use when you notice a repeating multi-step task ("how Nate ships a release", "how to handle inbound replies"). Stored under 00-System/procedures/ with category=procedure and trigger verbs that surface it later. Different from memory_write/MEMORY.md: those store facts, this stores reusable HOW-TO. From Mem0\'s 2026 procedural-memory pattern.', {
367
+ title: z.string().describe('Short procedure title (becomes filename slug)'),
368
+ steps: z.string().describe('Numbered steps or markdown body describing how to perform the task'),
369
+ triggers: z.array(z.string()).min(1).describe('Verb phrases (e.g. ["ship release", "publish to npm"]) that should surface this procedure when the user query contains them. Lowercase preferred.'),
370
+ notes: z.string().optional().describe('Optional context: when to use, when NOT to use, gotchas'),
371
+ }, async ({ title, steps, triggers, notes }) => {
372
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
373
+ if (!slug)
374
+ return textResult('Error: title must contain alphanumerics');
375
+ const proceduresDir = path.join(SYSTEM_DIR, 'procedures');
376
+ mkdirSync(proceduresDir, { recursive: true });
377
+ const filePath = path.join(proceduresDir, `${slug}.md`);
378
+ const triggersYaml = triggers.map((t) => ` - ${JSON.stringify(t.toLowerCase())}`).join('\n');
379
+ const body = [
380
+ '---',
381
+ `title: ${JSON.stringify(title)}`,
382
+ 'category: procedure',
383
+ 'triggers:',
384
+ triggersYaml,
385
+ `created_at: ${new Date().toISOString()}`,
386
+ ACTIVE_AGENT_SLUG ? `agent_slug: ${JSON.stringify(ACTIVE_AGENT_SLUG)}` : '',
387
+ '---',
388
+ '',
389
+ `# ${title}`,
390
+ '',
391
+ '## Steps',
392
+ '',
393
+ steps.trim(),
394
+ '',
395
+ ...(notes ? ['## Notes', '', notes.trim(), ''] : []),
396
+ ].filter((line) => line !== '').join('\n') + '\n';
397
+ writeFileSync(filePath, body, 'utf-8');
398
+ const rel = path.relative(VAULT_DIR, filePath);
399
+ await incrementalSync(rel, ACTIVE_AGENT_SLUG ?? undefined);
400
+ return textResult(`Recorded procedure: ${rel} (triggers: ${triggers.join(', ')})`);
401
+ });
365
402
  // ── 3. memory_search ───────────────────────────────────────────────────
366
403
  server.tool('memory_search', getToolDescription('memory_search') ?? 'FTS5 search across all vault notes. Returns matching chunks with relevance scores. Optional category/topic filters narrow results.', {
367
404
  query: z.string().describe('Search text'),
368
405
  limit: z.number().optional().describe('Max results (default 20)'),
369
- category: z.enum(['facts', 'events', 'discoveries', 'preferences', 'advice']).optional().describe('Filter by category'),
406
+ category: z.enum(['facts', 'events', 'discoveries', 'preferences', 'advice', 'procedure']).optional().describe('Filter by category'),
370
407
  topic: z.string().optional().describe('Filter by topic'),
371
408
  }, async ({ query, limit, category, topic }) => {
372
409
  const maxResults = limit ?? 20;
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface SearchResult {
7
7
  content: string;
8
8
  score: float;
9
9
  chunkType: string;
10
- matchType: 'fts' | 'recency' | 'timeline' | 'vector';
10
+ matchType: 'fts' | 'recency' | 'timeline' | 'vector' | 'graph';
11
11
  lastUpdated: string;
12
12
  chunkId: number;
13
13
  salience: number;
@@ -17,7 +17,7 @@ export interface SearchResult {
17
17
  topic?: string | null;
18
18
  pinned?: boolean;
19
19
  }
20
- export type ChunkCategory = 'facts' | 'events' | 'discoveries' | 'preferences' | 'advice';
20
+ export type ChunkCategory = 'facts' | 'events' | 'discoveries' | 'preferences' | 'advice' | 'procedure';
21
21
  export interface Chunk {
22
22
  sourceFile: string;
23
23
  section: string;
@@ -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;
@@ -716,6 +762,14 @@ export interface RemoteAccessConfig {
716
762
  autoPost: boolean;
717
763
  lastStarted?: string;
718
764
  }
765
+ export interface SessionRecord {
766
+ id: string;
767
+ expiresAt: number;
768
+ persistent: boolean;
769
+ createdAt: number;
770
+ lastUsedAt: number;
771
+ userAgent?: string;
772
+ }
719
773
  export interface ConfigRevision {
720
774
  id?: number;
721
775
  agentSlug: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.2.2",
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",