clementine-agent 1.0.21 → 1.0.23

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.
@@ -2075,6 +2075,16 @@ export async function cmdDashboard(opts) {
2075
2075
  res.status(500).json({ error: String(err) });
2076
2076
  }
2077
2077
  });
2078
+ // ── Team routing audit ──────────────────────────────────────────
2079
+ app.get('/api/routing-audit', async (_req, res) => {
2080
+ try {
2081
+ const { getRecentRouteDecisions } = await import('../gateway/router.js');
2082
+ res.json({ decisions: getRecentRouteDecisions(50) });
2083
+ }
2084
+ catch (err) {
2085
+ res.status(500).json({ error: String(err) });
2086
+ }
2087
+ });
2078
2088
  // ── Claims + trust score ────────────────────────────────────────
2079
2089
  app.get('/api/claims', async (req, res) => {
2080
2090
  try {
@@ -9417,6 +9427,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
9417
9427
  <div class="card-header">Recent claims</div>
9418
9428
  <div class="card-body" id="panel-claims"><div class="empty-state">Loading...</div></div>
9419
9429
  </div>
9430
+ <div class="card" style="margin-top:16px">
9431
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
9432
+ <span>Team routing decisions</span>
9433
+ <span style="font-size:11px;color:var(--text-muted)">Only owner-facing Clementine sessions are classified &mdash; agent-bot DMs bypass routing entirely.</span>
9434
+ </div>
9435
+ <div class="card-body" id="panel-routing-audit"><div class="empty-state">Loading...</div></div>
9436
+ </div>
9420
9437
  </div>
9421
9438
 
9422
9439
  <!-- ═══ Logs Page ═══ -->
@@ -10458,7 +10475,7 @@ function navigateTo(page, opts) {
10458
10475
  document.getElementById('builder-input').focus();
10459
10476
  }
10460
10477
  if (page === 'automations') { refreshCron(); refreshTimers(); refreshSelfImprove(); refreshSkills(); refreshBrokenJobs(); }
10461
- if (page === 'claims') { refreshClaims(); }
10478
+ if (page === 'claims') { refreshClaims(); refreshRoutingAudit(); }
10462
10479
  if (page === 'intelligence') { refreshMemory(); }
10463
10480
  if (page === 'settings') { refreshSettings(); refreshRemoteAccess(); refreshSalesforce(); refreshClaudeIntegrations(); refreshMcpServers(); }
10464
10481
  if (page === 'logs') refreshLogs();
@@ -16401,6 +16418,44 @@ async function refreshClaims(filter) {
16401
16418
  }
16402
16419
  }
16403
16420
 
16421
+ async function refreshRoutingAudit() {
16422
+ var container = document.getElementById('panel-routing-audit');
16423
+ if (!container) return;
16424
+ try {
16425
+ var r = await apiFetch('/api/routing-audit');
16426
+ var d = await r.json();
16427
+ var decisions = d.decisions || [];
16428
+ if (decisions.length === 0) {
16429
+ container.innerHTML = '<div class="empty-state">No routing decisions yet. Send Clementine a message that could be delegated and it will show up here.</div>';
16430
+ return;
16431
+ }
16432
+ var actionColor = {
16433
+ 'auto-delegated': '#22c55e',
16434
+ 'soft-suggested': '#f59e0b',
16435
+ 'stayed-with-clementine': '#6b7280',
16436
+ };
16437
+ var html = '<div style="display:flex;flex-direction:column;gap:6px;font-size:12px">';
16438
+ for (var de of decisions) {
16439
+ var color = actionColor[de.action] || '#6b7280';
16440
+ var confPct = Math.round((de.confidence || 0) * 100);
16441
+ html += '<div style="padding:8px 10px;border:1px solid var(--border);border-left:3px solid ' + color + ';border-radius:4px;background:var(--bg-secondary)">'
16442
+ + '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
16443
+ + '<span style="font-size:10px;padding:1px 6px;background:' + color + '22;color:' + color + ';border-radius:3px">' + esc(de.action) + '</span>'
16444
+ + '<span style="font-size:11px"><strong>' + esc(de.targetAgent) + '</strong> @ ' + confPct + '%</span>'
16445
+ + '<span style="font-size:10px;color:var(--text-muted)">' + timeAgo(de.timestamp) + '</span>'
16446
+ + '<span style="font-size:10px;color:var(--text-muted);margin-left:auto">' + esc(de.sessionKey) + '</span>'
16447
+ + '</div>'
16448
+ + '<div style="font-size:11px;color:var(--text-secondary);margin-top:4px">\u201c' + esc(de.messageSnippet.slice(0, 200)) + '\u201d</div>'
16449
+ + '<div style="font-size:10px;color:var(--text-muted);margin-top:2px;font-style:italic">' + esc(de.reasoning) + '</div>'
16450
+ + '</div>';
16451
+ }
16452
+ html += '</div>';
16453
+ container.innerHTML = html;
16454
+ } catch (e) {
16455
+ container.innerHTML = '<div class="empty-state" style="color:var(--red)">Failed to load routing audit</div>';
16456
+ }
16457
+ }
16458
+
16404
16459
  async function markClaim(id, status) {
16405
16460
  var endpoint = status === 'verified' ? 'mark-verified' : status === 'failed' ? 'mark-failed' : 'dismiss';
16406
16461
  try {
@@ -13,6 +13,7 @@
13
13
  * Extraction is regex-only to keep cost at $0 per DM. For nuanced
14
14
  * claims the dashboard's manual verify/fail path covers the gap.
15
15
  */
16
+ import type { Gateway } from './router.js';
16
17
  export type ClaimType = 'scheduled' | 'fixed' | 'will_do' | 'sent' | 'added' | 'unknown';
17
18
  export type VerifyStrategy = 'cron_run_check' | 'config_inspect' | 'manual';
18
19
  export type ClaimStatus = 'pending' | 'verified' | 'failed' | 'expired' | 'dismissed';
@@ -35,6 +36,13 @@ export interface Claim {
35
36
  * Caller supplies sessionKey for traceability. Never throws.
36
37
  */
37
38
  export declare function extractClaims(text: string, sessionKey?: string | null, agentSlug?: string | null): Omit<Claim, 'status' | 'extractedAt' | 'verifiedAt' | 'verdict'>[];
39
+ /**
40
+ * Drain the LLM-fallback queue: pick up to N enqueued DMs, ask Haiku
41
+ * to extract claims via the same shape the regex patterns use, persist
42
+ * any found. Best-effort — errors just leave the queue unchanged for
43
+ * the next sweep.
44
+ */
45
+ export declare function drainLLMFallback(gateway: Gateway, maxPerSweep?: number): Promise<number>;
38
46
  export declare function recordClaims(claims: Omit<Claim, 'status' | 'extractedAt' | 'verifiedAt' | 'verdict'>[]): Promise<void>;
39
47
  export declare function listClaims(opts?: {
40
48
  status?: ClaimStatus;
@@ -13,7 +13,7 @@
13
13
  * Extraction is regex-only to keep cost at $0 per DM. For nuanced
14
14
  * claims the dashboard's manual verify/fail path covers the gap.
15
15
  */
16
- import { randomBytes } from 'node:crypto';
16
+ import { createHash, randomBytes } from 'node:crypto';
17
17
  import path from 'node:path';
18
18
  import pino from 'pino';
19
19
  import { BASE_DIR, MEMORY_DB_PATH, VAULT_DIR } from '../config.js';
@@ -115,6 +115,42 @@ const PATTERNS = [
115
115
  verifyStrategy: 'manual',
116
116
  },
117
117
  ];
118
+ /**
119
+ * In-memory queue of DMs that regex-extraction missed but that look like
120
+ * they might contain claims (long enough, user-facing session). The
121
+ * heartbeat sweep drains this queue and runs the LLM fallback.
122
+ *
123
+ * Bounded to prevent memory growth — oldest entries are evicted.
124
+ */
125
+ const MAX_PENDING_LLM = 20;
126
+ const pendingLLMExtraction = [];
127
+ function enqueueForLLM(text, sessionKey, agentSlug) {
128
+ // De-dup by text hash within the queue — don't re-enqueue the same DM.
129
+ const hash = sha1(text);
130
+ if (pendingLLMExtraction.some(e => sha1(e.text) === hash))
131
+ return;
132
+ pendingLLMExtraction.push({ text, sessionKey, agentSlug, queuedAt: Date.now() });
133
+ while (pendingLLMExtraction.length > MAX_PENDING_LLM)
134
+ pendingLLMExtraction.shift();
135
+ }
136
+ function sha1(s) {
137
+ return createHash('sha1').update(s).digest('hex');
138
+ }
139
+ /** Should a non-matching DM be considered for LLM fallback? */
140
+ function isLLMFallbackCandidate(text, sessionKey) {
141
+ if (!sessionKey)
142
+ return false;
143
+ if (text.length < 100)
144
+ return false;
145
+ // Owner-facing DMs only. Skip heartbeat check-ins (they have their own
146
+ // gate) and skip cron notification messages that are the system talking
147
+ // about itself.
148
+ if (!sessionKey.startsWith('discord:') && !sessionKey.startsWith('slack:') && !sessionKey.startsWith('telegram:'))
149
+ return false;
150
+ if (text.startsWith('**[') && text.includes('check-in]'))
151
+ return false;
152
+ return true;
153
+ }
118
154
  /**
119
155
  * Extract claims from a message. Returns empty array if nothing matched.
120
156
  * Caller supplies sessionKey for traceability. Never throws.
@@ -157,8 +193,116 @@ export function extractClaims(text, sessionKey, agentSlug) {
157
193
  agentSlug: agentSlug ?? null,
158
194
  });
159
195
  }
196
+ // Regex missed this DM but it looks like it could contain a claim the
197
+ // regex patterns can't catch ("Got that done", "Sent it, you should see
198
+ // it in a minute"). Queue for LLM fallback on the next heartbeat.
199
+ if (out.length === 0 && isLLMFallbackCandidate(text, sessionKey ?? null)) {
200
+ enqueueForLLM(text, sessionKey ?? null, agentSlug ?? null);
201
+ }
160
202
  return out;
161
203
  }
204
+ /**
205
+ * Drain the LLM-fallback queue: pick up to N enqueued DMs, ask Haiku
206
+ * to extract claims via the same shape the regex patterns use, persist
207
+ * any found. Best-effort — errors just leave the queue unchanged for
208
+ * the next sweep.
209
+ */
210
+ export async function drainLLMFallback(gateway, maxPerSweep = 3) {
211
+ let drained = 0;
212
+ const batch = pendingLLMExtraction.splice(0, Math.min(maxPerSweep, pendingLLMExtraction.length));
213
+ for (const item of batch) {
214
+ try {
215
+ const claims = await llmExtractClaims(item.text, gateway);
216
+ if (claims.length === 0)
217
+ continue;
218
+ const toRecord = claims.map(c => ({
219
+ id: randomBytes(6).toString('hex'),
220
+ sessionKey: item.sessionKey,
221
+ messageSnippet: item.text.slice(0, 400),
222
+ claimType: c.claimType,
223
+ subject: c.subject,
224
+ dueAt: c.dueAt,
225
+ verifyStrategy: c.verifyStrategy,
226
+ agentSlug: item.agentSlug,
227
+ }));
228
+ await recordClaims(toRecord);
229
+ drained += claims.length;
230
+ }
231
+ catch (err) {
232
+ logger.debug({ err }, 'LLM fallback extraction failed for one DM');
233
+ }
234
+ }
235
+ return drained;
236
+ }
237
+ async function llmExtractClaims(text, gateway) {
238
+ const prompt = [
239
+ 'You are analyzing a chat message Clementine (an AI assistant) just sent to her owner. Did Clementine make any commitments, promises, or claims about something she did or will do?',
240
+ '',
241
+ 'Only extract claims where there\'s a clear, concrete action. Do NOT extract:',
242
+ '- Status updates ("inbox has 5 messages")',
243
+ '- Questions ("Should I proceed?")',
244
+ '- Suggestions ("You might want to check X")',
245
+ '- Routine check-ins or greetings',
246
+ '',
247
+ 'DO extract:',
248
+ '- "I scheduled X" / "I added Y to your tasks" / "I fixed Z"',
249
+ '- "I\'ll send X at Ypm" / "Will run Y tomorrow"',
250
+ '- "Sent email to X" / "Posted to #channel"',
251
+ '',
252
+ '## Message:',
253
+ text.slice(0, 1500),
254
+ '',
255
+ 'Output a JSON object only (no fences):',
256
+ '{',
257
+ ' "claims": [',
258
+ ' {',
259
+ ' "claimType": "scheduled|fixed|will_do|sent|added",',
260
+ ' "subject": "short description of what (the noun phrase)",',
261
+ ' "dueAt": "ISO timestamp if a specific time was mentioned, else null"',
262
+ ' }',
263
+ ' ]',
264
+ '}',
265
+ 'Empty array if no real commitments.',
266
+ ].join('\n');
267
+ let raw;
268
+ try {
269
+ raw = await gateway.handleCronJob('llm-claim-extract', prompt, 1, // tier 1
270
+ 3, // tight maxTurns
271
+ 'haiku');
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ try {
277
+ const m = raw.match(/\{[\s\S]*\}/);
278
+ if (!m)
279
+ return [];
280
+ const parsed = JSON.parse(m[0]);
281
+ const claims = parsed.claims ?? [];
282
+ const out = [];
283
+ const validTypes = ['scheduled', 'fixed', 'will_do', 'sent', 'added'];
284
+ for (const c of claims) {
285
+ if (typeof c.subject !== 'string' || !c.subject.trim())
286
+ continue;
287
+ const type = typeof c.claimType === 'string' && validTypes.includes(c.claimType)
288
+ ? c.claimType
289
+ : 'unknown';
290
+ if (type === 'unknown')
291
+ continue;
292
+ const dueAt = typeof c.dueAt === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(c.dueAt) ? c.dueAt : null;
293
+ out.push({
294
+ claimType: type,
295
+ subject: c.subject.trim().slice(0, 200),
296
+ dueAt,
297
+ verifyStrategy: type === 'scheduled' ? 'cron_run_check' : 'manual',
298
+ });
299
+ }
300
+ return out;
301
+ }
302
+ catch {
303
+ return [];
304
+ }
305
+ }
162
306
  // ── Persistence ──────────────────────────────────────────────────────
163
307
  async function getStore() {
164
308
  const { MemoryStore } = await import('../memory/store.js');
@@ -76,8 +76,48 @@ function readRunLog(filePath) {
76
76
  return [];
77
77
  }
78
78
  }
79
- function isFailure(entry) {
80
- return entry.status === 'error' || entry.status === 'retried' || isSemanticFailure(entry);
79
+ function isFailure(entry, gradeCache) {
80
+ if (entry.status === 'error' || entry.status === 'retried')
81
+ return true;
82
+ if (isSemanticFailure(entry))
83
+ return true;
84
+ // Outcome grader verdict, if we have one for this (job, time) tuple.
85
+ // Key format: `${jobName}|${startedAt}`. A `false` grade means the LLM
86
+ // judged the apparent-ok run as semantically failed.
87
+ if (gradeCache) {
88
+ const key = `${entry.jobName}|${entry.startedAt}`;
89
+ const passed = gradeCache.get(key);
90
+ if (passed === false)
91
+ return true;
92
+ }
93
+ return false;
94
+ }
95
+ /**
96
+ * Pre-load outcome grades for recent runs of all jobs so the synchronous
97
+ * isFailure check can consult them without hitting SQLite per call.
98
+ * Returns a map keyed by `${jobName}|${startedAt}` with the `passed` verdict.
99
+ */
100
+ function loadGradeCache() {
101
+ const cache = new Map();
102
+ try {
103
+ const { MEMORY_DB_PATH } = require('../config.js');
104
+ if (!existsSync(MEMORY_DB_PATH))
105
+ return cache;
106
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
107
+ const Database = require('better-sqlite3');
108
+ const db = new Database(MEMORY_DB_PATH, { readonly: true });
109
+ try {
110
+ const rows = db.prepare(`SELECT job_name, started_at, passed FROM graded_runs
111
+ WHERE graded_at >= datetime('now', '-14 days')`).all();
112
+ for (const r of rows) {
113
+ cache.set(`${r.job_name}|${r.started_at}`, r.passed === 1);
114
+ }
115
+ }
116
+ catch { /* graded_runs may not exist on older DBs */ }
117
+ db.close();
118
+ }
119
+ catch { /* non-fatal */ }
120
+ return cache;
81
121
  }
82
122
  /**
83
123
  * "Semantic failure" — a run the scheduler called `ok` but whose agent output
@@ -162,6 +202,7 @@ export function computeBrokenJobs(now = Date.now()) {
162
202
  return [];
163
203
  }
164
204
  const dormantCutoffMs = now - 7 * 24 * 60 * 60 * 1000;
205
+ const gradeCache = loadGradeCache();
165
206
  for (const file of files) {
166
207
  const entries = readRunLog(path.join(RUNS_DIR, file));
167
208
  if (entries.length === 0)
@@ -196,7 +237,7 @@ export function computeBrokenJobs(now = Date.now()) {
196
237
  const ts = Date.parse(e.startedAt);
197
238
  return Number.isFinite(ts) && ts >= sinceMs;
198
239
  });
199
- const failures = inWindow.filter(isFailure);
240
+ const failures = inWindow.filter(e => isFailure(e, gradeCache));
200
241
  // Consecutive-failure signal: scan from most recent entry backward.
201
242
  // Stops at the first non-failure (ignoring 'skipped' which is neither
202
243
  // signal). Catches daily jobs that fail every run without accumulating
@@ -206,7 +247,7 @@ export function computeBrokenJobs(now = Date.now()) {
206
247
  const e = entries[i];
207
248
  if (e.status === 'skipped')
208
249
  continue;
209
- if (isFailure(e))
250
+ if (isFailure(e, gradeCache))
210
251
  consecutiveFailures++;
211
252
  else
212
253
  break;
@@ -221,7 +262,7 @@ export function computeBrokenJobs(now = Date.now()) {
221
262
  // back to the most recent errors anywhere in the log.
222
263
  const errSource = failures.length > 0
223
264
  ? failures
224
- : entries.filter(isFailure);
265
+ : entries.filter(e => isFailure(e, gradeCache));
225
266
  const distinctErrors = [];
226
267
  const seen = new Set();
227
268
  for (let i = errSource.length - 1; i >= 0 && distinctErrors.length < 3; i--) {
@@ -413,6 +454,18 @@ function formatReport(jobs) {
413
454
  * Returns the jobs that triggered a fresh notification (mostly for tests/logs).
414
455
  */
415
456
  export async function runFailureSweep(send, gateway, now = Date.now()) {
457
+ // Opportunistically grade suspicious ok runs BEFORE computing broken
458
+ // jobs, so fresh grades feed into this same sweep's detection.
459
+ // Scoped to a handful of recent suspicious entries per job to keep cost
460
+ // bounded (~$0.01 per grade; cached forever).
461
+ if (gateway) {
462
+ try {
463
+ await gradeSuspiciousRecentRuns(gateway, now);
464
+ }
465
+ catch (err) {
466
+ logger.warn({ err }, 'Suspicious-run grading pre-pass failed (non-fatal)');
467
+ }
468
+ }
416
469
  const broken = computeBrokenJobs(now);
417
470
  if (broken.length === 0) {
418
471
  // Clear cooldowns AND diagnostic cache entries for jobs that recovered.
@@ -499,4 +552,54 @@ function appendAuditLog(action, jobNames) {
499
552
  }
500
553
  catch { /* non-fatal */ }
501
554
  }
555
+ /**
556
+ * Scan each job's recent runs for suspicious apparent-ok entries and grade
557
+ * them. Each job contributes at most 2 LLM calls per sweep. Results are
558
+ * cached per (jobName, startedAt), so a suspicious run grades exactly once.
559
+ */
560
+ async function gradeSuspiciousRecentRuns(gateway, now) {
561
+ const { isSuspicious, gradeRun, getGrade } = await import('./outcome-grader.js');
562
+ if (!existsSync(RUNS_DIR))
563
+ return;
564
+ // Only look at the last 48h so we're not burning grades on ancient entries.
565
+ const sinceMs = now - 48 * 60 * 60 * 1000;
566
+ let files = [];
567
+ try {
568
+ files = readdirSync(RUNS_DIR).filter(f => f.endsWith('.jsonl'));
569
+ }
570
+ catch {
571
+ return;
572
+ }
573
+ for (const file of files) {
574
+ const entries = readRunLog(path.join(RUNS_DIR, file));
575
+ const recent = entries
576
+ .filter(e => {
577
+ const ts = Date.parse(e.startedAt);
578
+ return Number.isFinite(ts) && ts >= sinceMs;
579
+ })
580
+ .filter(isSuspicious);
581
+ // Budget: at most 2 per job per sweep. Take the newest.
582
+ for (const entry of recent.slice(-2)) {
583
+ const cached = await getGrade(entry.jobName, entry.startedAt);
584
+ if (cached)
585
+ continue;
586
+ // Attempt to read the job prompt for richer grading context.
587
+ const jobPrompt = await loadJobPrompt(entry.jobName);
588
+ await gradeRun(entry, gateway, jobPrompt ?? undefined);
589
+ }
590
+ }
591
+ }
592
+ /** Load the current CRON.md prompt for a job. Returns null if not found. */
593
+ async function loadJobPrompt(jobName) {
594
+ try {
595
+ const { parseCronJobs, parseAgentCronJobs } = await import('./cron-scheduler.js');
596
+ const { AGENTS_DIR } = await import('../config.js');
597
+ const allJobs = [...parseCronJobs(), ...parseAgentCronJobs(AGENTS_DIR)];
598
+ const job = allJobs.find(j => j.name === jobName);
599
+ return job?.prompt ?? null;
600
+ }
601
+ catch {
602
+ return null;
603
+ }
604
+ }
502
605
  //# sourceMappingURL=failure-monitor.js.map
@@ -114,12 +114,25 @@ export class HeartbeatScheduler {
114
114
  }).catch(err => logger.warn({ err }, 'Failure sweep import failed'));
115
115
  // Claim verification sweep — auto-verify pending claims whose due
116
116
  // times have passed (e.g. "I scheduled X for 8am" → check at 9am).
117
- import('./claim-tracker.js').then(({ verifyDueClaims }) => {
118
- verifyDueClaims().then(({ verified, failed, expired }) => {
117
+ import('./claim-tracker.js').then(async ({ verifyDueClaims, drainLLMFallback }) => {
118
+ try {
119
+ const { verified, failed, expired } = await verifyDueClaims();
119
120
  if (verified + failed + expired > 0) {
120
121
  logger.info({ verified, failed, expired }, 'Claim verification sweep complete');
121
122
  }
122
- }).catch(err => logger.warn({ err }, 'Claim verification sweep failed'));
123
+ }
124
+ catch (err) {
125
+ logger.warn({ err }, 'Claim verification sweep failed');
126
+ }
127
+ // LLM fallback for regex-missed DMs — bounded batch per sweep
128
+ try {
129
+ const drained = await drainLLMFallback(this.gateway, 3);
130
+ if (drained > 0)
131
+ logger.info({ count: drained }, 'LLM claim fallback extracted');
132
+ }
133
+ catch (err) {
134
+ logger.debug({ err }, 'LLM claim fallback failed (non-fatal)');
135
+ }
123
136
  }).catch(err => logger.warn({ err }, 'Claim tracker import failed'));
124
137
  const now = new Date();
125
138
  const hour = now.getHours();
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Clementine TypeScript — Outcome grader.
3
+ *
4
+ * Second-pass check for cron runs the scheduler called `ok` but that
5
+ * might actually be semantic failures. Covers the gap between the
6
+ * marker-based semantic detection in failure-monitor (which caught
7
+ * BLOCKED / FAILED / etc. in output) and the empty-output-too-short
8
+ * case (which false-positives on legitimate quiet healthchecks).
9
+ *
10
+ * Strategy: only invoke the LLM when the run is SUSPICIOUS (empty
11
+ * preview with non-trivial duration, or ambiguous content). Cost:
12
+ * bounded to ~$0.01 per suspicious run, cached forever per
13
+ * (job_name, started_at) tuple.
14
+ */
15
+ import type { CronRunEntry } from '../types.js';
16
+ import type { Gateway } from './router.js';
17
+ export interface Grade {
18
+ jobName: string;
19
+ startedAt: string;
20
+ passed: boolean;
21
+ score: number;
22
+ reasoning: string;
23
+ gradedAt: string;
24
+ }
25
+ /**
26
+ * Decide whether a run warrants LLM grading. Heuristic — designed to
27
+ * fire on the exact pattern that slipped through today: apparent-ok
28
+ * runs with empty output + duration suggesting real work happened.
29
+ */
30
+ export declare function isSuspicious(entry: CronRunEntry): boolean;
31
+ export declare function getGrade(jobName: string, startedAt: string): Promise<Grade | null>;
32
+ export declare function recordGrade(grade: Grade): Promise<void>;
33
+ /**
34
+ * Grade a single cron run. Returns cached grade if we've already graded
35
+ * this (job, startedAt) tuple. Returns null if grading fails — caller
36
+ * should fall back to existing signals.
37
+ */
38
+ export declare function gradeRun(entry: CronRunEntry, gateway: Gateway, jobPrompt?: string): Promise<Grade | null>;
39
+ /** Look up recent grades for a job — used by the dashboard. */
40
+ export declare function recentGrades(jobName: string, limit?: number): Promise<Grade[]>;
41
+ //# sourceMappingURL=outcome-grader.d.ts.map
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Clementine TypeScript — Outcome grader.
3
+ *
4
+ * Second-pass check for cron runs the scheduler called `ok` but that
5
+ * might actually be semantic failures. Covers the gap between the
6
+ * marker-based semantic detection in failure-monitor (which caught
7
+ * BLOCKED / FAILED / etc. in output) and the empty-output-too-short
8
+ * case (which false-positives on legitimate quiet healthchecks).
9
+ *
10
+ * Strategy: only invoke the LLM when the run is SUSPICIOUS (empty
11
+ * preview with non-trivial duration, or ambiguous content). Cost:
12
+ * bounded to ~$0.01 per suspicious run, cached forever per
13
+ * (job_name, started_at) tuple.
14
+ */
15
+ import pino from 'pino';
16
+ import { MEMORY_DB_PATH, VAULT_DIR } from '../config.js';
17
+ const logger = pino({ name: 'clementine.outcome-grader' });
18
+ async function getStore() {
19
+ const { MemoryStore } = await import('../memory/store.js');
20
+ const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
21
+ store.initialize();
22
+ return store;
23
+ }
24
+ /**
25
+ * Decide whether a run warrants LLM grading. Heuristic — designed to
26
+ * fire on the exact pattern that slipped through today: apparent-ok
27
+ * runs with empty output + duration suggesting real work happened.
28
+ */
29
+ export function isSuspicious(entry) {
30
+ if (entry.status !== 'ok')
31
+ return false;
32
+ const preview = (entry.outputPreview ?? '').trim();
33
+ // Case 1: empty or near-empty preview with meaningful duration.
34
+ // 20s threshold catches today's empty-market-leader-followup pattern
35
+ // (23s + $0.57 cost, returned nothing). Legitimate quiet healthchecks
36
+ // can run 15-33s too — we'll grade them once, the LLM correctly judges
37
+ // "nothing to report" as passed, and the cached result means no re-grade.
38
+ if (preview.length < 20 && entry.durationMs > 20_000)
39
+ return true;
40
+ // Case 2: reasonable preview but contains soft-negative language that
41
+ // marker-based detection might miss. Kept tight so we don't spend on
42
+ // normal variance.
43
+ const lower = preview.toLowerCase();
44
+ if (/\b(partial|skipped\s+\d+|could\s+not\s+complete|insufficient|timeout(?!ed)|not\s+enough|attempting|retrying)\b/.test(lower)) {
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ export async function getGrade(jobName, startedAt) {
50
+ try {
51
+ const store = await getStore();
52
+ const db = store.conn;
53
+ const row = db.prepare(`SELECT job_name AS jobName, started_at AS startedAt, passed, score, reasoning, graded_at AS gradedAt
54
+ FROM graded_runs WHERE job_name = ? AND started_at = ?`).get(jobName, startedAt);
55
+ store.close();
56
+ if (!row)
57
+ return null;
58
+ return { ...row, passed: row.passed === 1 };
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export async function recordGrade(grade) {
65
+ try {
66
+ const store = await getStore();
67
+ const db = store.conn;
68
+ db.prepare(`INSERT OR REPLACE INTO graded_runs (job_name, started_at, passed, score, reasoning)
69
+ VALUES (?, ?, ?, ?, ?)`).run(grade.jobName, grade.startedAt, grade.passed ? 1 : 0, grade.score, grade.reasoning);
70
+ store.close();
71
+ }
72
+ catch (err) {
73
+ logger.warn({ err, jobName: grade.jobName }, 'Failed to record grade');
74
+ }
75
+ }
76
+ function buildPrompt(entry, jobPrompt) {
77
+ return [
78
+ 'You are judging whether a cron job execution actually accomplished its intent.',
79
+ '',
80
+ `## Job: ${entry.jobName}`,
81
+ `## Duration: ${Math.round(entry.durationMs / 1000)}s`,
82
+ '',
83
+ '## Job instructions (the prompt the agent was given):',
84
+ jobPrompt ? jobPrompt.slice(0, 2000) : '(instructions unavailable)',
85
+ '',
86
+ '## What the agent produced (output preview, may be truncated):',
87
+ (entry.outputPreview ?? '(empty)').slice(0, 1500),
88
+ '',
89
+ '## Your job',
90
+ 'Decide: did the agent actually accomplish the task, or did it superficially succeed while failing semantically?',
91
+ 'Examples of semantic success that looks like failure: a healthcheck that returns nothing because everything is healthy; a reply-detection sweep that returns nothing because no replies came in.',
92
+ 'Examples of semantic failure that looks like success: the agent hits a blocker, logs status=ok, returns empty; the agent fails auth and returns a generic "cannot proceed"; the agent reports "attempting X" but never actually does X.',
93
+ '',
94
+ 'Output ONLY a JSON object, no fences:',
95
+ '{',
96
+ ' "passed": true|false,',
97
+ ' "score": 0-5 (5 = clearly accomplished, 0 = clearly failed),',
98
+ ' "reasoning": "one sentence explaining your judgment"',
99
+ '}',
100
+ ].join('\n');
101
+ }
102
+ function parseGrade(raw) {
103
+ try {
104
+ const m = raw.match(/\{[\s\S]*\}/);
105
+ if (!m)
106
+ return null;
107
+ const p = JSON.parse(m[0]);
108
+ if (typeof p.passed !== 'boolean')
109
+ return null;
110
+ const score = typeof p.score === 'number' ? Math.max(0, Math.min(5, Math.round(p.score))) : (p.passed ? 4 : 1);
111
+ return {
112
+ passed: p.passed,
113
+ score,
114
+ reasoning: typeof p.reasoning === 'string' ? p.reasoning.slice(0, 300) : '(no reasoning)',
115
+ };
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /**
122
+ * Grade a single cron run. Returns cached grade if we've already graded
123
+ * this (job, startedAt) tuple. Returns null if grading fails — caller
124
+ * should fall back to existing signals.
125
+ */
126
+ export async function gradeRun(entry, gateway, jobPrompt) {
127
+ // Cache lookup
128
+ const cached = await getGrade(entry.jobName, entry.startedAt);
129
+ if (cached)
130
+ return cached;
131
+ if (!isSuspicious(entry))
132
+ return null;
133
+ const prompt = buildPrompt(entry, jobPrompt ?? null);
134
+ let raw;
135
+ try {
136
+ raw = await gateway.handleCronJob(`grade:${entry.jobName}`, prompt, 1, // tier 1
137
+ 3, // maxTurns — tight
138
+ 'haiku');
139
+ }
140
+ catch (err) {
141
+ logger.warn({ err, jobName: entry.jobName }, 'Outcome grader LLM call failed');
142
+ return null;
143
+ }
144
+ const parsed = parseGrade(raw);
145
+ if (!parsed) {
146
+ logger.warn({ jobName: entry.jobName, rawHead: raw.slice(0, 200) }, 'Outcome grader returned unparseable response');
147
+ return null;
148
+ }
149
+ const grade = {
150
+ jobName: entry.jobName,
151
+ startedAt: entry.startedAt,
152
+ ...parsed,
153
+ gradedAt: new Date().toISOString(),
154
+ };
155
+ await recordGrade(grade);
156
+ logger.info({ jobName: grade.jobName, passed: grade.passed, score: grade.score }, 'Graded run');
157
+ return grade;
158
+ }
159
+ /** Look up recent grades for a job — used by the dashboard. */
160
+ export async function recentGrades(jobName, limit = 10) {
161
+ try {
162
+ const store = await getStore();
163
+ const db = store.conn;
164
+ const rows = db.prepare(`SELECT job_name AS jobName, started_at AS startedAt, passed, score, reasoning, graded_at AS gradedAt
165
+ FROM graded_runs WHERE job_name = ? ORDER BY started_at DESC LIMIT ?`).all(jobName, limit);
166
+ store.close();
167
+ return rows.map(r => ({ ...r, passed: r.passed === 1 }));
168
+ }
169
+ catch {
170
+ return [];
171
+ }
172
+ }
173
+ //# sourceMappingURL=outcome-grader.js.map