clementine-agent 1.18.29 → 1.18.31

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.
@@ -3989,6 +3989,362 @@ export async function cmdDashboard(opts) {
3989
3989
  res.status(500).json({ error: 'Create failed', detail: String(err) });
3990
3990
  }
3991
3991
  });
3992
+ // ── Routines API (canonical surface for the Build tab) ─────────
3993
+ // The "Routines" UI uses this surface exclusively. Workflows + cron
3994
+ // jobs both flow through here as a single Routine concept; legacy
3995
+ // /api/builder/* and /api/cron/* endpoints remain for one minor
3996
+ // version, then are removed.
3997
+ app.get('/api/routines', async (_req, res) => {
3998
+ try {
3999
+ const { listAllForBuilder } = await import('../dashboard/builder/serializer.js');
4000
+ res.json({ routines: listAllForBuilder() });
4001
+ }
4002
+ catch (err) {
4003
+ res.status(500).json({ error: 'list failed', detail: String(err) });
4004
+ }
4005
+ });
4006
+ app.get('/api/routines/mcp-tools', async (_req, res) => {
4007
+ try {
4008
+ const { discoverMcpServers, loadToolInventory } = await import('../agent/mcp-bridge.js');
4009
+ const servers = discoverMcpServers();
4010
+ const inv = loadToolInventory();
4011
+ const allTools = inv?.tools ?? [];
4012
+ // Group flat tool names of shape `mcp__<server>__<tool>` (server may
4013
+ // contain underscores — split on the first `__` after the prefix).
4014
+ const grouped = {};
4015
+ for (const s of servers) {
4016
+ grouped[s.name] = { name: s.name, enabled: s.enabled !== false, tools: [] };
4017
+ }
4018
+ for (const t of allTools) {
4019
+ if (!t.startsWith('mcp__'))
4020
+ continue;
4021
+ const rest = t.slice(5);
4022
+ const idx = rest.indexOf('__');
4023
+ if (idx < 0)
4024
+ continue;
4025
+ const server = rest.slice(0, idx);
4026
+ const tool = rest.slice(idx + 2);
4027
+ if (!grouped[server])
4028
+ grouped[server] = { name: server, enabled: true, tools: [] };
4029
+ if (!grouped[server].tools.includes(tool))
4030
+ grouped[server].tools.push(tool);
4031
+ }
4032
+ const out = Object.values(grouped)
4033
+ .filter(s => s.tools.length > 0 || s.enabled)
4034
+ .sort((a, b) => a.name.localeCompare(b.name));
4035
+ res.json({ servers: out });
4036
+ }
4037
+ catch (err) {
4038
+ res.status(500).json({ error: 'mcp-tools failed', detail: String(err) });
4039
+ }
4040
+ });
4041
+ app.get('/api/routines/cli-tools', async (_req, res) => {
4042
+ try {
4043
+ // Reuse discoverCliTools() defined elsewhere in this file.
4044
+ const tools = discoverCliTools().filter(t => t.installed && !t.blocked);
4045
+ res.json({ tools: tools.map(t => ({ cmd: t.name, description: t.description, userDefined: !!t.userDefined })) });
4046
+ }
4047
+ catch (err) {
4048
+ res.status(500).json({ error: 'cli-tools failed', detail: String(err) });
4049
+ }
4050
+ });
4051
+ app.get('/api/routines/:id', async (req, res) => {
4052
+ try {
4053
+ const id = decodeURIComponent(req.params.id);
4054
+ const { readWorkflow } = await import('../dashboard/builder/serializer.js');
4055
+ const { validateWorkflow } = await import('../dashboard/builder/validation.js');
4056
+ const wf = readWorkflow(id);
4057
+ if (!wf) {
4058
+ res.status(404).json({ error: 'Not found' });
4059
+ return;
4060
+ }
4061
+ res.json({ id, routine: wf, validation: validateWorkflow(wf) });
4062
+ }
4063
+ catch (err) {
4064
+ res.status(500).json({ error: 'read failed', detail: String(err) });
4065
+ }
4066
+ });
4067
+ app.post('/api/routines', async (req, res) => {
4068
+ try {
4069
+ const body = req.body;
4070
+ if (!body || !body.name) {
4071
+ res.status(400).json({ error: 'name required' });
4072
+ return;
4073
+ }
4074
+ const [{ saveWorkflow, workflowId: makeId }, { emitBuilderEvent }] = await Promise.all([
4075
+ import('../dashboard/builder/serializer.js'),
4076
+ import('../dashboard/builder/events.js'),
4077
+ ]);
4078
+ const slug = body.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'routine';
4079
+ const agentSlug = body.agent ? (String(body.agent).trim() || undefined) : undefined;
4080
+ const wf = {
4081
+ name: body.name,
4082
+ description: body.description ?? '',
4083
+ enabled: true,
4084
+ trigger: body.schedule ? { schedule: body.schedule, manual: false } : { manual: true },
4085
+ inputs: {},
4086
+ steps: [{
4087
+ id: 's1',
4088
+ prompt: body.initialPrompt ?? 'Describe what this routine should do.',
4089
+ dependsOn: [],
4090
+ tier: 1,
4091
+ maxTurns: 15,
4092
+ }],
4093
+ sourceFile: '',
4094
+ agentSlug,
4095
+ };
4096
+ const id = makeId(slug, agentSlug);
4097
+ const result = saveWorkflow(id, wf);
4098
+ if (!result.ok) {
4099
+ res.status(400).json({ error: result.error });
4100
+ return;
4101
+ }
4102
+ emitBuilderEvent({ type: 'workflow:created', workflowId: id, payload: { workflow: wf } });
4103
+ res.json({ ok: true, id });
4104
+ }
4105
+ catch (err) {
4106
+ res.status(500).json({ error: 'create failed', detail: String(err) });
4107
+ }
4108
+ });
4109
+ app.put('/api/routines/:id', async (req, res) => {
4110
+ try {
4111
+ const id = decodeURIComponent(req.params.id);
4112
+ const body = req.body;
4113
+ if (!body || typeof body.routine !== 'object') {
4114
+ res.status(400).json({ error: 'Missing routine body' });
4115
+ return;
4116
+ }
4117
+ const [{ readWorkflow, saveWorkflow }, { validateWorkflow }, { emitBuilderEvent }] = await Promise.all([
4118
+ import('../dashboard/builder/serializer.js'),
4119
+ import('../dashboard/builder/validation.js'),
4120
+ import('../dashboard/builder/events.js'),
4121
+ ]);
4122
+ const existing = readWorkflow(id);
4123
+ if (!existing) {
4124
+ res.status(404).json({ error: 'Not found' });
4125
+ return;
4126
+ }
4127
+ const incoming = body.routine;
4128
+ const next = { ...incoming, sourceFile: existing.sourceFile };
4129
+ const v = validateWorkflow(next);
4130
+ if (!v.ok && !body.force) {
4131
+ res.status(400).json({ error: 'validation', validation: v });
4132
+ return;
4133
+ }
4134
+ const result = saveWorkflow(id, next);
4135
+ if (!result.ok) {
4136
+ res.status(400).json({ error: result.error });
4137
+ return;
4138
+ }
4139
+ emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: next } });
4140
+ res.json({ ok: true, validation: v });
4141
+ }
4142
+ catch (err) {
4143
+ res.status(500).json({ error: 'save failed', detail: String(err) });
4144
+ }
4145
+ });
4146
+ app.delete('/api/routines/:id', async (req, res) => {
4147
+ try {
4148
+ const id = decodeURIComponent(req.params.id);
4149
+ const [{ readWorkflow, parseBuilderId }, { emitBuilderEvent }] = await Promise.all([
4150
+ import('../dashboard/builder/serializer.js'),
4151
+ import('../dashboard/builder/events.js'),
4152
+ ]);
4153
+ const parsed = parseBuilderId(id);
4154
+ if (!parsed) {
4155
+ res.status(400).json({ error: 'Bad id' });
4156
+ return;
4157
+ }
4158
+ if (parsed.origin === 'cron') {
4159
+ res.status(400).json({ error: 'This routine came from a legacy cron entry — disable it instead, or edit CRON.md directly.' });
4160
+ return;
4161
+ }
4162
+ const wf = readWorkflow(id);
4163
+ if (!wf) {
4164
+ res.status(404).json({ error: 'Not found' });
4165
+ return;
4166
+ }
4167
+ if (wf.sourceFile && existsSync(wf.sourceFile))
4168
+ unlinkSync(wf.sourceFile);
4169
+ emitBuilderEvent({ type: 'workflow:deleted', workflowId: id });
4170
+ res.json({ ok: true });
4171
+ }
4172
+ catch (err) {
4173
+ res.status(500).json({ error: String(err) });
4174
+ }
4175
+ });
4176
+ app.post('/api/routines/:id/toggle', async (req, res) => {
4177
+ try {
4178
+ const id = decodeURIComponent(req.params.id);
4179
+ const { readWorkflow, saveWorkflow } = await import('../dashboard/builder/serializer.js');
4180
+ const wf = readWorkflow(id);
4181
+ if (!wf) {
4182
+ res.status(404).json({ error: 'Not found' });
4183
+ return;
4184
+ }
4185
+ wf.enabled = !wf.enabled;
4186
+ const result = saveWorkflow(id, wf);
4187
+ if (!result.ok) {
4188
+ res.status(400).json({ error: result.error });
4189
+ return;
4190
+ }
4191
+ res.json({ ok: true, enabled: wf.enabled });
4192
+ }
4193
+ catch (err) {
4194
+ res.status(500).json({ error: 'toggle failed', detail: String(err) });
4195
+ }
4196
+ });
4197
+ app.post('/api/routines/:id/run', async (req, res) => {
4198
+ try {
4199
+ const id = decodeURIComponent(req.params.id);
4200
+ const { readWorkflow, parseBuilderId } = await import('../dashboard/builder/serializer.js');
4201
+ const wf = readWorkflow(id);
4202
+ if (!wf) {
4203
+ res.status(404).json({ error: 'Not found' });
4204
+ return;
4205
+ }
4206
+ const parsed = parseBuilderId(id);
4207
+ const body = (req.body ?? {});
4208
+ // Cron-origin routines: spawn the cli `cron run <name>` (single-step prompt path).
4209
+ if (parsed?.origin === 'cron') {
4210
+ const child = spawn('node', [DIST_ENTRY, 'cron', 'run', wf.name], {
4211
+ detached: true,
4212
+ stdio: ['ignore', 'pipe', 'pipe'],
4213
+ cwd: BASE_DIR,
4214
+ env: { ...process.env, CLEMENTINE_HOME: BASE_DIR },
4215
+ });
4216
+ child.on('exit', (code) => {
4217
+ broadcastEvent({ type: 'cron_complete', data: { job: wf.name, code } });
4218
+ responseCache.delete('activity:');
4219
+ });
4220
+ child.unref();
4221
+ broadcastEvent({ type: 'cron_triggered', data: { job: wf.name } });
4222
+ res.json({ ok: true, message: `Triggered routine: ${wf.name}` });
4223
+ return;
4224
+ }
4225
+ // Workflow-origin routines: side-effect approval gate, then route through gateway.handleWorkflow.
4226
+ const sideEffects = wf.steps
4227
+ .filter(step => {
4228
+ const kind = step.kind ?? 'prompt';
4229
+ if (kind === 'channel' || kind === 'mcp' || kind === 'cli')
4230
+ return true;
4231
+ return /\b(send|post|publish|email|webhook|delete|write|update|create)\b/i.test(step.prompt || '');
4232
+ })
4233
+ .map(step => ({
4234
+ id: step.id,
4235
+ kind: step.kind ?? 'prompt',
4236
+ label: step.channel
4237
+ ? `${step.channel.channel}:${step.channel.target}`
4238
+ : step.mcp
4239
+ ? `${step.mcp.server}.${step.mcp.tool}`
4240
+ : step.cli
4241
+ ? `${step.cli.cmd}${step.cli.args?.length ? ' ' + step.cli.args.join(' ') : ''}`
4242
+ : step.prompt.slice(0, 80),
4243
+ }));
4244
+ if (sideEffects.length > 0 && body.approvedSideEffects !== true) {
4245
+ res.status(409).json({
4246
+ ok: false,
4247
+ error: 'approval_required',
4248
+ message: 'This routine may send, write, post, or call external tools. Approve side effects before running it.',
4249
+ sideEffects,
4250
+ });
4251
+ return;
4252
+ }
4253
+ res.json({ ok: true, message: `Routine "${wf.name}" triggered` });
4254
+ broadcastEvent({ type: 'workflow_triggered', data: { id, name: wf.name } });
4255
+ getGateway().then(gw => gw.handleWorkflow(wf, body.inputs || {})).then(result => {
4256
+ broadcastEvent({ type: 'workflow_complete', data: { id, name: wf.name, status: 'ok', preview: (result || '').slice(0, 300) } });
4257
+ }).catch(err => {
4258
+ broadcastEvent({ type: 'workflow_complete', data: { id, name: wf.name, status: 'error', error: String(err) } });
4259
+ });
4260
+ }
4261
+ catch (err) {
4262
+ res.status(500).json({ error: 'run failed', detail: String(err) });
4263
+ }
4264
+ });
4265
+ app.post('/api/routines/:id/dry-run', async (req, res) => {
4266
+ try {
4267
+ const id = decodeURIComponent(req.params.id);
4268
+ const [{ readWorkflow }, { dryRunWorkflow }] = await Promise.all([
4269
+ import('../dashboard/builder/serializer.js'),
4270
+ import('../dashboard/builder/dry-run.js'),
4271
+ ]);
4272
+ const wf = readWorkflow(id);
4273
+ if (!wf) {
4274
+ res.status(404).json({ error: 'Not found' });
4275
+ return;
4276
+ }
4277
+ res.json(dryRunWorkflow(wf));
4278
+ }
4279
+ catch (err) {
4280
+ res.status(500).json({ error: 'dry-run failed', detail: String(err) });
4281
+ }
4282
+ });
4283
+ app.post('/api/routines/:id/test', async (req, res) => {
4284
+ try {
4285
+ const id = decodeURIComponent(req.params.id);
4286
+ const body = (req.body ?? {});
4287
+ const [{ readWorkflow }, { runWorkflowTest }] = await Promise.all([
4288
+ import('../dashboard/builder/serializer.js'),
4289
+ import('../dashboard/builder/runner.js'),
4290
+ ]);
4291
+ const wf = readWorkflow(id);
4292
+ if (!wf) {
4293
+ res.status(404).json({ error: 'Not found' });
4294
+ return;
4295
+ }
4296
+ const runId = (await import('node:crypto')).randomUUID();
4297
+ res.json({ ok: true, runId });
4298
+ runWorkflowTest(wf, {
4299
+ workflowId: id,
4300
+ runId,
4301
+ mode: body.mode ?? 'mock',
4302
+ perStepTimeoutMs: body.perStepTimeoutMs,
4303
+ totalBudgetMs: body.totalBudgetMs,
4304
+ }).catch(() => { });
4305
+ }
4306
+ catch (err) {
4307
+ res.status(500).json({ error: 'test failed to start', detail: String(err) });
4308
+ }
4309
+ });
4310
+ app.get('/api/routines/:id/runs', async (req, res) => {
4311
+ try {
4312
+ const id = decodeURIComponent(req.params.id);
4313
+ const { readWorkflow } = await import('../dashboard/builder/serializer.js');
4314
+ const wf = readWorkflow(id);
4315
+ if (!wf) {
4316
+ res.status(404).json({ error: 'Not found' });
4317
+ return;
4318
+ }
4319
+ const safe = wf.name.replace(/[^a-zA-Z0-9_-]/g, '_');
4320
+ const cronLogPath = path.join(BASE_DIR, 'cron-logs', `${safe}.jsonl`);
4321
+ const workflowLogPath = path.join(BASE_DIR, 'workflows', 'runs', `${safe}.jsonl`);
4322
+ const runs = [];
4323
+ for (const file of [cronLogPath, workflowLogPath]) {
4324
+ if (!existsSync(file))
4325
+ continue;
4326
+ try {
4327
+ const lines = readFileSync(file, 'utf-8').split('\n').filter(l => l.trim());
4328
+ for (const line of lines.slice(-50)) {
4329
+ try {
4330
+ runs.push(JSON.parse(line));
4331
+ }
4332
+ catch { /* skip malformed */ }
4333
+ }
4334
+ }
4335
+ catch { /* skip unreadable */ }
4336
+ }
4337
+ runs.sort((a, b) => {
4338
+ const at = String(a.startedAt || a.timestamp || '');
4339
+ const bt = String(b.startedAt || b.timestamp || '');
4340
+ return bt.localeCompare(at);
4341
+ });
4342
+ res.json({ runs: runs.slice(0, 50) });
4343
+ }
4344
+ catch (err) {
4345
+ res.status(500).json({ error: 'runs read failed', detail: String(err) });
4346
+ }
4347
+ });
3992
4348
  // SSE events handler moved before auth middleware (see above)
3993
4349
  // ── POST routes (actions) ──────────────────────────────────────
3994
4350
  app.post('/api/cron/run/:job', (req, res) => {
@@ -7044,6 +7400,55 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7044
7400
  res.status(500).json({ error: String(err) });
7045
7401
  }
7046
7402
  });
7403
+ // Learned facts — durable cross-session beliefs with supersession lineage.
7404
+ app.get('/api/memory/learnings', async (req, res) => {
7405
+ try {
7406
+ const gateway = await getGateway();
7407
+ const store = gateway.assistant?.memoryStore;
7408
+ if (!store || typeof store.listAllLearnedFacts !== 'function') {
7409
+ res.status(503).json({ error: 'Learnings store not available' });
7410
+ return;
7411
+ }
7412
+ const limit = Math.min(parseInt(String(req.query.limit ?? '100'), 10) || 100, 1000);
7413
+ const showAll = String(req.query.all ?? '') === '1';
7414
+ const facts = showAll
7415
+ ? store.listAllLearnedFacts({ limit })
7416
+ : store.listActiveLearnedFacts({ limit });
7417
+ res.json({ ok: true, facts });
7418
+ }
7419
+ catch (err) {
7420
+ res.status(500).json({ error: String(err) });
7421
+ }
7422
+ });
7423
+ app.post('/api/memory/learnings/action', async (req, res) => {
7424
+ try {
7425
+ const gateway = await getGateway();
7426
+ const store = gateway.assistant?.memoryStore;
7427
+ if (!store || typeof store.setLearnedFactStatus !== 'function') {
7428
+ res.status(503).json({ error: 'Learnings store not available' });
7429
+ return;
7430
+ }
7431
+ const id = Number(req.body?.id);
7432
+ const action = String(req.body?.action ?? '');
7433
+ if (!Number.isInteger(id) || id <= 0) {
7434
+ res.status(400).json({ error: 'id required' });
7435
+ return;
7436
+ }
7437
+ let updated = false;
7438
+ if (action === 'cancel')
7439
+ updated = store.setLearnedFactStatus(id, 'cancelled');
7440
+ else if (action === 'reinstate')
7441
+ updated = store.setLearnedFactStatus(id, 'active');
7442
+ else {
7443
+ res.status(400).json({ error: 'invalid action' });
7444
+ return;
7445
+ }
7446
+ res.json({ ok: updated });
7447
+ }
7448
+ catch (err) {
7449
+ res.status(500).json({ error: String(err) });
7450
+ }
7451
+ });
7047
7452
  // Commitments — durable promises tracked across sessions.
7048
7453
  app.get('/api/memory/commitments', async (req, res) => {
7049
7454
  try {
@@ -14679,189 +15084,732 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14679
15084
  </div>
14680
15085
  </div>
14681
15086
 
14682
- <!-- ═══ Builder Page — Conversational Artifact Creation ═══ -->
15087
+ <!-- ═══ Build Page — Routines (single unified surface) ═══ -->
14683
15088
  <div class="page" id="page-build">
14684
- <div class="tab-bar" id="build-tabs" style="margin:0;padding:0 18px;background:var(--bg-secondary);border-bottom:1px solid var(--border)">
14685
- <button class="active" data-build-tab="crons" data-icon="clock" onclick="switchBuildTab('crons')"><span class="icon-slot"></span> Operations <span class="tab-badge" id="build-tab-cron-count" style="display:none">0</span></button>
14686
- <button data-build-tab="workflows" data-icon="workflow" onclick="switchBuildTab('workflows')"><span class="icon-slot"></span> Workflow Builder</button>
14687
- <button data-build-tab="skills" data-icon="shield" onclick="switchBuildTab('skills')"><span class="icon-slot"></span> Skills <span class="tab-badge" id="build-tab-skill-count" style="display:none">0</span></button>
14688
- <button data-build-tab="templates" data-icon="fileText" onclick="switchBuildTab('templates')"><span class="icon-slot"></span> Templates</button>
14689
- </div>
14690
- <!-- Builder header strip — persists across tabs (except Templates) -->
14691
- <div id="build-header-strip" style="display:flex;align-items:center;gap:12px;padding:10px 18px;border-bottom:1px solid var(--border)">
14692
- <select id="builder-type" onchange="resetBuilder();updateBuilderMode()" style="display:none">
14693
- <option value="skill">skill</option>
14694
- <option value="cron">cron</option>
14695
- <option value="agent">agent</option>
14696
- <option value="workflow">workflow</option>
14697
- </select>
14698
- <label style="font-size:11px;color:var(--text-muted);font-weight:500;letter-spacing:0.04em;text-transform:uppercase">Owner</label>
14699
- <select id="builder-owner" onchange="onBuilderOwnerChange()" title="Filter and create scoped to this owner" style="padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;min-width:160px">
14700
- <option value="__all__">All agents</option>
14701
- <option value="">Clementine (global)</option>
14702
- </select>
14703
- <span id="builder-agent-label" style="padding:0;font-size:13px;color:var(--text-secondary);font-weight:500"></span>
14704
- <input type="hidden" id="builder-agent" value="">
15089
+ <!-- Toolbar -->
15090
+ <div id="routines-toolbar" style="display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border);background:var(--bg-secondary);flex-wrap:wrap">
15091
+ <h2 style="margin:0;font-size:18px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px"><span data-icon="workflow" class="icon-slot"></span> Routines</h2>
15092
+ <span id="routines-count" style="font-size:11px;color:var(--text-muted)"></span>
15093
+ <span id="routines-editor-breadcrumb" style="display:none;font-size:12px;color:var(--text-muted)"> &rsaquo; <span id="routines-editor-name" style="color:var(--text-primary);font-weight:500"></span></span>
14705
15094
  <span style="flex:1"></span>
14706
- <button class="btn-sm btn-primary" id="builder-new-btn" onclick="newFromBuildHeader()" title="Create a new artifact for this tab" style="padding:4px 14px;border-radius:6px;cursor:pointer;font-size:12px">New Workflow</button>
14707
- <button class="btn-sm" id="builder-test-btn" onclick="testBuilderSkill()" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);padding:4px 12px;border-radius:6px;cursor:pointer;font-size:12px;display:none">Test</button>
14708
- <button class="btn-sm btn-primary" id="builder-save-btn" onclick="saveBuilderArtifact()" style="padding:4px 16px;font-size:12px;display:none">Save</button>
14709
- </div>
14710
- <div id="build-usage-panel" style="padding:12px 18px;border-bottom:1px solid var(--border);background:var(--bg-primary)">
14711
- <div class="skel-block"><div class="skel-row med"></div><div class="skel-row short"></div></div>
15095
+ <label id="routines-owner-label" style="font-size:11px;color:var(--text-muted);font-weight:500;letter-spacing:0.04em;text-transform:uppercase">Owner</label>
15096
+ <select id="routines-owner-filter" onchange="window.RoutinesUI && RoutinesUI.renderList()" style="padding:5px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;min-width:160px">
15097
+ <option value="__all__">All</option>
15098
+ <option value="__global__">Clementine (global)</option>
15099
+ </select>
15100
+ <button id="routines-back-btn" style="display:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)" onclick="window.RoutinesUI && RoutinesUI.closeEditor()">&larr; Back to list</button>
15101
+ <button id="routines-assist-btn" class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openAssist()" title="Describe a routine in natural language" style="padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Generate from prompt</button>
15102
+ <button id="routines-create-btn" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openCreate()" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ New Routine</button>
14712
15103
  </div>
14713
- <!-- Build tab content area: canvas DOMINATES, chat is a sidebar -->
14714
- <div id="build-tab-workflows" data-build-tabpane="workflows" style="display:none;flex:1;min-height:0;overflow:hidden">
14715
- <!-- Left: Chat sidebar (compact) -->
14716
- <div id="builder-chat-sidebar" style="width:360px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border);background:var(--bg-secondary)">
14717
- <div id="builder-messages" style="flex:1;overflow-y:auto;padding:16px">
14718
- <div class="empty-state" id="builder-empty-state" style="margin-top:40px">
14719
- <p style="color:var(--text-muted);margin-bottom:12px">Describe what you want to build.</p>
14720
- <div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center">
14721
- <button class="btn btn-sm quick-pill" onclick="builderQuick('Create a workflow that researches a topic, writes a draft, and sends it for review')">Research workflow</button>
14722
- <button class="btn btn-sm quick-pill" onclick="builderQuick('Create a workflow that reviews open PRs, summarizes risk, and sends a digest')">PR review digest</button>
14723
- <button class="btn btn-sm quick-pill" onclick="builderQuick('Create a workflow that collects leads, scores them against my ICP, and prepares outreach drafts')">Lead research flow</button>
14724
- <button class="btn btn-sm quick-pill" onclick="builderQuick('Create a workflow that reviews recent notes, extracts open items, and drafts next priorities')">Weekly review workflow</button>
14725
- </div>
14726
- </div>
14727
- </div>
14728
- <div id="builder-file-area" style="padding:8px 16px;border-top:1px solid var(--border);background:var(--bg-secondary)">
14729
- <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
14730
- <span style="font-size:11px;font-weight:600;color:var(--text-secondary)">Reference Files</span>
14731
- <label style="cursor:pointer;display:inline-flex;align-items:center;gap:4px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--text-primary)">
14732
- + Add
14733
- <input type="file" multiple accept=".csv,.md,.txt,.json,.docx,.xlsx,.yaml,.yml,.xml,.html,.py,.js,.ts" style="display:none" onchange="handleBuilderFileUpload(event)">
14734
- </label>
14735
- <span style="font-size:10px;color:var(--text-muted)">Sent with each message so the AI can reference them</span>
14736
- </div>
14737
- <div id="builder-attachments-list"></div>
14738
- </div>
14739
- <div style="display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--border);align-items:center">
14740
- <label style="cursor:pointer;display:flex;align-items:center;color:var(--text-muted);font-size:18px" title="Attach file">
14741
- <input type="file" multiple accept=".csv,.md,.txt,.json,.docx,.xlsx,.yaml,.yml,.xml,.html,.py,.js,.ts" style="display:none" onchange="handleBuilderFileUpload(event)">&#x1F4CE;
14742
- </label>
14743
- <input type="text" id="builder-input" placeholder="Describe what you want to build..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendBuilderChat()}" style="flex:1;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--bg-input);color:var(--text-primary);font-size:13px">
14744
- <button class="btn-primary" onclick="sendBuilderChat()" style="padding:10px 18px;border-radius:8px">Send</button>
15104
+ <!-- List view (default) -->
15105
+ <div id="routines-list-pane" style="flex:1;min-height:0;overflow-y:auto;padding:18px;background:var(--bg-primary)">
15106
+ <div id="routines-list-empty" class="empty-state" style="display:none;padding:64px 18px;text-align:center;color:var(--text-muted)">
15107
+ <div style="font-size:38px;opacity:0.4;margin-bottom:14px">&#9881;</div>
15108
+ <div style="font-size:15px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">No routines yet</div>
15109
+ <div style="font-size:12px;line-height:1.5;max-width:380px;margin:0 auto 16px">A Routine is a sequence of steps &mdash; call MCP tools, run local CLIs, prompt the agent, branch on results &mdash; that runs on a schedule or on demand. Example: &ldquo;at 8am check email; if anything urgent, summarize and Slack me.&rdquo;</div>
15110
+ <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
15111
+ <button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openCreate()" style="padding:6px 14px">+ New Routine</button>
15112
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openAssist()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Generate from prompt</button>
14745
15113
  </div>
14746
15114
  </div>
14747
- <!-- Right: Canvas (dominant) + Existing Skills drawer -->
14748
- <div id="builder-right-pane" style="flex:1;min-width:0;display:flex;flex-direction:column;background:var(--bg-primary)">
14749
- <div style="padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:10px;flex-wrap:wrap">
14750
- <span id="builder-right-pane-title">Workflow Builder</span>
14751
- <span id="builder-preview-status" style="font-size:11px;color:var(--text-muted)"></span>
14752
- <span style="flex:1"></span>
14753
- <select id="builder-canvas-picker" onchange="openBuilderWorkflow(this.value)" style="display:none;padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;max-width:240px">
14754
- <option value="">— pick a workflow —</option>
14755
- </select>
14756
- <button id="builder-canvas-validate-btn" onclick="validateBuilderCanvas()" title="Static checks (cycles, missing fields, deps)" style="display:none;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:11px">Validate</button>
14757
- <button id="builder-canvas-dryrun-btn" onclick="dryRunBuilderCanvas()" title="Describe what each step would do (no execution)" style="display:none;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:11px">Dry-run</button>
14758
- <button id="builder-canvas-test-btn" onclick="testBuilderCanvas()" title="Mock-safe validation run; prompt steps are stubbed" style="display:none;background:var(--clementine);border:none;color:#fff;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">Mock Test</button>
14759
- <button id="builder-canvas-real-run-btn" onclick="runBuilderCanvasReal()" title="Run through the real workflow engine; side effects require approval" style="display:none;background:var(--green);border:none;color:#fff;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">Run Real</button>
14760
- <button id="builder-canvas-cancel-btn" onclick="cancelBuilderTest()" title="Cancel test run" style="display:none;background:var(--red);border:none;color:#fff;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">Cancel</button>
14761
- </div>
14762
- <div id="builder-preview" style="flex:1;overflow-y:auto;padding:16px">
14763
- <div class="empty-state" style="font-size:13px;color:var(--text-muted)">The artifact will appear here as you build it</div>
14764
- </div>
14765
- <div id="builder-canvas-host" style="display:none;flex:1;flex-direction:column;min-height:0;position:relative">
14766
- <div id="builder-canvas-banner" style="padding:8px 14px;background:var(--bg-tertiary);border-bottom:1px solid var(--border);font-size:11px;color:var(--text-muted);display:none"></div>
14767
- <div id="builder-canvas" style="flex:1;background:var(--bg-tertiary);position:relative;overflow:hidden"></div>
14768
- <!-- Floating add-node FAB + palette popover -->
14769
- <button id="builder-palette-btn" onclick="toggleBuilderPalette()" title="Add a step" style="position:absolute;left:14px;bottom:48px;width:40px;height:40px;border-radius:50%;background:var(--clementine);color:#fff;border:none;font-size:20px;font-weight:600;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.25);z-index:10">+</button>
14770
- <div id="builder-palette-pop" style="display:none;position:absolute;left:60px;bottom:48px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);z-index:11;min-width:160px">
14771
- <div onclick="_builderAddNodeOfKind('prompt')" class="builder-palette-item" data-kind="prompt">prompt</div>
14772
- <div onclick="_builderAddNodeOfKind('mcp')" class="builder-palette-item" data-kind="mcp">mcp tool</div>
14773
- <div onclick="_builderAddNodeOfKind('channel')" class="builder-palette-item" data-kind="channel">channel</div>
14774
- <div onclick="_builderAddNodeOfKind('transform')" class="builder-palette-item" data-kind="transform">transform</div>
14775
- </div>
14776
- <!-- Slide-out config panel -->
14777
- <div id="builder-config-panel" style="display:none;position:absolute;right:0;top:0;bottom:0;width:340px;background:var(--bg-secondary);border-left:1px solid var(--border);box-shadow:-4px 0 16px rgba(0,0,0,0.15);z-index:12;flex-direction:column"></div>
14778
- <!-- Empty-state CTA — visible when no workflow is open on the canvas -->
14779
- <div id="builder-canvas-empty" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:14px;color:var(--text-muted);text-align:center;padding:32px;pointer-events:none">
14780
- <div style="font-size:38px;opacity:0.4">&#128279;</div>
14781
- <div style="font-size:14px;font-weight:500;color:var(--text-secondary)">No workflow open</div>
14782
- <div style="font-size:12px;line-height:1.5;max-width:280px">
14783
- Pick one from the dropdown above &mdash; or click <strong>New</strong> in the header to create one from scratch, or open the <strong>Templates</strong> tab for starter patterns.
14784
- </div>
14785
- </div>
14786
- <div id="builder-canvas-footer" style="padding:6px 14px;border-top:1px solid var(--border);font-size:11px;color:var(--text-muted);display:flex;gap:14px;align-items:center">
14787
- <span id="builder-canvas-status"></span>
14788
- <span style="flex:1"></span>
14789
- <button id="builder-delete-btn" onclick="deleteCurrentBuilderWorkflow()" title="Delete this workflow" style="display:none;background:none;border:1px solid transparent;color:var(--red);font-size:11px;cursor:pointer;padding:2px 8px;border-radius:var(--radius-xs)">Delete</button>
14790
- <span id="builder-canvas-id" style="font-family:'JetBrains Mono',monospace;opacity:0.6"></span>
14791
- </div>
14792
- </div>
14793
- <!-- Existing skills drawer (visible in skill mode) -->
14794
- <div id="builder-skills-drawer" style="display:none;border-top:2px solid var(--border);max-height:260px;overflow-y:auto">
14795
- <div style="padding:10px 16px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;background:var(--bg-secondary);z-index:1">
14796
- <span style="font-size:12px;font-weight:600;color:var(--text-secondary)">Existing Skills</span>
14797
- <span id="builder-skills-count" style="font-size:10px;color:var(--text-muted)"></span>
14798
- </div>
14799
- <div id="builder-skills-list" style="padding:0 12px 12px"></div>
14800
- </div>
15115
+ <div id="routines-list-wrap" style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden">
15116
+ <table style="width:100%;border-collapse:collapse;font-size:13px">
15117
+ <thead style="text-align:left;color:var(--text-muted);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;background:var(--bg-tertiary)">
15118
+ <tr>
15119
+ <th style="padding:11px 14px">Name</th>
15120
+ <th style="padding:11px 14px">Owner</th>
15121
+ <th style="padding:11px 14px">Schedule</th>
15122
+ <th style="padding:11px 14px">Steps</th>
15123
+ <th style="padding:11px 14px">Last run</th>
15124
+ <th style="padding:11px 14px;text-align:center">Enabled</th>
15125
+ <th style="padding:11px 14px;text-align:right">Actions</th>
15126
+ </tr>
15127
+ </thead>
15128
+ <tbody id="routines-list-body"></tbody>
15129
+ </table>
14801
15130
  </div>
14802
15131
  </div>
14803
-
14804
- <!-- Scheduled Tasks tab — simple recurring-task management. The visual canvas is for workflows. -->
14805
- <div id="build-tab-crons" data-build-tabpane="crons" style="display:block;flex:1;min-height:0;overflow-y:auto;padding:18px;background:var(--bg-primary)">
14806
- <div style="display:flex;align-items:flex-start;gap:14px;margin-bottom:16px;flex-wrap:wrap">
14807
- <div style="flex:1;min-width:260px">
14808
- <h2 style="font-size:18px;font-weight:600;margin:0 0 4px;color:var(--text-primary)">Build Operations</h2>
14809
- <p style="font-size:13px;color:var(--text-muted);margin:0;line-height:1.45">Scheduled tasks, scheduled workflows, and live runtime work. Cards here are the turn-on, turn-off, run-now surface.</p>
15132
+ <!-- Editor pane (hidden by default; replaces list when a routine is opened) -->
15133
+ <div id="routines-editor-pane" style="display:none;flex:1;min-height:0;overflow-y:auto;background:var(--bg-primary);padding:18px"></div>
15134
+ <!-- Run history drawer (slide-out from right) -->
15135
+ <div id="routines-runs-drawer" style="display:none;position:fixed;top:var(--header-h, 56px);right:0;bottom:0;width:520px;max-width:100vw;background:var(--bg-secondary);border-left:1px solid var(--border);box-shadow:-4px 0 24px rgba(0,0,0,0.18);z-index:120;overflow-y:auto"></div>
15136
+ <!-- Create modal -->
15137
+ <div id="routines-create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
15138
+ <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:22px;width:480px;max-width:92vw;display:flex;flex-direction:column;gap:12px">
15139
+ <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New routine</h3>
15140
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Name</label>
15141
+ <input type="text" id="routines-create-name" placeholder="e.g. 8am Email Triage" style="padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px">
15142
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Description (optional)</label>
15143
+ <input type="text" id="routines-create-description" placeholder="What does it do?" style="padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px">
15144
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Schedule (optional cron expression)</label>
15145
+ <input type="text" id="routines-create-schedule" placeholder="e.g. 0 8 * * * (leave blank for manual)" style="padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:'JetBrains Mono',monospace">
15146
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Owner</label>
15147
+ <select id="routines-create-owner" style="padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px">
15148
+ <option value="">Clementine (global)</option>
15149
+ </select>
15150
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:6px">
15151
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeCreate()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Cancel</button>
15152
+ <button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.submitCreate()" style="padding:6px 14px">Create</button>
14810
15153
  </div>
14811
- <div style="display:flex;gap:8px;flex-wrap:wrap">
14812
- <button class="btn-sm btn-primary" onclick="openCreateCronModal(getBuildCreateOwner())" style="padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px">New Scheduled Task</button>
14813
- <button class="btn-sm" onclick="createScheduledWorkflowFromBuild()" style="padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px">New Scheduled Workflow</button>
15154
+ </div>
15155
+ </div>
15156
+ <!-- Assist modal (Generate from prompt) -->
15157
+ <div id="routines-assist-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
15158
+ <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:22px;width:560px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;gap:14px">
15159
+ <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">Generate routine from prompt</h3>
15160
+ <p style="margin:0;font-size:12px;color:var(--text-muted);line-height:1.5">Describe what the routine should do. The assistant will draft a starter sequence you can edit. Example: &ldquo;Every morning at 8am, check unread Gmail; if anything looks urgent, summarize and send to Slack #me.&rdquo;</p>
15161
+ <textarea id="routines-assist-input" rows="6" placeholder="Describe the routine&hellip;" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:inherit;resize:vertical;box-sizing:border-box"></textarea>
15162
+ <div id="routines-assist-status" style="font-size:11px;color:var(--text-muted);min-height:14px"></div>
15163
+ <div style="display:flex;gap:8px;justify-content:flex-end">
15164
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeAssist()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Cancel</button>
15165
+ <button id="routines-assist-submit" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.submitAssist()" style="padding:6px 14px">Generate</button>
14814
15166
  </div>
14815
15167
  </div>
14816
- <div id="panel-cron"></div>
14817
15168
  </div>
14818
-
14819
- <!-- Templates tab — starter patterns -->
14820
- <div id="build-tab-templates" data-build-tabpane="templates" style="display:none;padding:24px;overflow-y:auto">
14821
- <div style="max-width:920px;margin:0 auto">
14822
- <h2 style="font-size:18px;font-weight:600;margin:0 0 6px;color:var(--text-primary)">Start from a template</h2>
14823
- <p style="font-size:13px;color:var(--text-muted);margin:0 0 18px">Pick a pre-built pattern to fork into a new editable workflow.</p>
14824
- <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px">
14825
- <div class="card clickable-row" onclick="forkBuildTemplate('daily-news-digest')" style="padding:18px">
14826
- <div style="font-size:24px;margin-bottom:8px">&#128240;</div>
14827
- <div style="font-weight:600;font-size:14px;margin-bottom:4px">Daily news digest</div>
14828
- <div style="font-size:12px;color:var(--text-muted);line-height:1.4">Scheduled 7am: pull RSS sources, summarize, send to Slack/email.</div>
14829
- <div style="margin-top:10px;font-size:11px;color:var(--clementine);font-weight:500">scheduled workflow · 4 steps</div>
14830
- </div>
14831
- <div class="card clickable-row" onclick="forkBuildTemplate('lead-picker')" style="padding:18px">
14832
- <div style="font-size:24px;margin-bottom:8px">&#128202;</div>
14833
- <div style="font-weight:600;font-size:14px;margin-bottom:4px">Lead picker → Salesforce</div>
14834
- <div style="font-size:12px;color:var(--text-muted);line-height:1.4">Manual workflow: search leads by ICP, review, push selected to SF.</div>
14835
- <div style="margin-top:10px;font-size:11px;color:var(--clementine);font-weight:500">manual · 3 steps</div>
14836
- </div>
14837
- <div class="card clickable-row" onclick="forkBuildTemplate('pr-review-queue')" style="padding:18px">
14838
- <div style="font-size:24px;margin-bottom:8px">&#128221;</div>
14839
- <div style="font-weight:600;font-size:14px;margin-bottom:4px">PR review queue</div>
14840
- <div style="font-size:12px;color:var(--text-muted);line-height:1.4">Scheduled 9am M-F: list open PRs, summarize risk, message to Slack.</div>
14841
- <div style="margin-top:10px;font-size:11px;color:var(--clementine);font-weight:500">scheduled workflow · 3 steps</div>
14842
- </div>
14843
- <div class="card clickable-row" onclick="forkBuildTemplate('email-triage')" style="padding:18px">
14844
- <div style="font-size:24px;margin-bottom:8px">&#128231;</div>
14845
- <div style="font-weight:600;font-size:14px;margin-bottom:4px">Email triage</div>
14846
- <div style="font-size:12px;color:var(--text-muted);line-height:1.4">Scheduled 8am: list unread emails, classify by intent, draft replies for review.</div>
14847
- <div style="margin-top:10px;font-size:11px;color:var(--clementine);font-weight:500">scheduled workflow · 4 steps</div>
14848
- </div>
14849
- <div class="card clickable-row" onclick="forkBuildTemplate('weekly-review')" style="padding:18px">
14850
- <div style="font-size:24px;margin-bottom:8px">&#128197;</div>
14851
- <div style="font-weight:600;font-size:14px;margin-bottom:4px">Weekly review</div>
14852
- <div style="font-size:12px;color:var(--text-muted);line-height:1.4">Scheduled Fri 6pm: review the week's daily notes, generate review note.</div>
14853
- <div style="margin-top:10px;font-size:11px;color:var(--clementine);font-weight:500">scheduled workflow · 3 steps</div>
14854
- </div>
14855
- <div class="card clickable-row" onclick="forkBuildTemplate('blank-workflow')" style="padding:18px;border-style:dashed">
14856
- <div style="font-size:24px;margin-bottom:8px">&#10133;</div>
14857
- <div style="font-weight:600;font-size:14px;margin-bottom:4px">Blank workflow</div>
14858
- <div style="font-size:12px;color:var(--text-muted);line-height:1.4">Start from scratch with a single prompt step.</div>
14859
- <div style="margin-top:10px;font-size:11px;color:var(--clementine);font-weight:500">manual · 1 step</div>
14860
- </div>
15169
+ <!-- Step picker modal -->
15170
+ <div id="routines-step-picker" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
15171
+ <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:0;width:640px;max-width:94vw;max-height:80vh;display:flex;flex-direction:column;overflow:hidden">
15172
+ <div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px">
15173
+ <h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Add step</h3>
15174
+ <span style="flex:1"></span>
15175
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeStepPicker()" style="padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">&times;</button>
14861
15176
  </div>
15177
+ <div id="routines-step-picker-body" style="padding:16px 20px;overflow-y:auto;flex:1"></div>
14862
15178
  </div>
14863
15179
  </div>
14864
15180
  </div>
15181
+ <script>
15182
+ // ── Routines UI ─────────────────────────────────────────────────
15183
+ // Vanilla JS module that drives the new Routines surface. State-light,
15184
+ // re-fetches from /api/routines on most actions to stay correct without
15185
+ // a client-side data store. Renders linear step lists; no canvas.
15186
+ (function() {
15187
+ var R = {
15188
+ state: {
15189
+ list: [],
15190
+ owners: [],
15191
+ mcpTools: null, // { servers: [{name,enabled,tools:[]}, ...] }
15192
+ cliTools: null, // [{cmd,description,userDefined}, ...]
15193
+ editing: null, // { id, routine, dirty }
15194
+ assistBusy: false,
15195
+ },
15196
+ init: function() {
15197
+ // Load reference data lazily; trigger list render immediately.
15198
+ this.loadOwners();
15199
+ this.refreshList();
15200
+ this.loadMcpTools();
15201
+ this.loadCliTools();
15202
+ },
15203
+ // ── data ────────────────────────────────────────────────────
15204
+ loadOwners: function() {
15205
+ // Reuse the agent registry the rest of the dashboard uses.
15206
+ fetch('/api/agents').then(function(r){ return r.json(); }).then(function(data){
15207
+ R.state.owners = (data.agents || []).map(function(a){ return { slug: a.slug, name: a.name || a.slug }; });
15208
+ R.populateOwnerSelects();
15209
+ }).catch(function(){ /* non-fatal */ });
15210
+ },
15211
+ populateOwnerSelects: function() {
15212
+ var filter = document.getElementById('routines-owner-filter');
15213
+ var creator = document.getElementById('routines-create-owner');
15214
+ var keepFilter = filter && filter.value;
15215
+ if (filter) {
15216
+ filter.innerHTML = '<option value="__all__">All</option><option value="__global__">Clementine (global)</option>'
15217
+ + R.state.owners.map(function(o){ return '<option value="' + R.esc(o.slug) + '">@' + R.esc(o.name) + '</option>'; }).join('');
15218
+ if (keepFilter) filter.value = keepFilter;
15219
+ }
15220
+ if (creator) {
15221
+ creator.innerHTML = '<option value="">Clementine (global)</option>'
15222
+ + R.state.owners.map(function(o){ return '<option value="' + R.esc(o.slug) + '">@' + R.esc(o.name) + '</option>'; }).join('');
15223
+ }
15224
+ },
15225
+ loadMcpTools: function() {
15226
+ fetch('/api/routines/mcp-tools').then(function(r){ return r.json(); }).then(function(data){
15227
+ R.state.mcpTools = data && data.servers ? data : { servers: [] };
15228
+ }).catch(function(){ R.state.mcpTools = { servers: [] }; });
15229
+ },
15230
+ loadCliTools: function() {
15231
+ fetch('/api/routines/cli-tools').then(function(r){ return r.json(); }).then(function(data){
15232
+ R.state.cliTools = (data && data.tools) || [];
15233
+ }).catch(function(){ R.state.cliTools = []; });
15234
+ },
15235
+ refreshList: function() {
15236
+ fetch('/api/routines').then(function(r){ return r.json(); }).then(function(data){
15237
+ R.state.list = (data && data.routines) || [];
15238
+ R.renderList();
15239
+ }).catch(function(){ R.state.list = []; R.renderList(); });
15240
+ },
15241
+ // ── list view ───────────────────────────────────────────────
15242
+ renderList: function() {
15243
+ var body = document.getElementById('routines-list-body');
15244
+ var empty = document.getElementById('routines-list-empty');
15245
+ var wrap = document.getElementById('routines-list-wrap');
15246
+ var count = document.getElementById('routines-count');
15247
+ if (!body || !empty || !wrap) return;
15248
+ var filter = (document.getElementById('routines-owner-filter') || {}).value || '__all__';
15249
+ var rows = R.state.list.filter(function(r){
15250
+ if (filter === '__all__') return true;
15251
+ if (filter === '__global__') return r.scope === 'global';
15252
+ return r.scope === 'agent' && r.agentSlug === filter;
15253
+ });
15254
+ if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? ' routine' : ' routines');
15255
+ if (rows.length === 0) {
15256
+ empty.style.display = 'block';
15257
+ wrap.style.display = 'none';
15258
+ return;
15259
+ }
15260
+ empty.style.display = 'none';
15261
+ wrap.style.display = 'block';
15262
+ body.innerHTML = rows.map(function(r){
15263
+ var owner = r.scope === 'agent' ? '@' + R.esc(r.agentSlug || '?') : 'Clementine';
15264
+ var schedule = r.schedule ? '<code style="font-family:\\x27JetBrains Mono\\x27,monospace;font-size:11px">' + R.esc(r.schedule) + '</code>' : '<span style="color:var(--text-muted)">manual</span>';
15265
+ var enabledBadge = '<input type="checkbox" ' + (r.enabled ? 'checked' : '') + ' onchange="event.stopPropagation();window.RoutinesUI&&RoutinesUI.toggle(\\x27' + R.esc(r.id) + '\\x27)" style="cursor:pointer">';
15266
+ var origin = r.origin === 'cron' ? '<span title="Legacy cron entry — single prompt step" style="font-size:10px;color:var(--text-muted);margin-left:6px">[cron]</span>' : '';
15267
+ return '<tr style="border-top:1px solid var(--border);cursor:pointer" onclick="window.RoutinesUI&&RoutinesUI.openEditor(\\x27' + R.esc(r.id) + '\\x27)">'
15268
+ + '<td style="padding:11px 14px;color:var(--text-primary);font-weight:500">' + R.esc(r.name) + origin + '</td>'
15269
+ + '<td style="padding:11px 14px;color:var(--text-secondary);font-size:12px">' + R.esc(owner) + '</td>'
15270
+ + '<td style="padding:11px 14px">' + schedule + '</td>'
15271
+ + '<td style="padding:11px 14px;color:var(--text-secondary);font-size:12px">' + r.stepCount + '</td>'
15272
+ + '<td style="padding:11px 14px;color:var(--text-muted);font-size:12px">&mdash;</td>'
15273
+ + '<td style="padding:11px 14px;text-align:center" onclick="event.stopPropagation()">' + enabledBadge + '</td>'
15274
+ + '<td style="padding:11px 14px;text-align:right;white-space:nowrap" onclick="event.stopPropagation()">'
15275
+ + '<button class="btn-sm" title="Run now" onclick="window.RoutinesUI&&RoutinesUI.run(\\x27' + R.esc(r.id) + '\\x27)" style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-tertiary);color:var(--text-primary);border-radius:4px;cursor:pointer;font-size:11px;margin-right:4px">&#9654; Run</button>'
15276
+ + '<button class="btn-sm" title="Run history" onclick="window.RoutinesUI&&RoutinesUI.openRuns(\\x27' + R.esc(r.id) + '\\x27)" style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-tertiary);color:var(--text-secondary);border-radius:4px;cursor:pointer;font-size:11px">History</button>'
15277
+ + '</td></tr>';
15278
+ }).join('');
15279
+ },
15280
+ toggle: function(id) {
15281
+ fetch('/api/routines/' + encodeURIComponent(id) + '/toggle', { method: 'POST' })
15282
+ .then(function(r){ return r.json(); })
15283
+ .then(function(){ R.refreshList(); })
15284
+ .catch(function(err){ alert('Toggle failed: ' + err); });
15285
+ },
15286
+ run: function(id, approvedSideEffects) {
15287
+ fetch('/api/routines/' + encodeURIComponent(id) + '/run', {
15288
+ method: 'POST',
15289
+ headers: { 'Content-Type': 'application/json' },
15290
+ body: JSON.stringify({ approvedSideEffects: approvedSideEffects === true })
15291
+ }).then(function(r){
15292
+ if (r.status === 409) {
15293
+ return r.json().then(function(j){
15294
+ var lines = (j.sideEffects || []).map(function(s){ return ' • ' + s.kind + ': ' + s.label; }).join('\\n');
15295
+ if (confirm('This routine has side effects:\\n\\n' + lines + '\\n\\nProceed?')) R.run(id, true);
15296
+ });
15297
+ }
15298
+ return r.json().then(function(j){
15299
+ if (j.ok) R.flash('Triggered.');
15300
+ else alert('Run failed: ' + (j.error || 'unknown'));
15301
+ });
15302
+ }).catch(function(err){ alert('Run failed: ' + err); });
15303
+ },
15304
+ // ── editor ──────────────────────────────────────────────────
15305
+ openEditor: function(id) {
15306
+ fetch('/api/routines/' + encodeURIComponent(id))
15307
+ .then(function(r){ return r.json(); })
15308
+ .then(function(data){
15309
+ if (!data || !data.routine) { alert('Failed to load routine'); return; }
15310
+ R.state.editing = { id: data.id, routine: data.routine, dirty: false, validation: data.validation };
15311
+ R.showEditor();
15312
+ }).catch(function(err){ alert('Open failed: ' + err); });
15313
+ },
15314
+ showEditor: function() {
15315
+ document.getElementById('routines-list-pane').style.display = 'none';
15316
+ document.getElementById('routines-editor-pane').style.display = 'block';
15317
+ document.getElementById('routines-back-btn').style.display = 'inline-block';
15318
+ document.getElementById('routines-create-btn').style.display = 'none';
15319
+ document.getElementById('routines-assist-btn').style.display = 'none';
15320
+ document.getElementById('routines-owner-filter').style.display = 'none';
15321
+ document.getElementById('routines-owner-label').style.display = 'none';
15322
+ document.getElementById('routines-editor-breadcrumb').style.display = 'inline';
15323
+ document.getElementById('routines-editor-name').textContent = R.state.editing.routine.name;
15324
+ R.renderEditor();
15325
+ },
15326
+ closeEditor: function() {
15327
+ if (R.state.editing && R.state.editing.dirty && !confirm('Discard unsaved changes?')) return;
15328
+ R.state.editing = null;
15329
+ document.getElementById('routines-list-pane').style.display = 'block';
15330
+ document.getElementById('routines-editor-pane').style.display = 'none';
15331
+ document.getElementById('routines-back-btn').style.display = 'none';
15332
+ document.getElementById('routines-create-btn').style.display = 'inline-block';
15333
+ document.getElementById('routines-assist-btn').style.display = 'inline-block';
15334
+ document.getElementById('routines-owner-filter').style.display = 'inline-block';
15335
+ document.getElementById('routines-owner-label').style.display = 'inline';
15336
+ document.getElementById('routines-editor-breadcrumb').style.display = 'none';
15337
+ R.refreshList();
15338
+ },
15339
+ renderEditor: function() {
15340
+ var pane = document.getElementById('routines-editor-pane');
15341
+ if (!pane || !R.state.editing) return;
15342
+ var wf = R.state.editing.routine;
15343
+ var html = '<div style="max-width:920px;margin:0 auto">';
15344
+ html += '<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px">'
15345
+ + '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">'
15346
+ + '<input type="text" id="re-name" value="' + R.esc(wf.name) + '" oninput="window.RoutinesUI&&RoutinesUI.markDirty()" style="flex:1;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:14px;font-weight:600">'
15347
+ + '<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)"><input type="checkbox" id="re-enabled" ' + (wf.enabled ? 'checked' : '') + ' onchange="window.RoutinesUI&&RoutinesUI.markDirty()"> Enabled</label>'
15348
+ + '</div>'
15349
+ + '<input type="text" id="re-description" value="' + R.esc(wf.description || '') + '" placeholder="Description (optional)" oninput="window.RoutinesUI&&RoutinesUI.markDirty()" style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;margin-bottom:10px;box-sizing:border-box">'
15350
+ + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Schedule</label>'
15351
+ + '<input type="text" id="re-schedule" value="' + R.esc(wf.trigger && wf.trigger.schedule || '') + '" placeholder="cron e.g. 0 8 * * * (blank = manual)" oninput="window.RoutinesUI&&RoutinesUI.markDirty()" style="flex:1;min-width:240px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:12px;font-family:\\x27JetBrains Mono\\x27,monospace">'
15352
+ + '</div></div>';
15353
+ // Steps
15354
+ html += '<div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin:18px 0 8px">Steps</div>';
15355
+ html += '<div id="re-steps-list">' + (wf.steps || []).map(function(s, i){ return R.renderStepCard(s, i); }).join('') + '</div>';
15356
+ html += '<button class="btn-sm" onclick="window.RoutinesUI&&RoutinesUI.openStepPicker()" style="margin-top:8px;padding:8px 14px;border:1px dashed var(--border);background:transparent;color:var(--text-secondary);border-radius:6px;cursor:pointer;font-size:12px;width:100%">+ Add step</button>';
15357
+ // Action bar
15358
+ html += '<div id="re-action-bar" style="position:sticky;bottom:0;background:var(--bg-primary);border-top:1px solid var(--border);padding:14px 0;margin-top:24px;display:flex;gap:8px;align-items:center">'
15359
+ + '<button class="btn-sm" onclick="window.RoutinesUI&&RoutinesUI.dryRunCurrent()" style="padding:6px 12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);border-radius:6px;cursor:pointer;font-size:12px">Dry-run</button>'
15360
+ + '<button class="btn-sm" onclick="window.RoutinesUI&&RoutinesUI.testCurrent()" style="padding:6px 12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);border-radius:6px;cursor:pointer;font-size:12px">Mock test</button>'
15361
+ + '<button class="btn-sm" onclick="window.RoutinesUI&&RoutinesUI.openRuns(R.state && R.state.editing && R.state.editing.id)" style="padding:6px 12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);border-radius:6px;cursor:pointer;font-size:12px">History</button>'
15362
+ + '<span style="flex:1"></span>'
15363
+ + (R.state.editing.routine.sourceFile && R.state.editing.id.indexOf("cron:") !== 0 ? '<button class="btn-sm" onclick="window.RoutinesUI&&RoutinesUI.deleteCurrent()" style="padding:6px 12px;background:transparent;border:1px solid var(--red);color:var(--red);border-radius:6px;cursor:pointer;font-size:12px">Delete</button>' : '')
15364
+ + '<button class="btn-sm" onclick="window.RoutinesUI&&RoutinesUI.runCurrent()" style="padding:6px 14px;background:var(--green);border:none;color:#fff;border-radius:6px;cursor:pointer;font-size:12px">&#9654; Run now</button>'
15365
+ + '<button class="btn-sm btn-primary" id="re-save-btn" onclick="window.RoutinesUI&&RoutinesUI.saveCurrent()" style="padding:6px 16px">Save</button>'
15366
+ + '</div>';
15367
+ html += '<div id="re-status" style="font-size:11px;color:var(--text-muted);min-height:14px;padding:6px 0"></div>';
15368
+ html += '</div>';
15369
+ pane.innerHTML = html;
15370
+ if (window.hydrateLucideIcons) window.hydrateLucideIcons();
15371
+ },
15372
+ renderStepCard: function(step, idx) {
15373
+ var kind = step.kind || 'prompt';
15374
+ var kindColor = { prompt: '#5e72e4', mcp: '#2dce89', cli: '#fb6340', conditional: '#f5365c', channel: '#11cdef', transform: '#ffd600', loop: '#8965e0' }[kind] || '#888';
15375
+ var badge = '<span style="display:inline-block;background:' + kindColor + '22;color:' + kindColor + ';padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em">' + kind + '</span>';
15376
+ var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">'
15377
+ + '<span style="font-size:11px;color:var(--text-muted);font-weight:600;min-width:24px">#' + (idx + 1) + '</span>'
15378
+ + badge
15379
+ + '<input type="text" value="' + R.esc(step.id) + '" onchange="window.RoutinesUI&&RoutinesUI.updateStep(' + idx + ',\\x27id\\x27,this.value)" style="font-family:\\x27JetBrains Mono\\x27,monospace;padding:3px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:11px;min-width:120px">'
15380
+ + '<span style="flex:1"></span>'
15381
+ + (idx > 0 ? '<button title="Move up" onclick="window.RoutinesUI&&RoutinesUI.moveStep(' + idx + ',-1)" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px">&uarr;</button>' : '')
15382
+ + (idx < (R.state.editing.routine.steps.length - 1) ? '<button title="Move down" onclick="window.RoutinesUI&&RoutinesUI.moveStep(' + idx + ',1)" style="background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px">&darr;</button>' : '')
15383
+ + '<button title="Remove" onclick="window.RoutinesUI&&RoutinesUI.removeStep(' + idx + ')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px">&times;</button>'
15384
+ + '</div>';
15385
+ var body = R.renderStepBody(step, idx);
15386
+ var depsList = (step.dependsOn || []).join(', ');
15387
+ var depsRow = '<div style="display:flex;align-items:center;gap:8px;margin-top:8px"><label style="font-size:10px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;min-width:60px">After</label>'
15388
+ + '<input type="text" value="' + R.esc(depsList) + '" placeholder="step ids, comma-separated (blank = independent)" onchange="window.RoutinesUI&&RoutinesUI.updateStepDeps(' + idx + ',this.value)" style="flex:1;padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:11px;font-family:\\x27JetBrains Mono\\x27,monospace"></div>';
15389
+ return '<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;margin-bottom:8px;border-left:3px solid ' + kindColor + '">' + head + body + depsRow + '</div>';
15390
+ },
15391
+ renderStepBody: function(step, idx) {
15392
+ var kind = step.kind || 'prompt';
15393
+ switch (kind) {
15394
+ case 'prompt':
15395
+ return '<textarea rows="3" placeholder="Prompt to send to the agent" oninput="window.RoutinesUI&&RoutinesUI.updateStep(' + idx + ',\\x27prompt\\x27,this.value)" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px;font-family:inherit;resize:vertical;box-sizing:border-box">' + R.esc(step.prompt || '') + '</textarea>';
15396
+ case 'mcp':
15397
+ var mcp = step.mcp || { server: '', tool: '', inputs: {} };
15398
+ var serverOptions = '<option value="">— pick server —</option>' + (R.state.mcpTools && R.state.mcpTools.servers || []).map(function(s){ return '<option value="' + R.esc(s.name) + '" ' + (mcp.server === s.name ? 'selected' : '') + '>' + R.esc(s.name) + ' (' + s.tools.length + ')</option>'; }).join('');
15399
+ var server = (R.state.mcpTools && R.state.mcpTools.servers || []).find(function(s){ return s.name === mcp.server; });
15400
+ var toolOptions = '<option value="">— pick tool —</option>' + (server ? server.tools.map(function(t){ return '<option value="' + R.esc(t) + '" ' + (mcp.tool === t ? 'selected' : '') + '>' + R.esc(t) + '</option>'; }).join('') : '');
15401
+ var inputsJson = JSON.stringify(mcp.inputs || {}, null, 2);
15402
+ return '<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
15403
+ + '<select onchange="window.RoutinesUI&&RoutinesUI.updateMcp(' + idx + ',\\x27server\\x27,this.value)" style="flex:1;min-width:160px;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px">' + serverOptions + '</select>'
15404
+ + '<select onchange="window.RoutinesUI&&RoutinesUI.updateMcp(' + idx + ',\\x27tool\\x27,this.value)" style="flex:1;min-width:160px;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px">' + toolOptions + '</select>'
15405
+ + '</div>'
15406
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Inputs (JSON; values may use {{steps.x}} or {{input.name}})</label>'
15407
+ + '<textarea rows="3" oninput="window.RoutinesUI&&RoutinesUI.updateMcpInputs(' + idx + ',this.value)" style="width:100%;padding:6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:11px;font-family:\\x27JetBrains Mono\\x27,monospace;resize:vertical;box-sizing:border-box">' + R.esc(inputsJson) + '</textarea>';
15408
+ case 'cli':
15409
+ var cli = step.cli || { cmd: '', args: [] };
15410
+ var cliOptions = '<option value="">— pick CLI —</option>' + (R.state.cliTools || []).map(function(c){ return '<option value="' + R.esc(c.cmd) + '" ' + (cli.cmd === c.cmd ? 'selected' : '') + '>' + R.esc(c.cmd) + ' &mdash; ' + R.esc(c.description) + '</option>'; }).join('');
15411
+ if (cli.cmd && !(R.state.cliTools || []).some(function(c){ return c.cmd === cli.cmd; })) {
15412
+ cliOptions += '<option value="' + R.esc(cli.cmd) + '" selected>' + R.esc(cli.cmd) + ' (custom)</option>';
15413
+ }
15414
+ return '<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
15415
+ + '<select onchange="window.RoutinesUI&&RoutinesUI.updateCli(' + idx + ',\\x27cmd\\x27,this.value)" style="flex:1;min-width:200px;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px">' + cliOptions + '</select>'
15416
+ + '<input type="number" value="' + (cli.timeoutMs || 60000) + '" onchange="window.RoutinesUI&&RoutinesUI.updateCli(' + idx + ',\\x27timeoutMs\\x27,parseInt(this.value,10))" placeholder="timeout ms" style="width:120px;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px" title="Timeout in milliseconds">'
15417
+ + '</div>'
15418
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Args (one per line; supports {{steps.x}} templates)</label>'
15419
+ + '<textarea rows="3" oninput="window.RoutinesUI&&RoutinesUI.updateCliArgs(' + idx + ',this.value)" placeholder="--json\\norg list" style="width:100%;padding:6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:11px;font-family:\\x27JetBrains Mono\\x27,monospace;resize:vertical;box-sizing:border-box">' + R.esc((cli.args || []).join('\\n')) + '</textarea>'
15420
+ + '<label style="display:flex;align-items:center;gap:6px;margin-top:6px;font-size:11px;color:var(--text-secondary)"><input type="checkbox" ' + (cli.captureStderr ? 'checked' : '') + ' onchange="window.RoutinesUI&&RoutinesUI.updateCli(' + idx + ',\\x27captureStderr\\x27,this.checked)"> Include stderr in output</label>';
15421
+ case 'conditional':
15422
+ var cond = step.conditional || { condition: '', trueNext: [], falseNext: [] };
15423
+ return '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Condition (JS expression — has access to <code>steps.&lt;id&gt;</code>)</label>'
15424
+ + '<input type="text" value="' + R.esc(cond.condition || '') + '" placeholder="e.g. steps.s1.messages.length > 0" oninput="window.RoutinesUI&&RoutinesUI.updateConditional(' + idx + ',\\x27condition\\x27,this.value)" style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px;font-family:\\x27JetBrains Mono\\x27,monospace;margin-bottom:6px;box-sizing:border-box">'
15425
+ + '<div style="display:flex;gap:8px"><div style="flex:1">'
15426
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">If true → run these step ids (comma)</label>'
15427
+ + '<input type="text" value="' + R.esc((cond.trueNext || []).join(', ')) + '" oninput="window.RoutinesUI&&RoutinesUI.updateConditional(' + idx + ',\\x27trueNext\\x27,this.value)" style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:11px;font-family:\\x27JetBrains Mono\\x27,monospace;box-sizing:border-box">'
15428
+ + '</div><div style="flex:1">'
15429
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">If false → run these step ids</label>'
15430
+ + '<input type="text" value="' + R.esc((cond.falseNext || []).join(', ')) + '" oninput="window.RoutinesUI&&RoutinesUI.updateConditional(' + idx + ',\\x27falseNext\\x27,this.value)" style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:11px;font-family:\\x27JetBrains Mono\\x27,monospace;box-sizing:border-box">'
15431
+ + '</div></div>';
15432
+ case 'channel':
15433
+ var ch = step.channel || { channel: 'discord', target: '', content: '' };
15434
+ return '<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
15435
+ + '<select onchange="window.RoutinesUI&&RoutinesUI.updateChannel(' + idx + ',\\x27channel\\x27,this.value)" style="padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px">'
15436
+ + ['discord','slack','telegram','whatsapp','email','webhook'].map(function(c){ return '<option value="' + c + '"' + (ch.channel === c ? ' selected' : '') + '>' + c + '</option>'; }).join('')
15437
+ + '</select>'
15438
+ + '<input type="text" value="' + R.esc(ch.target || '') + '" placeholder="channel id, #channel, email, URL…" oninput="window.RoutinesUI&&RoutinesUI.updateChannel(' + idx + ',\\x27target\\x27,this.value)" style="flex:1;min-width:200px;padding:6px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px">'
15439
+ + '</div>'
15440
+ + '<textarea rows="3" placeholder="Message content (supports {{steps.x}})" oninput="window.RoutinesUI&&RoutinesUI.updateChannel(' + idx + ',\\x27content\\x27,this.value)" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px;font-family:inherit;resize:vertical;box-sizing:border-box">' + R.esc(ch.content || '') + '</textarea>';
15441
+ case 'transform':
15442
+ var tr = step.transform || { expression: '' };
15443
+ return '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Expression (sandboxed JS; returns the step output)</label>'
15444
+ + '<textarea rows="3" oninput="window.RoutinesUI&&RoutinesUI.updateTransform(' + idx + ',this.value)" placeholder="e.g. steps.s1.items.filter(x =&gt; x.urgent)" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px;font-family:\\x27JetBrains Mono\\x27,monospace;resize:vertical;box-sizing:border-box">' + R.esc(tr.expression || '') + '</textarea>';
15445
+ case 'loop':
15446
+ var lp = step.loop || { items: '', bodyStepIds: [] };
15447
+ return '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Items expression (yields an array)</label>'
15448
+ + '<input type="text" value="' + R.esc(lp.items || '') + '" placeholder="e.g. steps.s1.results" oninput="window.RoutinesUI&&RoutinesUI.updateLoop(' + idx + ',\\x27items\\x27,this.value)" style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:12px;font-family:\\x27JetBrains Mono\\x27,monospace;margin-bottom:6px;box-sizing:border-box">'
15449
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Body step ids (comma-separated)</label>'
15450
+ + '<input type="text" value="' + R.esc((lp.bodyStepIds || []).join(', ')) + '" oninput="window.RoutinesUI&&RoutinesUI.updateLoop(' + idx + ',\\x27bodyStepIds\\x27,this.value)" style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text-primary);font-size:11px;font-family:\\x27JetBrains Mono\\x27,monospace;box-sizing:border-box">';
15451
+ default:
15452
+ return '<em style="font-size:11px;color:var(--text-muted)">Unknown step kind: ' + R.esc(kind) + '</em>';
15453
+ }
15454
+ },
15455
+ markDirty: function() {
15456
+ if (!R.state.editing) return;
15457
+ R.state.editing.dirty = true;
15458
+ // Sync header inputs back to the routine.
15459
+ var nm = document.getElementById('re-name'); if (nm) R.state.editing.routine.name = nm.value;
15460
+ var de = document.getElementById('re-description'); if (de) R.state.editing.routine.description = de.value;
15461
+ var sc = document.getElementById('re-schedule'); if (sc) {
15462
+ var v = sc.value.trim();
15463
+ R.state.editing.routine.trigger = v ? { schedule: v, manual: false } : { manual: true };
15464
+ }
15465
+ var en = document.getElementById('re-enabled'); if (en) R.state.editing.routine.enabled = en.checked;
15466
+ R.setStatus('Unsaved changes');
15467
+ },
15468
+ updateStep: function(idx, field, value) {
15469
+ if (!R.state.editing) return;
15470
+ R.state.editing.routine.steps[idx][field] = value;
15471
+ R.markDirty();
15472
+ },
15473
+ updateStepDeps: function(idx, csv) {
15474
+ if (!R.state.editing) return;
15475
+ R.state.editing.routine.steps[idx].dependsOn = csv.split(',').map(function(s){ return s.trim(); }).filter(function(s){ return s; });
15476
+ R.markDirty();
15477
+ },
15478
+ updateMcp: function(idx, field, value) {
15479
+ if (!R.state.editing) return;
15480
+ var s = R.state.editing.routine.steps[idx];
15481
+ s.mcp = s.mcp || { server: '', tool: '', inputs: {} };
15482
+ s.mcp[field] = value;
15483
+ if (field === 'server') s.mcp.tool = ''; // reset tool when server changes
15484
+ R.markDirty();
15485
+ R.renderEditor(); // re-render so tool dropdown updates
15486
+ },
15487
+ updateMcpInputs: function(idx, json) {
15488
+ if (!R.state.editing) return;
15489
+ var s = R.state.editing.routine.steps[idx];
15490
+ s.mcp = s.mcp || { server: '', tool: '', inputs: {} };
15491
+ try { s.mcp.inputs = JSON.parse(json); R.setStatus(''); }
15492
+ catch (e) { R.setStatus('Invalid JSON in step ' + s.id + ' inputs (will not save until fixed)'); return; }
15493
+ R.markDirty();
15494
+ },
15495
+ updateCli: function(idx, field, value) {
15496
+ if (!R.state.editing) return;
15497
+ var s = R.state.editing.routine.steps[idx];
15498
+ s.cli = s.cli || { cmd: '', args: [] };
15499
+ s.cli[field] = value;
15500
+ R.markDirty();
15501
+ },
15502
+ updateCliArgs: function(idx, txt) {
15503
+ if (!R.state.editing) return;
15504
+ var s = R.state.editing.routine.steps[idx];
15505
+ s.cli = s.cli || { cmd: '', args: [] };
15506
+ s.cli.args = txt.split('\\n').map(function(l){ return l.trim(); }).filter(function(l){ return l; });
15507
+ R.markDirty();
15508
+ },
15509
+ updateConditional: function(idx, field, value) {
15510
+ if (!R.state.editing) return;
15511
+ var s = R.state.editing.routine.steps[idx];
15512
+ s.conditional = s.conditional || { condition: '', trueNext: [], falseNext: [] };
15513
+ if (field === 'trueNext' || field === 'falseNext') {
15514
+ s.conditional[field] = String(value).split(',').map(function(x){ return x.trim(); }).filter(function(x){ return x; });
15515
+ } else {
15516
+ s.conditional[field] = value;
15517
+ }
15518
+ R.markDirty();
15519
+ },
15520
+ updateChannel: function(idx, field, value) {
15521
+ if (!R.state.editing) return;
15522
+ var s = R.state.editing.routine.steps[idx];
15523
+ s.channel = s.channel || { channel: 'discord', target: '', content: '' };
15524
+ s.channel[field] = value;
15525
+ R.markDirty();
15526
+ },
15527
+ updateTransform: function(idx, value) {
15528
+ if (!R.state.editing) return;
15529
+ var s = R.state.editing.routine.steps[idx];
15530
+ s.transform = { expression: value };
15531
+ R.markDirty();
15532
+ },
15533
+ updateLoop: function(idx, field, value) {
15534
+ if (!R.state.editing) return;
15535
+ var s = R.state.editing.routine.steps[idx];
15536
+ s.loop = s.loop || { items: '', bodyStepIds: [] };
15537
+ if (field === 'bodyStepIds') {
15538
+ s.loop.bodyStepIds = String(value).split(',').map(function(x){ return x.trim(); }).filter(function(x){ return x; });
15539
+ } else {
15540
+ s.loop.items = value;
15541
+ }
15542
+ R.markDirty();
15543
+ },
15544
+ moveStep: function(idx, dir) {
15545
+ if (!R.state.editing) return;
15546
+ var steps = R.state.editing.routine.steps;
15547
+ var j = idx + dir;
15548
+ if (j < 0 || j >= steps.length) return;
15549
+ var t = steps[idx]; steps[idx] = steps[j]; steps[j] = t;
15550
+ R.markDirty();
15551
+ R.renderEditor();
15552
+ },
15553
+ removeStep: function(idx) {
15554
+ if (!R.state.editing) return;
15555
+ if (R.state.editing.routine.steps.length <= 1) { alert('A routine must have at least one step.'); return; }
15556
+ if (!confirm('Remove this step?')) return;
15557
+ var removed = R.state.editing.routine.steps.splice(idx, 1)[0];
15558
+ // Strip lingering dependsOn references.
15559
+ for (var i = 0; i < R.state.editing.routine.steps.length; i++) {
15560
+ R.state.editing.routine.steps[i].dependsOn = (R.state.editing.routine.steps[i].dependsOn || []).filter(function(d){ return d !== removed.id; });
15561
+ }
15562
+ R.markDirty();
15563
+ R.renderEditor();
15564
+ },
15565
+ // ── step picker ─────────────────────────────────────────────
15566
+ openStepPicker: function() {
15567
+ var modal = document.getElementById('routines-step-picker');
15568
+ var body = document.getElementById('routines-step-picker-body');
15569
+ if (!modal || !body) return;
15570
+ var kinds = [
15571
+ { kind: 'prompt', label: 'Prompt', desc: 'Send a prompt to the agent. Use this when the work needs reasoning or freeform tools.' },
15572
+ { kind: 'mcp', label: 'MCP tool', desc: 'Call a specific MCP server tool (Composio, Claude integrations, local MCP).' },
15573
+ { kind: 'cli', label: 'Local CLI', desc: 'Run an installed CLI (sf, gh, gcloud, …) and capture stdout.' },
15574
+ { kind: 'conditional', label: 'If / Else', desc: 'Branch on a JS expression evaluated against prior step outputs.' },
15575
+ { kind: 'channel', label: 'Channel send', desc: 'Send a message to Discord, Slack, Telegram, email, or webhook.' },
15576
+ { kind: 'transform', label: 'Transform', desc: 'Sandboxed JS expression that reshapes prior step output.' },
15577
+ { kind: 'loop', label: 'Loop', desc: 'Iterate over an array; runs the listed body steps for each item.' },
15578
+ ];
15579
+ body.innerHTML = kinds.map(function(k){
15580
+ return '<div onclick="window.RoutinesUI&&RoutinesUI.addStep(\\x27' + k.kind + '\\x27)" style="padding:12px 14px;border:1px solid var(--border);border-radius:6px;margin-bottom:8px;cursor:pointer;background:var(--bg-tertiary)"><div style="font-weight:600;font-size:13px;color:var(--text-primary);margin-bottom:3px">' + k.label + '</div><div style="font-size:11px;color:var(--text-muted)">' + k.desc + '</div></div>';
15581
+ }).join('');
15582
+ modal.style.display = 'flex';
15583
+ },
15584
+ closeStepPicker: function() {
15585
+ var m = document.getElementById('routines-step-picker'); if (m) m.style.display = 'none';
15586
+ },
15587
+ addStep: function(kind) {
15588
+ if (!R.state.editing) return;
15589
+ var steps = R.state.editing.routine.steps;
15590
+ var n = steps.length + 1;
15591
+ var nextId = 's' + n;
15592
+ while (steps.some(function(s){ return s.id === nextId; })) { n++; nextId = 's' + n; }
15593
+ var step = { id: nextId, prompt: '', dependsOn: steps.length ? [steps[steps.length - 1].id] : [], tier: 1, maxTurns: 15 };
15594
+ if (kind !== 'prompt') step.kind = kind;
15595
+ if (kind === 'mcp') step.mcp = { server: '', tool: '', inputs: {} };
15596
+ if (kind === 'cli') step.cli = { cmd: '', args: [], timeoutMs: 60000 };
15597
+ if (kind === 'conditional') step.conditional = { condition: '', trueNext: [], falseNext: [] };
15598
+ if (kind === 'channel') step.channel = { channel: 'discord', target: '', content: '' };
15599
+ if (kind === 'transform') step.transform = { expression: '' };
15600
+ if (kind === 'loop') step.loop = { items: '', bodyStepIds: [] };
15601
+ steps.push(step);
15602
+ R.markDirty();
15603
+ R.closeStepPicker();
15604
+ R.renderEditor();
15605
+ },
15606
+ // ── editor actions ──────────────────────────────────────────
15607
+ saveCurrent: function() {
15608
+ if (!R.state.editing) return;
15609
+ R.markDirty(); // capture latest header values
15610
+ var btn = document.getElementById('re-save-btn');
15611
+ if (btn) btn.textContent = 'Saving…';
15612
+ fetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15613
+ method: 'PUT',
15614
+ headers: { 'Content-Type': 'application/json' },
15615
+ body: JSON.stringify({ routine: R.state.editing.routine })
15616
+ }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, status: r.status, body: j }; }); })
15617
+ .then(function(res){
15618
+ if (btn) btn.textContent = 'Save';
15619
+ if (!res.ok) {
15620
+ if (res.body.error === 'validation') {
15621
+ var msg = (res.body.validation.issues || []).map(function(i){ return '• ' + i.severity + ': ' + i.message; }).join('\\n');
15622
+ if (confirm('Validation issues:\\n\\n' + msg + '\\n\\nSave anyway?')) {
15623
+ fetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15624
+ method: 'PUT',
15625
+ headers: { 'Content-Type': 'application/json' },
15626
+ body: JSON.stringify({ routine: R.state.editing.routine, force: true })
15627
+ }).then(function(){ R.state.editing.dirty = false; R.setStatus('Saved (with warnings)'); R.refreshList(); });
15628
+ }
15629
+ return;
15630
+ }
15631
+ R.setStatus('Save failed: ' + (res.body.error || res.body.detail || 'unknown'));
15632
+ return;
15633
+ }
15634
+ R.state.editing.dirty = false;
15635
+ R.setStatus('Saved.');
15636
+ R.refreshList();
15637
+ }).catch(function(err){
15638
+ if (btn) btn.textContent = 'Save';
15639
+ R.setStatus('Save error: ' + err);
15640
+ });
15641
+ },
15642
+ runCurrent: function() {
15643
+ if (!R.state.editing) return;
15644
+ if (R.state.editing.dirty && !confirm('You have unsaved changes. Run anyway (using last saved version)?')) return;
15645
+ R.run(R.state.editing.id);
15646
+ },
15647
+ dryRunCurrent: function() {
15648
+ if (!R.state.editing) return;
15649
+ fetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/dry-run', { method: 'POST' })
15650
+ .then(function(r){ return r.json(); })
15651
+ .then(function(d){
15652
+ var lines = ['Dry-run for ' + R.state.editing.routine.name + ':\\n'];
15653
+ (d.steps || []).forEach(function(s){ lines.push('• ' + s.description + (s.warnings.length ? '\\n ⚠ ' + s.warnings.join('; ') : '')); });
15654
+ if (d.notes && d.notes.length) lines.push('\\n' + d.notes.join('\\n'));
15655
+ alert(lines.join('\\n'));
15656
+ }).catch(function(err){ alert('Dry-run failed: ' + err); });
15657
+ },
15658
+ testCurrent: function() {
15659
+ if (!R.state.editing) return;
15660
+ fetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/test', {
15661
+ method: 'POST',
15662
+ headers: { 'Content-Type': 'application/json' },
15663
+ body: JSON.stringify({ mode: 'mock' })
15664
+ }).then(function(r){ return r.json(); }).then(function(d){
15665
+ if (d.ok) R.setStatus('Mock test started (runId: ' + d.runId + '). See run history for output.');
15666
+ else R.setStatus('Test failed to start: ' + (d.error || 'unknown'));
15667
+ }).catch(function(err){ R.setStatus('Test error: ' + err); });
15668
+ },
15669
+ deleteCurrent: function() {
15670
+ if (!R.state.editing) return;
15671
+ if (!confirm('Delete routine "' + R.state.editing.routine.name + '"? This is permanent.')) return;
15672
+ fetch('/api/routines/' + encodeURIComponent(R.state.editing.id), { method: 'DELETE' })
15673
+ .then(function(r){ return r.json(); })
15674
+ .then(function(j){
15675
+ if (j.ok) { R.state.editing = null; R.closeEditor(); R.refreshList(); }
15676
+ else alert('Delete failed: ' + (j.error || 'unknown'));
15677
+ }).catch(function(err){ alert('Delete error: ' + err); });
15678
+ },
15679
+ setStatus: function(msg) {
15680
+ var el = document.getElementById('re-status');
15681
+ if (el) el.textContent = msg || '';
15682
+ },
15683
+ flash: function(msg) {
15684
+ // Lightweight toast — reuse existing flash if available, else log.
15685
+ if (window.flashMessage) window.flashMessage(msg);
15686
+ else if (window.console) console.log('[routines]', msg);
15687
+ },
15688
+ // ── runs drawer ─────────────────────────────────────────────
15689
+ openRuns: function(id) {
15690
+ if (!id) return;
15691
+ var drawer = document.getElementById('routines-runs-drawer');
15692
+ if (!drawer) return;
15693
+ drawer.innerHTML = '<div style="padding:18px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:10px"><h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Run history</h3><span style="flex:1"></span><button onclick="window.RoutinesUI&&RoutinesUI.closeRuns()" style="background:none;border:none;color:var(--text-muted);font-size:18px;cursor:pointer">&times;</button></div><div style="font-size:12px;color:var(--text-muted)">Loading…</div></div>';
15694
+ drawer.style.display = 'block';
15695
+ fetch('/api/routines/' + encodeURIComponent(id) + '/runs').then(function(r){ return r.json(); }).then(function(d){
15696
+ var runs = d.runs || [];
15697
+ var html = '<div style="padding:18px"><div style="display:flex;align-items:center;gap:8px;margin-bottom:14px"><h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Run history</h3><span style="flex:1"></span><button onclick="window.RoutinesUI&&RoutinesUI.closeRuns()" style="background:none;border:none;color:var(--text-muted);font-size:18px;cursor:pointer">&times;</button></div>';
15698
+ if (runs.length === 0) {
15699
+ html += '<div style="font-size:12px;color:var(--text-muted);padding:24px 0;text-align:center">No runs yet.</div>';
15700
+ } else {
15701
+ html += runs.map(function(run){
15702
+ var when = run.startedAt || run.timestamp || '';
15703
+ var status = run.status || 'unknown';
15704
+ var color = { ok: 'var(--green)', error: 'var(--red)', partial: '#f5a623', skipped: 'var(--text-muted)', retried: '#f5a623' }[status] || 'var(--text-muted)';
15705
+ var dur = run.durationMs ? Math.round(run.durationMs / 100) / 10 + 's' : '';
15706
+ var preview = run.outputPreview || run.output_preview || run.outputPreview || '';
15707
+ return '<div style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;padding:10px 14px;margin-bottom:8px"><div style="display:flex;align-items:center;gap:10px;font-size:12px"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + color + '"></span><span style="color:var(--text-primary);font-weight:500">' + R.esc(status) + '</span><span style="color:var(--text-muted)">' + R.esc(when) + '</span><span style="flex:1"></span><span style="color:var(--text-muted);font-size:11px">' + dur + '</span></div>' + (preview ? '<div style="margin-top:8px;font-size:11px;color:var(--text-secondary);font-family:\\x27JetBrains Mono\\x27,monospace;white-space:pre-wrap;max-height:120px;overflow:auto">' + R.esc(String(preview).slice(0, 800)) + '</div>' : '') + '</div>';
15708
+ }).join('');
15709
+ }
15710
+ html += '</div>';
15711
+ drawer.innerHTML = html;
15712
+ }).catch(function(err){
15713
+ drawer.innerHTML = '<div style="padding:18px"><div style="display:flex;align-items:center;gap:8px"><h3 style="margin:0;font-size:15px;font-weight:600">Run history</h3><span style="flex:1"></span><button onclick="window.RoutinesUI&&RoutinesUI.closeRuns()" style="background:none;border:none;color:var(--text-muted);font-size:18px;cursor:pointer">&times;</button></div><div style="margin-top:14px;font-size:12px;color:var(--red)">Failed to load: ' + R.esc(String(err)) + '</div></div>';
15714
+ });
15715
+ },
15716
+ closeRuns: function() {
15717
+ var d = document.getElementById('routines-runs-drawer'); if (d) d.style.display = 'none';
15718
+ },
15719
+ // ── create modal ────────────────────────────────────────────
15720
+ openCreate: function() {
15721
+ var m = document.getElementById('routines-create-modal'); if (!m) return;
15722
+ document.getElementById('routines-create-name').value = '';
15723
+ document.getElementById('routines-create-description').value = '';
15724
+ document.getElementById('routines-create-schedule').value = '';
15725
+ m.style.display = 'flex';
15726
+ },
15727
+ closeCreate: function() {
15728
+ var m = document.getElementById('routines-create-modal'); if (m) m.style.display = 'none';
15729
+ },
15730
+ submitCreate: function() {
15731
+ var name = document.getElementById('routines-create-name').value.trim();
15732
+ if (!name) { alert('Name is required'); return; }
15733
+ var body = {
15734
+ name: name,
15735
+ description: document.getElementById('routines-create-description').value.trim(),
15736
+ schedule: document.getElementById('routines-create-schedule').value.trim() || undefined,
15737
+ agent: document.getElementById('routines-create-owner').value || undefined,
15738
+ };
15739
+ fetch('/api/routines', {
15740
+ method: 'POST',
15741
+ headers: { 'Content-Type': 'application/json' },
15742
+ body: JSON.stringify(body)
15743
+ }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
15744
+ .then(function(res){
15745
+ if (!res.ok) { alert('Create failed: ' + (res.body.error || 'unknown')); return; }
15746
+ R.closeCreate();
15747
+ R.refreshList();
15748
+ R.openEditor(res.body.id);
15749
+ }).catch(function(err){ alert('Create error: ' + err); });
15750
+ },
15751
+ // ── assist (generate from prompt) ───────────────────────────
15752
+ openAssist: function() {
15753
+ var m = document.getElementById('routines-assist-modal'); if (!m) return;
15754
+ document.getElementById('routines-assist-input').value = '';
15755
+ document.getElementById('routines-assist-status').textContent = '';
15756
+ m.style.display = 'flex';
15757
+ setTimeout(function(){ document.getElementById('routines-assist-input').focus(); }, 50);
15758
+ },
15759
+ closeAssist: function() {
15760
+ var m = document.getElementById('routines-assist-modal'); if (m) m.style.display = 'none';
15761
+ },
15762
+ submitAssist: function() {
15763
+ if (R.state.assistBusy) return;
15764
+ var prompt = document.getElementById('routines-assist-input').value.trim();
15765
+ if (!prompt) return;
15766
+ R.state.assistBusy = true;
15767
+ var btn = document.getElementById('routines-assist-submit');
15768
+ var status = document.getElementById('routines-assist-status');
15769
+ if (btn) { btn.textContent = 'Generating…'; btn.disabled = true; }
15770
+ if (status) status.textContent = 'Asking the assistant to draft a routine…';
15771
+ // Reuse the existing /api/builder/chat which is already wired and
15772
+ // produces workflow drafts. We also pass mode hint so the agent
15773
+ // knows to focus on building one routine.
15774
+ fetch('/api/builder/chat', {
15775
+ method: 'POST',
15776
+ headers: { 'Content-Type': 'application/json' },
15777
+ body: JSON.stringify({ message: prompt, mode: 'workflow' })
15778
+ }).then(function(r){ return r.json(); }).then(function(d){
15779
+ R.state.assistBusy = false;
15780
+ if (btn) { btn.textContent = 'Generate'; btn.disabled = false; }
15781
+ if (status) status.textContent = d && d.message ? 'Draft created. Refreshing list…' : 'Draft response received.';
15782
+ R.refreshList();
15783
+ setTimeout(function(){ R.closeAssist(); }, 800);
15784
+ }).catch(function(err){
15785
+ R.state.assistBusy = false;
15786
+ if (btn) { btn.textContent = 'Generate'; btn.disabled = false; }
15787
+ if (status) status.textContent = 'Assist failed: ' + err;
15788
+ });
15789
+ },
15790
+ // ── helpers ─────────────────────────────────────────────────
15791
+ esc: function(s) {
15792
+ if (s == null) return '';
15793
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
15794
+ },
15795
+ };
15796
+ window.RoutinesUI = R;
15797
+ // Compatibility shims for legacy callers in other parts of the dashboard.
15798
+ // The old switchBuildTab(tab) is referenced from KPI tiles, getting-started cards,
15799
+ // and the navigateTo dispatcher. Map them all to the unified Routines view.
15800
+ if (typeof window.switchBuildTab !== 'function' || true) {
15801
+ window.switchBuildTab = function() { try { R.init(); } catch (e) { /* */ } };
15802
+ }
15803
+ // Auto-init when the user lands on the build page.
15804
+ document.addEventListener('DOMContentLoaded', function() {
15805
+ var nav = document.querySelector('[data-page="build"]');
15806
+ if (nav) nav.addEventListener('click', function() { setTimeout(function() { R.init(); }, 50); });
15807
+ // If page-build is already active on load (deep-link), init now.
15808
+ var page = document.getElementById('page-build');
15809
+ if (page && page.classList.contains('active')) R.init();
15810
+ });
15811
+ })();
15812
+ </script>
14865
15813
 
14866
15814
  <!-- page-agent-detail merged into Team page; click an agent in Roster to drill down. -->
14867
15815
 
@@ -15085,6 +16033,21 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15085
16033
  <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
15086
16034
  </div>
15087
16035
  </div>
16036
+ <div class="card" style="margin-bottom:14px">
16037
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
16038
+ <span>Persistent learnings</span>
16039
+ <div style="display:flex;align-items:center;gap:8px">
16040
+ <select id="learnings-filter-scope" onchange="refreshLearnings()" style="font-size:12px;padding:4px 6px;border:1px solid var(--border);border-radius:4px;background:var(--bg-input);color:var(--text)">
16041
+ <option value="active" selected>Active</option>
16042
+ <option value="all">Active + superseded + cancelled</option>
16043
+ </select>
16044
+ <span style="font-size:11px;color:var(--text-muted)">Distilled durable beliefs from past sessions</span>
16045
+ </div>
16046
+ </div>
16047
+ <div class="card-body" id="panel-learnings" style="padding:0">
16048
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
16049
+ </div>
16050
+ </div>
15088
16051
  <div class="card" style="margin-bottom:14px">
15089
16052
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
15090
16053
  <span>Open commitments</span>
@@ -18630,6 +19593,7 @@ function switchTab(group, tab) {
18630
19593
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
18631
19594
  if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
18632
19595
  if (typeof refreshCommitments === 'function') refreshCommitments();
19596
+ if (typeof refreshLearnings === 'function') refreshLearnings();
18633
19597
  if (typeof refreshSupersedes === 'function') refreshSupersedes();
18634
19598
  if (typeof refreshCoverageStrip === 'function') refreshCoverageStrip();
18635
19599
  }
@@ -24989,6 +25953,7 @@ async function submitQuickAddMemory() {
24989
25953
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
24990
25954
  if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
24991
25955
  if (typeof refreshCommitments === 'function') refreshCommitments();
25956
+ if (typeof refreshLearnings === 'function') refreshLearnings();
24992
25957
  if (typeof refreshMemory === 'function') refreshMemory();
24993
25958
  }, 600);
24994
25959
  } catch (err) {
@@ -25142,6 +26107,71 @@ async function refreshRecentWrites() {
25142
26107
  }
25143
26108
  }
25144
26109
 
26110
+ async function refreshLearnings() {
26111
+ var el = document.getElementById('panel-learnings');
26112
+ if (!el) return;
26113
+ try {
26114
+ var sel = document.getElementById('learnings-filter-scope');
26115
+ var scope = sel ? sel.value : 'active';
26116
+ var url = '/api/memory/learnings?limit=100' + (scope === 'all' ? '&all=1' : '');
26117
+ var r = await apiFetch(url);
26118
+ var d = await r.json();
26119
+ if (!d.ok || !Array.isArray(d.facts)) {
26120
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
26121
+ return;
26122
+ }
26123
+ if (d.facts.length === 0) {
26124
+ el.innerHTML = '<div class="empty-state" style="padding:14px">No persistent learnings yet. They land automatically when episode consolidation extracts a durable user preference, fact, goal, or workflow pattern.</div>';
26125
+ return;
26126
+ }
26127
+ var html = '<table class="data-table" style="width:100%">';
26128
+ html += '<thead><tr>'
26129
+ + '<th style="width:90px">Kind</th>'
26130
+ + '<th>Belief</th>'
26131
+ + '<th style="width:120px">Status</th>'
26132
+ + '<th style="width:140px">Captured</th>'
26133
+ + '<th style="width:140px">Actions</th>'
26134
+ + '</tr></thead><tbody>';
26135
+ var kindColors = { preference: '#a78bfa', fact: '#10b981', goal: '#f59e0b', workflow: '#06b6d4' };
26136
+ for (var i = 0; i < d.facts.length; i++) {
26137
+ var f = d.facts[i];
26138
+ var color = kindColors[f.kind] || 'var(--text-muted)';
26139
+ var statusBadge = '';
26140
+ if (f.status === 'active') statusBadge = '<span style="color:#10b981">active</span>';
26141
+ else if (f.status === 'superseded') statusBadge = '<span style="color:var(--text-muted)">superseded → #' + (f.supersededById || '?') + '</span>';
26142
+ else if (f.status === 'cancelled') statusBadge = '<span style="color:#ef4444">cancelled</span>';
26143
+ else statusBadge = esc(f.status || '');
26144
+ var when = '';
26145
+ try { when = new Date(f.createdAt + 'Z').toLocaleString(); } catch { when = f.createdAt; }
26146
+ var actions = '';
26147
+ if (f.status === 'active') actions = '<button class="btn-sm" onclick="learningAction(' + f.id + ', \\'cancel\\')">Cancel</button>';
26148
+ else if (f.status === 'cancelled') actions = '<button class="btn-sm" onclick="learningAction(' + f.id + ', \\'reinstate\\')">Reinstate</button>';
26149
+ html += '<tr>'
26150
+ + '<td style="font-size:11px;color:' + color + ';font-weight:600">' + esc(f.kind) + '</td>'
26151
+ + '<td style="font-size:12px">' + esc(f.text) + '</td>'
26152
+ + '<td style="font-size:11px">' + statusBadge + '</td>'
26153
+ + '<td style="font-size:11px;color:var(--text-muted)">' + esc(when) + '</td>'
26154
+ + '<td>' + actions + '</td>'
26155
+ + '</tr>';
26156
+ }
26157
+ html += '</tbody></table>';
26158
+ el.innerHTML = html;
26159
+ } catch (err) {
26160
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
26161
+ }
26162
+ }
26163
+
26164
+ async function learningAction(id, action) {
26165
+ try {
26166
+ var r = await apiJson('POST', '/api/memory/learnings/action', { id: id, action: action });
26167
+ if (r.error) { toast('Action failed: ' + r.error, 'error'); return; }
26168
+ toast('Learning ' + action, 'success');
26169
+ refreshLearnings();
26170
+ } catch (err) {
26171
+ toast('Failed: ' + String(err), 'error');
26172
+ }
26173
+ }
26174
+
25145
26175
  async function refreshCommitments() {
25146
26176
  var el = document.getElementById('panel-commitments');
25147
26177
  if (!el) return;