clementine-agent 1.18.31 → 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
  }
@@ -4071,27 +4115,60 @@ export async function cmdDashboard(opts) {
4071
4115
  res.status(400).json({ error: 'name required' });
4072
4116
  return;
4073
4117
  }
4074
- const [{ saveWorkflow, workflowId: makeId }, { emitBuilderEvent }] = await Promise.all([
4118
+ const [{ saveWorkflow, workflowId: makeId }, { emitBuilderEvent }, yamlMod] = await Promise.all([
4075
4119
  import('../dashboard/builder/serializer.js'),
4076
4120
  import('../dashboard/builder/events.js'),
4121
+ import('js-yaml'),
4077
4122
  ]);
4078
4123
  const slug = body.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'routine';
4079
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
+ }
4080
4156
  const wf = {
4081
4157
  name: body.name,
4082
4158
  description: body.description ?? '',
4083
4159
  enabled: true,
4084
4160
  trigger: body.schedule ? { schedule: body.schedule, manual: false } : { manual: true },
4085
4161
  inputs: {},
4086
- steps: [{
4162
+ steps: (steps ?? [{
4087
4163
  id: 's1',
4088
- prompt: body.initialPrompt ?? 'Describe what this routine should do.',
4164
+ prompt: body.initialPrompt ?? 'Describe what this trick should do.',
4089
4165
  dependsOn: [],
4090
4166
  tier: 1,
4091
4167
  maxTurns: 15,
4092
- }],
4168
+ }]),
4093
4169
  sourceFile: '',
4094
4170
  agentSlug,
4171
+ ...(body.model ? { model: body.model } : {}),
4095
4172
  };
4096
4173
  const id = makeId(slug, agentSlug);
4097
4174
  const result = saveWorkflow(id, wf);
@@ -4156,7 +4233,7 @@ export async function cmdDashboard(opts) {
4156
4233
  return;
4157
4234
  }
4158
4235
  if (parsed.origin === 'cron') {
4159
- res.status(400).json({ error: 'This routine came from a legacy cron entry — disable it instead, or edit CRON.md directly.' });
4236
+ res.status(400).json({ error: 'This trick came from a legacy cron entry — disable it instead, or edit CRON.md directly.' });
4160
4237
  return;
4161
4238
  }
4162
4239
  const wf = readWorkflow(id);
@@ -4219,7 +4296,7 @@ export async function cmdDashboard(opts) {
4219
4296
  });
4220
4297
  child.unref();
4221
4298
  broadcastEvent({ type: 'cron_triggered', data: { job: wf.name } });
4222
- res.json({ ok: true, message: `Triggered routine: ${wf.name}` });
4299
+ res.json({ ok: true, message: `Triggered trick: ${wf.name}` });
4223
4300
  return;
4224
4301
  }
4225
4302
  // Workflow-origin routines: side-effect approval gate, then route through gateway.handleWorkflow.
@@ -4245,12 +4322,12 @@ export async function cmdDashboard(opts) {
4245
4322
  res.status(409).json({
4246
4323
  ok: false,
4247
4324
  error: 'approval_required',
4248
- message: 'This routine may send, write, post, or call external tools. Approve side effects before running it.',
4325
+ message: 'This trick may send, write, post, or call external tools. Approve side effects before running it.',
4249
4326
  sideEffects,
4250
4327
  });
4251
4328
  return;
4252
4329
  }
4253
- res.json({ ok: true, message: `Routine "${wf.name}" triggered` });
4330
+ res.json({ ok: true, message: `Trick "${wf.name}" triggered` });
4254
4331
  broadcastEvent({ type: 'workflow_triggered', data: { id, name: wf.name } });
4255
4332
  getGateway().then(gw => gw.handleWorkflow(wf, body.inputs || {})).then(result => {
4256
4333
  broadcastEvent({ type: 'workflow_complete', data: { id, name: wf.name, status: 'ok', preview: (result || '').slice(0, 300) } });
@@ -7942,10 +8019,14 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7942
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` +
7943
8020
  `When the user says "save" or approves, output the final artifact block.]\n\n`
7944
8021
  : type === 'workflow'
7945
- ? `[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` +
7946
- '```json-artifact\n{"type":"workflow","name":"...","description":"...","schedule":"","steps":"step1:\\n prompt: ...\\nstep2:\\n prompt: ...\\n dependsOn: step1"}\n```\n' +
7947
- `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` +
7948
- `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` +
7949
8030
  `When the user says "save" or approves, output the final artifact block.]\n\n`
7950
8031
  : `[BUILDER MODE: You are helping configure an artifact. Output structured JSON blocks as you build.]\n\n`;
7951
8032
  enrichedMessage = builderPrefix + fileContext + toolContext + artifactContext + message;
@@ -14156,6 +14237,75 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14156
14237
  font-weight: 500;
14157
14238
  }
14158
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
+
14159
14309
  /* ── Task Cards ─────────────────────────── */
14160
14310
  .task-grid {
14161
14311
  display: grid;
@@ -14882,7 +15032,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14882
15032
  <div class="nav-item active" data-page="home" data-icon="home" title="Chat, today, activity">
14883
15033
  <span class="nav-icon"></span> Home
14884
15034
  </div>
14885
- <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">
14886
15036
  <span class="nav-icon"></span> Build
14887
15037
  <span class="nav-badge" id="nav-cron-count" style="display:none">0</span>
14888
15038
  </div>
@@ -15088,7 +15238,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15088
15238
  <div class="page" id="page-build">
15089
15239
  <!-- Toolbar -->
15090
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">
15091
- <h2 style="margin:0;font-size:18px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px"><span data-icon="workflow" class="icon-slot"></span> Routines</h2>
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>
15092
15242
  <span id="routines-count" style="font-size:11px;color:var(--text-muted)"></span>
15093
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>
15094
15244
  <span style="flex:1"></span>
@@ -15098,18 +15248,18 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15098
15248
  <option value="__global__">Clementine (global)</option>
15099
15249
  </select>
15100
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>
15101
- <button id="routines-assist-btn" class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openAssist()" title="Describe a routine in natural language" style="padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Generate from prompt</button>
15102
- <button id="routines-create-btn" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openCreate()" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ New Routine</button>
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>
15103
15253
  </div>
15104
15254
  <!-- List view (default) -->
15105
15255
  <div id="routines-list-pane" style="flex:1;min-height:0;overflow-y:auto;padding:18px;background:var(--bg-primary)">
15106
15256
  <div id="routines-list-empty" class="empty-state" style="display:none;padding:64px 18px;text-align:center;color:var(--text-muted)">
15107
15257
  <div style="font-size:38px;opacity:0.4;margin-bottom:14px">&#9881;</div>
15108
- <div style="font-size:15px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">No routines yet</div>
15109
- <div style="font-size:12px;line-height:1.5;max-width:380px;margin:0 auto 16px">A Routine is a sequence of steps &mdash; call MCP tools, run local CLIs, prompt the agent, branch on results &mdash; that runs on a schedule or on demand. Example: &ldquo;at 8am check email; if anything urgent, summarize and Slack me.&rdquo;</div>
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>
15110
15260
  <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
15111
- <button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openCreate()" style="padding:6px 14px">+ New Routine</button>
15112
- <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openAssist()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Generate from prompt</button>
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>
15113
15263
  </div>
15114
15264
  </div>
15115
15265
  <div id="routines-list-wrap" style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden">
@@ -15136,7 +15286,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15136
15286
  <!-- Create modal -->
15137
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">
15138
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">
15139
- <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New routine</h3>
15289
+ <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New trick</h3>
15140
15290
  <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Name</label>
15141
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">
15142
15292
  <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Description (optional)</label>
@@ -15153,16 +15303,29 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15153
15303
  </div>
15154
15304
  </div>
15155
15305
  </div>
15156
- <!-- Assist modal (Generate from prompt) -->
15157
- <div id="routines-assist-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
15158
- <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:22px;width:560px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;gap:14px">
15159
- <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">Generate routine from prompt</h3>
15160
- <p style="margin:0;font-size:12px;color:var(--text-muted);line-height:1.5">Describe what the routine should do. The assistant will draft a starter sequence you can edit. Example: &ldquo;Every morning at 8am, check unread Gmail; if anything looks urgent, summarize and send to Slack #me.&rdquo;</p>
15161
- <textarea id="routines-assist-input" rows="6" placeholder="Describe the routine&hellip;" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px;font-family:inherit;resize:vertical;box-sizing:border-box"></textarea>
15162
- <div id="routines-assist-status" style="font-size:11px;color:var(--text-muted);min-height:14px"></div>
15163
- <div style="display:flex;gap:8px;justify-content:flex-end">
15164
- <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeAssist()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Cancel</button>
15165
- <button id="routines-assist-submit" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.submitAssist()" style="padding:6px 14px">Generate</button>
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>
15311
+ <span style="flex:1"></span>
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>
15313
+ </div>
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>
15323
+ </div>
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>
15328
+ </div>
15166
15329
  </div>
15167
15330
  </div>
15168
15331
  </div>
@@ -15203,7 +15366,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15203
15366
  // ── data ────────────────────────────────────────────────────
15204
15367
  loadOwners: function() {
15205
15368
  // Reuse the agent registry the rest of the dashboard uses.
15206
- fetch('/api/agents').then(function(r){ return r.json(); }).then(function(data){
15369
+ apiFetch('/api/agents').then(function(r){ return r.json(); }).then(function(data){
15207
15370
  R.state.owners = (data.agents || []).map(function(a){ return { slug: a.slug, name: a.name || a.slug }; });
15208
15371
  R.populateOwnerSelects();
15209
15372
  }).catch(function(){ /* non-fatal */ });
@@ -15223,17 +15386,17 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15223
15386
  }
15224
15387
  },
15225
15388
  loadMcpTools: function() {
15226
- fetch('/api/routines/mcp-tools').then(function(r){ return r.json(); }).then(function(data){
15389
+ apiFetch('/api/routines/mcp-tools').then(function(r){ return r.json(); }).then(function(data){
15227
15390
  R.state.mcpTools = data && data.servers ? data : { servers: [] };
15228
15391
  }).catch(function(){ R.state.mcpTools = { servers: [] }; });
15229
15392
  },
15230
15393
  loadCliTools: function() {
15231
- fetch('/api/routines/cli-tools').then(function(r){ return r.json(); }).then(function(data){
15394
+ apiFetch('/api/routines/cli-tools').then(function(r){ return r.json(); }).then(function(data){
15232
15395
  R.state.cliTools = (data && data.tools) || [];
15233
15396
  }).catch(function(){ R.state.cliTools = []; });
15234
15397
  },
15235
15398
  refreshList: function() {
15236
- fetch('/api/routines').then(function(r){ return r.json(); }).then(function(data){
15399
+ apiFetch('/api/routines').then(function(r){ return r.json(); }).then(function(data){
15237
15400
  R.state.list = (data && data.routines) || [];
15238
15401
  R.renderList();
15239
15402
  }).catch(function(){ R.state.list = []; R.renderList(); });
@@ -15251,7 +15414,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15251
15414
  if (filter === '__global__') return r.scope === 'global';
15252
15415
  return r.scope === 'agent' && r.agentSlug === filter;
15253
15416
  });
15254
- if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? ' routine' : ' routines');
15417
+ if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? ' trick' : ' tricks');
15255
15418
  if (rows.length === 0) {
15256
15419
  empty.style.display = 'block';
15257
15420
  wrap.style.display = 'none';
@@ -15278,13 +15441,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15278
15441
  }).join('');
15279
15442
  },
15280
15443
  toggle: function(id) {
15281
- fetch('/api/routines/' + encodeURIComponent(id) + '/toggle', { method: 'POST' })
15444
+ apiFetch('/api/routines/' + encodeURIComponent(id) + '/toggle', { method: 'POST' })
15282
15445
  .then(function(r){ return r.json(); })
15283
15446
  .then(function(){ R.refreshList(); })
15284
15447
  .catch(function(err){ alert('Toggle failed: ' + err); });
15285
15448
  },
15286
15449
  run: function(id, approvedSideEffects) {
15287
- fetch('/api/routines/' + encodeURIComponent(id) + '/run', {
15450
+ apiFetch('/api/routines/' + encodeURIComponent(id) + '/run', {
15288
15451
  method: 'POST',
15289
15452
  headers: { 'Content-Type': 'application/json' },
15290
15453
  body: JSON.stringify({ approvedSideEffects: approvedSideEffects === true })
@@ -15292,7 +15455,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15292
15455
  if (r.status === 409) {
15293
15456
  return r.json().then(function(j){
15294
15457
  var lines = (j.sideEffects || []).map(function(s){ return ' • ' + s.kind + ': ' + s.label; }).join('\\n');
15295
- if (confirm('This routine has side effects:\\n\\n' + lines + '\\n\\nProceed?')) R.run(id, true);
15458
+ if (confirm('This trick has side effects:\\n\\n' + lines + '\\n\\nProceed?')) R.run(id, true);
15296
15459
  });
15297
15460
  }
15298
15461
  return r.json().then(function(j){
@@ -15303,10 +15466,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15303
15466
  },
15304
15467
  // ── editor ──────────────────────────────────────────────────
15305
15468
  openEditor: function(id) {
15306
- fetch('/api/routines/' + encodeURIComponent(id))
15469
+ apiFetch('/api/routines/' + encodeURIComponent(id))
15307
15470
  .then(function(r){ return r.json(); })
15308
15471
  .then(function(data){
15309
- if (!data || !data.routine) { alert('Failed to load routine'); return; }
15472
+ if (!data || !data.routine) { alert('Failed to load trick'); return; }
15310
15473
  R.state.editing = { id: data.id, routine: data.routine, dirty: false, validation: data.validation };
15311
15474
  R.showEditor();
15312
15475
  }).catch(function(err){ alert('Open failed: ' + err); });
@@ -15347,8 +15510,11 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15347
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>'
15348
15511
  + '</div>'
15349
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">'
15350
- + '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Schedule</label>'
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>'
15351
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')
15352
15518
  + '</div></div>';
15353
15519
  // Steps
15354
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>';
@@ -15373,10 +15539,15 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15373
15539
  var kind = step.kind || 'prompt';
15374
15540
  var kindColor = { prompt: '#5e72e4', mcp: '#2dce89', cli: '#fb6340', conditional: '#f5365c', channel: '#11cdef', transform: '#ffd600', loop: '#8965e0' }[kind] || '#888';
15375
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
+ : '';
15376
15546
  var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">'
15377
15547
  + '<span style="font-size:11px;color:var(--text-muted);font-weight:600;min-width:24px">#' + (idx + 1) + '</span>'
15378
15548
  + badge
15379
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
15380
15551
  + '<span style="flex:1"></span>'
15381
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>' : '')
15382
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>' : '')
@@ -15463,6 +15634,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15463
15634
  R.state.editing.routine.trigger = v ? { schedule: v, manual: false } : { manual: true };
15464
15635
  }
15465
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;
15466
15638
  R.setStatus('Unsaved changes');
15467
15639
  },
15468
15640
  updateStep: function(idx, field, value) {
@@ -15552,7 +15724,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15552
15724
  },
15553
15725
  removeStep: function(idx) {
15554
15726
  if (!R.state.editing) return;
15555
- if (R.state.editing.routine.steps.length <= 1) { alert('A routine must have at least one step.'); return; }
15727
+ if (R.state.editing.routine.steps.length <= 1) { alert('A trick must have at least one step.'); return; }
15556
15728
  if (!confirm('Remove this step?')) return;
15557
15729
  var removed = R.state.editing.routine.steps.splice(idx, 1)[0];
15558
15730
  // Strip lingering dependsOn references.
@@ -15609,7 +15781,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15609
15781
  R.markDirty(); // capture latest header values
15610
15782
  var btn = document.getElementById('re-save-btn');
15611
15783
  if (btn) btn.textContent = 'Saving…';
15612
- fetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15784
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15613
15785
  method: 'PUT',
15614
15786
  headers: { 'Content-Type': 'application/json' },
15615
15787
  body: JSON.stringify({ routine: R.state.editing.routine })
@@ -15620,7 +15792,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15620
15792
  if (res.body.error === 'validation') {
15621
15793
  var msg = (res.body.validation.issues || []).map(function(i){ return '• ' + i.severity + ': ' + i.message; }).join('\\n');
15622
15794
  if (confirm('Validation issues:\\n\\n' + msg + '\\n\\nSave anyway?')) {
15623
- fetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15795
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id), {
15624
15796
  method: 'PUT',
15625
15797
  headers: { 'Content-Type': 'application/json' },
15626
15798
  body: JSON.stringify({ routine: R.state.editing.routine, force: true })
@@ -15646,7 +15818,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15646
15818
  },
15647
15819
  dryRunCurrent: function() {
15648
15820
  if (!R.state.editing) return;
15649
- fetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/dry-run', { method: 'POST' })
15821
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/dry-run', { method: 'POST' })
15650
15822
  .then(function(r){ return r.json(); })
15651
15823
  .then(function(d){
15652
15824
  var lines = ['Dry-run for ' + R.state.editing.routine.name + ':\\n'];
@@ -15657,7 +15829,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15657
15829
  },
15658
15830
  testCurrent: function() {
15659
15831
  if (!R.state.editing) return;
15660
- fetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/test', {
15832
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id) + '/test', {
15661
15833
  method: 'POST',
15662
15834
  headers: { 'Content-Type': 'application/json' },
15663
15835
  body: JSON.stringify({ mode: 'mock' })
@@ -15669,7 +15841,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15669
15841
  deleteCurrent: function() {
15670
15842
  if (!R.state.editing) return;
15671
15843
  if (!confirm('Delete routine "' + R.state.editing.routine.name + '"? This is permanent.')) return;
15672
- fetch('/api/routines/' + encodeURIComponent(R.state.editing.id), { method: 'DELETE' })
15844
+ apiFetch('/api/routines/' + encodeURIComponent(R.state.editing.id), { method: 'DELETE' })
15673
15845
  .then(function(r){ return r.json(); })
15674
15846
  .then(function(j){
15675
15847
  if (j.ok) { R.state.editing = null; R.closeEditor(); R.refreshList(); }
@@ -15692,7 +15864,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15692
15864
  if (!drawer) return;
15693
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>';
15694
15866
  drawer.style.display = 'block';
15695
- fetch('/api/routines/' + encodeURIComponent(id) + '/runs').then(function(r){ return r.json(); }).then(function(d){
15867
+ apiFetch('/api/routines/' + encodeURIComponent(id) + '/runs').then(function(r){ return r.json(); }).then(function(d){
15696
15868
  var runs = d.runs || [];
15697
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>';
15698
15870
  if (runs.length === 0) {
@@ -15736,7 +15908,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15736
15908
  schedule: document.getElementById('routines-create-schedule').value.trim() || undefined,
15737
15909
  agent: document.getElementById('routines-create-owner').value || undefined,
15738
15910
  };
15739
- fetch('/api/routines', {
15911
+ apiFetch('/api/routines', {
15740
15912
  method: 'POST',
15741
15913
  headers: { 'Content-Type': 'application/json' },
15742
15914
  body: JSON.stringify(body)
@@ -15748,50 +15920,172 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15748
15920
  R.openEditor(res.body.id);
15749
15921
  }).catch(function(err){ alert('Create error: ' + err); });
15750
15922
  },
15751
- // ── assist (generate from prompt) ───────────────────────────
15752
- openAssist: function() {
15753
- var m = document.getElementById('routines-assist-modal'); if (!m) return;
15754
- document.getElementById('routines-assist-input').value = '';
15755
- document.getElementById('routines-assist-status').textContent = '';
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.');
15756
15945
  m.style.display = 'flex';
15757
- setTimeout(function(){ document.getElementById('routines-assist-input').focus(); }, 50);
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();
15758
15955
  },
15759
- closeAssist: function() {
15760
- var m = document.getElementById('routines-assist-modal'); if (m) m.style.display = 'none';
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';
15761
15985
  },
15762
- submitAssist: function() {
15763
- if (R.state.assistBusy) return;
15764
- var prompt = document.getElementById('routines-assist-input').value.trim();
15765
- if (!prompt) return;
15766
- R.state.assistBusy = true;
15767
- var btn = document.getElementById('routines-assist-submit');
15768
- var status = document.getElementById('routines-assist-status');
15769
- if (btn) { btn.textContent = 'Generating…'; btn.disabled = true; }
15770
- if (status) status.textContent = 'Asking the assistant to draft a routine…';
15771
- // Reuse the existing /api/builder/chat which is already wired and
15772
- // produces workflow drafts. We also pass mode hint so the agent
15773
- // knows to focus on building one routine.
15774
- fetch('/api/builder/chat', {
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', {
15775
15999
  method: 'POST',
15776
16000
  headers: { 'Content-Type': 'application/json' },
15777
- body: JSON.stringify({ message: prompt, mode: 'workflow' })
15778
- }).then(function(r){ return r.json(); }).then(function(d){
15779
- R.state.assistBusy = false;
15780
- if (btn) { btn.textContent = 'Generate'; btn.disabled = false; }
15781
- if (status) status.textContent = d && d.message ? 'Draft created. Refreshing list…' : 'Draft response received.';
15782
- R.refreshList();
15783
- setTimeout(function(){ R.closeAssist(); }, 800);
15784
- }).catch(function(err){
15785
- R.state.assistBusy = false;
15786
- if (btn) { btn.textContent = 'Generate'; btn.disabled = false; }
15787
- if (status) status.textContent = 'Assist failed: ' + err;
15788
- });
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
+ });
15789
16059
  },
15790
16060
  // ── helpers ─────────────────────────────────────────────────
15791
16061
  esc: function(s) {
15792
16062
  if (s == null) return '';
15793
16063
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
15794
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
+ },
15795
16089
  };
15796
16090
  window.RoutinesUI = R;
15797
16091
  // Compatibility shims for legacy callers in other parts of the dashboard.
@@ -15911,12 +16205,12 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15911
16205
  </div>
15912
16206
  <div class="tab-bar" id="intelligence-tabs" style="margin:0 0 0 18px">
15913
16207
  <button class="active" data-icon="layoutDashboard" onclick="switchTab('intelligence','overview')"><span class="icon-slot"></span> Overview</button>
15914
- <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>
15915
16209
  <button data-icon="upload" onclick="switchTab('intelligence','seed')"><span class="icon-slot"></span> Seed</button>
15916
16210
  <button data-icon="repeat" onclick="switchTab('intelligence','sources')"><span class="icon-slot"></span> Automate</button>
15917
16211
  <button data-icon="listChecks" onclick="switchTab('intelligence','runs')"><span class="icon-slot"></span> Runs</button>
15918
16212
  <button data-icon="sparkles" onclick="switchTab('intelligence','graph')"><span class="icon-slot"></span> Knowledge</button>
15919
- <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>
15920
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>
15921
16215
  <button data-icon="users" onclick="switchTab('intelligence','user-model')"><span class="icon-slot"></span> User Model</button>
15922
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>
@@ -16294,24 +16588,55 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16294
16588
  <div id="brain-runs-list"></div>
16295
16589
  </div>
16296
16590
  <div class="tab-pane" id="tab-intelligence-files">
16297
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap">
16298
- <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()">
16299
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">
16300
16594
  <option value="">All authors</option>
16301
16595
  <option value="__shared__">Shared (vault root)</option>
16302
16596
  </select>
16303
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">
16304
16598
  <option value="7">Past 7 days</option>
16305
- <option value="30" selected>Past 30 days</option>
16599
+ <option value="30">Past 30 days</option>
16306
16600
  <option value="90">Past 90 days</option>
16307
16601
  <option value="365">Past year</option>
16602
+ <option value="9999" selected>All time</option>
16308
16603
  </select>
16309
- <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>
16310
16605
  </div>
16311
- <div id="vault-files-folder-chips" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px"></div>
16312
- <div id="vault-files-list">
16313
- <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>
16314
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>
16315
16640
  </div>
16316
16641
  <div class="tab-pane" id="tab-intelligence-health">
16317
16642
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;flex-wrap:wrap">
@@ -19597,7 +19922,10 @@ function switchTab(group, tab) {
19597
19922
  if (typeof refreshSupersedes === 'function') refreshSupersedes();
19598
19923
  if (typeof refreshCoverageStrip === 'function') refreshCoverageStrip();
19599
19924
  }
19600
- if (tab === 'files' && typeof refreshVaultFiles === 'function') refreshVaultFiles();
19925
+ if (tab === 'files' && typeof refreshVaultFiles === 'function') {
19926
+ if (typeof vaultRestoreFromHash === 'function') vaultRestoreFromHash();
19927
+ refreshVaultFiles();
19928
+ }
19601
19929
  if (tab === 'sources') {
19602
19930
  if (typeof brainLoadSources === 'function') brainLoadSources();
19603
19931
  if (typeof brainLoadFeedConnectors === 'function') brainLoadFeedConnectors();
@@ -21025,7 +21353,7 @@ let scheduledWorkflowData = [];
21025
21353
  let buildUsageByTask = {};
21026
21354
 
21027
21355
  function jsStr(s) {
21028
- 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');
21029
21357
  }
21030
21358
 
21031
21359
  function durationLabel(ms) {
@@ -26333,28 +26661,118 @@ async function memoryHealthAction(action, extra) {
26333
26661
  }
26334
26662
  }
26335
26663
 
26336
- // ── Vault Files (Brain → Files tab) ──────────────────────────────
26337
- var _vaultFilesCache = null;
26338
- 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
+ }
26339
26712
 
26340
26713
  async function refreshVaultFiles() {
26341
26714
  var listEl = document.getElementById('vault-files-list');
26342
26715
  if (!listEl) return;
26343
- var q = document.getElementById('vault-files-search')?.value || '';
26344
- var agent = document.getElementById('vault-files-agent-filter')?.value || '';
26345
- var since = document.getElementById('vault-files-since')?.value || '30';
26346
- // Show skeleton while loading
26347
- 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>';
26348
26720
  try {
26349
26721
  var url = '/api/vault-files?sinceDays=' + encodeURIComponent(since)
26350
26722
  + (q ? '&q=' + encodeURIComponent(q) : '')
26351
26723
  + (agent ? '&agent=' + encodeURIComponent(agent) : '')
26352
- + (_vaultFilesFolder ? '&folder=' + encodeURIComponent(_vaultFilesFolder) : '');
26353
- var r = await apiFetch(url);
26354
- 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
+
26355
26740
  var files = d.files || [];
26356
26741
  _vaultFilesCache = files;
26357
- // 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)
26358
26776
  var agentSel = document.getElementById('vault-files-agent-filter');
26359
26777
  if (agentSel && agentSel.options.length <= 2) {
26360
26778
  var slugs = [...new Set(files.map(function(f) { return f.agentSlug; }).filter(Boolean))].sort();
@@ -26365,100 +26783,226 @@ async function refreshVaultFiles() {
26365
26783
  agentSel.appendChild(opt);
26366
26784
  });
26367
26785
  }
26368
- // Render folder filter chips (using folderCounts from server)
26369
- var chipsEl = document.getElementById('vault-files-folder-chips');
26370
- if (chipsEl && d.folderCounts) {
26371
- var folders = Object.entries(d.folderCounts).sort(function(a, b) { return b[1] - a[1]; });
26372
- var totalCount = folders.reduce(function(s, p) { return s + p[1]; }, 0);
26373
- var chipHtml = '<div class="vault-folder-chip' + (_vaultFilesFolder === '' ? ' active' : '') + '" data-folder="" onclick="setVaultFolderFilter(\\x27\\x27)">All <span style="opacity:0.6">' + totalCount + '</span></div>';
26374
- folders.forEach(function(p) {
26375
- var folder = p[0]; var count = p[1];
26376
- if (!folder) return;
26377
- 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>';
26378
- });
26379
- 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)' : '');
26380
26797
  }
26798
+
26381
26799
  if (files.length === 0) {
26382
- 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>';
26383
26801
  return;
26384
26802
  }
26385
- 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>';
26386
- 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 = '';
26387
26805
  for (var i = 0; i < files.length; i++) {
26388
26806
  var f = files[i];
26389
- var agentBadge = f.agentSlug
26390
- ? '<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>'
26391
- : '<span style="font-size:10px;background:var(--bg-tertiary);color:var(--text-muted);padding:2px 7px;border-radius:var(--radius-xs)">shared</span>';
26392
- var typeBadge = f.type ? '<span style="font-size:10px;color:var(--text-muted);margin-right:6px">' + esc(f.type) + '</span>' : '';
26393
- 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">'
26394
- + '<div style="flex:1;min-width:0">'
26395
- + '<div style="font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(f.title) + '</div>'
26396
- + '<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>'
26397
- + '</div>'
26398
- + '<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">'
26399
- + typeBadge + agentBadge
26400
- + '<span style="font-size:11px;color:var(--text-muted);min-width:60px;text-align:right">' + esc(timeAgo(f.mtime)) + '</span>'
26401
- + '</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
26402
26824
  + '</div>';
26403
26825
  }
26404
- html += '</div>';
26405
26826
  listEl.innerHTML = html;
26406
- // Wire row clicks
26407
- listEl.querySelectorAll('.vault-file-row').forEach(function(row) {
26408
- 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);
26409
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
+ }
26410
26837
  } catch (err) {
26411
26838
  listEl.innerHTML = '<div style="padding:24px;color:var(--red);font-size:13px">Failed to load: ' + esc(String(err)) + '</div>';
26412
26839
  }
26413
26840
  }
26414
26841
 
26415
- 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) {
26416
26870
  if (!relPath) return;
26417
- // Build/reuse a slide-out drawer for content preview
26418
- var drawer = document.getElementById('vault-file-drawer');
26419
- if (!drawer) {
26420
- drawer = document.createElement('div');
26421
- drawer.id = 'vault-file-drawer';
26422
- 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';
26423
- drawer.innerHTML =
26424
- '<div style="display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--border);flex-shrink:0">'
26425
- + '<div style="flex:1;min-width:0">'
26426
- + '<div id="vault-file-drawer-title" style="font-weight:600;font-size:15px;letter-spacing:-0.01em"></div>'
26427
- + '<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>'
26428
- + '</div>'
26429
- + '<button class="btn-icon btn-sm" onclick="closeVaultFileDrawer()" title="Close">' + lucide('x', 'icn-sm') + '</button>'
26430
- + '</div>'
26431
- + '<div id="vault-file-drawer-body" style="flex:1;overflow-y:auto;padding:18px 22px;font-size:13px;line-height:1.55"></div>';
26432
- document.body.appendChild(drawer);
26433
- }
26434
- var titleEl = document.getElementById('vault-file-drawer-title');
26435
- var pathEl = document.getElementById('vault-file-drawer-path');
26436
- var body = document.getElementById('vault-file-drawer-body');
26437
- if (titleEl) titleEl.textContent = relPath.split('/').pop().replace(/\\.md$/, '');
26438
- if (pathEl) pathEl.textContent = relPath;
26439
- 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>';
26440
- 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>';
26441
26883
  try {
26442
26884
  var r = await apiFetch('/api/vault-file?path=' + encodeURIComponent(relPath));
26443
26885
  var d = await r.json();
26444
- if (d.error) {
26445
- body.innerHTML = '<div style="color:var(--red)">' + esc(d.error) + '</div>';
26446
- return;
26447
- }
26448
- 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;
26449
26894
  } catch (err) {
26450
- 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>';
26451
26896
  }
26452
26897
  }
26453
26898
 
26899
+ // Back-compat (legacy drawer-style call sites). The drawer is gone, but the
26900
+ // inline reader covers the same job.
26454
26901
  function closeVaultFileDrawer() {
26455
- var drawer = document.getElementById('vault-file-drawer');
26456
- if (drawer) drawer.style.transform = 'translateX(100%)';
26457
- }
26458
-
26459
- function setVaultFolderFilter(folder) {
26460
- _vaultFilesFolder = folder || '';
26461
- 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';
26462
27006
  }
26463
27007
 
26464
27008
  // ── Goals: inline create form ────────────────────────────────────