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.
- package/dist/agent/complexity-classifier.d.ts +30 -0
- package/dist/agent/complexity-classifier.js +153 -0
- package/dist/agent/insight-engine.js +42 -0
- package/dist/agent/route-classifier.d.ts +52 -0
- package/dist/agent/route-classifier.js +197 -0
- package/dist/agent/self-improve.js +74 -29
- package/dist/cli/dashboard.js +56 -1
- package/dist/gateway/claim-tracker.d.ts +8 -0
- package/dist/gateway/claim-tracker.js +145 -1
- package/dist/gateway/failure-monitor.js +108 -5
- package/dist/gateway/heartbeat-scheduler.js +16 -3
- package/dist/gateway/outcome-grader.d.ts +41 -0
- package/dist/gateway/outcome-grader.js +173 -0
- package/dist/gateway/router.d.ts +19 -0
- package/dist/gateway/router.js +144 -2
- package/dist/memory/store.js +11 -0
- package/package.json +1 -1
package/dist/cli/dashboard.js
CHANGED
|
@@ -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 — 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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|