clementine-agent 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -411,10 +411,24 @@ Routing rule: if the fact is something the agent should *always know* (not just
411
411
  ## Rules:
412
412
  - Only save genuinely NEW facts not already present in the Current Memory above.
413
413
  - If updating an existing topic, use memory_write(action="update_memory") to REPLACE the section, not append duplicates.
414
+ - If a stored fact is now wrong (user corrected it, situation changed), use memory_write(action="supersede", supersedes_chunk_id=N, reason="…") instead of appending — the old chunk becomes invisible to retrieval, provenance is preserved.
414
415
  - If there's nothing new to save, respond "No new facts." and exit — do NOT call any tools.
415
416
  - Use the MCP tools (user_model, memory_write, note_create, task_add, note_take).
416
417
  - NEVER respond to ${OWNER}. You are invisible. Just save facts and exit.
417
418
 
419
+ ## Salience hint, confidence, reason (memory_write):
420
+ Every memory_write call may include \`salience_hint\` (0.5–2.0), \`confidence\` (0–1), and \`reason\` (one short sentence). Use them — retrieval prioritizes high-salience, deprioritizes low-confidence, and reasons make the memory system explainable.
421
+
422
+ salience_hint:
423
+ - 0.5 — tentative, single-mention, may not be durable
424
+ - 1.0 — normal (default; equivalent to omitting)
425
+ - 1.5 — durable preference, decision, or strong stated opinion
426
+ - 2.0 — identity-level fact (rare): role, name, foundational stance
427
+
428
+ confidence: 1.0 = certain (default), 0.7 = probable, 0.5 = uncertain or heard secondhand, 0.3 = tentative. Lowers retrieval ranking without hiding.
429
+
430
+ reason: one sentence answering "why is this worth keeping?" — e.g. "user just stated firm preference for plain .env over keychain after being burned by it." Skip routine cases.
431
+
418
432
  ## Behavioral Correction Detection:
419
433
  If ${OWNER} corrects HOW the assistant behaved (not a factual correction), output a JSON block:
420
434
  \`\`\`json-behavioral
@@ -48,6 +48,8 @@ export interface AuditEvent {
48
48
  */
49
49
  export declare function logAuditJsonl(event: AuditEvent): void;
50
50
  export declare function setHeartbeatMode(active: boolean, tier2Allowed?: boolean): void;
51
+ export declare function resetBrowserHarnessApproval(): void;
52
+ export declare function isBrowserHarnessApproved(): boolean;
51
53
  export declare function setApprovalCallback(cb: ((desc: string) => Promise<boolean>) | null): void;
52
54
  export declare function setProfileTier(tier: number | null): void;
53
55
  export declare function setProfileAllowedTools(tools: string[] | null): void;
@@ -120,6 +120,16 @@ export function setHeartbeatMode(active, tier2Allowed = false) {
120
120
  heartbeatActive = active;
121
121
  heartbeatTier2Allowed = tier2Allowed;
122
122
  }
123
+ // Session-scoped approval for browser harness T3 actions. Once the user
124
+ // approves a session, subsequent T3 calls within that session auto-allow.
125
+ // Resets on daemon restart (in-memory) and on explicit revoke.
126
+ let browserHarnessSessionApproved = false;
127
+ export function resetBrowserHarnessApproval() {
128
+ browserHarnessSessionApproved = false;
129
+ }
130
+ export function isBrowserHarnessApproved() {
131
+ return browserHarnessSessionApproved;
132
+ }
123
133
  export function setApprovalCallback(cb) {
124
134
  approvalCallback = cb;
125
135
  }
@@ -197,11 +207,23 @@ export function logToolUse(toolName, toolInput) {
197
207
  // These apply to actual heartbeats and tier-1 cron jobs (read-only).
198
208
  // Tier 2+ cron jobs and unleashed tasks bypass these restrictions.
199
209
  const HEARTBEAT_DISALLOWED_TIER2 = ['Write', 'Edit', 'Bash'];
210
+ // Browser harness write-class tools — drive the user's real Chrome with their
211
+ // live cookies/sessions. NEVER run these without interactive approval. The
212
+ // MCP server name is "browser-harness" so the SDK exposes them as
213
+ // mcp__browser-harness__<tool>.
214
+ const BROWSER_HARNESS_T3_TOOLS = [
215
+ 'mcp__browser-harness__browser_click_xy',
216
+ 'mcp__browser-harness__browser_type_text',
217
+ 'mcp__browser-harness__browser_press_key',
218
+ 'mcp__browser-harness__browser_scroll',
219
+ 'mcp__browser-harness__browser_run_python',
220
+ ];
200
221
  const HEARTBEAT_DISALLOWED_ALWAYS = [
201
222
  'Bash', // No raw shell in low-tier autonomous mode
202
223
  'Task', // No sub-agents in heartbeats (too short to benefit)
203
224
  'Skill', // Skill packs load heavy context and waste turns
204
225
  'TodoWrite', // Internal bookkeeping wastes autonomous turns
226
+ ...BROWSER_HARNESS_T3_TOOLS, // Browser writes never run unsupervised
205
227
  ];
206
228
  export function getHeartbeatDisallowedTools() {
207
229
  const disallowed = [...HEARTBEAT_DISALLOWED_ALWAYS];
@@ -315,6 +337,42 @@ export async function enforceToolPermissions(toolName, toolInput, sourceOverride
315
337
  };
316
338
  }
317
339
  }
340
+ // ── Browser harness T3 — never autonomous, approve once per session ─
341
+ // These tools click/type/scroll/run-python in the user's REAL Chrome
342
+ // with their live cookies. They must never run without explicit consent.
343
+ const effectiveSourceForBrowser = sourceOverride ?? interactionSource;
344
+ if (BROWSER_HARNESS_T3_TOOLS.includes(toolName)) {
345
+ // Hard block during any autonomous context (cron tier-2, unleashed,
346
+ // heartbeat, member-channel sources). Heartbeat block is also handled
347
+ // above via getHeartbeatDisallowedTools, but this catches tier-2 cron
348
+ // and unleashed where heartbeatActive=false.
349
+ if (heartbeatActive || effectiveSourceForBrowser === 'autonomous') {
350
+ appendAuditFile(`[BROWSER-HARNESS] DENIED autonomous: ${toolName}`);
351
+ return {
352
+ behavior: 'deny',
353
+ message: `${toolName} controls your live browser — blocked during autonomous execution. Run interactively instead.`,
354
+ };
355
+ }
356
+ // Interactive: ask once per session. Subsequent T3 calls auto-allow
357
+ // until daemon restart (or explicit revoke via resetBrowserHarnessApproval).
358
+ if (!browserHarnessSessionApproved) {
359
+ if (approvalCallback) {
360
+ const approved = await approvalCallback('Allow Clementine to control your browser this session? Clicks, types, and key presses will run in your real Chrome with your live cookies and logins.');
361
+ if (!approved) {
362
+ return { behavior: 'deny', message: 'Browser control denied by user.' };
363
+ }
364
+ browserHarnessSessionApproved = true;
365
+ appendAuditFile('[BROWSER-HARNESS] Session approval granted');
366
+ }
367
+ else {
368
+ // No approval callback wired — be safe, deny.
369
+ return {
370
+ behavior: 'deny',
371
+ message: 'Browser control requires interactive approval, but no approval callback is set in this context.',
372
+ };
373
+ }
374
+ }
375
+ }
318
376
  // ── Profile tier restrictions (restrict, never elevate) ────────
319
377
  if (activeProfileTier !== null) {
320
378
  if (activeProfileTier < 2 && ['Bash', 'Write', 'Edit'].includes(toolName)) {
@@ -3160,6 +3160,7 @@ export async function cmdDashboard(opts) {
3160
3160
  import('../dashboard/builder/events.js'),
3161
3161
  ]);
3162
3162
  const slug = body.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'workflow';
3163
+ const agentSlug = body.agent ? (String(body.agent).trim() || undefined) : undefined;
3163
3164
  const wf = {
3164
3165
  name: body.name,
3165
3166
  description: body.description ?? '',
@@ -3174,8 +3175,9 @@ export async function cmdDashboard(opts) {
3174
3175
  maxTurns: 15,
3175
3176
  }],
3176
3177
  sourceFile: '',
3178
+ agentSlug,
3177
3179
  };
3178
- const id = workflowId(slug);
3180
+ const id = workflowId(slug, agentSlug);
3179
3181
  const result = saveWorkflow(id, wf);
3180
3182
  if (!result.ok) {
3181
3183
  res.status(400).json({ error: result.error });
@@ -5391,6 +5393,72 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5391
5393
  // content out of search results.
5392
5394
  // Memory Health snapshot — single endpoint feeding the dashboard tab.
5393
5395
  // Read-only aggregate over the existing tables; no caching needed (cheap).
5396
+ // Self-correction stats — supersession provenance. Powers the
5397
+ // "Self-correction (supersedes)" card on Brain → Health.
5398
+ app.get('/api/memory/supersedes', async (_req, res) => {
5399
+ try {
5400
+ const gateway = await getGateway();
5401
+ const store = gateway.assistant?.memoryStore;
5402
+ if (!store?.getSupersedeStats) {
5403
+ res.status(503).json({ error: 'Memory store not available' });
5404
+ return;
5405
+ }
5406
+ res.json({ ok: true, stats: store.getSupersedeStats() });
5407
+ }
5408
+ catch (err) {
5409
+ res.status(500).json({ error: String(err) });
5410
+ }
5411
+ });
5412
+ // Knowledge graph signals — wikilink density + per-match-type recall
5413
+ // contribution. Powers the "Knowledge graph signals" card.
5414
+ app.get('/api/memory/graph-stats', async (_req, res) => {
5415
+ try {
5416
+ const gateway = await getGateway();
5417
+ const store = gateway.assistant?.memoryStore;
5418
+ if (!store?.getGraphStats) {
5419
+ res.status(503).json({ error: 'Memory store not available' });
5420
+ return;
5421
+ }
5422
+ res.json({ ok: true, stats: store.getGraphStats({ topN: 12, lookbackHours: 24 * 7 }) });
5423
+ }
5424
+ catch (err) {
5425
+ res.status(500).json({ error: String(err) });
5426
+ }
5427
+ });
5428
+ // Cross-channel session bridge — recent session summaries grouped by
5429
+ // channel. Powers the "Cross-channel handoff" card.
5430
+ app.get('/api/memory/session-bridge', async (_req, res) => {
5431
+ try {
5432
+ const gateway = await getGateway();
5433
+ const store = gateway.assistant?.memoryStore;
5434
+ if (!store?.getRecentSummariesByChannel) {
5435
+ res.status(503).json({ error: 'Memory store not available' });
5436
+ return;
5437
+ }
5438
+ res.json({ ok: true, grouped: store.getRecentSummariesByChannel(3) });
5439
+ }
5440
+ catch (err) {
5441
+ res.status(500).json({ error: String(err) });
5442
+ }
5443
+ });
5444
+ // Recent writes — what the agent has been capturing, including reason and
5445
+ // salience hint when supplied. Powers the Brain → Health "Recent writes" panel.
5446
+ app.get('/api/memory/writes/recent', async (req, res) => {
5447
+ try {
5448
+ const limit = Math.min(Number(req.query.limit) || 50, 200);
5449
+ const gateway = await getGateway();
5450
+ const store = gateway.assistant?.memoryStore;
5451
+ if (!store?.getRecentWrites) {
5452
+ res.status(503).json({ error: 'Memory store not available' });
5453
+ return;
5454
+ }
5455
+ const writes = store.getRecentWrites(limit);
5456
+ res.json({ ok: true, writes });
5457
+ }
5458
+ catch (err) {
5459
+ res.status(500).json({ error: String(err) });
5460
+ }
5461
+ });
5394
5462
  app.get('/api/memory/health', async (_req, res) => {
5395
5463
  try {
5396
5464
  const gateway = await getGateway();
@@ -5428,6 +5496,25 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5428
5496
  res.json({ ok: true, action, report });
5429
5497
  return;
5430
5498
  }
5499
+ if (action === 'reembed-dense') {
5500
+ // Run backfill in the background — first call also pays the model
5501
+ // load cost (~440MB download on first ever run). We respond immediately
5502
+ // so the UI doesn't time out; the user re-polls /api/memory/health.
5503
+ const limit = Number(req.body?.limit) > 0 ? Number(req.body.limit) : 200;
5504
+ const embeddings = await import('../memory/embeddings.js');
5505
+ const ready = await embeddings.probeDenseReady();
5506
+ if (!ready) {
5507
+ res.status(503).json({ error: 'Dense embedding model failed to load' });
5508
+ return;
5509
+ }
5510
+ // Fire-and-forget; surface progress via subsequent /api/memory/health polls.
5511
+ store.backfillDenseEmbeddings({ limit }).catch((err) => {
5512
+ // eslint-disable-next-line no-console
5513
+ console.error('[dashboard] reembed-dense failed', err);
5514
+ });
5515
+ res.json({ ok: true, action, started: true, limit });
5516
+ return;
5517
+ }
5431
5518
  res.status(400).json({ error: 'Unknown action: ' + action });
5432
5519
  }
5433
5520
  catch (err) {
@@ -12315,6 +12402,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12315
12402
  <option value="agent">agent</option>
12316
12403
  <option value="workflow">workflow</option>
12317
12404
  </select>
12405
+ <label style="font-size:11px;color:var(--text-muted);font-weight:500;letter-spacing:0.04em;text-transform:uppercase">Owner</label>
12406
+ <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">
12407
+ <option value="">Clementine (global)</option>
12408
+ </select>
12318
12409
  <span id="builder-agent-label" style="padding:0;font-size:13px;color:var(--text-secondary);font-weight:500"></span>
12319
12410
  <input type="hidden" id="builder-agent" value="">
12320
12411
  <span style="flex:1"></span>
@@ -12771,6 +12862,42 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12771
12862
  <div id="memory-health-content">
12772
12863
  <div class="skel-block"><div class="skel-row med"></div><div class="skel-row"></div><div class="skel-row short"></div></div>
12773
12864
  </div>
12865
+ <div class="card" style="margin-top:18px">
12866
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
12867
+ <span>Recent writes</span>
12868
+ <span style="font-size:11px;color:var(--text-muted)">What the agent captured, with reason &amp; salience</span>
12869
+ </div>
12870
+ <div class="card-body" id="panel-recent-writes" style="padding:0">
12871
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
12872
+ </div>
12873
+ </div>
12874
+ <div class="card" style="margin-top:18px">
12875
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
12876
+ <span>Self-correction (supersedes)</span>
12877
+ <span style="font-size:11px;color:var(--text-muted)">Old facts the agent has explicitly replaced</span>
12878
+ </div>
12879
+ <div class="card-body" id="panel-supersedes" style="padding:0">
12880
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
12881
+ </div>
12882
+ </div>
12883
+ <div class="card" style="margin-top:18px">
12884
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
12885
+ <span>Knowledge graph signals</span>
12886
+ <span style="font-size:11px;color:var(--text-muted)">Wikilink density &amp; which retrieval signal earns its keep (last 7d)</span>
12887
+ </div>
12888
+ <div class="card-body" id="panel-graph-stats" style="padding:0">
12889
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
12890
+ </div>
12891
+ </div>
12892
+ <div class="card" style="margin-top:18px">
12893
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
12894
+ <span>Cross-channel handoff</span>
12895
+ <span style="font-size:11px;color:var(--text-muted)">Recent session summaries by channel — proves continuity</span>
12896
+ </div>
12897
+ <div class="card-body" id="panel-session-bridge" style="padding:0">
12898
+ <div class="skel-block" style="padding:14px"><div class="skel-row med"></div><div class="skel-row short"></div></div>
12899
+ </div>
12900
+ </div>
12774
12901
  <div class="card" style="margin-top:18px">
12775
12902
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
12776
12903
  <span>Trust &amp; claim verification</span>
@@ -15050,6 +15177,10 @@ function navigateTo(page, opts) {
15050
15177
  try { switchTab('intelligence', intelTab); } catch (e) { /* */ }
15051
15178
  if (bt === 'health') {
15052
15179
  if (typeof refreshMemoryHealth === 'function') refreshMemoryHealth();
15180
+ if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
15181
+ if (typeof refreshSupersedes === 'function') refreshSupersedes();
15182
+ if (typeof refreshGraphStats === 'function') refreshGraphStats();
15183
+ if (typeof refreshSessionBridge === 'function') refreshSessionBridge();
15053
15184
  if (typeof refreshClaims === 'function') refreshClaims();
15054
15185
  if (typeof refreshRoutingAudit === 'function') refreshRoutingAudit();
15055
15186
  }
@@ -15392,6 +15523,10 @@ function switchTab(group, tab) {
15392
15523
  if (tab === 'files' && typeof refreshVaultFiles === 'function') refreshVaultFiles();
15393
15524
  if (tab === 'health') {
15394
15525
  if (typeof refreshMemoryHealth === 'function') refreshMemoryHealth();
15526
+ if (typeof refreshRecentWrites === 'function') refreshRecentWrites();
15527
+ if (typeof refreshSupersedes === 'function') refreshSupersedes();
15528
+ if (typeof refreshGraphStats === 'function') refreshGraphStats();
15529
+ if (typeof refreshSessionBridge === 'function') refreshSessionBridge();
15395
15530
  if (typeof refreshClaims === 'function') refreshClaims();
15396
15531
  if (typeof refreshRoutingAudit === 'function') refreshRoutingAudit();
15397
15532
  }
@@ -20625,12 +20760,220 @@ function formatBytes(n) {
20625
20760
  return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
20626
20761
  }
20627
20762
 
20628
- async function memoryHealthAction(action) {
20629
- var labels = { 'janitor': 'cleanup', 'rebuild-fts': 'FTS rebuild', 'fix-orphans': 'orphan fix' };
20763
+ async function refreshSupersedes() {
20764
+ var el = document.getElementById('panel-supersedes');
20765
+ if (!el) return;
20766
+ try {
20767
+ var r = await apiFetch('/api/memory/supersedes');
20768
+ var d = await r.json();
20769
+ if (!d.ok || !d.stats) {
20770
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
20771
+ return;
20772
+ }
20773
+ var s = d.stats;
20774
+ if (!s.recent || s.recent.length === 0) {
20775
+ el.innerHTML = '<div class="empty-state" style="padding:14px">No supersedes yet. The agent will record corrections here when it explicitly replaces a stale fact.</div>';
20776
+ return;
20777
+ }
20778
+ var html = '<div style="padding:10px 14px;font-size:12px;color:var(--text-muted)">Total: <b>' + (s.superseded || 0) + '</b> chunks superseded.</div>';
20779
+ html += '<table class="data-table" style="width:100%"><thead><tr>'
20780
+ + '<th style="width:140px">When</th>'
20781
+ + '<th style="width:90px">Old → New</th>'
20782
+ + '<th>Reason</th></tr></thead><tbody>';
20783
+ for (var i = 0; i < s.recent.length; i++) {
20784
+ var sup = s.recent[i];
20785
+ var when = '';
20786
+ try { when = new Date(sup.supersededAt + 'Z').toLocaleString(); } catch { when = sup.supersededAt; }
20787
+ html += '<tr>'
20788
+ + '<td style="font-size:11px;color:var(--text-muted)">' + esc(when) + '</td>'
20789
+ + '<td style="font-size:12px"><code>#' + sup.oldId + '</code> → <code>#' + sup.newId + '</code></td>'
20790
+ + '<td style="font-size:12px">' + (sup.reason ? esc(sup.reason) : '<span style="color:var(--text-muted);opacity:0.6">no reason recorded</span>') + '</td>'
20791
+ + '</tr>';
20792
+ }
20793
+ html += '</tbody></table>';
20794
+ el.innerHTML = html;
20795
+ } catch (err) {
20796
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
20797
+ }
20798
+ }
20799
+
20800
+ async function refreshGraphStats() {
20801
+ var el = document.getElementById('panel-graph-stats');
20802
+ if (!el) return;
20803
+ try {
20804
+ var r = await apiFetch('/api/memory/graph-stats');
20805
+ var d = await r.json();
20806
+ if (!d.ok || !d.stats) {
20807
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
20808
+ return;
20809
+ }
20810
+ var s = d.stats;
20811
+ var cb = s.recallContributionByType || {};
20812
+ var totalMatches = 0;
20813
+ for (var k in cb) totalMatches += (cb[k] || 0);
20814
+
20815
+ var html = '<div style="padding:12px 14px;display:grid;grid-template-columns:1fr 1fr;gap:14px">';
20816
+ html += '<div>';
20817
+ html += '<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Wikilinks</div>';
20818
+ html += '<div style="font-size:24px;font-weight:700;margin-bottom:8px">' + (s.wikilinkCount || 0).toLocaleString() + '</div>';
20819
+ if (s.topLinkedTargets && s.topLinkedTargets.length > 0) {
20820
+ html += '<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Top-linked entities</div>';
20821
+ html += '<table style="width:100%;font-size:12px"><tbody>';
20822
+ for (var i = 0; i < s.topLinkedTargets.length; i++) {
20823
+ var t = s.topLinkedTargets[i];
20824
+ html += '<tr><td style="padding:2px 0">' + esc(t.target) + '</td><td style="text-align:right;color:var(--text-muted);padding:2px 0">' + t.count + '</td></tr>';
20825
+ }
20826
+ html += '</tbody></table>';
20827
+ } else {
20828
+ html += '<div class="empty-state" style="font-size:12px">No wikilinks yet — add [[Name]] refs in vault notes to grow the graph.</div>';
20829
+ }
20830
+ html += '</div>';
20831
+
20832
+ html += '<div>';
20833
+ html += '<div style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Recall contribution (last 7d, ' + (s.tracesAnalyzed || 0) + ' traces)</div>';
20834
+ if (totalMatches === 0) {
20835
+ html += '<div class="empty-state" style="font-size:12px">No recall traces yet. Run a chat to populate.</div>';
20836
+ } else {
20837
+ var order = ['fts', 'vector', 'graph', 'recency'];
20838
+ var labels = { fts: 'Lexical (FTS5)', vector: 'Semantic (dense)', graph: 'Graph (wikilinks)', recency: 'Recency' };
20839
+ var colors = { fts: '#60a5fa', vector: '#10b981', graph: '#f59e0b', recency: '#9ca3af' };
20840
+ html += '<table style="width:100%;font-size:12px"><tbody>';
20841
+ for (var j = 0; j < order.length; j++) {
20842
+ var key = order[j];
20843
+ var cnt = cb[key] || 0;
20844
+ var pct = totalMatches > 0 ? Math.round((cnt / totalMatches) * 100) : 0;
20845
+ html += '<tr><td style="padding:3px 8px 3px 0;width:140px">' + labels[key] + '</td>'
20846
+ + '<td style="padding:3px 0">'
20847
+ + '<div style="display:flex;align-items:center;gap:8px">'
20848
+ + '<div style="flex:1;background:var(--bg-secondary, #1a1a1a);border-radius:3px;overflow:hidden;height:14px">'
20849
+ + '<div style="width:' + pct + '%;background:' + colors[key] + ';height:100%"></div>'
20850
+ + '</div>'
20851
+ + '<span style="font-variant-numeric:tabular-nums;width:50px;text-align:right;color:var(--text-muted)">' + pct + '%</span>'
20852
+ + '</div>'
20853
+ + '</td></tr>';
20854
+ }
20855
+ html += '</tbody></table>';
20856
+ }
20857
+ html += '</div></div>';
20858
+ el.innerHTML = html;
20859
+ } catch (err) {
20860
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
20861
+ }
20862
+ }
20863
+
20864
+ async function refreshSessionBridge() {
20865
+ var el = document.getElementById('panel-session-bridge');
20866
+ if (!el) return;
20867
+ try {
20868
+ var r = await apiFetch('/api/memory/session-bridge');
20869
+ var d = await r.json();
20870
+ if (!d.ok || !d.grouped) {
20871
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
20872
+ return;
20873
+ }
20874
+ var channels = Object.keys(d.grouped).sort();
20875
+ if (channels.length === 0) {
20876
+ el.innerHTML = '<div class="empty-state" style="padding:14px">No session summaries yet. They populate as conversations end.</div>';
20877
+ return;
20878
+ }
20879
+ var html = '<div style="padding:12px 14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px">';
20880
+ for (var ci = 0; ci < channels.length; ci++) {
20881
+ var ch = channels[ci];
20882
+ var summaries = d.grouped[ch] || [];
20883
+ html += '<div style="border:1px solid var(--border);border-radius:6px;padding:10px;background:var(--bg-secondary, transparent)">';
20884
+ html += '<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.05em">' + esc(ch) + ' &middot; ' + summaries.length + ' recent</div>';
20885
+ for (var si = 0; si < summaries.length; si++) {
20886
+ var ss = summaries[si];
20887
+ var when = '';
20888
+ try { when = new Date(ss.createdAt + 'Z').toLocaleString(); } catch { when = ss.createdAt; }
20889
+ var preview = (ss.summary || '').slice(0, 200);
20890
+ html += '<div style="font-size:12px;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--border)">'
20891
+ + '<div style="color:var(--text-muted);font-size:10px;margin-bottom:2px">' + esc(when) + ' &middot; ' + ss.exchangeCount + ' exchanges</div>'
20892
+ + esc(preview) + (ss.summary && ss.summary.length > 200 ? '…' : '')
20893
+ + '</div>';
20894
+ }
20895
+ html += '</div>';
20896
+ }
20897
+ html += '</div>';
20898
+ el.innerHTML = html;
20899
+ } catch (err) {
20900
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
20901
+ }
20902
+ }
20903
+
20904
+ async function refreshRecentWrites() {
20905
+ var el = document.getElementById('panel-recent-writes');
20906
+ if (!el) return;
20907
+ try {
20908
+ var r = await apiFetch('/api/memory/writes/recent?limit=30');
20909
+ var d = await r.json();
20910
+ if (!d.ok || !Array.isArray(d.writes)) {
20911
+ el.innerHTML = '<div class="empty-state" style="padding:14px">' + esc(d.error || 'No data') + '</div>';
20912
+ return;
20913
+ }
20914
+ if (d.writes.length === 0) {
20915
+ el.innerHTML = '<div class="empty-state" style="padding:14px">No writes captured yet. The agent will start logging memory_write activity here once Phase 2 reaches her.</div>';
20916
+ return;
20917
+ }
20918
+ var html = '<table class="data-table" style="width:100%">';
20919
+ html += '<thead><tr>'
20920
+ + '<th style="width:120px">When</th>'
20921
+ + '<th style="width:80px">Agent</th>'
20922
+ + '<th style="width:120px">Action</th>'
20923
+ + '<th>Where</th>'
20924
+ + '<th style="width:60px;text-align:right">Sal</th>'
20925
+ + '<th>Reason</th>'
20926
+ + '<th style="width:90px">Status</th>'
20927
+ + '</tr></thead><tbody>';
20928
+ for (var i = 0; i < d.writes.length; i++) {
20929
+ var w = d.writes[i];
20930
+ var when = '';
20931
+ try { when = new Date(w.extractedAt + 'Z').toLocaleString(); } catch { when = w.extractedAt; }
20932
+ var where = w.section ? esc(w.section) : (w.filePath ? esc(w.filePath) : '—');
20933
+ var sal = (w.salienceHint != null) ? Number(w.salienceHint).toFixed(1) : '—';
20934
+ var salColor = (w.salienceHint != null && w.salienceHint > 1.0) ? 'var(--success, #10b981)'
20935
+ : (w.salienceHint != null && w.salienceHint < 1.0) ? 'var(--text-muted)' : 'var(--text-primary)';
20936
+ var statusBadge = w.status === 'dedup_skipped'
20937
+ ? '<span style="color:var(--text-muted);font-size:11px">deduped</span>'
20938
+ : w.status === 'active'
20939
+ ? '<span style="color:var(--success, #10b981);font-size:11px">written</span>'
20940
+ : '<span style="font-size:11px">' + esc(w.status) + '</span>';
20941
+ html += '<tr>'
20942
+ + '<td style="font-size:11px;color:var(--text-muted)">' + esc(when) + '</td>'
20943
+ + '<td style="font-size:11px">' + esc(w.agentSlug || 'global') + '</td>'
20944
+ + '<td style="font-size:12px"><code>' + esc(w.action || w.toolName) + '</code></td>'
20945
+ + '<td style="font-size:12px">' + where + '</td>'
20946
+ + '<td style="text-align:right;font-weight:600;color:' + salColor + '">' + sal + '</td>'
20947
+ + '<td style="font-size:12px;color:var(--text-muted)">' + (w.reason ? esc(w.reason) : '<span style="opacity:0.5">no reason given</span>') + '</td>'
20948
+ + '<td>' + statusBadge + '</td>'
20949
+ + '</tr>';
20950
+ }
20951
+ html += '</tbody></table>';
20952
+ el.innerHTML = html;
20953
+ } catch (err) {
20954
+ el.innerHTML = '<div class="empty-state" style="padding:14px">Failed to load: ' + esc(String(err)) + '</div>';
20955
+ }
20956
+ }
20957
+
20958
+ async function memoryHealthAction(action, extra) {
20959
+ var labels = { 'janitor': 'cleanup', 'rebuild-fts': 'FTS rebuild', 'fix-orphans': 'orphan fix', 'reembed-dense': 'dense embedding backfill' };
20630
20960
  if (!confirm('Run ' + (labels[action] || action) + ' now?')) return;
20631
20961
  try {
20632
- var r = await apiJson('POST', '/api/memory/health/action', { action: action });
20962
+ var body = Object.assign({ action: action }, extra || {});
20963
+ var r = await apiJson('POST', '/api/memory/health/action', body);
20633
20964
  if (r.error) { toast('Action failed: ' + r.error, 'error'); return; }
20965
+ if (action === 'reembed-dense' && r.started) {
20966
+ toast('Backfill started in background (' + (r.limit || '?') + ' chunks). Refreshing every 10s…', 'info');
20967
+ // Poll coverage updates so the user sees progress without manually refreshing.
20968
+ var pollCount = 0;
20969
+ var poll = setInterval(function() {
20970
+ refreshMemoryHealth();
20971
+ pollCount++;
20972
+ if (pollCount >= 12) clearInterval(poll); // ~2 minutes
20973
+ }, 10000);
20974
+ refreshMemoryHealth();
20975
+ return;
20976
+ }
20634
20977
  var detail = '';
20635
20978
  if (r.result) detail = ' — ' + Object.entries(r.result).slice(0, 4).map(function(p) { return p[0] + ':' + p[1]; }).join(', ');
20636
20979
  if (r.report) detail = ' — orphans nulled: ' + (r.report.orphanRefsNulled || 0) + ', FTS rebuilds: ' + (r.report.ftsRebuilds || 0);
@@ -20857,8 +21200,38 @@ async function refreshMemoryHealth() {
20857
21200
  html += '<div class="metric-hero"><div class="metric-hero-value">' + formatBytes(h.dbSizeBytes)
20858
21201
  + '</div><div class="metric-hero-label">DB File Size</div>'
20859
21202
  + '<div class="metric-hero-sub">last vacuum: ' + esc(h.lastVacuumAt || 'never') + '</div></div>';
21203
+
21204
+ // Dense embedding coverage — the leading indicator for retrieval quality.
21205
+ // <50% means the agent is mostly searching on TF-IDF and missing semantic matches.
21206
+ var de = h.denseEmbeddings || { withDense: 0, total: 0, models: [], currentModel: '', ready: false };
21207
+ var densePct = de.total > 0 ? ((de.withDense / de.total) * 100).toFixed(1) : '0.0';
21208
+ var denseColor = de.total === 0 ? 'var(--text-muted)'
21209
+ : (de.withDense / Math.max(1, de.total)) >= 0.95 ? 'var(--success, #10b981)'
21210
+ : (de.withDense / Math.max(1, de.total)) >= 0.5 ? 'var(--warning, #f59e0b)'
21211
+ : 'var(--danger, #ef4444)';
21212
+ var modelLabel = de.currentModel ? de.currentModel.split('/').pop() : '—';
21213
+ html += '<div class="metric-hero" style="border-left:3px solid ' + denseColor + '">'
21214
+ + '<div class="metric-hero-value" style="color:' + denseColor + '">' + densePct + '%</div>'
21215
+ + '<div class="metric-hero-label">Semantic Coverage</div>'
21216
+ + '<div class="metric-hero-sub">' + (de.withDense || 0) + ' of ' + (de.total || 0)
21217
+ + ' chunks &middot; ' + esc(modelLabel) + '</div></div>';
21218
+
20860
21219
  html += '</div>';
20861
21220
 
21221
+ // Coverage call-to-action — only render when there's work to do.
21222
+ if (de.total > 0 && de.withDense < de.total) {
21223
+ var missing = de.total - de.withDense;
21224
+ html += '<div class="card" style="margin-bottom:16px;border-left:3px solid ' + denseColor + '">';
21225
+ html += '<div class="card-body" style="padding:14px;display:flex;align-items:center;gap:14px;flex-wrap:wrap">';
21226
+ html += '<div style="flex:1;min-width:240px">';
21227
+ html += '<div style="font-weight:600;margin-bottom:4px">Retrieval running on sparse vectors for ' + missing.toLocaleString() + ' chunks</div>';
21228
+ html += '<div style="font-size:12px;color:var(--text-muted)">Backfill builds 768-dim neural embeddings for semantic search. First run downloads ~440MB.</div>';
21229
+ html += '</div>';
21230
+ html += '<button class="btn-sm" onclick="memoryHealthAction(\'reembed-dense\', { limit: 200 })" title="Embed up to 200 chunks now">Backfill 200</button>';
21231
+ html += '<button class="btn-sm" onclick="memoryHealthAction(\'reembed-dense\', { limit: 2000 })" title="Embed up to 2000 chunks now (slower)">Backfill 2000</button>';
21232
+ html += '</div></div>';
21233
+ }
21234
+
20862
21235
  // Two-column layout: categories + table sizes on left, top cited on right.
20863
21236
  html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">';
20864
21237
 
@@ -15,15 +15,24 @@
15
15
  * unless edited through this module, and edits preserve unrelated fields.
16
16
  */
17
17
  import type { WorkflowDefinition, CronJobDefinition, BuilderWorkflowSummary, WorkflowOriginKind } from '../../types.js';
18
- export declare function cronId(name: string): string;
19
- export declare function workflowId(filename: string): string;
20
- export declare function parseBuilderId(id: string): {
18
+ export declare function cronId(name: string, agentSlug?: string): string;
19
+ export declare function workflowId(filename: string, agentSlug?: string): string;
20
+ export type ParsedBuilderId = {
21
21
  origin: WorkflowOriginKind;
22
+ scope: 'global';
22
23
  key: string;
23
- } | null;
24
+ } | {
25
+ origin: WorkflowOriginKind;
26
+ scope: 'agent';
27
+ agentSlug: string;
28
+ key: string;
29
+ };
30
+ export declare function parseBuilderId(id: string): ParsedBuilderId | null;
24
31
  export declare function listAllForBuilder(): BuilderWorkflowSummary[];
25
32
  export declare function readWorkflow(id: string): WorkflowDefinition | null;
26
- export declare function cronJobToWorkflow(job: CronJobDefinition): WorkflowDefinition;
33
+ export declare function cronJobToWorkflow(job: CronJobDefinition, opts?: {
34
+ sourceFile?: string;
35
+ }): WorkflowDefinition;
27
36
  /** True if a workflow is shaped like a CRON.md entry (single prompt step + cron schedule). */
28
37
  export declare function isCronShape(wf: WorkflowDefinition): boolean;
29
38
  export declare function saveWorkflow(id: string, wf: WorkflowDefinition): {
@@ -32,11 +41,8 @@ export declare function saveWorkflow(id: string, wf: WorkflowDefinition): {
32
41
  ok: false;
33
42
  error: string;
34
43
  };
35
- /** Resolve the on-disk file path for a builder id (cron entries all share CRON_FILE). */
36
- export declare function sourceFileForId(id: string, parsedHint?: {
37
- origin: WorkflowOriginKind;
38
- key: string;
39
- }): string | null;
44
+ /** Resolve the on-disk file path for a builder id. */
45
+ export declare function sourceFileForId(id: string, parsedHint?: ParsedBuilderId): string | null;
40
46
  /** Drawflow node shape (subset we use). */
41
47
  interface DrawflowNode {
42
48
  id: number;