clementine-agent 1.18.30 → 1.18.32

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.
@@ -3363,6 +3363,8 @@ export async function cmdDashboard(opts) {
3363
3363
  const folderFilter = typeof req.query.folder === 'string' ? req.query.folder : '';
3364
3364
  const search = typeof req.query.q === 'string' ? req.query.q.toLowerCase() : '';
3365
3365
  const includeAuto = req.query.includeAuto === '1';
3366
+ const typeFilter = typeof req.query.type === 'string' ? req.query.type : '';
3367
+ const tagFilter = typeof req.query.tag === 'string' ? req.query.tag : '';
3366
3368
  const cutoffMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
3367
3369
  const vaultRoot = path.join(BASE_DIR, 'vault');
3368
3370
  const matter = (await import('gray-matter')).default;
@@ -3413,6 +3415,8 @@ export async function cmdDashboard(opts) {
3413
3415
  // Skip system housekeeping files (their author will surface via mtime in agent's own dir)
3414
3416
  let title = path.basename(rel, '.md');
3415
3417
  let typeTag = null;
3418
+ let categoryTag = null;
3419
+ let tags = [];
3416
3420
  try {
3417
3421
  const head = readFileSync(full, 'utf-8').slice(0, 4000);
3418
3422
  const parsed = matter(head);
@@ -3428,6 +3432,14 @@ export async function cmdDashboard(opts) {
3428
3432
  }
3429
3433
  if (typeof data.type === 'string')
3430
3434
  typeTag = data.type;
3435
+ if (typeof data.category === 'string')
3436
+ categoryTag = data.category;
3437
+ if (Array.isArray(data.tags)) {
3438
+ tags = data.tags.filter((t) => typeof t === 'string');
3439
+ }
3440
+ else if (typeof data.tags === 'string') {
3441
+ tags = data.tags.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
3442
+ }
3431
3443
  }
3432
3444
  catch { /* */ }
3433
3445
  files.push({
@@ -3439,6 +3451,8 @@ export async function cmdDashboard(opts) {
3439
3451
  mtime: new Date(stat.mtimeMs).toISOString(),
3440
3452
  sizeBytes: stat.size,
3441
3453
  type: typeTag,
3454
+ category: categoryTag,
3455
+ tags,
3442
3456
  });
3443
3457
  }
3444
3458
  }
@@ -3452,19 +3466,33 @@ export async function cmdDashboard(opts) {
3452
3466
  return false;
3453
3467
  if (folderFilter && f.folder !== folderFilter)
3454
3468
  return false;
3469
+ if (typeFilter && f.type !== typeFilter)
3470
+ return false;
3471
+ if (tagFilter && !f.tags.includes(tagFilter))
3472
+ return false;
3455
3473
  if (search) {
3456
- const hay = (f.title + ' ' + f.relPath).toLowerCase();
3474
+ const hay = (f.title + ' ' + f.relPath + ' ' +
3475
+ (f.type || '') + ' ' + (f.category || '') + ' ' +
3476
+ f.tags.join(' ')).toLowerCase();
3457
3477
  if (!hay.includes(search))
3458
3478
  return false;
3459
3479
  }
3460
3480
  return true;
3461
3481
  })
3462
3482
  .slice(0, limit);
3463
- // Compute folder counts for filter chips
3483
+ // Facet counts reflect the unfiltered set so the rail keeps the full
3484
+ // vocabulary visible after a filter is applied.
3464
3485
  const folderCounts = {};
3465
- for (const f of files)
3486
+ const typeCounts = {};
3487
+ const tagCounts = {};
3488
+ for (const f of files) {
3466
3489
  folderCounts[f.folder] = (folderCounts[f.folder] || 0) + 1;
3467
- res.json({ files: filtered, total: files.length, folderCounts });
3490
+ if (f.type)
3491
+ typeCounts[f.type] = (typeCounts[f.type] || 0) + 1;
3492
+ for (const t of f.tags)
3493
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
3494
+ }
3495
+ res.json({ files: filtered, total: files.length, folderCounts, typeCounts, tagCounts });
3468
3496
  }
3469
3497
  catch (err) {
3470
3498
  res.status(500).json({ error: String(err) });
@@ -3482,6 +3510,22 @@ export async function cmdDashboard(opts) {
3482
3510
  res.status(404).json({ error: 'Not found' });
3483
3511
  return;
3484
3512
  }
3513
+ const headOnly = req.query.head === '1';
3514
+ if (headOnly) {
3515
+ const stat = statSync(full);
3516
+ const head = readFileSync(full, 'utf-8').slice(0, 4000);
3517
+ const matter = (await import('gray-matter')).default;
3518
+ const parsed = matter(head);
3519
+ const snippet = (parsed.content || '').replace(/^#+\s.*$/m, '').trim().slice(0, 400);
3520
+ res.json({
3521
+ path: relPath,
3522
+ frontmatter: parsed.data,
3523
+ snippet,
3524
+ sizeBytes: stat.size,
3525
+ mtime: new Date(stat.mtimeMs).toISOString(),
3526
+ });
3527
+ return;
3528
+ }
3485
3529
  const content = readFileSync(full, 'utf-8');
3486
3530
  res.json({ path: relPath, content });
3487
3531
  }
@@ -3989,6 +4033,395 @@ export async function cmdDashboard(opts) {
3989
4033
  res.status(500).json({ error: 'Create failed', detail: String(err) });
3990
4034
  }
3991
4035
  });
4036
+ // ── Routines API (canonical surface for the Build tab) ─────────
4037
+ // The "Routines" UI uses this surface exclusively. Workflows + cron
4038
+ // jobs both flow through here as a single Routine concept; legacy
4039
+ // /api/builder/* and /api/cron/* endpoints remain for one minor
4040
+ // version, then are removed.
4041
+ app.get('/api/routines', async (_req, res) => {
4042
+ try {
4043
+ const { listAllForBuilder } = await import('../dashboard/builder/serializer.js');
4044
+ res.json({ routines: listAllForBuilder() });
4045
+ }
4046
+ catch (err) {
4047
+ res.status(500).json({ error: 'list failed', detail: String(err) });
4048
+ }
4049
+ });
4050
+ app.get('/api/routines/mcp-tools', async (_req, res) => {
4051
+ try {
4052
+ const { discoverMcpServers, loadToolInventory } = await import('../agent/mcp-bridge.js');
4053
+ const servers = discoverMcpServers();
4054
+ const inv = loadToolInventory();
4055
+ const allTools = inv?.tools ?? [];
4056
+ // Group flat tool names of shape `mcp__<server>__<tool>` (server may
4057
+ // contain underscores — split on the first `__` after the prefix).
4058
+ const grouped = {};
4059
+ for (const s of servers) {
4060
+ grouped[s.name] = { name: s.name, enabled: s.enabled !== false, tools: [] };
4061
+ }
4062
+ for (const t of allTools) {
4063
+ if (!t.startsWith('mcp__'))
4064
+ continue;
4065
+ const rest = t.slice(5);
4066
+ const idx = rest.indexOf('__');
4067
+ if (idx < 0)
4068
+ continue;
4069
+ const server = rest.slice(0, idx);
4070
+ const tool = rest.slice(idx + 2);
4071
+ if (!grouped[server])
4072
+ grouped[server] = { name: server, enabled: true, tools: [] };
4073
+ if (!grouped[server].tools.includes(tool))
4074
+ grouped[server].tools.push(tool);
4075
+ }
4076
+ const out = Object.values(grouped)
4077
+ .filter(s => s.tools.length > 0 || s.enabled)
4078
+ .sort((a, b) => a.name.localeCompare(b.name));
4079
+ res.json({ servers: out });
4080
+ }
4081
+ catch (err) {
4082
+ res.status(500).json({ error: 'mcp-tools failed', detail: String(err) });
4083
+ }
4084
+ });
4085
+ app.get('/api/routines/cli-tools', async (_req, res) => {
4086
+ try {
4087
+ // Reuse discoverCliTools() defined elsewhere in this file.
4088
+ const tools = discoverCliTools().filter(t => t.installed && !t.blocked);
4089
+ res.json({ tools: tools.map(t => ({ cmd: t.name, description: t.description, userDefined: !!t.userDefined })) });
4090
+ }
4091
+ catch (err) {
4092
+ res.status(500).json({ error: 'cli-tools failed', detail: String(err) });
4093
+ }
4094
+ });
4095
+ app.get('/api/routines/:id', async (req, res) => {
4096
+ try {
4097
+ const id = decodeURIComponent(req.params.id);
4098
+ const { readWorkflow } = await import('../dashboard/builder/serializer.js');
4099
+ const { validateWorkflow } = await import('../dashboard/builder/validation.js');
4100
+ const wf = readWorkflow(id);
4101
+ if (!wf) {
4102
+ res.status(404).json({ error: 'Not found' });
4103
+ return;
4104
+ }
4105
+ res.json({ id, routine: wf, validation: validateWorkflow(wf) });
4106
+ }
4107
+ catch (err) {
4108
+ res.status(500).json({ error: 'read failed', detail: String(err) });
4109
+ }
4110
+ });
4111
+ app.post('/api/routines', async (req, res) => {
4112
+ try {
4113
+ const body = req.body;
4114
+ if (!body || !body.name) {
4115
+ res.status(400).json({ error: 'name required' });
4116
+ return;
4117
+ }
4118
+ const [{ saveWorkflow, workflowId: makeId }, { emitBuilderEvent }, yamlMod] = await Promise.all([
4119
+ import('../dashboard/builder/serializer.js'),
4120
+ import('../dashboard/builder/events.js'),
4121
+ import('js-yaml'),
4122
+ ]);
4123
+ const slug = body.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'routine';
4124
+ const agentSlug = body.agent ? (String(body.agent).trim() || undefined) : undefined;
4125
+ // Parse the agent's draft if present; on any parse error fall back to the
4126
+ // single-step stub so the user lands in the editor with something usable.
4127
+ let steps = null;
4128
+ if (body.draftYaml && typeof body.draftYaml === 'string') {
4129
+ try {
4130
+ const parsed = yamlMod.load(body.draftYaml);
4131
+ if (parsed && typeof parsed === 'object') {
4132
+ steps = Object.entries(parsed).map(([id, raw]) => {
4133
+ const r = (raw && typeof raw === 'object') ? raw : {};
4134
+ const dependsOn = Array.isArray(r.dependsOn)
4135
+ ? r.dependsOn.map(String)
4136
+ : (typeof r.dependsOn === 'string' ? r.dependsOn.split(',').map(s => s.trim()).filter(Boolean) : []);
4137
+ return {
4138
+ id: String(id),
4139
+ prompt: String(r.prompt ?? ''),
4140
+ dependsOn,
4141
+ tier: typeof r.tier === 'number' ? r.tier : 1,
4142
+ maxTurns: typeof r.maxTurns === 'number' ? r.maxTurns : 15,
4143
+ ...(typeof r.model === 'string' ? { model: r.model } : {}),
4144
+ ...(typeof r.workDir === 'string' ? { workDir: r.workDir } : {}),
4145
+ ...(typeof r.kind === 'string' && r.kind !== 'prompt' ? { kind: r.kind } : {}),
4146
+ };
4147
+ }).filter(s => s.id);
4148
+ if (steps.length === 0)
4149
+ steps = null;
4150
+ }
4151
+ }
4152
+ catch {
4153
+ steps = null;
4154
+ }
4155
+ }
4156
+ const wf = {
4157
+ name: body.name,
4158
+ description: body.description ?? '',
4159
+ enabled: true,
4160
+ trigger: body.schedule ? { schedule: body.schedule, manual: false } : { manual: true },
4161
+ inputs: {},
4162
+ steps: (steps ?? [{
4163
+ id: 's1',
4164
+ prompt: body.initialPrompt ?? 'Describe what this trick should do.',
4165
+ dependsOn: [],
4166
+ tier: 1,
4167
+ maxTurns: 15,
4168
+ }]),
4169
+ sourceFile: '',
4170
+ agentSlug,
4171
+ ...(body.model ? { model: body.model } : {}),
4172
+ };
4173
+ const id = makeId(slug, agentSlug);
4174
+ const result = saveWorkflow(id, wf);
4175
+ if (!result.ok) {
4176
+ res.status(400).json({ error: result.error });
4177
+ return;
4178
+ }
4179
+ emitBuilderEvent({ type: 'workflow:created', workflowId: id, payload: { workflow: wf } });
4180
+ res.json({ ok: true, id });
4181
+ }
4182
+ catch (err) {
4183
+ res.status(500).json({ error: 'create failed', detail: String(err) });
4184
+ }
4185
+ });
4186
+ app.put('/api/routines/:id', async (req, res) => {
4187
+ try {
4188
+ const id = decodeURIComponent(req.params.id);
4189
+ const body = req.body;
4190
+ if (!body || typeof body.routine !== 'object') {
4191
+ res.status(400).json({ error: 'Missing routine body' });
4192
+ return;
4193
+ }
4194
+ const [{ readWorkflow, saveWorkflow }, { validateWorkflow }, { emitBuilderEvent }] = await Promise.all([
4195
+ import('../dashboard/builder/serializer.js'),
4196
+ import('../dashboard/builder/validation.js'),
4197
+ import('../dashboard/builder/events.js'),
4198
+ ]);
4199
+ const existing = readWorkflow(id);
4200
+ if (!existing) {
4201
+ res.status(404).json({ error: 'Not found' });
4202
+ return;
4203
+ }
4204
+ const incoming = body.routine;
4205
+ const next = { ...incoming, sourceFile: existing.sourceFile };
4206
+ const v = validateWorkflow(next);
4207
+ if (!v.ok && !body.force) {
4208
+ res.status(400).json({ error: 'validation', validation: v });
4209
+ return;
4210
+ }
4211
+ const result = saveWorkflow(id, next);
4212
+ if (!result.ok) {
4213
+ res.status(400).json({ error: result.error });
4214
+ return;
4215
+ }
4216
+ emitBuilderEvent({ type: 'workflow:patched', workflowId: id, payload: { workflow: next } });
4217
+ res.json({ ok: true, validation: v });
4218
+ }
4219
+ catch (err) {
4220
+ res.status(500).json({ error: 'save failed', detail: String(err) });
4221
+ }
4222
+ });
4223
+ app.delete('/api/routines/:id', async (req, res) => {
4224
+ try {
4225
+ const id = decodeURIComponent(req.params.id);
4226
+ const [{ readWorkflow, parseBuilderId }, { emitBuilderEvent }] = await Promise.all([
4227
+ import('../dashboard/builder/serializer.js'),
4228
+ import('../dashboard/builder/events.js'),
4229
+ ]);
4230
+ const parsed = parseBuilderId(id);
4231
+ if (!parsed) {
4232
+ res.status(400).json({ error: 'Bad id' });
4233
+ return;
4234
+ }
4235
+ if (parsed.origin === 'cron') {
4236
+ res.status(400).json({ error: 'This trick came from a legacy cron entry — disable it instead, or edit CRON.md directly.' });
4237
+ return;
4238
+ }
4239
+ const wf = readWorkflow(id);
4240
+ if (!wf) {
4241
+ res.status(404).json({ error: 'Not found' });
4242
+ return;
4243
+ }
4244
+ if (wf.sourceFile && existsSync(wf.sourceFile))
4245
+ unlinkSync(wf.sourceFile);
4246
+ emitBuilderEvent({ type: 'workflow:deleted', workflowId: id });
4247
+ res.json({ ok: true });
4248
+ }
4249
+ catch (err) {
4250
+ res.status(500).json({ error: String(err) });
4251
+ }
4252
+ });
4253
+ app.post('/api/routines/:id/toggle', async (req, res) => {
4254
+ try {
4255
+ const id = decodeURIComponent(req.params.id);
4256
+ const { readWorkflow, saveWorkflow } = await import('../dashboard/builder/serializer.js');
4257
+ const wf = readWorkflow(id);
4258
+ if (!wf) {
4259
+ res.status(404).json({ error: 'Not found' });
4260
+ return;
4261
+ }
4262
+ wf.enabled = !wf.enabled;
4263
+ const result = saveWorkflow(id, wf);
4264
+ if (!result.ok) {
4265
+ res.status(400).json({ error: result.error });
4266
+ return;
4267
+ }
4268
+ res.json({ ok: true, enabled: wf.enabled });
4269
+ }
4270
+ catch (err) {
4271
+ res.status(500).json({ error: 'toggle failed', detail: String(err) });
4272
+ }
4273
+ });
4274
+ app.post('/api/routines/:id/run', async (req, res) => {
4275
+ try {
4276
+ const id = decodeURIComponent(req.params.id);
4277
+ const { readWorkflow, parseBuilderId } = await import('../dashboard/builder/serializer.js');
4278
+ const wf = readWorkflow(id);
4279
+ if (!wf) {
4280
+ res.status(404).json({ error: 'Not found' });
4281
+ return;
4282
+ }
4283
+ const parsed = parseBuilderId(id);
4284
+ const body = (req.body ?? {});
4285
+ // Cron-origin routines: spawn the cli `cron run <name>` (single-step prompt path).
4286
+ if (parsed?.origin === 'cron') {
4287
+ const child = spawn('node', [DIST_ENTRY, 'cron', 'run', wf.name], {
4288
+ detached: true,
4289
+ stdio: ['ignore', 'pipe', 'pipe'],
4290
+ cwd: BASE_DIR,
4291
+ env: { ...process.env, CLEMENTINE_HOME: BASE_DIR },
4292
+ });
4293
+ child.on('exit', (code) => {
4294
+ broadcastEvent({ type: 'cron_complete', data: { job: wf.name, code } });
4295
+ responseCache.delete('activity:');
4296
+ });
4297
+ child.unref();
4298
+ broadcastEvent({ type: 'cron_triggered', data: { job: wf.name } });
4299
+ res.json({ ok: true, message: `Triggered trick: ${wf.name}` });
4300
+ return;
4301
+ }
4302
+ // Workflow-origin routines: side-effect approval gate, then route through gateway.handleWorkflow.
4303
+ const sideEffects = wf.steps
4304
+ .filter(step => {
4305
+ const kind = step.kind ?? 'prompt';
4306
+ if (kind === 'channel' || kind === 'mcp' || kind === 'cli')
4307
+ return true;
4308
+ return /\b(send|post|publish|email|webhook|delete|write|update|create)\b/i.test(step.prompt || '');
4309
+ })
4310
+ .map(step => ({
4311
+ id: step.id,
4312
+ kind: step.kind ?? 'prompt',
4313
+ label: step.channel
4314
+ ? `${step.channel.channel}:${step.channel.target}`
4315
+ : step.mcp
4316
+ ? `${step.mcp.server}.${step.mcp.tool}`
4317
+ : step.cli
4318
+ ? `${step.cli.cmd}${step.cli.args?.length ? ' ' + step.cli.args.join(' ') : ''}`
4319
+ : step.prompt.slice(0, 80),
4320
+ }));
4321
+ if (sideEffects.length > 0 && body.approvedSideEffects !== true) {
4322
+ res.status(409).json({
4323
+ ok: false,
4324
+ error: 'approval_required',
4325
+ message: 'This trick may send, write, post, or call external tools. Approve side effects before running it.',
4326
+ sideEffects,
4327
+ });
4328
+ return;
4329
+ }
4330
+ res.json({ ok: true, message: `Trick "${wf.name}" triggered` });
4331
+ broadcastEvent({ type: 'workflow_triggered', data: { id, name: wf.name } });
4332
+ getGateway().then(gw => gw.handleWorkflow(wf, body.inputs || {})).then(result => {
4333
+ broadcastEvent({ type: 'workflow_complete', data: { id, name: wf.name, status: 'ok', preview: (result || '').slice(0, 300) } });
4334
+ }).catch(err => {
4335
+ broadcastEvent({ type: 'workflow_complete', data: { id, name: wf.name, status: 'error', error: String(err) } });
4336
+ });
4337
+ }
4338
+ catch (err) {
4339
+ res.status(500).json({ error: 'run failed', detail: String(err) });
4340
+ }
4341
+ });
4342
+ app.post('/api/routines/:id/dry-run', async (req, res) => {
4343
+ try {
4344
+ const id = decodeURIComponent(req.params.id);
4345
+ const [{ readWorkflow }, { dryRunWorkflow }] = await Promise.all([
4346
+ import('../dashboard/builder/serializer.js'),
4347
+ import('../dashboard/builder/dry-run.js'),
4348
+ ]);
4349
+ const wf = readWorkflow(id);
4350
+ if (!wf) {
4351
+ res.status(404).json({ error: 'Not found' });
4352
+ return;
4353
+ }
4354
+ res.json(dryRunWorkflow(wf));
4355
+ }
4356
+ catch (err) {
4357
+ res.status(500).json({ error: 'dry-run failed', detail: String(err) });
4358
+ }
4359
+ });
4360
+ app.post('/api/routines/:id/test', async (req, res) => {
4361
+ try {
4362
+ const id = decodeURIComponent(req.params.id);
4363
+ const body = (req.body ?? {});
4364
+ const [{ readWorkflow }, { runWorkflowTest }] = await Promise.all([
4365
+ import('../dashboard/builder/serializer.js'),
4366
+ import('../dashboard/builder/runner.js'),
4367
+ ]);
4368
+ const wf = readWorkflow(id);
4369
+ if (!wf) {
4370
+ res.status(404).json({ error: 'Not found' });
4371
+ return;
4372
+ }
4373
+ const runId = (await import('node:crypto')).randomUUID();
4374
+ res.json({ ok: true, runId });
4375
+ runWorkflowTest(wf, {
4376
+ workflowId: id,
4377
+ runId,
4378
+ mode: body.mode ?? 'mock',
4379
+ perStepTimeoutMs: body.perStepTimeoutMs,
4380
+ totalBudgetMs: body.totalBudgetMs,
4381
+ }).catch(() => { });
4382
+ }
4383
+ catch (err) {
4384
+ res.status(500).json({ error: 'test failed to start', detail: String(err) });
4385
+ }
4386
+ });
4387
+ app.get('/api/routines/:id/runs', async (req, res) => {
4388
+ try {
4389
+ const id = decodeURIComponent(req.params.id);
4390
+ const { readWorkflow } = await import('../dashboard/builder/serializer.js');
4391
+ const wf = readWorkflow(id);
4392
+ if (!wf) {
4393
+ res.status(404).json({ error: 'Not found' });
4394
+ return;
4395
+ }
4396
+ const safe = wf.name.replace(/[^a-zA-Z0-9_-]/g, '_');
4397
+ const cronLogPath = path.join(BASE_DIR, 'cron-logs', `${safe}.jsonl`);
4398
+ const workflowLogPath = path.join(BASE_DIR, 'workflows', 'runs', `${safe}.jsonl`);
4399
+ const runs = [];
4400
+ for (const file of [cronLogPath, workflowLogPath]) {
4401
+ if (!existsSync(file))
4402
+ continue;
4403
+ try {
4404
+ const lines = readFileSync(file, 'utf-8').split('\n').filter(l => l.trim());
4405
+ for (const line of lines.slice(-50)) {
4406
+ try {
4407
+ runs.push(JSON.parse(line));
4408
+ }
4409
+ catch { /* skip malformed */ }
4410
+ }
4411
+ }
4412
+ catch { /* skip unreadable */ }
4413
+ }
4414
+ runs.sort((a, b) => {
4415
+ const at = String(a.startedAt || a.timestamp || '');
4416
+ const bt = String(b.startedAt || b.timestamp || '');
4417
+ return bt.localeCompare(at);
4418
+ });
4419
+ res.json({ runs: runs.slice(0, 50) });
4420
+ }
4421
+ catch (err) {
4422
+ res.status(500).json({ error: 'runs read failed', detail: String(err) });
4423
+ }
4424
+ });
3992
4425
  // SSE events handler moved before auth middleware (see above)
3993
4426
  // ── POST routes (actions) ──────────────────────────────────────
3994
4427
  app.post('/api/cron/run/:job', (req, res) => {
@@ -7044,6 +7477,55 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7044
7477
  res.status(500).json({ error: String(err) });
7045
7478
  }
7046
7479
  });
7480
+ // Learned facts — durable cross-session beliefs with supersession lineage.
7481
+ app.get('/api/memory/learnings', async (req, res) => {
7482
+ try {
7483
+ const gateway = await getGateway();
7484
+ const store = gateway.assistant?.memoryStore;
7485
+ if (!store || typeof store.listAllLearnedFacts !== 'function') {
7486
+ res.status(503).json({ error: 'Learnings store not available' });
7487
+ return;
7488
+ }
7489
+ const limit = Math.min(parseInt(String(req.query.limit ?? '100'), 10) || 100, 1000);
7490
+ const showAll = String(req.query.all ?? '') === '1';
7491
+ const facts = showAll
7492
+ ? store.listAllLearnedFacts({ limit })
7493
+ : store.listActiveLearnedFacts({ limit });
7494
+ res.json({ ok: true, facts });
7495
+ }
7496
+ catch (err) {
7497
+ res.status(500).json({ error: String(err) });
7498
+ }
7499
+ });
7500
+ app.post('/api/memory/learnings/action', async (req, res) => {
7501
+ try {
7502
+ const gateway = await getGateway();
7503
+ const store = gateway.assistant?.memoryStore;
7504
+ if (!store || typeof store.setLearnedFactStatus !== 'function') {
7505
+ res.status(503).json({ error: 'Learnings store not available' });
7506
+ return;
7507
+ }
7508
+ const id = Number(req.body?.id);
7509
+ const action = String(req.body?.action ?? '');
7510
+ if (!Number.isInteger(id) || id <= 0) {
7511
+ res.status(400).json({ error: 'id required' });
7512
+ return;
7513
+ }
7514
+ let updated = false;
7515
+ if (action === 'cancel')
7516
+ updated = store.setLearnedFactStatus(id, 'cancelled');
7517
+ else if (action === 'reinstate')
7518
+ updated = store.setLearnedFactStatus(id, 'active');
7519
+ else {
7520
+ res.status(400).json({ error: 'invalid action' });
7521
+ return;
7522
+ }
7523
+ res.json({ ok: updated });
7524
+ }
7525
+ catch (err) {
7526
+ res.status(500).json({ error: String(err) });
7527
+ }
7528
+ });
7047
7529
  // Commitments — durable promises tracked across sessions.
7048
7530
  app.get('/api/memory/commitments', async (req, res) => {
7049
7531
  try {
@@ -7537,10 +8019,14 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7537
8019
  `Help the user think about what makes a good agent: clear role, specific tools, focused personality. Keep it conversational — one question at a time.\n` +
7538
8020
  `When the user says "save" or approves, output the final artifact block.]\n\n`
7539
8021
  : type === 'workflow'
7540
- ? `[BUILDER MODE: You are helping build a multi-step workflow pipeline. As you develop the workflow, output the current state as a JSON block:\n` +
7541
- '```json-artifact\n{"type":"workflow","name":"...","description":"...","schedule":"","steps":"step1:\\n prompt: ...\\nstep2:\\n prompt: ...\\n dependsOn: step1"}\n```\n' +
7542
- `Update this block in EVERY response as the workflow evolves. Ask about: what the workflow should accomplish, what steps are needed, which agents should run each step, dependencies between steps, and whether it should be triggered on a schedule or manually.\n` +
7543
- `Workflows are defined as markdown files with YAML frontmatter. Each step has an id, prompt, optional agent, and optional dependsOn array.\n` +
8022
+ ? `[BUILDER MODE: You are helping the user build a "trick" — a (possibly multi-step) thing Clementine can do on a schedule or on demand. As you develop it, output the current state as a JSON block:\n` +
8023
+ '```json-artifact\n{"type":"workflow","name":"...","description":"...","schedule":"","model":"","steps":"step1:\\n prompt: ...\\nstep2:\\n prompt: ...\\n dependsOn: step1"}\n```\n' +
8024
+ `Update this block in EVERY response. Keep it conversational one question at a time. Ask about (in roughly this order):\n` +
8025
+ ` 1. The goal (one sentence is fine).\n` +
8026
+ ` 2. When it should run — natural language is fine ("every weekday at 9"); convert to a cron expression in the schedule field. Empty schedule = manual.\n` +
8027
+ ` 3. Which tools, projects, or channels she'll need (MCP servers, local CLIs like sf/gh/gcloud, Slack/Discord targets).\n` +
8028
+ ` 4. Which model — claude-opus-4-7 (most capable), claude-sonnet-4-6 (balanced), or claude-haiku-4-5-20251001 (fastest). Leave model empty if the user doesn't care.\n` +
8029
+ `Most tricks need only one prompt step. Add steps only when the user explicitly wants a multi-step pipeline.\n` +
7544
8030
  `When the user says "save" or approves, output the final artifact block.]\n\n`
7545
8031
  : `[BUILDER MODE: You are helping configure an artifact. Output structured JSON blocks as you build.]\n\n`;
7546
8032
  enrichedMessage = builderPrefix + fileContext + toolContext + artifactContext + message;
@@ -13751,6 +14237,75 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
13751
14237
  font-weight: 500;
13752
14238
  }
13753
14239
 
14240
+ /* Unified Memory tab — facet rail */
14241
+ .vault-facet-list { display: flex; flex-direction: column; gap: 3px; }
14242
+ .vault-facet-row {
14243
+ display: flex; align-items: center; justify-content: space-between;
14244
+ padding: 5px 8px; border-radius: 6px; cursor: pointer;
14245
+ color: var(--text-secondary); font-size: 12px;
14246
+ user-select: none; transition: background var(--motion);
14247
+ }
14248
+ .vault-facet-row:hover { background: var(--bg-hover); color: var(--text-primary); }
14249
+ .vault-facet-row.active { background: var(--clementine-bg); color: var(--clementine); font-weight: 500; }
14250
+ .vault-facet-row .vault-facet-count { font-size: 11px; opacity: 0.6; margin-left: 8px; }
14251
+ .vault-facet-row.active .vault-facet-count { opacity: 0.85; }
14252
+
14253
+ /* Unified Memory tab — file list rows */
14254
+ .vault-mem-row {
14255
+ display: flex; flex-direction: column; gap: 3px;
14256
+ padding: 10px 14px; border-bottom: 1px solid var(--border-light);
14257
+ cursor: pointer; transition: background var(--motion);
14258
+ }
14259
+ .vault-mem-row:hover { background: var(--bg-hover); }
14260
+ .vault-mem-row.active { background: var(--clementine-bg); }
14261
+ .vault-mem-row.active .vault-mem-row-title { color: var(--clementine); }
14262
+ .vault-mem-row-title {
14263
+ font-weight: 500; color: var(--text-primary); font-size: 13px;
14264
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
14265
+ }
14266
+ .vault-mem-row-meta {
14267
+ font-size: 11px; color: var(--text-muted);
14268
+ display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
14269
+ }
14270
+ .vault-mem-row-path {
14271
+ font-family: 'JetBrains Mono', monospace; font-size: 10px;
14272
+ color: var(--text-muted); opacity: 0.75;
14273
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
14274
+ }
14275
+ .vault-pill {
14276
+ display: inline-block; padding: 1px 7px; border-radius: 10px;
14277
+ font-size: 10px; line-height: 1.5; background: var(--bg-tertiary); color: var(--text-secondary);
14278
+ }
14279
+ .vault-pill.type { background: var(--clementine-bg); color: var(--clementine); }
14280
+ .vault-pill.tag { background: var(--bg-tertiary); color: var(--text-secondary); }
14281
+ .vault-pill.match { background: rgba(245,158,11,0.15); color: #d97706; }
14282
+
14283
+ /* Unified Memory tab — reader */
14284
+ .vault-reader-fm {
14285
+ background: var(--bg-secondary); border: 1px solid var(--border-light);
14286
+ border-radius: var(--radius-sm); padding: 10px 14px; margin-bottom: 14px;
14287
+ font-size: 12px; display: grid; grid-template-columns: max-content 1fr;
14288
+ gap: 4px 12px; align-items: baseline;
14289
+ }
14290
+ .vault-reader-fm .k { color: var(--text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
14291
+ .vault-reader-fm .v { color: var(--text-primary); word-break: break-word; }
14292
+ .vault-reader-body { max-width: 760px; }
14293
+ .vault-reader-body h1 { font-size: 22px; margin: 0 0 12px; }
14294
+ .vault-reader-body h2 { font-size: 17px; margin: 22px 0 8px; padding-top: 8px; border-top: 1px solid var(--border-light); }
14295
+ .vault-reader-body h3 { font-size: 14px; margin: 16px 0 6px; color: var(--text-secondary); }
14296
+ .vault-reader-body p, .vault-reader-body li { font-size: 14px; line-height: 1.65; }
14297
+ .vault-reader-body code { background: var(--bg-tertiary); padding: 1px 5px; border-radius: 3px; font-size: 12px; }
14298
+ .vault-reader-body pre { background: var(--bg-tertiary); padding: 10px 12px; border-radius: 6px; overflow-x: auto; font-size: 12px; }
14299
+ .vault-reader-toc {
14300
+ margin-top: 18px; padding: 10px 14px;
14301
+ background: var(--bg-secondary); border: 1px solid var(--border-light);
14302
+ border-radius: var(--radius-sm); font-size: 12px;
14303
+ }
14304
+ .vault-reader-toc-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 6px; }
14305
+ .vault-reader-toc a { display: block; color: var(--text-secondary); text-decoration: none; padding: 2px 0; }
14306
+ .vault-reader-toc a:hover { color: var(--clementine); }
14307
+ .vault-reader-toc a.lvl-3 { padding-left: 14px; font-size: 11px; }
14308
+
13754
14309
  /* ── Task Cards ─────────────────────────── */
13755
14310
  .task-grid {
13756
14311
  display: grid;
@@ -14477,7 +15032,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14477
15032
  <div class="nav-item active" data-page="home" data-icon="home" title="Chat, today, activity">
14478
15033
  <span class="nav-icon"></span> Home
14479
15034
  </div>
14480
- <div class="nav-item" data-page="build" data-icon="workflow" title="Workflows, crons, skills">
15035
+ <div class="nav-item" data-page="build" data-icon="workflow" title="Tricks Clementine knows">
14481
15036
  <span class="nav-icon"></span> Build
14482
15037
  <span class="nav-badge" id="nav-cron-count" style="display:none">0</span>
14483
15038
  </div>
@@ -14679,189 +15234,876 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14679
15234
  </div>
14680
15235
  </div>
14681
15236
 
14682
- <!-- ═══ Builder Page — Conversational Artifact Creation ═══ -->
15237
+ <!-- ═══ Build Page — Routines (single unified surface) ═══ -->
14683
15238
  <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="">
15239
+ <!-- Toolbar -->
15240
+ <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">
15241
+ <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> Tricks</h2>
15242
+ <span id="routines-count" style="font-size:11px;color:var(--text-muted)"></span>
15243
+ <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
15244
  <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>
15245
+ <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>
15246
+ <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">
15247
+ <option value="__all__">All</option>
15248
+ <option value="__global__">Clementine (global)</option>
15249
+ </select>
15250
+ <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>
15251
+ <button id="routines-assist-btn" class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openCreate()" title="Skip the chat and fill out a form yourself" 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)">Build manually</button>
15252
+ <button id="routines-create-btn" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ New Trick</button>
14712
15253
  </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>
15254
+ <!-- List view (default) -->
15255
+ <div id="routines-list-pane" style="flex:1;min-height:0;overflow-y:auto;padding:18px;background:var(--bg-primary)">
15256
+ <div id="routines-list-empty" class="empty-state" style="display:none;padding:64px 18px;text-align:center;color:var(--text-muted)">
15257
+ <div style="font-size:38px;opacity:0.4;margin-bottom:14px">&#9881;</div>
15258
+ <div style="font-size:15px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">No tricks yet</div>
15259
+ <div style="font-size:12px;line-height:1.5;max-width:380px;margin:0 auto 16px">A Trick is a sequence of steps Clementine performs on cue &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>
15260
+ <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
15261
+ <button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px">+ New Trick</button>
15262
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openCreate()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Build manually</button>
14738
15263
  </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>
15264
+ </div>
15265
+ <div id="routines-list-wrap" style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden">
15266
+ <table style="width:100%;border-collapse:collapse;font-size:13px">
15267
+ <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)">
15268
+ <tr>
15269
+ <th style="padding:11px 14px">Name</th>
15270
+ <th style="padding:11px 14px">Owner</th>
15271
+ <th style="padding:11px 14px">Schedule</th>
15272
+ <th style="padding:11px 14px">Steps</th>
15273
+ <th style="padding:11px 14px">Last run</th>
15274
+ <th style="padding:11px 14px;text-align:center">Enabled</th>
15275
+ <th style="padding:11px 14px;text-align:right">Actions</th>
15276
+ </tr>
15277
+ </thead>
15278
+ <tbody id="routines-list-body"></tbody>
15279
+ </table>
15280
+ </div>
15281
+ </div>
15282
+ <!-- Editor pane (hidden by default; replaces list when a routine is opened) -->
15283
+ <div id="routines-editor-pane" style="display:none;flex:1;min-height:0;overflow-y:auto;background:var(--bg-primary);padding:18px"></div>
15284
+ <!-- Run history drawer (slide-out from right) -->
15285
+ <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>
15286
+ <!-- Create modal -->
15287
+ <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">
15288
+ <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">
15289
+ <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New trick</h3>
15290
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Name</label>
15291
+ <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">
15292
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Description (optional)</label>
15293
+ <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">
15294
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Schedule (optional cron expression)</label>
15295
+ <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">
15296
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Owner</label>
15297
+ <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">
15298
+ <option value="">Clementine (global)</option>
15299
+ </select>
15300
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:6px">
15301
+ <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>
15302
+ <button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.submitCreate()" style="padding:6px 14px">Create</button>
14745
15303
  </div>
14746
15304
  </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>
15305
+ </div>
15306
+ <!-- Chat-first builder modal — multi-turn conversation that drafts a trick spec live -->
15307
+ <div id="routines-chat-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
15308
+ <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);width:760px;max-width:96vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden">
15309
+ <div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px">
15310
+ <h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Build a trick with Clementine</h3>
14752
15311
  <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>
15312
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeChat()" style="padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">&times;</button>
14761
15313
  </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>
15314
+ <!-- Live spec preview -->
15315
+ <div id="routines-chat-preview" style="display:none;padding:10px 18px;border-bottom:1px solid var(--border);background:var(--bg-tertiary);font-size:12px;color:var(--text-secondary)"></div>
15316
+ <!-- Messages -->
15317
+ <div id="routines-chat-messages" style="flex:1;min-height:240px;overflow-y:auto;padding:14px 18px;display:flex;flex-direction:column;gap:10px"></div>
15318
+ <!-- Composer -->
15319
+ <div style="padding:12px 18px;border-top:1px solid var(--border);background:var(--bg-secondary)">
15320
+ <div style="display:flex;gap:8px;align-items:flex-end">
15321
+ <textarea id="routines-chat-input" rows="2" placeholder="Tell Clementine what you want her to do…" style="flex:1;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:inherit;resize:vertical;box-sizing:border-box" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();window.RoutinesUI&&RoutinesUI.sendChat();}"></textarea>
15322
+ <button id="routines-chat-send" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.sendChat()" style="padding:8px 16px;align-self:flex-end">Send</button>
14791
15323
  </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>
15324
+ <div style="display:flex;align-items:center;gap:10px;margin-top:8px;font-size:11px">
15325
+ <span id="routines-chat-status" style="color:var(--text-muted);flex:1;min-height:14px"></span>
15326
+ <button id="routines-chat-save" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.saveChatDraft()" style="display:none;padding:5px 12px;font-size:11px">Save trick</button>
15327
+ <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeChat(true);RoutinesUI.openCreate();" style="padding:4px 10px;background:transparent;border:none;color:var(--text-muted);font-size:11px;cursor:pointer;text-decoration:underline">Build manually instead</button>
14798
15328
  </div>
14799
- <div id="builder-skills-list" style="padding:0 12px 12px"></div>
14800
- </div>
14801
- </div>
14802
- </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>
14810
- </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>
14814
15329
  </div>
14815
15330
  </div>
14816
- <div id="panel-cron"></div>
14817
15331
  </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>
15332
+ <!-- Step picker modal -->
15333
+ <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">
15334
+ <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">
15335
+ <div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px">
15336
+ <h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Add step</h3>
15337
+ <span style="flex:1"></span>
15338
+ <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
15339
  </div>
15340
+ <div id="routines-step-picker-body" style="padding:16px 20px;overflow-y:auto;flex:1"></div>
14862
15341
  </div>
14863
15342
  </div>
14864
15343
  </div>
15344
+ <script>
15345
+ // ── Routines UI ─────────────────────────────────────────────────
15346
+ // Vanilla JS module that drives the new Routines surface. State-light,
15347
+ // re-fetches from /api/routines on most actions to stay correct without
15348
+ // a client-side data store. Renders linear step lists; no canvas.
15349
+ (function() {
15350
+ var R = {
15351
+ state: {
15352
+ list: [],
15353
+ owners: [],
15354
+ mcpTools: null, // { servers: [{name,enabled,tools:[]}, ...] }
15355
+ cliTools: null, // [{cmd,description,userDefined}, ...]
15356
+ editing: null, // { id, routine, dirty }
15357
+ assistBusy: false,
15358
+ },
15359
+ init: function() {
15360
+ // Load reference data lazily; trigger list render immediately.
15361
+ this.loadOwners();
15362
+ this.refreshList();
15363
+ this.loadMcpTools();
15364
+ this.loadCliTools();
15365
+ },
15366
+ // ── data ────────────────────────────────────────────────────
15367
+ loadOwners: function() {
15368
+ // Reuse the agent registry the rest of the dashboard uses.
15369
+ apiFetch('/api/agents').then(function(r){ return r.json(); }).then(function(data){
15370
+ R.state.owners = (data.agents || []).map(function(a){ return { slug: a.slug, name: a.name || a.slug }; });
15371
+ R.populateOwnerSelects();
15372
+ }).catch(function(){ /* non-fatal */ });
15373
+ },
15374
+ populateOwnerSelects: function() {
15375
+ var filter = document.getElementById('routines-owner-filter');
15376
+ var creator = document.getElementById('routines-create-owner');
15377
+ var keepFilter = filter && filter.value;
15378
+ if (filter) {
15379
+ filter.innerHTML = '<option value="__all__">All</option><option value="__global__">Clementine (global)</option>'
15380
+ + R.state.owners.map(function(o){ return '<option value="' + R.esc(o.slug) + '">@' + R.esc(o.name) + '</option>'; }).join('');
15381
+ if (keepFilter) filter.value = keepFilter;
15382
+ }
15383
+ if (creator) {
15384
+ creator.innerHTML = '<option value="">Clementine (global)</option>'
15385
+ + R.state.owners.map(function(o){ return '<option value="' + R.esc(o.slug) + '">@' + R.esc(o.name) + '</option>'; }).join('');
15386
+ }
15387
+ },
15388
+ loadMcpTools: function() {
15389
+ apiFetch('/api/routines/mcp-tools').then(function(r){ return r.json(); }).then(function(data){
15390
+ R.state.mcpTools = data && data.servers ? data : { servers: [] };
15391
+ }).catch(function(){ R.state.mcpTools = { servers: [] }; });
15392
+ },
15393
+ loadCliTools: function() {
15394
+ apiFetch('/api/routines/cli-tools').then(function(r){ return r.json(); }).then(function(data){
15395
+ R.state.cliTools = (data && data.tools) || [];
15396
+ }).catch(function(){ R.state.cliTools = []; });
15397
+ },
15398
+ refreshList: function() {
15399
+ apiFetch('/api/routines').then(function(r){ return r.json(); }).then(function(data){
15400
+ R.state.list = (data && data.routines) || [];
15401
+ R.renderList();
15402
+ }).catch(function(){ R.state.list = []; R.renderList(); });
15403
+ },
15404
+ // ── list view ───────────────────────────────────────────────
15405
+ renderList: function() {
15406
+ var body = document.getElementById('routines-list-body');
15407
+ var empty = document.getElementById('routines-list-empty');
15408
+ var wrap = document.getElementById('routines-list-wrap');
15409
+ var count = document.getElementById('routines-count');
15410
+ if (!body || !empty || !wrap) return;
15411
+ var filter = (document.getElementById('routines-owner-filter') || {}).value || '__all__';
15412
+ var rows = R.state.list.filter(function(r){
15413
+ if (filter === '__all__') return true;
15414
+ if (filter === '__global__') return r.scope === 'global';
15415
+ return r.scope === 'agent' && r.agentSlug === filter;
15416
+ });
15417
+ if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? ' trick' : ' tricks');
15418
+ if (rows.length === 0) {
15419
+ empty.style.display = 'block';
15420
+ wrap.style.display = 'none';
15421
+ return;
15422
+ }
15423
+ empty.style.display = 'none';
15424
+ wrap.style.display = 'block';
15425
+ body.innerHTML = rows.map(function(r){
15426
+ var owner = r.scope === 'agent' ? '@' + R.esc(r.agentSlug || '?') : 'Clementine';
15427
+ 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>';
15428
+ var enabledBadge = '<input type="checkbox" ' + (r.enabled ? 'checked' : '') + ' onchange="event.stopPropagation();window.RoutinesUI&&RoutinesUI.toggle(\\x27' + R.esc(r.id) + '\\x27)" style="cursor:pointer">';
15429
+ 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>' : '';
15430
+ return '<tr style="border-top:1px solid var(--border);cursor:pointer" onclick="window.RoutinesUI&&RoutinesUI.openEditor(\\x27' + R.esc(r.id) + '\\x27)">'
15431
+ + '<td style="padding:11px 14px;color:var(--text-primary);font-weight:500">' + R.esc(r.name) + origin + '</td>'
15432
+ + '<td style="padding:11px 14px;color:var(--text-secondary);font-size:12px">' + R.esc(owner) + '</td>'
15433
+ + '<td style="padding:11px 14px">' + schedule + '</td>'
15434
+ + '<td style="padding:11px 14px;color:var(--text-secondary);font-size:12px">' + r.stepCount + '</td>'
15435
+ + '<td style="padding:11px 14px;color:var(--text-muted);font-size:12px">&mdash;</td>'
15436
+ + '<td style="padding:11px 14px;text-align:center" onclick="event.stopPropagation()">' + enabledBadge + '</td>'
15437
+ + '<td style="padding:11px 14px;text-align:right;white-space:nowrap" onclick="event.stopPropagation()">'
15438
+ + '<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>'
15439
+ + '<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>'
15440
+ + '</td></tr>';
15441
+ }).join('');
15442
+ },
15443
+ toggle: function(id) {
15444
+ apiFetch('/api/routines/' + encodeURIComponent(id) + '/toggle', { method: 'POST' })
15445
+ .then(function(r){ return r.json(); })
15446
+ .then(function(){ R.refreshList(); })
15447
+ .catch(function(err){ alert('Toggle failed: ' + err); });
15448
+ },
15449
+ run: function(id, approvedSideEffects) {
15450
+ apiFetch('/api/routines/' + encodeURIComponent(id) + '/run', {
15451
+ method: 'POST',
15452
+ headers: { 'Content-Type': 'application/json' },
15453
+ body: JSON.stringify({ approvedSideEffects: approvedSideEffects === true })
15454
+ }).then(function(r){
15455
+ if (r.status === 409) {
15456
+ return r.json().then(function(j){
15457
+ var lines = (j.sideEffects || []).map(function(s){ return ' • ' + s.kind + ': ' + s.label; }).join('\\n');
15458
+ if (confirm('This trick has side effects:\\n\\n' + lines + '\\n\\nProceed?')) R.run(id, true);
15459
+ });
15460
+ }
15461
+ return r.json().then(function(j){
15462
+ if (j.ok) R.flash('Triggered.');
15463
+ else alert('Run failed: ' + (j.error || 'unknown'));
15464
+ });
15465
+ }).catch(function(err){ alert('Run failed: ' + err); });
15466
+ },
15467
+ // ── editor ──────────────────────────────────────────────────
15468
+ openEditor: function(id) {
15469
+ apiFetch('/api/routines/' + encodeURIComponent(id))
15470
+ .then(function(r){ return r.json(); })
15471
+ .then(function(data){
15472
+ if (!data || !data.routine) { alert('Failed to load trick'); return; }
15473
+ R.state.editing = { id: data.id, routine: data.routine, dirty: false, validation: data.validation };
15474
+ R.showEditor();
15475
+ }).catch(function(err){ alert('Open failed: ' + err); });
15476
+ },
15477
+ showEditor: function() {
15478
+ document.getElementById('routines-list-pane').style.display = 'none';
15479
+ document.getElementById('routines-editor-pane').style.display = 'block';
15480
+ document.getElementById('routines-back-btn').style.display = 'inline-block';
15481
+ document.getElementById('routines-create-btn').style.display = 'none';
15482
+ document.getElementById('routines-assist-btn').style.display = 'none';
15483
+ document.getElementById('routines-owner-filter').style.display = 'none';
15484
+ document.getElementById('routines-owner-label').style.display = 'none';
15485
+ document.getElementById('routines-editor-breadcrumb').style.display = 'inline';
15486
+ document.getElementById('routines-editor-name').textContent = R.state.editing.routine.name;
15487
+ R.renderEditor();
15488
+ },
15489
+ closeEditor: function() {
15490
+ if (R.state.editing && R.state.editing.dirty && !confirm('Discard unsaved changes?')) return;
15491
+ R.state.editing = null;
15492
+ document.getElementById('routines-list-pane').style.display = 'block';
15493
+ document.getElementById('routines-editor-pane').style.display = 'none';
15494
+ document.getElementById('routines-back-btn').style.display = 'none';
15495
+ document.getElementById('routines-create-btn').style.display = 'inline-block';
15496
+ document.getElementById('routines-assist-btn').style.display = 'inline-block';
15497
+ document.getElementById('routines-owner-filter').style.display = 'inline-block';
15498
+ document.getElementById('routines-owner-label').style.display = 'inline';
15499
+ document.getElementById('routines-editor-breadcrumb').style.display = 'none';
15500
+ R.refreshList();
15501
+ },
15502
+ renderEditor: function() {
15503
+ var pane = document.getElementById('routines-editor-pane');
15504
+ if (!pane || !R.state.editing) return;
15505
+ var wf = R.state.editing.routine;
15506
+ var html = '<div style="max-width:920px;margin:0 auto">';
15507
+ html += '<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px">'
15508
+ + '<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">'
15509
+ + '<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">'
15510
+ + '<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>'
15511
+ + '</div>'
15512
+ + '<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">'
15513
+ + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px"><label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em;min-width:62px">Schedule</label>'
15514
+ + '<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">'
15515
+ + '</div>'
15516
+ + '<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;min-width:62px">Model</label>'
15517
+ + R.modelSelect('re-model', wf.model || '', 'Default for prompt steps that don\\x27t override')
15518
+ + '</div></div>';
15519
+ // Steps
15520
+ 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>';
15521
+ html += '<div id="re-steps-list">' + (wf.steps || []).map(function(s, i){ return R.renderStepCard(s, i); }).join('') + '</div>';
15522
+ 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>';
15523
+ // Action bar
15524
+ 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">'
15525
+ + '<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>'
15526
+ + '<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>'
15527
+ + '<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>'
15528
+ + '<span style="flex:1"></span>'
15529
+ + (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>' : '')
15530
+ + '<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>'
15531
+ + '<button class="btn-sm btn-primary" id="re-save-btn" onclick="window.RoutinesUI&&RoutinesUI.saveCurrent()" style="padding:6px 16px">Save</button>'
15532
+ + '</div>';
15533
+ html += '<div id="re-status" style="font-size:11px;color:var(--text-muted);min-height:14px;padding:6px 0"></div>';
15534
+ html += '</div>';
15535
+ pane.innerHTML = html;
15536
+ if (window.hydrateLucideIcons) window.hydrateLucideIcons();
15537
+ },
15538
+ renderStepCard: function(step, idx) {
15539
+ var kind = step.kind || 'prompt';
15540
+ var kindColor = { prompt: '#5e72e4', mcp: '#2dce89', cli: '#fb6340', conditional: '#f5365c', channel: '#11cdef', transform: '#ffd600', loop: '#8965e0' }[kind] || '#888';
15541
+ 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>';
15542
+ // Model picker is only meaningful for prompt steps (other kinds don't call the LLM directly).
15543
+ var modelCtl = (kind === 'prompt')
15544
+ ? R.modelSelect('', step.model || '', 'Use trick default', { idx: idx, small: true })
15545
+ : '';
15546
+ var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">'
15547
+ + '<span style="font-size:11px;color:var(--text-muted);font-weight:600;min-width:24px">#' + (idx + 1) + '</span>'
15548
+ + badge
15549
+ + '<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">'
15550
+ + modelCtl
15551
+ + '<span style="flex:1"></span>'
15552
+ + (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>' : '')
15553
+ + (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>' : '')
15554
+ + '<button title="Remove" onclick="window.RoutinesUI&&RoutinesUI.removeStep(' + idx + ')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px">&times;</button>'
15555
+ + '</div>';
15556
+ var body = R.renderStepBody(step, idx);
15557
+ var depsList = (step.dependsOn || []).join(', ');
15558
+ 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>'
15559
+ + '<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>';
15560
+ 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>';
15561
+ },
15562
+ renderStepBody: function(step, idx) {
15563
+ var kind = step.kind || 'prompt';
15564
+ switch (kind) {
15565
+ case 'prompt':
15566
+ 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>';
15567
+ case 'mcp':
15568
+ var mcp = step.mcp || { server: '', tool: '', inputs: {} };
15569
+ 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('');
15570
+ var server = (R.state.mcpTools && R.state.mcpTools.servers || []).find(function(s){ return s.name === mcp.server; });
15571
+ 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('') : '');
15572
+ var inputsJson = JSON.stringify(mcp.inputs || {}, null, 2);
15573
+ return '<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
15574
+ + '<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>'
15575
+ + '<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>'
15576
+ + '</div>'
15577
+ + '<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>'
15578
+ + '<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>';
15579
+ case 'cli':
15580
+ var cli = step.cli || { cmd: '', args: [] };
15581
+ 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('');
15582
+ if (cli.cmd && !(R.state.cliTools || []).some(function(c){ return c.cmd === cli.cmd; })) {
15583
+ cliOptions += '<option value="' + R.esc(cli.cmd) + '" selected>' + R.esc(cli.cmd) + ' (custom)</option>';
15584
+ }
15585
+ return '<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
15586
+ + '<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>'
15587
+ + '<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">'
15588
+ + '</div>'
15589
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Args (one per line; supports {{steps.x}} templates)</label>'
15590
+ + '<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>'
15591
+ + '<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>';
15592
+ case 'conditional':
15593
+ var cond = step.conditional || { condition: '', trueNext: [], falseNext: [] };
15594
+ 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>'
15595
+ + '<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">'
15596
+ + '<div style="display:flex;gap:8px"><div style="flex:1">'
15597
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">If true → run these step ids (comma)</label>'
15598
+ + '<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">'
15599
+ + '</div><div style="flex:1">'
15600
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">If false → run these step ids</label>'
15601
+ + '<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">'
15602
+ + '</div></div>';
15603
+ case 'channel':
15604
+ var ch = step.channel || { channel: 'discord', target: '', content: '' };
15605
+ return '<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
15606
+ + '<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">'
15607
+ + ['discord','slack','telegram','whatsapp','email','webhook'].map(function(c){ return '<option value="' + c + '"' + (ch.channel === c ? ' selected' : '') + '>' + c + '</option>'; }).join('')
15608
+ + '</select>'
15609
+ + '<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">'
15610
+ + '</div>'
15611
+ + '<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>';
15612
+ case 'transform':
15613
+ var tr = step.transform || { expression: '' };
15614
+ return '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Expression (sandboxed JS; returns the step output)</label>'
15615
+ + '<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>';
15616
+ case 'loop':
15617
+ var lp = step.loop || { items: '', bodyStepIds: [] };
15618
+ return '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Items expression (yields an array)</label>'
15619
+ + '<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">'
15620
+ + '<label style="display:block;font-size:10px;color:var(--text-muted);margin-bottom:4px">Body step ids (comma-separated)</label>'
15621
+ + '<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">';
15622
+ default:
15623
+ return '<em style="font-size:11px;color:var(--text-muted)">Unknown step kind: ' + R.esc(kind) + '</em>';
15624
+ }
15625
+ },
15626
+ markDirty: function() {
15627
+ if (!R.state.editing) return;
15628
+ R.state.editing.dirty = true;
15629
+ // Sync header inputs back to the routine.
15630
+ var nm = document.getElementById('re-name'); if (nm) R.state.editing.routine.name = nm.value;
15631
+ var de = document.getElementById('re-description'); if (de) R.state.editing.routine.description = de.value;
15632
+ var sc = document.getElementById('re-schedule'); if (sc) {
15633
+ var v = sc.value.trim();
15634
+ R.state.editing.routine.trigger = v ? { schedule: v, manual: false } : { manual: true };
15635
+ }
15636
+ var en = document.getElementById('re-enabled'); if (en) R.state.editing.routine.enabled = en.checked;
15637
+ var md = document.getElementById('re-model'); if (md) R.state.editing.routine.model = md.value || undefined;
15638
+ R.setStatus('Unsaved changes');
15639
+ },
15640
+ updateStep: function(idx, field, value) {
15641
+ if (!R.state.editing) return;
15642
+ R.state.editing.routine.steps[idx][field] = value;
15643
+ R.markDirty();
15644
+ },
15645
+ updateStepDeps: function(idx, csv) {
15646
+ if (!R.state.editing) return;
15647
+ R.state.editing.routine.steps[idx].dependsOn = csv.split(',').map(function(s){ return s.trim(); }).filter(function(s){ return s; });
15648
+ R.markDirty();
15649
+ },
15650
+ updateMcp: function(idx, field, value) {
15651
+ if (!R.state.editing) return;
15652
+ var s = R.state.editing.routine.steps[idx];
15653
+ s.mcp = s.mcp || { server: '', tool: '', inputs: {} };
15654
+ s.mcp[field] = value;
15655
+ if (field === 'server') s.mcp.tool = ''; // reset tool when server changes
15656
+ R.markDirty();
15657
+ R.renderEditor(); // re-render so tool dropdown updates
15658
+ },
15659
+ updateMcpInputs: function(idx, json) {
15660
+ if (!R.state.editing) return;
15661
+ var s = R.state.editing.routine.steps[idx];
15662
+ s.mcp = s.mcp || { server: '', tool: '', inputs: {} };
15663
+ try { s.mcp.inputs = JSON.parse(json); R.setStatus(''); }
15664
+ catch (e) { R.setStatus('Invalid JSON in step ' + s.id + ' inputs (will not save until fixed)'); return; }
15665
+ R.markDirty();
15666
+ },
15667
+ updateCli: function(idx, field, value) {
15668
+ if (!R.state.editing) return;
15669
+ var s = R.state.editing.routine.steps[idx];
15670
+ s.cli = s.cli || { cmd: '', args: [] };
15671
+ s.cli[field] = value;
15672
+ R.markDirty();
15673
+ },
15674
+ updateCliArgs: function(idx, txt) {
15675
+ if (!R.state.editing) return;
15676
+ var s = R.state.editing.routine.steps[idx];
15677
+ s.cli = s.cli || { cmd: '', args: [] };
15678
+ s.cli.args = txt.split('\\n').map(function(l){ return l.trim(); }).filter(function(l){ return l; });
15679
+ R.markDirty();
15680
+ },
15681
+ updateConditional: function(idx, field, value) {
15682
+ if (!R.state.editing) return;
15683
+ var s = R.state.editing.routine.steps[idx];
15684
+ s.conditional = s.conditional || { condition: '', trueNext: [], falseNext: [] };
15685
+ if (field === 'trueNext' || field === 'falseNext') {
15686
+ s.conditional[field] = String(value).split(',').map(function(x){ return x.trim(); }).filter(function(x){ return x; });
15687
+ } else {
15688
+ s.conditional[field] = value;
15689
+ }
15690
+ R.markDirty();
15691
+ },
15692
+ updateChannel: function(idx, field, value) {
15693
+ if (!R.state.editing) return;
15694
+ var s = R.state.editing.routine.steps[idx];
15695
+ s.channel = s.channel || { channel: 'discord', target: '', content: '' };
15696
+ s.channel[field] = value;
15697
+ R.markDirty();
15698
+ },
15699
+ updateTransform: function(idx, value) {
15700
+ if (!R.state.editing) return;
15701
+ var s = R.state.editing.routine.steps[idx];
15702
+ s.transform = { expression: value };
15703
+ R.markDirty();
15704
+ },
15705
+ updateLoop: function(idx, field, value) {
15706
+ if (!R.state.editing) return;
15707
+ var s = R.state.editing.routine.steps[idx];
15708
+ s.loop = s.loop || { items: '', bodyStepIds: [] };
15709
+ if (field === 'bodyStepIds') {
15710
+ s.loop.bodyStepIds = String(value).split(',').map(function(x){ return x.trim(); }).filter(function(x){ return x; });
15711
+ } else {
15712
+ s.loop.items = value;
15713
+ }
15714
+ R.markDirty();
15715
+ },
15716
+ moveStep: function(idx, dir) {
15717
+ if (!R.state.editing) return;
15718
+ var steps = R.state.editing.routine.steps;
15719
+ var j = idx + dir;
15720
+ if (j < 0 || j >= steps.length) return;
15721
+ var t = steps[idx]; steps[idx] = steps[j]; steps[j] = t;
15722
+ R.markDirty();
15723
+ R.renderEditor();
15724
+ },
15725
+ removeStep: function(idx) {
15726
+ if (!R.state.editing) return;
15727
+ if (R.state.editing.routine.steps.length <= 1) { alert('A trick must have at least one step.'); return; }
15728
+ if (!confirm('Remove this step?')) return;
15729
+ var removed = R.state.editing.routine.steps.splice(idx, 1)[0];
15730
+ // Strip lingering dependsOn references.
15731
+ for (var i = 0; i < R.state.editing.routine.steps.length; i++) {
15732
+ R.state.editing.routine.steps[i].dependsOn = (R.state.editing.routine.steps[i].dependsOn || []).filter(function(d){ return d !== removed.id; });
15733
+ }
15734
+ R.markDirty();
15735
+ R.renderEditor();
15736
+ },
15737
+ // ── step picker ─────────────────────────────────────────────
15738
+ openStepPicker: function() {
15739
+ var modal = document.getElementById('routines-step-picker');
15740
+ var body = document.getElementById('routines-step-picker-body');
15741
+ if (!modal || !body) return;
15742
+ var kinds = [
15743
+ { kind: 'prompt', label: 'Prompt', desc: 'Send a prompt to the agent. Use this when the work needs reasoning or freeform tools.' },
15744
+ { kind: 'mcp', label: 'MCP tool', desc: 'Call a specific MCP server tool (Composio, Claude integrations, local MCP).' },
15745
+ { kind: 'cli', label: 'Local CLI', desc: 'Run an installed CLI (sf, gh, gcloud, …) and capture stdout.' },
15746
+ { kind: 'conditional', label: 'If / Else', desc: 'Branch on a JS expression evaluated against prior step outputs.' },
15747
+ { kind: 'channel', label: 'Channel send', desc: 'Send a message to Discord, Slack, Telegram, email, or webhook.' },
15748
+ { kind: 'transform', label: 'Transform', desc: 'Sandboxed JS expression that reshapes prior step output.' },
15749
+ { kind: 'loop', label: 'Loop', desc: 'Iterate over an array; runs the listed body steps for each item.' },
15750
+ ];
15751
+ body.innerHTML = kinds.map(function(k){
15752
+ 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>';
15753
+ }).join('');
15754
+ modal.style.display = 'flex';
15755
+ },
15756
+ closeStepPicker: function() {
15757
+ var m = document.getElementById('routines-step-picker'); if (m) m.style.display = 'none';
15758
+ },
15759
+ addStep: function(kind) {
15760
+ if (!R.state.editing) return;
15761
+ var steps = R.state.editing.routine.steps;
15762
+ var n = steps.length + 1;
15763
+ var nextId = 's' + n;
15764
+ while (steps.some(function(s){ return s.id === nextId; })) { n++; nextId = 's' + n; }
15765
+ var step = { id: nextId, prompt: '', dependsOn: steps.length ? [steps[steps.length - 1].id] : [], tier: 1, maxTurns: 15 };
15766
+ if (kind !== 'prompt') step.kind = kind;
15767
+ if (kind === 'mcp') step.mcp = { server: '', tool: '', inputs: {} };
15768
+ if (kind === 'cli') step.cli = { cmd: '', args: [], timeoutMs: 60000 };
15769
+ if (kind === 'conditional') step.conditional = { condition: '', trueNext: [], falseNext: [] };
15770
+ if (kind === 'channel') step.channel = { channel: 'discord', target: '', content: '' };
15771
+ if (kind === 'transform') step.transform = { expression: '' };
15772
+ if (kind === 'loop') step.loop = { items: '', bodyStepIds: [] };
15773
+ steps.push(step);
15774
+ R.markDirty();
15775
+ R.closeStepPicker();
15776
+ R.renderEditor();
15777
+ },
15778
+ // ── editor actions ──────────────────────────────────────────
15779
+ saveCurrent: function() {
15780
+ if (!R.state.editing) return;
15781
+ R.markDirty(); // capture latest header values
15782
+ var btn = document.getElementById('re-save-btn');
15783
+ if (btn) btn.textContent = 'Saving…';
15784
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15785
+ method: 'PUT',
15786
+ headers: { 'Content-Type': 'application/json' },
15787
+ body: JSON.stringify({ routine: R.state.editing.routine })
15788
+ }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, status: r.status, body: j }; }); })
15789
+ .then(function(res){
15790
+ if (btn) btn.textContent = 'Save';
15791
+ if (!res.ok) {
15792
+ if (res.body.error === 'validation') {
15793
+ var msg = (res.body.validation.issues || []).map(function(i){ return '• ' + i.severity + ': ' + i.message; }).join('\\n');
15794
+ if (confirm('Validation issues:\\n\\n' + msg + '\\n\\nSave anyway?')) {
15795
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15796
+ method: 'PUT',
15797
+ headers: { 'Content-Type': 'application/json' },
15798
+ body: JSON.stringify({ routine: R.state.editing.routine, force: true })
15799
+ }).then(function(){ R.state.editing.dirty = false; R.setStatus('Saved (with warnings)'); R.refreshList(); });
15800
+ }
15801
+ return;
15802
+ }
15803
+ R.setStatus('Save failed: ' + (res.body.error || res.body.detail || 'unknown'));
15804
+ return;
15805
+ }
15806
+ R.state.editing.dirty = false;
15807
+ R.setStatus('Saved.');
15808
+ R.refreshList();
15809
+ }).catch(function(err){
15810
+ if (btn) btn.textContent = 'Save';
15811
+ R.setStatus('Save error: ' + err);
15812
+ });
15813
+ },
15814
+ runCurrent: function() {
15815
+ if (!R.state.editing) return;
15816
+ if (R.state.editing.dirty && !confirm('You have unsaved changes. Run anyway (using last saved version)?')) return;
15817
+ R.run(R.state.editing.id);
15818
+ },
15819
+ dryRunCurrent: function() {
15820
+ if (!R.state.editing) return;
15821
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/dry-run', { method: 'POST' })
15822
+ .then(function(r){ return r.json(); })
15823
+ .then(function(d){
15824
+ var lines = ['Dry-run for ' + R.state.editing.routine.name + ':\\n'];
15825
+ (d.steps || []).forEach(function(s){ lines.push('• ' + s.description + (s.warnings.length ? '\\n ⚠ ' + s.warnings.join('; ') : '')); });
15826
+ if (d.notes && d.notes.length) lines.push('\\n' + d.notes.join('\\n'));
15827
+ alert(lines.join('\\n'));
15828
+ }).catch(function(err){ alert('Dry-run failed: ' + err); });
15829
+ },
15830
+ testCurrent: function() {
15831
+ if (!R.state.editing) return;
15832
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/test', {
15833
+ method: 'POST',
15834
+ headers: { 'Content-Type': 'application/json' },
15835
+ body: JSON.stringify({ mode: 'mock' })
15836
+ }).then(function(r){ return r.json(); }).then(function(d){
15837
+ if (d.ok) R.setStatus('Mock test started (runId: ' + d.runId + '). See run history for output.');
15838
+ else R.setStatus('Test failed to start: ' + (d.error || 'unknown'));
15839
+ }).catch(function(err){ R.setStatus('Test error: ' + err); });
15840
+ },
15841
+ deleteCurrent: function() {
15842
+ if (!R.state.editing) return;
15843
+ if (!confirm('Delete routine "' + R.state.editing.routine.name + '"? This is permanent.')) return;
15844
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id), { method: 'DELETE' })
15845
+ .then(function(r){ return r.json(); })
15846
+ .then(function(j){
15847
+ if (j.ok) { R.state.editing = null; R.closeEditor(); R.refreshList(); }
15848
+ else alert('Delete failed: ' + (j.error || 'unknown'));
15849
+ }).catch(function(err){ alert('Delete error: ' + err); });
15850
+ },
15851
+ setStatus: function(msg) {
15852
+ var el = document.getElementById('re-status');
15853
+ if (el) el.textContent = msg || '';
15854
+ },
15855
+ flash: function(msg) {
15856
+ // Lightweight toast — reuse existing flash if available, else log.
15857
+ if (window.flashMessage) window.flashMessage(msg);
15858
+ else if (window.console) console.log('[routines]', msg);
15859
+ },
15860
+ // ── runs drawer ─────────────────────────────────────────────
15861
+ openRuns: function(id) {
15862
+ if (!id) return;
15863
+ var drawer = document.getElementById('routines-runs-drawer');
15864
+ if (!drawer) return;
15865
+ 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>';
15866
+ drawer.style.display = 'block';
15867
+ apiFetch('/api/routines/' + encodeURIComponent(id) + '/runs').then(function(r){ return r.json(); }).then(function(d){
15868
+ var runs = d.runs || [];
15869
+ 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>';
15870
+ if (runs.length === 0) {
15871
+ html += '<div style="font-size:12px;color:var(--text-muted);padding:24px 0;text-align:center">No runs yet.</div>';
15872
+ } else {
15873
+ html += runs.map(function(run){
15874
+ var when = run.startedAt || run.timestamp || '';
15875
+ var status = run.status || 'unknown';
15876
+ var color = { ok: 'var(--green)', error: 'var(--red)', partial: '#f5a623', skipped: 'var(--text-muted)', retried: '#f5a623' }[status] || 'var(--text-muted)';
15877
+ var dur = run.durationMs ? Math.round(run.durationMs / 100) / 10 + 's' : '';
15878
+ var preview = run.outputPreview || run.output_preview || run.outputPreview || '';
15879
+ 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>';
15880
+ }).join('');
15881
+ }
15882
+ html += '</div>';
15883
+ drawer.innerHTML = html;
15884
+ }).catch(function(err){
15885
+ 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>';
15886
+ });
15887
+ },
15888
+ closeRuns: function() {
15889
+ var d = document.getElementById('routines-runs-drawer'); if (d) d.style.display = 'none';
15890
+ },
15891
+ // ── create modal ────────────────────────────────────────────
15892
+ openCreate: function() {
15893
+ var m = document.getElementById('routines-create-modal'); if (!m) return;
15894
+ document.getElementById('routines-create-name').value = '';
15895
+ document.getElementById('routines-create-description').value = '';
15896
+ document.getElementById('routines-create-schedule').value = '';
15897
+ m.style.display = 'flex';
15898
+ },
15899
+ closeCreate: function() {
15900
+ var m = document.getElementById('routines-create-modal'); if (m) m.style.display = 'none';
15901
+ },
15902
+ submitCreate: function() {
15903
+ var name = document.getElementById('routines-create-name').value.trim();
15904
+ if (!name) { alert('Name is required'); return; }
15905
+ var body = {
15906
+ name: name,
15907
+ description: document.getElementById('routines-create-description').value.trim(),
15908
+ schedule: document.getElementById('routines-create-schedule').value.trim() || undefined,
15909
+ agent: document.getElementById('routines-create-owner').value || undefined,
15910
+ };
15911
+ apiFetch('/api/routines', {
15912
+ method: 'POST',
15913
+ headers: { 'Content-Type': 'application/json' },
15914
+ body: JSON.stringify(body)
15915
+ }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
15916
+ .then(function(res){
15917
+ if (!res.ok) { alert('Create failed: ' + (res.body.error || 'unknown')); return; }
15918
+ R.closeCreate();
15919
+ R.refreshList();
15920
+ R.openEditor(res.body.id);
15921
+ }).catch(function(err){ alert('Create error: ' + err); });
15922
+ },
15923
+ // ── chat-first builder ──────────────────────────────────────
15924
+ // Multi-turn conversation that asks clarifying questions and
15925
+ // drafts a trick spec. The agent emits a fenced json-artifact
15926
+ // block; we parse it for the live preview + Save button.
15927
+ openChat: function() {
15928
+ var m = document.getElementById('routines-chat-modal'); if (!m) return;
15929
+ R.state.chatMessages = [];
15930
+ R.state.chatArtifact = null;
15931
+ R.state.chatBusy = false;
15932
+ document.getElementById('routines-chat-input').value = '';
15933
+ document.getElementById('routines-chat-status').textContent = '';
15934
+ document.getElementById('routines-chat-save').style.display = 'none';
15935
+ // Reset the builder session so the prior conversation doesn't leak in.
15936
+ apiFetch('/api/builder/reset', {
15937
+ method: 'POST',
15938
+ headers: { 'Content-Type': 'application/json' },
15939
+ body: JSON.stringify({ artifactType: 'workflow' })
15940
+ }).catch(function(){ /* non-fatal */ });
15941
+ R.renderChatMessages();
15942
+ R.renderChatPreview();
15943
+ // Seed with a greeting from the assistant so the panel isn't empty.
15944
+ R.appendChatMessage('assistant', 'Hi! Tell me what you want Clementine to do — a sentence is fine. I\\x27ll ask a couple of follow-ups (when it should run, which tools she needs, which model) and draft a trick you can save.');
15945
+ m.style.display = 'flex';
15946
+ setTimeout(function(){ document.getElementById('routines-chat-input').focus(); }, 50);
15947
+ },
15948
+ closeChat: function(silent) {
15949
+ var m = document.getElementById('routines-chat-modal'); if (m) m.style.display = 'none';
15950
+ if (!silent) R.refreshList();
15951
+ },
15952
+ appendChatMessage: function(role, text) {
15953
+ R.state.chatMessages.push({ role: role, text: text });
15954
+ R.renderChatMessages();
15955
+ },
15956
+ renderChatMessages: function() {
15957
+ var box = document.getElementById('routines-chat-messages'); if (!box) return;
15958
+ box.innerHTML = (R.state.chatMessages || []).map(function(m){
15959
+ var isUser = m.role === 'user';
15960
+ var bg = isUser ? 'var(--clementine,#ff8c21)' : 'var(--bg-tertiary)';
15961
+ var color = isUser ? '#fff' : 'var(--text-primary)';
15962
+ var align = isUser ? 'flex-end' : 'flex-start';
15963
+ return '<div style="display:flex;justify-content:' + align + '"><div style="max-width:78%;padding:8px 12px;border-radius:10px;background:' + bg + ';color:' + color + ';font-size:13px;line-height:1.5;white-space:pre-wrap">' + R.esc(m.text) + '</div></div>';
15964
+ }).join('');
15965
+ box.scrollTop = box.scrollHeight;
15966
+ },
15967
+ renderChatPreview: function() {
15968
+ var pv = document.getElementById('routines-chat-preview'); if (!pv) return;
15969
+ var a = R.state.chatArtifact;
15970
+ if (!a || !a.name) { pv.style.display = 'none'; return; }
15971
+ pv.style.display = 'block';
15972
+ var schedule = a.schedule ? '<code style="font-family:\\x27JetBrains Mono\\x27,monospace;font-size:11px">' + R.esc(a.schedule) + '</code>' : '<em style="color:var(--text-muted)">manual</em>';
15973
+ var stepCount = (a.steps && typeof a.steps === 'string')
15974
+ ? (a.steps.match(/^[a-zA-Z0-9_-]+:/gm) || []).length
15975
+ : (Array.isArray(a.steps) ? a.steps.length : 0);
15976
+ pv.innerHTML = '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><strong style="color:var(--text-primary)">' + R.esc(a.name) + '</strong>'
15977
+ + '<span style="color:var(--text-muted)">·</span><span>schedule ' + schedule + '</span>'
15978
+ + '<span style="color:var(--text-muted)">·</span><span>' + stepCount + ' step' + (stepCount === 1 ? '' : 's') + '</span>'
15979
+ + (a.model ? '<span style="color:var(--text-muted)">·</span><span>model <code>' + R.esc(a.model) + '</code></span>' : '')
15980
+ + '</div>'
15981
+ + (a.description ? '<div style="margin-top:4px;color:var(--text-muted);font-size:11px">' + R.esc(a.description) + '</div>' : '');
15982
+ // Save button shows once we have a name + at least one step.
15983
+ var saveBtn = document.getElementById('routines-chat-save');
15984
+ if (saveBtn) saveBtn.style.display = (a.name && stepCount > 0) ? '' : 'none';
15985
+ },
15986
+ sendChat: function() {
15987
+ if (R.state.chatBusy) return;
15988
+ var input = document.getElementById('routines-chat-input');
15989
+ var text = input.value.trim();
15990
+ if (!text) return;
15991
+ input.value = '';
15992
+ R.appendChatMessage('user', text);
15993
+ R.state.chatBusy = true;
15994
+ var sendBtn = document.getElementById('routines-chat-send');
15995
+ var status = document.getElementById('routines-chat-status');
15996
+ if (sendBtn) { sendBtn.textContent = 'Thinking…'; sendBtn.disabled = true; }
15997
+ if (status) status.textContent = 'Clementine is drafting…';
15998
+ apiFetch('/api/builder/chat', {
15999
+ method: 'POST',
16000
+ headers: { 'Content-Type': 'application/json' },
16001
+ body: JSON.stringify({
16002
+ message: text,
16003
+ artifactType: 'workflow',
16004
+ currentArtifact: R.state.chatArtifact || undefined,
16005
+ })
16006
+ }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
16007
+ .then(function(res){
16008
+ R.state.chatBusy = false;
16009
+ if (sendBtn) { sendBtn.textContent = 'Send'; sendBtn.disabled = false; }
16010
+ if (!res.ok) {
16011
+ if (status) status.textContent = 'Error: ' + (res.body && res.body.error || 'unknown');
16012
+ return;
16013
+ }
16014
+ // Endpoint returns { ok, response, artifact } — server already
16015
+ // strips the json-artifact fence and parses the JSON for us.
16016
+ var reply = (res.body && res.body.response) || '(no reply)';
16017
+ if (res.body && res.body.artifact) R.state.chatArtifact = res.body.artifact;
16018
+ R.appendChatMessage('assistant', reply);
16019
+ R.renderChatPreview();
16020
+ if (status) status.textContent = (res.body && res.body.artifact) ? 'Draft updated.' : '';
16021
+ })
16022
+ .catch(function(err){
16023
+ R.state.chatBusy = false;
16024
+ if (sendBtn) { sendBtn.textContent = 'Send'; sendBtn.disabled = false; }
16025
+ if (status) status.textContent = 'Chat error: ' + err;
16026
+ });
16027
+ },
16028
+ // Persist the current draft as a real trick. The artifact's steps
16029
+ // field can come back as a YAML-ish string (per the agent's prompt
16030
+ // template); we hand it off to /api/routines which parses it.
16031
+ saveChatDraft: function() {
16032
+ var a = R.state.chatArtifact;
16033
+ if (!a || !a.name) return;
16034
+ var btn = document.getElementById('routines-chat-save');
16035
+ if (btn) { btn.textContent = 'Saving…'; btn.disabled = true; }
16036
+ apiFetch('/api/routines', {
16037
+ method: 'POST',
16038
+ headers: { 'Content-Type': 'application/json' },
16039
+ body: JSON.stringify({
16040
+ name: a.name,
16041
+ description: a.description || '',
16042
+ schedule: a.schedule || '',
16043
+ model: a.model || undefined,
16044
+ draftYaml: a.steps,
16045
+ })
16046
+ }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
16047
+ .then(function(res){
16048
+ if (btn) { btn.textContent = 'Save trick'; btn.disabled = false; }
16049
+ if (!res.ok) {
16050
+ alert('Save failed: ' + (res.body && res.body.error || 'unknown'));
16051
+ return;
16052
+ }
16053
+ R.closeChat();
16054
+ if (res.body && res.body.id) R.openEditor(res.body.id);
16055
+ }).catch(function(err){
16056
+ if (btn) { btn.textContent = 'Save trick'; btn.disabled = false; }
16057
+ alert('Save failed: ' + err);
16058
+ });
16059
+ },
16060
+ // ── helpers ─────────────────────────────────────────────────
16061
+ esc: function(s) {
16062
+ if (s == null) return '';
16063
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
16064
+ },
16065
+ // Available Claude models for trick + step model pickers.
16066
+ MODEL_OPTS: [
16067
+ { id: 'claude-opus-4-7', label: 'Opus 4.7 — most capable' },
16068
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 — balanced' },
16069
+ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5 — fastest' },
16070
+ ],
16071
+ // Render a model select. If opts.idx is set, this is a per-step
16072
+ // picker and changes route through updateStep(idx, 'model', value);
16073
+ // otherwise it's the trick-level picker (id=re-model) that
16074
+ // markDirty reads.
16075
+ modelSelect: function(id, current, defaultLabel, opts) {
16076
+ var size = (opts && opts.small) ? 'padding:3px 6px;font-size:11px;min-width:140px' : 'padding:6px 10px;font-size:12px;min-width:200px';
16077
+ var idAttr = id ? ' id="' + id + '"' : '';
16078
+ var onchange = (opts && typeof opts.idx === 'number')
16079
+ ? ' onchange="window.RoutinesUI&&RoutinesUI.updateStep(' + opts.idx + ',\\x27model\\x27,this.value)"'
16080
+ : ' onchange="window.RoutinesUI&&RoutinesUI.markDirty()"';
16081
+ var html = '<select' + idAttr + onchange + ' style="' + size + ';border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary)" title="' + R.esc(defaultLabel || '') + '">';
16082
+ html += '<option value=""' + (current ? '' : ' selected') + '>' + R.esc(defaultLabel || 'inherit') + '</option>';
16083
+ R.MODEL_OPTS.forEach(function(m){
16084
+ html += '<option value="' + R.esc(m.id) + '"' + (current === m.id ? ' selected' : '') + '>' + R.esc(m.label) + '</option>';
16085
+ });
16086
+ html += '</select>';
16087
+ return html;
16088
+ },
16089
+ };
16090
+ window.RoutinesUI = R;
16091
+ // Compatibility shims for legacy callers in other parts of the dashboard.
16092
+ // The old switchBuildTab(tab) is referenced from KPI tiles, getting-started cards,
16093
+ // and the navigateTo dispatcher. Map them all to the unified Routines view.
16094
+ if (typeof window.switchBuildTab !== 'function' || true) {
16095
+ window.switchBuildTab = function() { try { R.init(); } catch (e) { /* */ } };
16096
+ }
16097
+ // Auto-init when the user lands on the build page.
16098
+ document.addEventListener('DOMContentLoaded', function() {
16099
+ var nav = document.querySelector('[data-page="build"]');
16100
+ if (nav) nav.addEventListener('click', function() { setTimeout(function() { R.init(); }, 50); });
16101
+ // If page-build is already active on load (deep-link), init now.
16102
+ var page = document.getElementById('page-build');
16103
+ if (page && page.classList.contains('active')) R.init();
16104
+ });
16105
+ })();
16106
+ </script>
14865
16107
 
14866
16108
  <!-- page-agent-detail merged into Team page; click an agent in Roster to drill down. -->
14867
16109
 
@@ -14963,12 +16205,12 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14963
16205
  </div>
14964
16206
  <div class="tab-bar" id="intelligence-tabs" style="margin:0 0 0 18px">
14965
16207
  <button class="active" data-icon="layoutDashboard" onclick="switchTab('intelligence','overview')"><span class="icon-slot"></span> Overview</button>
14966
- <button data-icon="database" onclick="switchTab('intelligence','search')"><span class="icon-slot"></span> Memory</button>
16208
+ <button data-icon="database" onclick="switchTab('intelligence','search')"><span class="icon-slot"></span> Chunks</button>
14967
16209
  <button data-icon="upload" onclick="switchTab('intelligence','seed')"><span class="icon-slot"></span> Seed</button>
14968
16210
  <button data-icon="repeat" onclick="switchTab('intelligence','sources')"><span class="icon-slot"></span> Automate</button>
14969
16211
  <button data-icon="listChecks" onclick="switchTab('intelligence','runs')"><span class="icon-slot"></span> Runs</button>
14970
16212
  <button data-icon="sparkles" onclick="switchTab('intelligence','graph')"><span class="icon-slot"></span> Knowledge</button>
14971
- <button data-icon="fileText" onclick="switchTab('intelligence','files')"><span class="icon-slot"></span> Files</button>
16213
+ <button data-icon="fileText" onclick="switchTab('intelligence','files')"><span class="icon-slot"></span> Memory</button>
14972
16214
  <button data-icon="zap" onclick="switchTab('intelligence','health')"><span class="icon-slot"></span> Health <span class="tab-badge" id="brain-health-badge" style="display:none;background:#ef4444;color:#fff">0</span></button>
14973
16215
  <button data-icon="users" onclick="switchTab('intelligence','user-model')"><span class="icon-slot"></span> User Model</button>
14974
16216
  <button data-icon="brain" onclick="switchTab('intelligence','learning')"><span class="icon-slot"></span> Learning <span class="tab-badge" id="brain-learning-badge" style="display:none;background:#f59e0b;color:#000">0</span></button>
@@ -15085,6 +16327,21 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15085
16327
  <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
15086
16328
  </div>
15087
16329
  </div>
16330
+ <div class="card" style="margin-bottom:14px">
16331
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
16332
+ <span>Persistent learnings</span>
16333
+ <div style="display:flex;align-items:center;gap:8px">
16334
+ <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)">
16335
+ <option value="active" selected>Active</option>
16336
+ <option value="all">Active + superseded + cancelled</option>
16337
+ </select>
16338
+ <span style="font-size:11px;color:var(--text-muted)">Distilled durable beliefs from past sessions</span>
16339
+ </div>
16340
+ </div>
16341
+ <div class="card-body" id="panel-learnings" style="padding:0">
16342
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
16343
+ </div>
16344
+ </div>
15088
16345
  <div class="card" style="margin-bottom:14px">
15089
16346
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
15090
16347
  <span>Open commitments</span>
@@ -15331,24 +16588,55 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15331
16588
  <div id="brain-runs-list"></div>
15332
16589
  </div>
15333
16590
  <div class="tab-pane" id="tab-intelligence-files">
15334
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap">
15335
- <input type="text" id="vault-files-search" placeholder="Search title or path..." style="flex:1;min-width:200px;padding:7px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-input);color:var(--text-primary);font-size:13px" oninput="refreshVaultFiles()">
16591
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap">
16592
+ <input type="text" id="vault-files-search" placeholder="Search title, frontmatter, or content..." style="flex:1;min-width:220px;padding:7px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-input);color:var(--text-primary);font-size:13px" oninput="refreshVaultFiles()">
15336
16593
  <select id="vault-files-agent-filter" onchange="refreshVaultFiles()" style="padding:7px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:12px">
15337
16594
  <option value="">All authors</option>
15338
16595
  <option value="__shared__">Shared (vault root)</option>
15339
16596
  </select>
15340
16597
  <select id="vault-files-since" onchange="refreshVaultFiles()" style="padding:7px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-secondary);color:var(--text-primary);font-size:12px">
15341
16598
  <option value="7">Past 7 days</option>
15342
- <option value="30" selected>Past 30 days</option>
16599
+ <option value="30">Past 30 days</option>
15343
16600
  <option value="90">Past 90 days</option>
15344
16601
  <option value="365">Past year</option>
16602
+ <option value="9999" selected>All time</option>
15345
16603
  </select>
15346
- <button class="btn-sm" onclick="refreshVaultFiles()">Refresh</button>
16604
+ <button class="btn-sm" onclick="refreshVaultFiles()" title="Refresh"><span class="icon-slot" data-icon="refreshCw"></span></button>
15347
16605
  </div>
15348
- <div id="vault-files-folder-chips" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px"></div>
15349
- <div id="vault-files-list">
15350
- <div class="skel-block"><div class="skel-row med"></div><div class="skel-row"></div><div class="skel-row short"></div></div>
16606
+ <div class="vault-mem-grid" style="display:grid;grid-template-columns:240px minmax(280px,1fr) minmax(360px,1.4fr);gap:12px;align-items:stretch;height:calc(100vh - 240px);min-height:520px">
16607
+
16608
+ <!-- Left rail: facet chips (folder / type / tag) -->
16609
+ <div class="vault-mem-rail" style="overflow-y:auto;padding:10px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-md);font-size:12px">
16610
+ <div style="font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin:2px 4px 6px">Folders</div>
16611
+ <div id="vault-files-folder-chips" class="vault-facet-list"></div>
16612
+ <div style="font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin:14px 4px 6px">Type</div>
16613
+ <div id="vault-files-type-chips" class="vault-facet-list"></div>
16614
+ <div style="font-size:10px;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin:14px 4px 6px">Tags</div>
16615
+ <div id="vault-files-tag-chips" class="vault-facet-list"></div>
16616
+ </div>
16617
+
16618
+ <!-- Middle: file list -->
16619
+ <div class="vault-mem-list" style="overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-card);display:flex;flex-direction:column;min-height:0">
16620
+ <div id="vault-files-list-meta" style="font-size:11px;color:var(--text-muted);padding:10px 14px;border-bottom:1px solid var(--border-light);flex-shrink:0">Loading…</div>
16621
+ <div id="vault-files-list" style="flex:1;overflow-y:auto;min-height:0">
16622
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row"></div><div class="skel-row short"></div></div>
16623
+ </div>
16624
+ </div>
16625
+
16626
+ <!-- Right: inline reader -->
16627
+ <div class="vault-mem-reader" style="overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-card);display:flex;flex-direction:column;min-height:0">
16628
+ <div id="vault-reader-header" style="padding:14px 18px;border-bottom:1px solid var(--border-light);flex-shrink:0">
16629
+ <div style="font-weight:600;font-size:15px;color:var(--text-primary)">No file selected</div>
16630
+ <div style="font-size:11px;color:var(--text-muted);margin-top:2px">Pick a file from the list to read it here.</div>
16631
+ </div>
16632
+ <div id="vault-reader-body" style="flex:1;overflow-y:auto;padding:18px 22px;font-size:14px;line-height:1.6;min-height:0">
16633
+ <div style="color:var(--text-muted);font-size:13px">Tip: hover a row for a peek, click to open.</div>
16634
+ </div>
16635
+ </div>
15351
16636
  </div>
16637
+
16638
+ <!-- Hover preview popover (shared, repositioned per row) -->
16639
+ <div id="vault-hover-popover" style="display:none;position:fixed;z-index:300;width:340px;max-width:90vw;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:0 10px 30px rgba(0,0,0,0.25);padding:12px 14px;font-size:12px;line-height:1.5;pointer-events:none"></div>
15352
16640
  </div>
15353
16641
  <div class="tab-pane" id="tab-intelligence-health">
15354
16642
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;flex-wrap:wrap">
@@ -18630,10 +19918,14 @@ function switchTab(group, tab) {
18630
19918
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
18631
19919
  if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
18632
19920
  if (typeof refreshCommitments === 'function') refreshCommitments();
19921
+ if (typeof refreshLearnings === 'function') refreshLearnings();
18633
19922
  if (typeof refreshSupersedes === 'function') refreshSupersedes();
18634
19923
  if (typeof refreshCoverageStrip === 'function') refreshCoverageStrip();
18635
19924
  }
18636
- if (tab === 'files' && typeof refreshVaultFiles === 'function') refreshVaultFiles();
19925
+ if (tab === 'files' && typeof refreshVaultFiles === 'function') {
19926
+ if (typeof vaultRestoreFromHash === 'function') vaultRestoreFromHash();
19927
+ refreshVaultFiles();
19928
+ }
18637
19929
  if (tab === 'sources') {
18638
19930
  if (typeof brainLoadSources === 'function') brainLoadSources();
18639
19931
  if (typeof brainLoadFeedConnectors === 'function') brainLoadFeedConnectors();
@@ -20061,7 +21353,7 @@ let scheduledWorkflowData = [];
20061
21353
  let buildUsageByTask = {};
20062
21354
 
20063
21355
  function jsStr(s) {
20064
- return String(s ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r/g, '\\r').replace(/\n/g, '\\n');
21356
+ return String(s ?? '').replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'").replace(/\\r/g, '\\\\r').replace(/\\n/g, '\\\\n');
20065
21357
  }
20066
21358
 
20067
21359
  function durationLabel(ms) {
@@ -24989,6 +26281,7 @@ async function submitQuickAddMemory() {
24989
26281
  if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
24990
26282
  if (typeof refreshRecentEpisodes === 'function') refreshRecentEpisodes();
24991
26283
  if (typeof refreshCommitments === 'function') refreshCommitments();
26284
+ if (typeof refreshLearnings === 'function') refreshLearnings();
24992
26285
  if (typeof refreshMemory === 'function') refreshMemory();
24993
26286
  }, 600);
24994
26287
  } catch (err) {
@@ -25142,6 +26435,71 @@ async function refreshRecentWrites() {
25142
26435
  }
25143
26436
  }
25144
26437
 
26438
+ async function refreshLearnings() {
26439
+ var el = document.getElementById('panel-learnings');
26440
+ if (!el) return;
26441
+ try {
26442
+ var sel = document.getElementById('learnings-filter-scope');
26443
+ var scope = sel ? sel.value : 'active';
26444
+ var url = '/api/memory/learnings?limit=100' + (scope === 'all' ? '&all=1' : '');
26445
+ var r = await apiFetch(url);
26446
+ var d = await r.json();
26447
+ if (!d.ok || !Array.isArray(d.facts)) {
26448
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
26449
+ return;
26450
+ }
26451
+ if (d.facts.length === 0) {
26452
+ 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>';
26453
+ return;
26454
+ }
26455
+ var html = '<table class="data-table" style="width:100%">';
26456
+ html += '<thead><tr>'
26457
+ + '<th style="width:90px">Kind</th>'
26458
+ + '<th>Belief</th>'
26459
+ + '<th style="width:120px">Status</th>'
26460
+ + '<th style="width:140px">Captured</th>'
26461
+ + '<th style="width:140px">Actions</th>'
26462
+ + '</tr></thead><tbody>';
26463
+ var kindColors = { preference: '#a78bfa', fact: '#10b981', goal: '#f59e0b', workflow: '#06b6d4' };
26464
+ for (var i = 0; i < d.facts.length; i++) {
26465
+ var f = d.facts[i];
26466
+ var color = kindColors[f.kind] || 'var(--text-muted)';
26467
+ var statusBadge = '';
26468
+ if (f.status === 'active') statusBadge = '<span style="color:#10b981">active</span>';
26469
+ else if (f.status === 'superseded') statusBadge = '<span style="color:var(--text-muted)">superseded → #' + (f.supersededById || '?') + '</span>';
26470
+ else if (f.status === 'cancelled') statusBadge = '<span style="color:#ef4444">cancelled</span>';
26471
+ else statusBadge = esc(f.status || '');
26472
+ var when = '';
26473
+ try { when = new Date(f.createdAt + 'Z').toLocaleString(); } catch { when = f.createdAt; }
26474
+ var actions = '';
26475
+ if (f.status === 'active') actions = '<button class="btn-sm" onclick="learningAction(' + f.id + ', \\'cancel\\')">Cancel</button>';
26476
+ else if (f.status === 'cancelled') actions = '<button class="btn-sm" onclick="learningAction(' + f.id + ', \\'reinstate\\')">Reinstate</button>';
26477
+ html += '<tr>'
26478
+ + '<td style="font-size:11px;color:' + color + ';font-weight:600">' + esc(f.kind) + '</td>'
26479
+ + '<td style="font-size:12px">' + esc(f.text) + '</td>'
26480
+ + '<td style="font-size:11px">' + statusBadge + '</td>'
26481
+ + '<td style="font-size:11px;color:var(--text-muted)">' + esc(when) + '</td>'
26482
+ + '<td>' + actions + '</td>'
26483
+ + '</tr>';
26484
+ }
26485
+ html += '</tbody></table>';
26486
+ el.innerHTML = html;
26487
+ } catch (err) {
26488
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
26489
+ }
26490
+ }
26491
+
26492
+ async function learningAction(id, action) {
26493
+ try {
26494
+ var r = await apiJson('POST', '/api/memory/learnings/action', { id: id, action: action });
26495
+ if (r.error) { toast('Action failed: ' + r.error, 'error'); return; }
26496
+ toast('Learning ' + action, 'success');
26497
+ refreshLearnings();
26498
+ } catch (err) {
26499
+ toast('Failed: ' + String(err), 'error');
26500
+ }
26501
+ }
26502
+
25145
26503
  async function refreshCommitments() {
25146
26504
  var el = document.getElementById('panel-commitments');
25147
26505
  if (!el) return;
@@ -25303,28 +26661,118 @@ async function memoryHealthAction(action, extra) {
25303
26661
  }
25304
26662
  }
25305
26663
 
25306
- // ── Vault Files (Brain → Files tab) ──────────────────────────────
25307
- var _vaultFilesCache = null;
25308
- var _vaultFilesFolder = ''; // current folder filter
26664
+ // ── Memory tab (Brain → Memory): unified file browser + reader ────
26665
+ // 3-pane layout (rail / list / reader). Search merges title+frontmatter
26666
+ // matches from /api/vault-files with content matches from /api/memory/search,
26667
+ // keyed by source_file. Filters and selected file persist in URL hash.
26668
+ var _vaultFilesCache = [];
26669
+ var _vaultFilesFolder = '';
26670
+ var _vaultFilesType = '';
26671
+ var _vaultFilesTag = '';
26672
+ var _vaultFilesActive = ''; // currently open relPath
26673
+ var _vaultContentMatches = {}; // relPath -> [chunkSnippets]
26674
+ var _vaultHoverTimer = null;
26675
+ var _vaultHoverCache = {};
26676
+ var _vaultSnippetCache = {}; // relPath -> snippet (from head=1)
26677
+
26678
+ function vaultParseHash() {
26679
+ var h = (location.hash || '').replace(/^#/, '');
26680
+ var p = h.startsWith('mem/') ? h.slice(4) : '';
26681
+ if (!p) return {};
26682
+ var out = {};
26683
+ p.split('&').forEach(function(kv) {
26684
+ var i = kv.indexOf('=');
26685
+ if (i < 0) return;
26686
+ out[decodeURIComponent(kv.slice(0, i))] = decodeURIComponent(kv.slice(i + 1));
26687
+ });
26688
+ return out;
26689
+ }
26690
+
26691
+ function vaultWriteHash() {
26692
+ var parts = [];
26693
+ if (_vaultFilesFolder) parts.push('folder=' + encodeURIComponent(_vaultFilesFolder));
26694
+ if (_vaultFilesType) parts.push('type=' + encodeURIComponent(_vaultFilesType));
26695
+ if (_vaultFilesTag) parts.push('tag=' + encodeURIComponent(_vaultFilesTag));
26696
+ if (_vaultFilesActive) parts.push('file=' + encodeURIComponent(_vaultFilesActive));
26697
+ var newHash = parts.length ? '#mem/' + parts.join('&') : '';
26698
+ if (location.hash !== newHash) {
26699
+ history.replaceState(null, '', location.pathname + location.search + newHash);
26700
+ }
26701
+ }
26702
+
26703
+ function vaultRestoreFromHash() {
26704
+ var p = vaultParseHash();
26705
+ if (!p || (!p.folder && !p.type && !p.tag && !p.file)) return false;
26706
+ _vaultFilesFolder = p.folder || '';
26707
+ _vaultFilesType = p.type || '';
26708
+ _vaultFilesTag = p.tag || '';
26709
+ _vaultFilesActive = p.file || '';
26710
+ return true;
26711
+ }
25309
26712
 
25310
26713
  async function refreshVaultFiles() {
25311
26714
  var listEl = document.getElementById('vault-files-list');
25312
26715
  if (!listEl) return;
25313
- var q = document.getElementById('vault-files-search')?.value || '';
25314
- var agent = document.getElementById('vault-files-agent-filter')?.value || '';
25315
- var since = document.getElementById('vault-files-since')?.value || '30';
25316
- // Show skeleton while loading
25317
- listEl.innerHTML = '<div class="skel-block"><div class="skel-row med"></div><div class="skel-row"></div><div class="skel-row short"></div></div>';
26716
+ var q = (document.getElementById('vault-files-search') || {}).value || '';
26717
+ var agent = (document.getElementById('vault-files-agent-filter') || {}).value || '';
26718
+ var since = (document.getElementById('vault-files-since') || {}).value || '9999';
26719
+ listEl.innerHTML = '<div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row"></div><div class="skel-row short"></div></div>';
25318
26720
  try {
25319
26721
  var url = '/api/vault-files?sinceDays=' + encodeURIComponent(since)
25320
26722
  + (q ? '&q=' + encodeURIComponent(q) : '')
25321
26723
  + (agent ? '&agent=' + encodeURIComponent(agent) : '')
25322
- + (_vaultFilesFolder ? '&folder=' + encodeURIComponent(_vaultFilesFolder) : '');
25323
- var r = await apiFetch(url);
25324
- var d = await r.json();
26724
+ + (_vaultFilesFolder ? '&folder=' + encodeURIComponent(_vaultFilesFolder) : '')
26725
+ + (_vaultFilesType ? '&type=' + encodeURIComponent(_vaultFilesType) : '')
26726
+ + (_vaultFilesTag ? '&tag=' + encodeURIComponent(_vaultFilesTag) : '')
26727
+ + '&limit=300';
26728
+
26729
+ // Run vault-files and content search in parallel when there is a query.
26730
+ var promises = [apiFetch(url).then(function(r) { return r.json(); })];
26731
+ if (q && q.length >= 2) {
26732
+ promises.push(apiFetch('/api/memory/search?q=' + encodeURIComponent(q) + '&limit=40')
26733
+ .then(function(r) { return r.json(); })
26734
+ .catch(function() { return null; }));
26735
+ }
26736
+ var results = await Promise.all(promises);
26737
+ var d = results[0];
26738
+ var contentHits = results[1];
26739
+
25325
26740
  var files = d.files || [];
25326
26741
  _vaultFilesCache = files;
25327
- // Populate agent filter from ALL files response (use server's full set, not filtered)
26742
+ _vaultContentMatches = {};
26743
+
26744
+ // Bucket content-search hits by source_file. Keep up to 2 snippets per file.
26745
+ if (contentHits && Array.isArray(contentHits.results)) {
26746
+ contentHits.results.forEach(function(row) {
26747
+ var sf = row.source_file || row.path || '';
26748
+ if (!sf) return;
26749
+ // source_file values often look like 'vault/02-People/Jordan.md' OR
26750
+ // a bare relPath. Normalize to relPath.
26751
+ var rel = sf.indexOf('vault/') === 0 ? sf.slice('vault/'.length) : sf;
26752
+ if (!_vaultContentMatches[rel]) _vaultContentMatches[rel] = [];
26753
+ if (_vaultContentMatches[rel].length < 2) {
26754
+ var txt = (row.content || '').replace(/\\s+/g, ' ').slice(0, 200);
26755
+ _vaultContentMatches[rel].push({ snippet: txt, section: row.section || '' });
26756
+ }
26757
+ });
26758
+ // Inject any content-only matches (file did not surface via title/path)
26759
+ // into the file list so the user can still open them.
26760
+ var existing = {};
26761
+ files.forEach(function(f) { existing[f.relPath] = true; });
26762
+ Object.keys(_vaultContentMatches).forEach(function(rel) {
26763
+ if (existing[rel]) return;
26764
+ files.push({
26765
+ path: '', relPath: rel,
26766
+ title: rel.split('/').pop().replace(/\\.md$/, ''),
26767
+ folder: rel.split('/')[0] || '',
26768
+ agentSlug: null, mtime: '', sizeBytes: 0,
26769
+ type: null, category: null, tags: [],
26770
+ _contentOnly: true,
26771
+ });
26772
+ });
26773
+ }
26774
+
26775
+ // Populate agent dropdown from cache (only first time)
25328
26776
  var agentSel = document.getElementById('vault-files-agent-filter');
25329
26777
  if (agentSel && agentSel.options.length <= 2) {
25330
26778
  var slugs = [...new Set(files.map(function(f) { return f.agentSlug; }).filter(Boolean))].sort();
@@ -25335,100 +26783,226 @@ async function refreshVaultFiles() {
25335
26783
  agentSel.appendChild(opt);
25336
26784
  });
25337
26785
  }
25338
- // Render folder filter chips (using folderCounts from server)
25339
- var chipsEl = document.getElementById('vault-files-folder-chips');
25340
- if (chipsEl && d.folderCounts) {
25341
- var folders = Object.entries(d.folderCounts).sort(function(a, b) { return b[1] - a[1]; });
25342
- var totalCount = folders.reduce(function(s, p) { return s + p[1]; }, 0);
25343
- var chipHtml = '<div class="vault-folder-chip' + (_vaultFilesFolder === '' ? ' active' : '') + '" data-folder="" onclick="setVaultFolderFilter(\\x27\\x27)">All <span style="opacity:0.6">' + totalCount + '</span></div>';
25344
- folders.forEach(function(p) {
25345
- var folder = p[0]; var count = p[1];
25346
- if (!folder) return;
25347
- chipHtml += '<div class="vault-folder-chip' + (_vaultFilesFolder === folder ? ' active' : '') + '" data-folder="' + esc(folder) + '" onclick="setVaultFolderFilter(\\x27' + esc(folder) + '\\x27)">' + esc(folder) + ' <span style="opacity:0.6">' + count + '</span></div>';
25348
- });
25349
- chipsEl.innerHTML = chipHtml;
26786
+
26787
+ // Render facet rails
26788
+ renderFacetChips('vault-files-folder-chips', d.folderCounts || {}, _vaultFilesFolder, vaultSetFolder, 30);
26789
+ renderFacetChips('vault-files-type-chips', d.typeCounts || {}, _vaultFilesType, vaultSetType, 20);
26790
+ renderFacetChips('vault-files-tag-chips', d.tagCounts || {}, _vaultFilesTag, vaultSetTag, 20);
26791
+
26792
+ // Meta line
26793
+ var metaEl = document.getElementById('vault-files-list-meta');
26794
+ if (metaEl) {
26795
+ var totalLabel = files.length === d.total ? files.length + ' files' : files.length + ' of ' + d.total + ' files';
26796
+ metaEl.textContent = totalLabel + (q ? ' matching "' + q + '"' : '') + (since !== '9999' ? ' (last ' + since + 'd)' : '');
25350
26797
  }
26798
+
25351
26799
  if (files.length === 0) {
25352
- listEl.innerHTML = '<div class="empty-cta"><div class="label">No recent files</div><div class="hint">Try a wider time window or different filter.</div></div>';
26800
+ listEl.innerHTML = '<div class="empty-cta" style="padding:30px 14px"><div class="label">No matches</div><div class="hint">Try a wider time window, fewer filters, or a different search.</div></div>';
25353
26801
  return;
25354
26802
  }
25355
- var html = '<div style="font-size:11px;color:var(--text-muted);margin-bottom:10px">Showing ' + files.length + ' of ' + d.total + ' files modified in the last ' + since + ' days.</div>';
25356
- html += '<div style="display:flex;flex-direction:column;gap:1px;border:1px solid var(--border);border-radius:var(--radius-md);overflow:hidden;background:var(--bg-card)">';
26803
+
26804
+ var html = '';
25357
26805
  for (var i = 0; i < files.length; i++) {
25358
26806
  var f = files[i];
25359
- var agentBadge = f.agentSlug
25360
- ? '<span style="font-size:10px;background:var(--clementine-bg);color:var(--clementine);padding:2px 7px;border-radius:var(--radius-xs);font-weight:500">' + esc(f.agentSlug) + '</span>'
25361
- : '<span style="font-size:10px;background:var(--bg-tertiary);color:var(--text-muted);padding:2px 7px;border-radius:var(--radius-xs)">shared</span>';
25362
- var typeBadge = f.type ? '<span style="font-size:10px;color:var(--text-muted);margin-right:6px">' + esc(f.type) + '</span>' : '';
25363
- html += '<div class="vault-file-row clickable-row" data-path="' + esc(f.relPath) + '" style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-secondary);border-bottom:1px solid var(--border-light);font-size:13px">'
25364
- + '<div style="flex:1;min-width:0">'
25365
- + '<div style="font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(f.title) + '</div>'
25366
- + '<div style="font-size:11px;color:var(--text-muted);font-family:\\x27JetBrains Mono\\x27,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px">' + esc(f.relPath) + '</div>'
25367
- + '</div>'
25368
- + '<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">'
25369
- + typeBadge + agentBadge
25370
- + '<span style="font-size:11px;color:var(--text-muted);min-width:60px;text-align:right">' + esc(timeAgo(f.mtime)) + '</span>'
25371
- + '</div>'
26807
+ var matches = _vaultContentMatches[f.relPath];
26808
+ var pills = '';
26809
+ if (f.type) pills += '<span class="vault-pill type">' + esc(f.type) + '</span>';
26810
+ (f.tags || []).slice(0, 3).forEach(function(t) {
26811
+ pills += '<span class="vault-pill tag">' + esc(t) + '</span>';
26812
+ });
26813
+ if (matches && matches.length) pills += '<span class="vault-pill match">' + matches.length + ' match' + (matches.length > 1 ? 'es' : '') + '</span>';
26814
+ var when = f.mtime ? esc(timeAgo(f.mtime)) : '';
26815
+ var snippetHtml = '';
26816
+ if (matches && matches.length) {
26817
+ snippetHtml = '<div style="font-size:11px;color:var(--text-secondary);margin-top:4px;border-left:2px solid var(--clementine);padding-left:8px;line-height:1.5">' + esc(matches[0].snippet) + '…</div>';
26818
+ }
26819
+ html += '<div class="vault-mem-row' + (_vaultFilesActive === f.relPath ? ' active' : '') + '" data-path="' + esc(f.relPath) + '">'
26820
+ + '<div class="vault-mem-row-title">' + esc(f.title) + '</div>'
26821
+ + '<div class="vault-mem-row-meta">' + pills + (pills && when ? '<span style="opacity:0.5">·</span>' : '') + (when ? '<span>' + when + '</span>' : '') + '</div>'
26822
+ + '<div class="vault-mem-row-path">' + esc(f.relPath) + '</div>'
26823
+ + snippetHtml
25372
26824
  + '</div>';
25373
26825
  }
25374
- html += '</div>';
25375
26826
  listEl.innerHTML = html;
25376
- // Wire row clicks
25377
- listEl.querySelectorAll('.vault-file-row').forEach(function(row) {
25378
- row.onclick = function() { openVaultFile(row.getAttribute('data-path')); };
26827
+ listEl.querySelectorAll('.vault-mem-row').forEach(function(row) {
26828
+ row.addEventListener('click', function() { openVaultFile(row.getAttribute('data-path')); });
26829
+ row.addEventListener('mouseenter', function(ev) { vaultStartHover(row.getAttribute('data-path'), ev); });
26830
+ row.addEventListener('mouseleave', vaultEndHover);
25379
26831
  });
26832
+
26833
+ // Re-open the previously active file (or any file from hash on first load)
26834
+ if (_vaultFilesActive && files.some(function(f) { return f.relPath === _vaultFilesActive; })) {
26835
+ openVaultFile(_vaultFilesActive, true);
26836
+ }
25380
26837
  } catch (err) {
25381
26838
  listEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:13px">Failed to load: ' + esc(String(err)) + '</div>';
25382
26839
  }
25383
26840
  }
25384
26841
 
25385
- async function openVaultFile(relPath) {
26842
+ function renderFacetChips(elId, counts, current, onPick, maxItems) {
26843
+ var el = document.getElementById(elId);
26844
+ if (!el) return;
26845
+ var entries = Object.entries(counts).sort(function(a, b) { return b[1] - a[1]; });
26846
+ if (entries.length === 0) { el.innerHTML = '<div style="font-size:11px;color:var(--text-muted);padding:4px 8px">(none)</div>'; return; }
26847
+ var totalCount = entries.reduce(function(s, p) { return s + p[1]; }, 0);
26848
+ var html = '<div class="vault-facet-row' + (current === '' ? ' active' : '') + '" data-val="">All <span class="vault-facet-count">' + totalCount + '</span></div>';
26849
+ entries.slice(0, maxItems).forEach(function(p) {
26850
+ var k = p[0]; var c = p[1];
26851
+ if (!k) return;
26852
+ html += '<div class="vault-facet-row' + (current === k ? ' active' : '') + '" data-val="' + esc(k) + '">'
26853
+ + '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(k) + '</span>'
26854
+ + '<span class="vault-facet-count">' + c + '</span>'
26855
+ + '</div>';
26856
+ });
26857
+ el.innerHTML = html;
26858
+ el.querySelectorAll('.vault-facet-row').forEach(function(row) {
26859
+ row.addEventListener('click', function() { onPick(row.getAttribute('data-val')); });
26860
+ });
26861
+ }
26862
+
26863
+ function vaultSetFolder(v) { _vaultFilesFolder = v || ''; vaultWriteHash(); refreshVaultFiles(); }
26864
+ function vaultSetType(v) { _vaultFilesType = v || ''; vaultWriteHash(); refreshVaultFiles(); }
26865
+ function vaultSetTag(v) { _vaultFilesTag = v || ''; vaultWriteHash(); refreshVaultFiles(); }
26866
+ // Backwards-compat name used elsewhere.
26867
+ function setVaultFolderFilter(folder) { vaultSetFolder(folder); }
26868
+
26869
+ async function openVaultFile(relPath, skipHashUpdate) {
25386
26870
  if (!relPath) return;
25387
- // Build/reuse a slide-out drawer for content preview
25388
- var drawer = document.getElementById('vault-file-drawer');
25389
- if (!drawer) {
25390
- drawer = document.createElement('div');
25391
- drawer.id = 'vault-file-drawer';
25392
- drawer.style.cssText = 'position:fixed;right:0;top:0;bottom:0;width:560px;max-width:92vw;background:var(--bg-secondary);border-left:1px solid var(--border);box-shadow:-8px 0 32px rgba(0,0,0,0.18);z-index:200;display:flex;flex-direction:column;transform:translateX(100%);transition:transform 200ms ease';
25393
- drawer.innerHTML =
25394
- '<div style="display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--border);flex-shrink:0">'
25395
- + '<div style="flex:1;min-width:0">'
25396
- + '<div id="vault-file-drawer-title" style="font-weight:600;font-size:15px;letter-spacing:-0.01em"></div>'
25397
- + '<div id="vault-file-drawer-path" style="font-size:11px;color:var(--text-muted);font-family:\\x27JetBrains Mono\\x27,monospace;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>'
25398
- + '</div>'
25399
- + '<button class="btn-icon btn-sm" onclick="closeVaultFileDrawer()" title="Close">' + lucide('x', 'icn-sm') + '</button>'
25400
- + '</div>'
25401
- + '<div id="vault-file-drawer-body" style="flex:1;overflow-y:auto;padding:18px 22px;font-size:13px;line-height:1.55"></div>';
25402
- document.body.appendChild(drawer);
25403
- }
25404
- var titleEl = document.getElementById('vault-file-drawer-title');
25405
- var pathEl = document.getElementById('vault-file-drawer-path');
25406
- var body = document.getElementById('vault-file-drawer-body');
25407
- if (titleEl) titleEl.textContent = relPath.split('/').pop().replace(/\\.md$/, '');
25408
- if (pathEl) pathEl.textContent = relPath;
25409
- if (body) body.innerHTML = '<div class="skel-block"><div class="skel-row"></div><div class="skel-row med"></div><div class="skel-row short"></div></div>';
25410
- drawer.style.transform = 'translateX(0)';
26871
+ _vaultFilesActive = relPath;
26872
+ if (!skipHashUpdate) vaultWriteHash();
26873
+ // Visually mark active row
26874
+ document.querySelectorAll('#vault-files-list .vault-mem-row').forEach(function(r) {
26875
+ r.classList.toggle('active', r.getAttribute('data-path') === relPath);
26876
+ });
26877
+ var headerEl = document.getElementById('vault-reader-header');
26878
+ var bodyEl = document.getElementById('vault-reader-body');
26879
+ if (!headerEl || !bodyEl) return;
26880
+ headerEl.innerHTML = '<div style="font-weight:600;font-size:15px">' + esc(relPath.split('/').pop().replace(/\\.md$/, '')) + '</div>'
26881
+ + '<div style="font-size:11px;color:var(--text-muted);font-family:\\x27JetBrains Mono\\x27,monospace;margin-top:2px">' + esc(relPath) + '</div>';
26882
+ bodyEl.innerHTML = '<div class="skel-block"><div class="skel-row"></div><div class="skel-row med"></div><div class="skel-row short"></div></div>';
25411
26883
  try {
25412
26884
  var r = await apiFetch('/api/vault-file?path=' + encodeURIComponent(relPath));
25413
26885
  var d = await r.json();
25414
- if (d.error) {
25415
- body.innerHTML = '<div style="color:var(--red)">' + esc(d.error) + '</div>';
25416
- return;
25417
- }
25418
- body.innerHTML = renderMd(d.content);
26886
+ if (d.error) { bodyEl.innerHTML = '<div style="color:var(--red)">' + esc(d.error) + '</div>'; return; }
26887
+ var raw = d.content || '';
26888
+ var fm = vaultExtractFrontmatter(raw);
26889
+ var bodyMd = fm.body;
26890
+ var fmCard = vaultRenderFmCard(fm.data);
26891
+ var rendered = renderMd(bodyMd);
26892
+ var toc = vaultBuildToc(bodyMd);
26893
+ bodyEl.innerHTML = fmCard + '<div class="vault-reader-body">' + rendered + '</div>' + toc;
25419
26894
  } catch (err) {
25420
- body.innerHTML = '<div style="color:var(--red)">Failed: ' + esc(String(err)) + '</div>';
26895
+ bodyEl.innerHTML = '<div style="color:var(--red)">Failed: ' + esc(String(err)) + '</div>';
25421
26896
  }
25422
26897
  }
25423
26898
 
26899
+ // Back-compat (legacy drawer-style call sites). The drawer is gone, but the
26900
+ // inline reader covers the same job.
25424
26901
  function closeVaultFileDrawer() {
25425
- var drawer = document.getElementById('vault-file-drawer');
25426
- if (drawer) drawer.style.transform = 'translateX(100%)';
25427
- }
25428
-
25429
- function setVaultFolderFilter(folder) {
25430
- _vaultFilesFolder = folder || '';
25431
- refreshVaultFiles();
26902
+ _vaultFilesActive = '';
26903
+ vaultWriteHash();
26904
+ var headerEl = document.getElementById('vault-reader-header');
26905
+ var bodyEl = document.getElementById('vault-reader-body');
26906
+ if (headerEl) headerEl.innerHTML = '<div style="font-weight:600;font-size:15px">No file selected</div><div style="font-size:11px;color:var(--text-muted);margin-top:2px">Pick a file from the list to read it here.</div>';
26907
+ if (bodyEl) bodyEl.innerHTML = '<div style="color:var(--text-muted);font-size:13px">Tip: hover a row for a peek, click to open.</div>';
26908
+ document.querySelectorAll('#vault-files-list .vault-mem-row.active').forEach(function(r) { r.classList.remove('active'); });
26909
+ }
26910
+
26911
+ function vaultExtractFrontmatter(raw) {
26912
+ // Lightweight client-side YAML frontmatter parse — enough for display.
26913
+ if (!raw || raw.indexOf('---') !== 0) return { data: {}, body: raw || '' };
26914
+ var end = raw.indexOf('\\n---', 3);
26915
+ if (end < 0) return { data: {}, body: raw };
26916
+ var fmText = raw.slice(3, end).replace(/^\\n/, '');
26917
+ var body = raw.slice(end + 4).replace(/^\\n/, '');
26918
+ var data = {};
26919
+ fmText.split(/\\n/).forEach(function(line) {
26920
+ var m = line.match(/^([A-Za-z0-9_\\-]+)\\s*:\\s*(.*)$/);
26921
+ if (!m) return;
26922
+ var k = m[1]; var v = m[2].trim();
26923
+ if (v.startsWith('[') && v.endsWith(']')) {
26924
+ v = v.slice(1, -1).split(',').map(function(s) { return s.trim().replace(/^["\\x27]|["\\x27]$/g, ''); }).filter(Boolean);
26925
+ } else {
26926
+ v = v.replace(/^["\\x27]|["\\x27]$/g, '');
26927
+ }
26928
+ data[k] = v;
26929
+ });
26930
+ return { data: data, body: body };
26931
+ }
26932
+
26933
+ function vaultRenderFmCard(data) {
26934
+ var keys = Object.keys(data || {});
26935
+ if (keys.length === 0) return '';
26936
+ var html = '<div class="vault-reader-fm">';
26937
+ keys.forEach(function(k) {
26938
+ var v = data[k];
26939
+ var display = Array.isArray(v)
26940
+ ? v.map(function(x) { return '<span class="vault-pill tag" style="margin-right:4px">' + esc(String(x)) + '</span>'; }).join('')
26941
+ : esc(String(v));
26942
+ html += '<div class="k">' + esc(k) + '</div><div class="v">' + display + '</div>';
26943
+ });
26944
+ html += '</div>';
26945
+ return html;
26946
+ }
26947
+
26948
+ function vaultBuildToc(md) {
26949
+ var lines = (md || '').split('\\n');
26950
+ var headings = [];
26951
+ for (var i = 0; i < lines.length; i++) {
26952
+ var m = lines[i].match(/^(#{2,3})\\s+(.+?)\\s*$/);
26953
+ if (!m) continue;
26954
+ var lvl = m[1].length;
26955
+ var txt = m[2];
26956
+ var slug = txt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
26957
+ headings.push({ lvl: lvl, txt: txt, slug: slug });
26958
+ }
26959
+ if (headings.length < 2) return '';
26960
+ var html = '<div class="vault-reader-toc"><div class="vault-reader-toc-title">On this page</div>';
26961
+ headings.forEach(function(h) {
26962
+ html += '<a class="lvl-' + h.lvl + '" href="#' + esc(h.slug) + '">' + esc(h.txt) + '</a>';
26963
+ });
26964
+ html += '</div>';
26965
+ return html;
26966
+ }
26967
+
26968
+ function vaultStartHover(relPath, ev) {
26969
+ if (!relPath || relPath === _vaultFilesActive) return;
26970
+ clearTimeout(_vaultHoverTimer);
26971
+ var x = ev.clientX, y = ev.clientY;
26972
+ _vaultHoverTimer = setTimeout(async function() {
26973
+ var pop = document.getElementById('vault-hover-popover');
26974
+ if (!pop) return;
26975
+ var data = _vaultHoverCache[relPath];
26976
+ if (!data) {
26977
+ try {
26978
+ var r = await apiFetch('/api/vault-file?head=1&path=' + encodeURIComponent(relPath));
26979
+ data = await r.json();
26980
+ _vaultHoverCache[relPath] = data;
26981
+ } catch { return; }
26982
+ }
26983
+ if (!data || data.error) return;
26984
+ var fm = data.frontmatter || {};
26985
+ var fmHtml = '';
26986
+ Object.keys(fm).slice(0, 5).forEach(function(k) {
26987
+ var v = fm[k];
26988
+ var dv = Array.isArray(v) ? v.join(', ') : String(v);
26989
+ fmHtml += '<div style="display:flex;gap:6px"><span style="color:var(--text-muted);min-width:60px">' + esc(k) + '</span><span>' + esc(dv) + '</span></div>';
26990
+ });
26991
+ pop.innerHTML = '<div style="font-weight:600;font-size:13px;margin-bottom:4px">' + esc(relPath.split('/').pop().replace(/\\.md$/, '')) + '</div>'
26992
+ + (fmHtml ? '<div style="margin-bottom:6px">' + fmHtml + '</div>' : '')
26993
+ + '<div style="color:var(--text-secondary)">' + esc(data.snippet || '(empty)') + '</div>';
26994
+ var px = Math.min(window.innerWidth - 360, x + 16);
26995
+ var py = Math.min(window.innerHeight - 200, y + 16);
26996
+ pop.style.left = px + 'px';
26997
+ pop.style.top = py + 'px';
26998
+ pop.style.display = 'block';
26999
+ }, 250);
27000
+ }
27001
+
27002
+ function vaultEndHover() {
27003
+ clearTimeout(_vaultHoverTimer);
27004
+ var pop = document.getElementById('vault-hover-popover');
27005
+ if (pop) pop.style.display = 'none';
25432
27006
  }
25433
27007
 
25434
27008
  // ── Goals: inline create form ────────────────────────────────────