clementine-agent 1.0.13 → 1.0.15

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.
@@ -135,8 +135,18 @@ async function cachedAsync(key, ttlMs, compute) {
135
135
  // ── Lazy gateway for chat ────────────────────────────────────────────
136
136
  let gatewayInstance = null;
137
137
  let gatewayInitializing = false;
138
+ let gatewayDispatcher = null;
139
+ /** SSE broadcaster; set once cmdDashboard has built the SSE infrastructure. */
140
+ let dashboardSseBroadcast = null;
138
141
  /** Reset the cached gateway (called when daemon PID changes). */
139
142
  function resetGateway() {
143
+ if (gatewayDispatcher) {
144
+ try {
145
+ gatewayDispatcher.shutdown();
146
+ }
147
+ catch { /* best-effort */ }
148
+ gatewayDispatcher = null;
149
+ }
140
150
  gatewayInstance = null;
141
151
  responseCache.clear();
142
152
  }
@@ -156,6 +166,26 @@ async function getGateway() {
156
166
  gatewayInstance = new GatewayClass(assistant);
157
167
  const { setApprovalCallback } = await import('../agent/hooks.js');
158
168
  setApprovalCallback(async () => false);
169
+ // Wire a local NotificationDispatcher so deep-task results launched from
170
+ // dashboard chat sessions can be pushed back into the browser via SSE.
171
+ try {
172
+ const { NotificationDispatcher } = await import('../gateway/notifications.js');
173
+ const dispatcher = new NotificationDispatcher();
174
+ dispatcher.register('dashboard', async (text, context) => {
175
+ if (!dashboardSseBroadcast)
176
+ return;
177
+ dashboardSseBroadcast({
178
+ type: 'deep_result',
179
+ data: { sessionKey: context?.sessionKey ?? null, text },
180
+ });
181
+ });
182
+ gatewayInstance.setDispatcher(dispatcher);
183
+ gatewayDispatcher = dispatcher;
184
+ }
185
+ catch (err) {
186
+ // Non-fatal — deep-task results from dashboard sessions just won't surface live
187
+ console.warn('Failed to wire dashboard SSE dispatcher:', err);
188
+ }
159
189
  return gatewayInstance;
160
190
  }
161
191
  catch (err) {
@@ -2009,6 +2039,8 @@ export async function cmdDashboard(opts) {
2009
2039
  }
2010
2040
  }
2011
2041
  }
2042
+ // Let the lazy-gateway dispatcher publish deep_result events through SSE.
2043
+ dashboardSseBroadcast = broadcastEvent;
2012
2044
  // SSE events handler moved before auth middleware (see above)
2013
2045
  // ── POST routes (actions) ──────────────────────────────────────
2014
2046
  app.post('/api/cron/run/:job', (req, res) => {
@@ -2043,6 +2075,16 @@ export async function cmdDashboard(opts) {
2043
2075
  res.status(500).json({ error: String(err) });
2044
2076
  }
2045
2077
  });
2078
+ // ── Broken jobs (failure monitor) ───────────────────────────────
2079
+ app.get('/api/cron/broken-jobs', async (_req, res) => {
2080
+ try {
2081
+ const { computeBrokenJobs } = await import('../gateway/failure-monitor.js');
2082
+ res.json({ jobs: computeBrokenJobs() });
2083
+ }
2084
+ catch (err) {
2085
+ res.status(500).json({ error: String(err) });
2086
+ }
2087
+ });
2046
2088
  // ── Cron trace viewer ──────────────────────────────────────────
2047
2089
  app.get('/api/cron/traces/:job', (req, res) => {
2048
2090
  try {
@@ -3743,19 +3785,87 @@ export async function cmdDashboard(opts) {
3743
3785
  }
3744
3786
  });
3745
3787
  // ── Skills (Procedural Memory) API ──────────────────────────────────
3746
- app.get('/api/skills', (_req, res) => {
3788
+ // NOTE: /api/skills/pending routes must come before /api/skills/:name so
3789
+ // Express doesn't capture "pending" as a :name param.
3790
+ app.get('/api/skills/pending', async (_req, res) => {
3791
+ try {
3792
+ const { listPendingSkills } = await import('../agent/skill-extractor.js');
3793
+ res.json({ skills: listPendingSkills() });
3794
+ }
3795
+ catch (err) {
3796
+ res.status(500).json({ error: String(err) });
3797
+ }
3798
+ });
3799
+ app.post('/api/skills/pending/:name/approve', async (req, res) => {
3800
+ try {
3801
+ const { approvePendingSkill } = await import('../agent/skill-extractor.js');
3802
+ const result = approvePendingSkill(req.params.name);
3803
+ if (!result.ok) {
3804
+ res.status(404).json(result);
3805
+ return;
3806
+ }
3807
+ res.json(result);
3808
+ }
3809
+ catch (err) {
3810
+ res.status(500).json({ error: String(err) });
3811
+ }
3812
+ });
3813
+ app.post('/api/skills/pending/:name/reject', async (req, res) => {
3814
+ try {
3815
+ const { rejectPendingSkill } = await import('../agent/skill-extractor.js');
3816
+ const result = rejectPendingSkill(req.params.name);
3817
+ if (!result.ok) {
3818
+ res.status(404).json(result);
3819
+ return;
3820
+ }
3821
+ res.json(result);
3822
+ }
3823
+ catch (err) {
3824
+ res.status(500).json({ error: String(err) });
3825
+ }
3826
+ });
3827
+ app.get('/api/skills', async (_req, res) => {
3747
3828
  try {
3748
3829
  const skillsDir = path.join(VAULT_DIR, '00-System', 'skills');
3749
3830
  if (!existsSync(skillsDir)) {
3750
3831
  res.json({ skills: [] });
3751
3832
  return;
3752
3833
  }
3834
+ // Aggregate last-7-day retrieval stats from skill_usage table (best-effort).
3835
+ const usageStats = new Map();
3836
+ if (existsSync(MEMORY_DB_PATH)) {
3837
+ try {
3838
+ const Database = (await import('better-sqlite3')).default;
3839
+ const db = new Database(MEMORY_DB_PATH, { readonly: true });
3840
+ try {
3841
+ const rows = db.prepare(`SELECT skill_name,
3842
+ COUNT(*) AS retrievals,
3843
+ MAX(retrieved_at) AS last_retrieved_at,
3844
+ AVG(score) AS avg_score
3845
+ FROM skill_usage
3846
+ WHERE retrieved_at >= datetime('now', '-7 days')
3847
+ GROUP BY skill_name`).all();
3848
+ for (const r of rows) {
3849
+ usageStats.set(r.skill_name, {
3850
+ retrievals7d: r.retrievals,
3851
+ lastRetrievedAt: r.last_retrieved_at,
3852
+ avgScore: r.avg_score,
3853
+ });
3854
+ }
3855
+ }
3856
+ catch { /* skill_usage may not exist on older DBs */ }
3857
+ db.close();
3858
+ }
3859
+ catch { /* non-fatal */ }
3860
+ }
3753
3861
  const files = readdirSync(skillsDir).filter(f => f.endsWith('.md'));
3754
3862
  const skills = files.map(f => {
3755
3863
  try {
3756
3864
  const parsed = matter(readFileSync(path.join(skillsDir, f), 'utf-8'));
3865
+ const name = f.replace('.md', '');
3866
+ const stats = usageStats.get(name);
3757
3867
  return {
3758
- name: f.replace('.md', ''),
3868
+ name,
3759
3869
  title: parsed.data.title ?? f,
3760
3870
  description: parsed.data.description ?? '',
3761
3871
  source: parsed.data.source ?? 'unknown',
@@ -3766,6 +3876,9 @@ export async function cmdDashboard(opts) {
3766
3876
  lastUsed: parsed.data.lastUsed ?? null,
3767
3877
  createdAt: parsed.data.createdAt ?? '',
3768
3878
  updatedAt: parsed.data.updatedAt ?? '',
3879
+ retrievals7d: stats?.retrievals7d ?? 0,
3880
+ lastRetrievedAt: stats?.lastRetrievedAt ?? null,
3881
+ avgScore: stats?.avgScore ?? null,
3769
3882
  };
3770
3883
  }
3771
3884
  catch {
@@ -8972,15 +9085,25 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
8972
9085
  <div class="page-title">Scheduled Tasks</div>
8973
9086
  <div class="tab-bar" id="automations-tabs">
8974
9087
  <button class="active" onclick="switchTab('automations','scheduled')">Scheduled Tasks</button>
9088
+ <button onclick="switchTab('automations','broken')">Broken Jobs <span class="tab-badge" id="tab-broken-count" title="repeatedly failing" style="display:none;background:#ef4444;color:#fff">0</span></button>
8975
9089
  <button onclick="switchTab('automations','timers')">Timers <span class="tab-badge" id="tab-timer-count" style="display:none">0</span></button>
8976
9090
  <button onclick="switchTab('automations','self-improve')">Self-Improve <span class="tab-badge" id="tab-si-pending" style="display:none">0</span></button>
8977
- <button onclick="switchTab('automations','skills')">Skills <span class="tab-badge" id="tab-skill-count" style="display:none">0</span></button>
9091
+ <button onclick="switchTab('automations','skills')">Skills <span class="tab-badge" id="tab-skill-count" style="display:none">0</span><span class="tab-badge" id="tab-pending-skill-count" title="pending approval" style="display:none;background:#f59e0b;color:#000">0</span></button>
8978
9092
  <button onclick="switchTab('automations','analytics')">Execution Analytics</button>
8979
9093
  </div>
8980
9094
  <div id="automations-tab-content">
8981
9095
  <div class="tab-pane active" id="tab-automations-scheduled">
8982
9096
  <div id="panel-cron"><div class="empty-state">Loading...</div></div>
8983
9097
  </div>
9098
+ <div class="tab-pane" id="tab-automations-broken">
9099
+ <div class="card">
9100
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
9101
+ <span>Repeatedly Failing Jobs (last 48h)</span>
9102
+ <span class="badge badge-gray" id="broken-count-badge" style="font-size:10px">0 jobs</span>
9103
+ </div>
9104
+ <div class="card-body" id="panel-broken-jobs"><div class="empty-state">Loading...</div></div>
9105
+ </div>
9106
+ </div>
8984
9107
  <div class="tab-pane" id="tab-automations-timers">
8985
9108
  <div class="card">
8986
9109
  <div class="card-body" id="panel-timers"><div class="empty-state">Loading...</div></div>
@@ -9032,6 +9155,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
9032
9155
  </div>
9033
9156
  </div>
9034
9157
  </div>
9158
+ <div class="card" id="pending-skills-card" style="display:none">
9159
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
9160
+ <span>Pending Approval</span>
9161
+ <span class="badge badge-orange" id="pending-skills-count-badge" style="font-size:10px">0 pending</span>
9162
+ </div>
9163
+ <div class="card-body" id="panel-pending-skills"></div>
9164
+ </div>
9035
9165
  <div class="card">
9036
9166
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
9037
9167
  <span>Learned Skills</span>
@@ -10197,7 +10327,7 @@ function navigateTo(page, opts) {
10197
10327
  updateBuilderMode();
10198
10328
  document.getElementById('builder-input').focus();
10199
10329
  }
10200
- if (page === 'automations') { refreshCron(); refreshTimers(); refreshSelfImprove(); refreshSkills(); }
10330
+ if (page === 'automations') { refreshCron(); refreshTimers(); refreshSelfImprove(); refreshSkills(); refreshBrokenJobs(); }
10201
10331
  if (page === 'intelligence') { refreshMemory(); }
10202
10332
  if (page === 'settings') { refreshSettings(); refreshRemoteAccess(); refreshSalesforce(); refreshClaudeIntegrations(); refreshMcpServers(); }
10203
10333
  if (page === 'logs') refreshLogs();
@@ -10238,6 +10368,7 @@ function switchTab(group, tab) {
10238
10368
  // Tab-specific refresh
10239
10369
  if (group === 'automations') {
10240
10370
  if (tab === 'scheduled') refreshCron();
10371
+ if (tab === 'broken') refreshBrokenJobs();
10241
10372
  if (tab === 'timers') refreshTimers();
10242
10373
  if (tab === 'self-improve') refreshSelfImprove();
10243
10374
  if (tab === 'workflows') refreshWorkflows();
@@ -16031,7 +16162,139 @@ async function expandSkill(name) {
16031
16162
  } catch(e) { toast('Failed to load skill', 'error'); }
16032
16163
  }
16033
16164
 
16165
+ async function refreshBrokenJobs() {
16166
+ try {
16167
+ var r = await apiFetch('/api/cron/broken-jobs');
16168
+ var d = await r.json();
16169
+ var jobs = d.jobs || [];
16170
+ var tabBadge = document.getElementById('tab-broken-count');
16171
+ if (tabBadge) {
16172
+ tabBadge.textContent = String(jobs.length);
16173
+ tabBadge.style.display = jobs.length > 0 ? '' : 'none';
16174
+ }
16175
+ var countBadge = document.getElementById('broken-count-badge');
16176
+ if (countBadge) countBadge.textContent = jobs.length + ' job' + (jobs.length !== 1 ? 's' : '');
16177
+ var container = document.getElementById('panel-broken-jobs');
16178
+ if (!container) return;
16179
+ if (jobs.length === 0) {
16180
+ container.innerHTML = '<div class="empty-state">All jobs healthy in the last 48h.</div>';
16181
+ return;
16182
+ }
16183
+ var html = '<div style="display:flex;flex-direction:column;gap:12px">';
16184
+ for (var j of jobs) {
16185
+ var breaker = j.circuitBreakerEngagedAt
16186
+ ? '<span class="badge" style="background:rgba(239,68,68,0.15);color:#ef4444;font-size:10px">circuit broken</span>'
16187
+ : '';
16188
+ var lastErrorAt = j.lastErrorAt ? timeAgo(j.lastErrorAt) : 'unknown';
16189
+ var failureRatio = j.errorCount48h + '/' + j.totalRuns48h;
16190
+ var advisorLine = j.lastAdvisorOpinion
16191
+ ? '<div style="font-size:11px;color:var(--text-muted);margin-top:6px"><strong>Advisor:</strong> ' + esc(j.lastAdvisorOpinion) + '</div>'
16192
+ : '';
16193
+ var errorsHtml = '';
16194
+ if (j.lastErrors && j.lastErrors.length > 0) {
16195
+ errorsHtml = '<div style="margin-top:8px;display:flex;flex-direction:column;gap:4px">';
16196
+ for (var e of j.lastErrors) {
16197
+ errorsHtml += '<pre style="font-size:11px;color:var(--text-secondary);background:var(--bg-tertiary);padding:6px 8px;border-radius:4px;white-space:pre-wrap;word-break:break-word;margin:0;max-height:120px;overflow-y:auto">' + esc(e) + '</pre>';
16198
+ }
16199
+ errorsHtml += '</div>';
16200
+ }
16201
+ var agentTag = j.agentSlug
16202
+ ? '<span class="badge badge-blue" style="font-size:10px">' + esc(j.agentSlug) + '</span>'
16203
+ : '';
16204
+ html += '<div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">'
16205
+ + '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
16206
+ + '<strong>' + esc(j.jobName) + '</strong> ' + agentTag + ' ' + breaker
16207
+ + '<span style="margin-left:auto;font-size:11px;color:var(--text-muted)">' + failureRatio + ' failed \\u00b7 last error ' + lastErrorAt + '</span>'
16208
+ + '</div>'
16209
+ + errorsHtml
16210
+ + advisorLine
16211
+ + '</div>';
16212
+ }
16213
+ html += '</div>';
16214
+ container.innerHTML = html;
16215
+ } catch(e) {
16216
+ var c = document.getElementById('panel-broken-jobs');
16217
+ if (c) c.innerHTML = '<div class="empty-state" style="color:var(--red)">Failed to load broken jobs</div>';
16218
+ }
16219
+ }
16220
+
16221
+ async function refreshPendingSkills() {
16222
+ try {
16223
+ var r = await apiFetch('/api/skills/pending');
16224
+ var d = await r.json();
16225
+ var pending = d.skills || [];
16226
+ var tabBadge = document.getElementById('tab-pending-skill-count');
16227
+ if (tabBadge) {
16228
+ tabBadge.textContent = String(pending.length);
16229
+ tabBadge.style.display = pending.length > 0 ? '' : 'none';
16230
+ }
16231
+ var card = document.getElementById('pending-skills-card');
16232
+ var countBadge = document.getElementById('pending-skills-count-badge');
16233
+ var container = document.getElementById('panel-pending-skills');
16234
+ if (!container) return;
16235
+ if (pending.length === 0) {
16236
+ if (card) card.style.display = 'none';
16237
+ container.innerHTML = '';
16238
+ return;
16239
+ }
16240
+ if (card) card.style.display = '';
16241
+ if (countBadge) countBadge.textContent = pending.length + ' pending';
16242
+
16243
+ var html = '<div style="display:flex;flex-direction:column;gap:10px">';
16244
+ for (var s of pending) {
16245
+ var sourceTag = s.source === 'cron' ? '<span class="badge badge-green" style="font-size:10px">cron</span>'
16246
+ : s.source === 'unleashed' ? '<span class="badge badge-purple" style="font-size:10px">unleashed</span>'
16247
+ : s.source === 'chat' ? '<span class="badge badge-blue" style="font-size:10px">chat</span>'
16248
+ : '<span class="badge badge-gray" style="font-size:10px">' + esc(s.source || 'unknown') + '</span>';
16249
+ var age = s.createdAt ? timeAgo(s.createdAt) : '';
16250
+ var scopeTag = s.agentSlug
16251
+ ? '<span style="font-size:10px;color:var(--text-muted)">for ' + esc(s.agentSlug) + '</span>'
16252
+ : '<span style="font-size:10px;color:var(--text-muted)">global</span>';
16253
+ html += '<div style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">'
16254
+ + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
16255
+ + '<strong>' + esc(s.title) + '</strong> ' + sourceTag + ' ' + scopeTag
16256
+ + (age ? ' <span style="font-size:10px;color:var(--text-muted)">\\u00b7 learned ' + age + '</span>' : '')
16257
+ + '<span style="margin-left:auto;display:flex;gap:6px">'
16258
+ + '<button onclick="approvePendingSkill(\\x27' + esc(s.name) + '\\x27)" style="background:var(--accent);border:1px solid var(--accent);border-radius:4px;padding:3px 10px;font-size:11px;color:white;cursor:pointer">Approve</button>'
16259
+ + '<button onclick="rejectPendingSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:3px 10px;font-size:11px;color:var(--red);cursor:pointer">Reject</button>'
16260
+ + '</span>'
16261
+ + '</div>'
16262
+ + '<div style="font-size:12px;color:var(--text-secondary)">' + esc(s.description || '') + '</div>'
16263
+ + '</div>';
16264
+ }
16265
+ html += '</div>';
16266
+ container.innerHTML = html;
16267
+ } catch(e) { /* non-fatal */ }
16268
+ }
16269
+
16270
+ async function approvePendingSkill(name) {
16271
+ try {
16272
+ var r = await apiJson('POST', '/api/skills/pending/' + encodeURIComponent(name) + '/approve', {});
16273
+ if (r && r.ok) {
16274
+ toast(r.message || 'Skill approved', 'success');
16275
+ refreshPendingSkills();
16276
+ refreshSkills();
16277
+ } else {
16278
+ toast((r && r.message) || 'Failed to approve', 'error');
16279
+ }
16280
+ } catch(e) { toast('Failed to approve skill', 'error'); }
16281
+ }
16282
+
16283
+ async function rejectPendingSkill(name) {
16284
+ if (!confirm('Reject this pending skill? It will be deleted.')) return;
16285
+ try {
16286
+ var r = await apiJson('POST', '/api/skills/pending/' + encodeURIComponent(name) + '/reject', {});
16287
+ if (r && r.ok) {
16288
+ toast(r.message || 'Skill rejected', 'success');
16289
+ refreshPendingSkills();
16290
+ } else {
16291
+ toast((r && r.message) || 'Failed to reject', 'error');
16292
+ }
16293
+ } catch(e) { toast('Failed to reject skill', 'error'); }
16294
+ }
16295
+
16034
16296
  async function refreshSkills() {
16297
+ refreshPendingSkills();
16035
16298
  try {
16036
16299
  var r = await apiFetch('/api/skills');
16037
16300
  var d = await r.json();
@@ -16075,12 +16338,15 @@ async function refreshSkills() {
16075
16338
  + (s.toolsUsed.length > 4 ? ' <span style="font-size:10px;color:var(--text-muted)">+' + (s.toolsUsed.length - 4) + '</span>' : '')
16076
16339
  + '</div>';
16077
16340
  }
16341
+ var retrieval7d = (typeof s.retrievals7d === 'number' && s.retrievals7d > 0)
16342
+ ? ' \\u00b7 ' + s.retrievals7d + ' retrievals (7d)'
16343
+ : '';
16078
16344
  html += '<div id="skill-card-' + esc(s.name) + '" style="padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary)">'
16079
16345
  + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">'
16080
16346
  + '<strong style="cursor:pointer" onclick="expandSkill(\\x27' + esc(s.name) + '\\x27)">' + esc(s.title) + '</strong> ' + sourceTag
16081
16347
  + (sourceCtx ? ' ' + sourceCtx : '')
16082
16348
  + '<span style="margin-left:auto;display:flex;align-items:center;gap:8px">'
16083
- + '<span style="font-size:11px;color:var(--text-muted)">used ' + s.useCount + 'x' + (age ? ' \\u00b7 ' + age : '') + '</span>'
16349
+ + '<span style="font-size:11px;color:var(--text-muted)">used ' + s.useCount + 'x' + (age ? ' \\u00b7 ' + age : '') + retrieval7d + '</span>'
16084
16350
  + '<button onclick="expandSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--text-secondary);cursor:pointer">View</button>'
16085
16351
  + '<button onclick="editSkillInBuilder(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--accent);cursor:pointer">Edit</button>'
16086
16352
  + '<button onclick="deleteSkill(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:11px;color:var(--red);cursor:pointer">Delete</button>'
@@ -17538,6 +17804,34 @@ try {
17538
17804
  toast('Daemon restarted \u2014 refreshing data...', 'info');
17539
17805
  setTimeout(function() { refreshAll(); }, 1500);
17540
17806
  }
17807
+ if (evt.type === 'deep_result') {
17808
+ try {
17809
+ var container = document.getElementById('chat-messages');
17810
+ var text = (evt.data && evt.data.text) ? evt.data.text : '';
17811
+ if (container && text) {
17812
+ var emptyState = container.querySelector('.empty-state');
17813
+ if (emptyState) emptyState.remove();
17814
+ var row = document.createElement('div');
17815
+ row.className = 'chat-assistant-row';
17816
+ var av = document.createElement('div');
17817
+ av.className = 'chat-avatar-sm';
17818
+ av.innerHTML = (lastStatusData && lastStatusData.name ? lastStatusData.name : 'C').charAt(0).toUpperCase();
17819
+ row.appendChild(av);
17820
+ var bubble = document.createElement('div');
17821
+ bubble.className = 'chat-bubble assistant';
17822
+ bubble.innerHTML = renderMd(text);
17823
+ var meta = document.createElement('div');
17824
+ meta.className = 'chat-meta';
17825
+ meta.textContent = new Date().toLocaleTimeString() + ' \u00b7 deep task';
17826
+ bubble.appendChild(meta);
17827
+ row.appendChild(bubble);
17828
+ container.appendChild(row);
17829
+ container.scrollTop = container.scrollHeight;
17830
+ } else {
17831
+ toast('Deep task result ready \u2014 open chat to view.', 'info');
17832
+ }
17833
+ } catch(e) { /* non-fatal */ }
17834
+ }
17541
17835
  } catch(err) { /* ignore */ }
17542
17836
  };
17543
17837
  } catch(err) { /* SSE not supported */ }
@@ -87,6 +87,11 @@ export declare class CronScheduler {
87
87
  private watchAgentsDir;
88
88
  private unwatchAgentsDir;
89
89
  reloadJobs(): void;
90
+ /**
91
+ * Wrap runLog.append so every completion also checks whether a fix
92
+ * verification is pending and DMs the verdict if so.
93
+ */
94
+ private _logRun;
90
95
  private runJob;
91
96
  /**
92
97
  * Log an advisor event to the events JSONL file for dashboard surfacing.
@@ -491,6 +491,9 @@ export class CronScheduler {
491
491
  this.watchingAgents = false;
492
492
  }
493
493
  reloadJobs() {
494
+ // Snapshot the pre-reload job definitions so fix-verification can diff
495
+ // and flag any currently-failing job whose config just changed.
496
+ const oldJobs = this.jobs.map(j => ({ ...j }));
494
497
  // Stop existing scheduled tasks (but NOT the file watcher)
495
498
  for (const [name, task] of this.scheduledTasks) {
496
499
  task.stop();
@@ -580,6 +583,30 @@ export class CronScheduler {
580
583
  logger.info(`Cron job '${def.name}' scheduled: ${def.schedule} (${SYSTEM_TIMEZONE})`);
581
584
  }
582
585
  }
586
+ // Fix-verification: detect any currently-failing job whose definition just
587
+ // changed, and record a pending verification for their next run.
588
+ // Skipped on the first load (oldJobs empty) since there's no edit to verify.
589
+ if (oldJobs.length > 0) {
590
+ import('./fix-verification.js').then(({ recordEditsForFailingJobs }) => {
591
+ try {
592
+ recordEditsForFailingJobs(oldJobs, this.jobs);
593
+ }
594
+ catch (err) {
595
+ logger.warn({ err }, 'Fix-verification capture failed');
596
+ }
597
+ }).catch(err => logger.warn({ err }, 'Fix-verification import failed'));
598
+ }
599
+ }
600
+ /**
601
+ * Wrap runLog.append so every completion also checks whether a fix
602
+ * verification is pending and DMs the verdict if so.
603
+ */
604
+ _logRun(entry) {
605
+ this.runLog.append(entry);
606
+ import('./fix-verification.js').then(({ checkAndDeliverVerification }) => {
607
+ checkAndDeliverVerification(entry, (text) => this.dispatcher.send(text, {}))
608
+ .catch(err => logger.warn({ err, job: entry.jobName }, 'Fix verification DM failed'));
609
+ }).catch(err => logger.warn({ err }, 'Fix-verification import failed'));
583
610
  }
584
611
  async runJob(job) {
585
612
  // Agent status check — skip if agent is paused/terminated
@@ -649,7 +676,7 @@ export class CronScheduler {
649
676
  // Non-zero exit or timeout → skip the job
650
677
  const exitCode = preCheckErr.status ?? 1;
651
678
  logger.info({ job: job.name, exitCode }, 'Pre-check failed — skipping job (no work to do)');
652
- this.runLog.append({
679
+ this._logRun({
653
680
  jobName: job.name,
654
681
  startedAt: new Date().toISOString(),
655
682
  finishedAt: new Date().toISOString(),
@@ -690,7 +717,7 @@ export class CronScheduler {
690
717
  });
691
718
  if (!approved) {
692
719
  logger.info({ job: job.name }, 'Cron job skipped by owner');
693
- this.runLog.append({
720
+ this._logRun({
694
721
  jobName: job.name,
695
722
  startedAt: new Date().toISOString(),
696
723
  finishedAt: new Date().toISOString(),
@@ -709,7 +736,7 @@ export class CronScheduler {
709
736
  const advice = getExecutionAdvice(job.name, job);
710
737
  if (advice.shouldSkip) {
711
738
  logger.info({ job: job.name, reason: advice.skipReason }, 'Execution advisor: circuit breaker — skipping job');
712
- this.runLog.append({
739
+ this._logRun({
713
740
  jobName: job.name,
714
741
  startedAt: new Date().toISOString(),
715
742
  finishedAt: new Date().toISOString(),
@@ -876,7 +903,7 @@ export class CronScheduler {
876
903
  this.gateway.injectContext(`discord:user:${DISCORD_OWNER_ID}`, `[Scheduled cron: ${job.name}]`, response);
877
904
  }
878
905
  }
879
- this.runLog.append(entry);
906
+ this._logRun(entry);
880
907
  // Fire-and-forget: extract procedural skill from successful long-running cron jobs
881
908
  if (entry.status === 'ok' && entry.durationMs > 30_000 && response && response.length > 500) {
882
909
  this.gateway.extractCronSkill(job.name, job.prompt, response, entry.durationMs, job.agentSlug)
@@ -902,7 +929,7 @@ export class CronScheduler {
902
929
  const errorType = errTerminalReason
903
930
  ? classifyTerminalReason(errTerminalReason)
904
931
  : classifyError(err);
905
- this.runLog.append({
932
+ this._logRun({
906
933
  jobName: job.name,
907
934
  startedAt: startedAt.toISOString(),
908
935
  finishedAt: finishedAt.toISOString(),
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Clementine TypeScript — Cron failure monitor.
3
+ *
4
+ * Surfaces cron jobs that have been failing repeatedly so they don't sit
5
+ * silently broken (which is what happened to ross-the-sdr:reply-detection —
6
+ * the existing circuit breaker fired ONCE at consErrors=5 and then went
7
+ * quiet for days).
8
+ *
9
+ * Threshold: a job is "broken" if either
10
+ * - it has >= 3 error/retried entries in the last 48h, OR
11
+ * - the circuit breaker engaged for it within the last 48h.
12
+ *
13
+ * Per-job 24h cooldown prevents re-spamming the owner with the same news.
14
+ *
15
+ * Read-only with respect to the cron run logs and advisor events; mutates
16
+ * only its own state file (cron/failure-monitor.json).
17
+ */
18
+ export interface BrokenJob {
19
+ jobName: string;
20
+ agentSlug?: string;
21
+ errorCount48h: number;
22
+ totalRuns48h: number;
23
+ lastErrorAt: string | null;
24
+ lastErrors: string[];
25
+ circuitBreakerEngagedAt: string | null;
26
+ lastAdvisorOpinion: string | null;
27
+ }
28
+ /**
29
+ * Compute the current set of broken jobs by scanning all run logs.
30
+ * Pure function (state-free) — used both by the monitor sweep and the dashboard endpoint.
31
+ */
32
+ export declare function computeBrokenJobs(now?: number): BrokenJob[];
33
+ /**
34
+ * Run a sweep: identify currently-broken jobs, pick the ones we haven't
35
+ * notified about recently, and dispatch one consolidated DM.
36
+ *
37
+ * Returns the jobs that triggered a fresh notification (mostly for tests/logs).
38
+ */
39
+ export declare function runFailureSweep(send: (text: string) => Promise<unknown>, now?: number): Promise<BrokenJob[]>;
40
+ //# sourceMappingURL=failure-monitor.d.ts.map