clementine-agent 1.0.20 → 1.0.21
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/cli/dashboard.js +217 -0
- package/dist/gateway/claim-tracker.d.ts +67 -0
- package/dist/gateway/claim-tracker.js +351 -0
- package/dist/gateway/heartbeat-scheduler.js +9 -0
- package/dist/gateway/notifications.d.ts +1 -0
- package/dist/gateway/notifications.js +16 -0
- package/dist/memory/store.js +18 -0
- package/package.json +1 -1
package/dist/cli/dashboard.js
CHANGED
|
@@ -2075,6 +2075,58 @@ export async function cmdDashboard(opts) {
|
|
|
2075
2075
|
res.status(500).json({ error: String(err) });
|
|
2076
2076
|
}
|
|
2077
2077
|
});
|
|
2078
|
+
// ── Claims + trust score ────────────────────────────────────────
|
|
2079
|
+
app.get('/api/claims', async (req, res) => {
|
|
2080
|
+
try {
|
|
2081
|
+
const { listClaims, trustScore } = await import('../gateway/claim-tracker.js');
|
|
2082
|
+
const status = req.query.status;
|
|
2083
|
+
const limit = Number(req.query.limit ?? 50);
|
|
2084
|
+
const sinceHours = req.query.sinceHours ? Number(req.query.sinceHours) : undefined;
|
|
2085
|
+
const validStatus = ['pending', 'verified', 'failed', 'expired', 'dismissed'];
|
|
2086
|
+
const claims = await listClaims({
|
|
2087
|
+
...(status && validStatus.includes(status) ? { status: status } : {}),
|
|
2088
|
+
limit: Number.isFinite(limit) ? limit : 50,
|
|
2089
|
+
...(sinceHours && Number.isFinite(sinceHours) ? { sinceHours } : {}),
|
|
2090
|
+
});
|
|
2091
|
+
const trust = await trustScore(30);
|
|
2092
|
+
res.json({ claims, trust });
|
|
2093
|
+
}
|
|
2094
|
+
catch (err) {
|
|
2095
|
+
res.status(500).json({ error: String(err) });
|
|
2096
|
+
}
|
|
2097
|
+
});
|
|
2098
|
+
app.post('/api/claims/:id/mark-verified', async (req, res) => {
|
|
2099
|
+
try {
|
|
2100
|
+
const { setClaimStatus } = await import('../gateway/claim-tracker.js');
|
|
2101
|
+
const verdict = typeof req.body?.verdict === 'string' ? req.body.verdict.slice(0, 400) : 'Manually verified by owner';
|
|
2102
|
+
const ok = await setClaimStatus(req.params.id, 'verified', verdict);
|
|
2103
|
+
res.json({ ok });
|
|
2104
|
+
}
|
|
2105
|
+
catch (err) {
|
|
2106
|
+
res.status(500).json({ error: String(err) });
|
|
2107
|
+
}
|
|
2108
|
+
});
|
|
2109
|
+
app.post('/api/claims/:id/mark-failed', async (req, res) => {
|
|
2110
|
+
try {
|
|
2111
|
+
const { setClaimStatus } = await import('../gateway/claim-tracker.js');
|
|
2112
|
+
const verdict = typeof req.body?.verdict === 'string' ? req.body.verdict.slice(0, 400) : 'Manually marked as failed by owner';
|
|
2113
|
+
const ok = await setClaimStatus(req.params.id, 'failed', verdict);
|
|
2114
|
+
res.json({ ok });
|
|
2115
|
+
}
|
|
2116
|
+
catch (err) {
|
|
2117
|
+
res.status(500).json({ error: String(err) });
|
|
2118
|
+
}
|
|
2119
|
+
});
|
|
2120
|
+
app.post('/api/claims/:id/dismiss', async (req, res) => {
|
|
2121
|
+
try {
|
|
2122
|
+
const { setClaimStatus } = await import('../gateway/claim-tracker.js');
|
|
2123
|
+
const ok = await setClaimStatus(req.params.id, 'dismissed', 'Dismissed — not a tracked promise');
|
|
2124
|
+
res.json({ ok });
|
|
2125
|
+
}
|
|
2126
|
+
catch (err) {
|
|
2127
|
+
res.status(500).json({ error: String(err) });
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2078
2130
|
// ── Broken jobs (failure monitor) ───────────────────────────────
|
|
2079
2131
|
app.get('/api/cron/broken-jobs', async (_req, res) => {
|
|
2080
2132
|
try {
|
|
@@ -8903,6 +8955,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
8903
8955
|
<div class="nav-item" data-page="intelligence">
|
|
8904
8956
|
<span class="nav-icon">🧠</span> Intelligence
|
|
8905
8957
|
</div>
|
|
8958
|
+
<div class="nav-item" data-page="claims">
|
|
8959
|
+
<span class="nav-icon">🔒</span> Trust & Claims
|
|
8960
|
+
<span class="nav-badge" id="nav-trust-score" style="display:none">--</span>
|
|
8961
|
+
</div>
|
|
8906
8962
|
<div class="nav-item" data-page="logs">
|
|
8907
8963
|
<span class="nav-icon">📜</span> Logs
|
|
8908
8964
|
</div>
|
|
@@ -9337,6 +9393,32 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
|
|
|
9337
9393
|
<div id="panel-sessions"><div class="empty-state">Loading...</div></div>
|
|
9338
9394
|
</div>
|
|
9339
9395
|
|
|
9396
|
+
<!-- ═══ Trust & Claims Page ═══ -->
|
|
9397
|
+
<div class="page" id="page-claims">
|
|
9398
|
+
<div class="page-title">Trust & Claims</div>
|
|
9399
|
+
<div class="card" style="margin-bottom:16px">
|
|
9400
|
+
<div class="card-body" style="display:flex;align-items:center;gap:16px;padding:16px">
|
|
9401
|
+
<div style="font-size:36px;font-weight:700" id="trust-score-big">--</div>
|
|
9402
|
+
<div style="flex:1">
|
|
9403
|
+
<div style="font-size:13px;font-weight:600">Clementine's trust score</div>
|
|
9404
|
+
<div style="font-size:11px;color:var(--text-muted)" id="trust-score-detail">
|
|
9405
|
+
Rolling over the last 30 verified or failed claims.
|
|
9406
|
+
</div>
|
|
9407
|
+
</div>
|
|
9408
|
+
<div style="display:flex;gap:6px">
|
|
9409
|
+
<button class="btn-sm" onclick="refreshClaims('all')" id="claims-filter-all" style="padding:4px 10px">All</button>
|
|
9410
|
+
<button class="btn-sm" onclick="refreshClaims('pending')" id="claims-filter-pending" style="padding:4px 10px">Pending</button>
|
|
9411
|
+
<button class="btn-sm" onclick="refreshClaims('verified')" id="claims-filter-verified" style="padding:4px 10px">Verified</button>
|
|
9412
|
+
<button class="btn-sm" onclick="refreshClaims('failed')" id="claims-filter-failed" style="padding:4px 10px">Failed</button>
|
|
9413
|
+
</div>
|
|
9414
|
+
</div>
|
|
9415
|
+
</div>
|
|
9416
|
+
<div class="card">
|
|
9417
|
+
<div class="card-header">Recent claims</div>
|
|
9418
|
+
<div class="card-body" id="panel-claims"><div class="empty-state">Loading...</div></div>
|
|
9419
|
+
</div>
|
|
9420
|
+
</div>
|
|
9421
|
+
|
|
9340
9422
|
<!-- ═══ Logs Page ═══ -->
|
|
9341
9423
|
<div class="page" id="page-logs">
|
|
9342
9424
|
<div class="page-title">Logs</div>
|
|
@@ -10376,6 +10458,7 @@ function navigateTo(page, opts) {
|
|
|
10376
10458
|
document.getElementById('builder-input').focus();
|
|
10377
10459
|
}
|
|
10378
10460
|
if (page === 'automations') { refreshCron(); refreshTimers(); refreshSelfImprove(); refreshSkills(); refreshBrokenJobs(); }
|
|
10461
|
+
if (page === 'claims') { refreshClaims(); }
|
|
10379
10462
|
if (page === 'intelligence') { refreshMemory(); }
|
|
10380
10463
|
if (page === 'settings') { refreshSettings(); refreshRemoteAccess(); refreshSalesforce(); refreshClaudeIntegrations(); refreshMcpServers(); }
|
|
10381
10464
|
if (page === 'logs') refreshLogs();
|
|
@@ -16210,6 +16293,129 @@ async function expandSkill(name) {
|
|
|
16210
16293
|
} catch(e) { toast('Failed to load skill', 'error'); }
|
|
16211
16294
|
}
|
|
16212
16295
|
|
|
16296
|
+
// ── Trust & Claims ────────────────────────
|
|
16297
|
+
var _claimsFilter = 'all';
|
|
16298
|
+
|
|
16299
|
+
function formatTrustScore(trust) {
|
|
16300
|
+
if (!trust) return { big: '--', detail: 'No claims recorded yet.' };
|
|
16301
|
+
if (trust.score === null) {
|
|
16302
|
+
return {
|
|
16303
|
+
big: trust.total === 0 ? '--' : String(trust.total),
|
|
16304
|
+
detail: trust.total === 0
|
|
16305
|
+
? 'No verified or failed claims yet. Score activates after 3+ verdicts.'
|
|
16306
|
+
: 'Only ' + trust.total + ' verdict' + (trust.total === 1 ? '' : 's') + ' so far \u2014 score activates at 3+.',
|
|
16307
|
+
};
|
|
16308
|
+
}
|
|
16309
|
+
var pct = Math.round(trust.score * 100);
|
|
16310
|
+
return {
|
|
16311
|
+
big: pct + '%',
|
|
16312
|
+
detail: trust.verified + ' verified, ' + trust.failed + ' failed over last ' + trust.total + ' judged claims.',
|
|
16313
|
+
};
|
|
16314
|
+
}
|
|
16315
|
+
|
|
16316
|
+
async function refreshClaims(filter) {
|
|
16317
|
+
if (filter && filter !== _claimsFilter) _claimsFilter = filter;
|
|
16318
|
+
try {
|
|
16319
|
+
var url = '/api/claims?limit=100';
|
|
16320
|
+
if (_claimsFilter && _claimsFilter !== 'all') url += '&status=' + encodeURIComponent(_claimsFilter);
|
|
16321
|
+
var r = await apiFetch(url);
|
|
16322
|
+
var d = await r.json();
|
|
16323
|
+
|
|
16324
|
+
// Trust score
|
|
16325
|
+
var t = formatTrustScore(d.trust);
|
|
16326
|
+
var big = document.getElementById('trust-score-big');
|
|
16327
|
+
var detail = document.getElementById('trust-score-detail');
|
|
16328
|
+
if (big) big.textContent = t.big;
|
|
16329
|
+
if (detail) detail.textContent = t.detail;
|
|
16330
|
+
var navBadge = document.getElementById('nav-trust-score');
|
|
16331
|
+
if (navBadge) {
|
|
16332
|
+
if (d.trust && d.trust.score !== null) {
|
|
16333
|
+
navBadge.textContent = Math.round(d.trust.score * 100) + '%';
|
|
16334
|
+
navBadge.style.display = '';
|
|
16335
|
+
} else {
|
|
16336
|
+
navBadge.style.display = 'none';
|
|
16337
|
+
}
|
|
16338
|
+
}
|
|
16339
|
+
|
|
16340
|
+
// Filter-button highlighting
|
|
16341
|
+
['all', 'pending', 'verified', 'failed'].forEach(function(k) {
|
|
16342
|
+
var btn = document.getElementById('claims-filter-' + k);
|
|
16343
|
+
if (!btn) return;
|
|
16344
|
+
if (k === _claimsFilter) {
|
|
16345
|
+
btn.style.background = 'var(--accent)';
|
|
16346
|
+
btn.style.color = 'white';
|
|
16347
|
+
btn.style.border = '1px solid var(--accent)';
|
|
16348
|
+
} else {
|
|
16349
|
+
btn.style.background = 'var(--bg-tertiary)';
|
|
16350
|
+
btn.style.color = 'var(--text-primary)';
|
|
16351
|
+
btn.style.border = '1px solid var(--border)';
|
|
16352
|
+
}
|
|
16353
|
+
});
|
|
16354
|
+
|
|
16355
|
+
var claims = d.claims || [];
|
|
16356
|
+
var container = document.getElementById('panel-claims');
|
|
16357
|
+
if (!container) return;
|
|
16358
|
+
if (claims.length === 0) {
|
|
16359
|
+
container.innerHTML = '<div class="empty-state">No claims in this view.</div>';
|
|
16360
|
+
return;
|
|
16361
|
+
}
|
|
16362
|
+
|
|
16363
|
+
var statusColor = {
|
|
16364
|
+
pending: '#f59e0b',
|
|
16365
|
+
verified: '#22c55e',
|
|
16366
|
+
failed: '#ef4444',
|
|
16367
|
+
expired: '#6b7280',
|
|
16368
|
+
dismissed: '#6b7280',
|
|
16369
|
+
};
|
|
16370
|
+
|
|
16371
|
+
var html = '<div style="display:flex;flex-direction:column;gap:8px">';
|
|
16372
|
+
for (var c of claims) {
|
|
16373
|
+
var color = statusColor[c.status] || '#6b7280';
|
|
16374
|
+
var due = c.dueAt ? ' \u00b7 due ' + timeAgo(c.dueAt) : '';
|
|
16375
|
+
var ver = c.verifiedAt ? ' \u00b7 verdict ' + timeAgo(c.verifiedAt) : '';
|
|
16376
|
+
var actions = c.status === 'pending'
|
|
16377
|
+
? '<span style="margin-left:auto;display:flex;gap:4px">'
|
|
16378
|
+
+ '<button onclick="markClaim(\\x27' + esc(c.id) + '\\x27,\\x27verified\\x27)" style="background:none;border:1px solid var(--border);border-radius:3px;padding:2px 8px;font-size:11px;color:#22c55e;cursor:pointer">Verified</button>'
|
|
16379
|
+
+ '<button onclick="markClaim(\\x27' + esc(c.id) + '\\x27,\\x27failed\\x27)" style="background:none;border:1px solid var(--border);border-radius:3px;padding:2px 8px;font-size:11px;color:#ef4444;cursor:pointer">Failed</button>'
|
|
16380
|
+
+ '<button onclick="markClaim(\\x27' + esc(c.id) + '\\x27,\\x27dismissed\\x27)" style="background:none;border:1px solid var(--border);border-radius:3px;padding:2px 8px;font-size:11px;color:var(--text-muted);cursor:pointer">Dismiss</button>'
|
|
16381
|
+
+ '</span>'
|
|
16382
|
+
: '';
|
|
16383
|
+
var verdictLine = c.verdict ? '<div style="font-size:11px;color:var(--text-muted);margin-top:4px;font-style:italic">\u201c' + esc(c.verdict) + '\u201d</div>' : '';
|
|
16384
|
+
html += '<div style="padding:10px;border:1px solid var(--border);border-left:3px solid ' + color + ';border-radius:6px;background:var(--bg-secondary)">'
|
|
16385
|
+
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">'
|
|
16386
|
+
+ '<span style="font-size:10px;padding:1px 6px;background:' + color + '33;color:' + color + ';border-radius:3px;text-transform:uppercase;letter-spacing:0.5px">' + esc(c.status) + '</span>'
|
|
16387
|
+
+ '<span style="font-size:10px;color:var(--text-muted)">' + esc(c.claimType) + '</span>'
|
|
16388
|
+
+ '<strong style="font-size:13px">' + esc(c.subject) + '</strong>'
|
|
16389
|
+
+ '<span style="font-size:10px;color:var(--text-muted);margin-left:auto">' + timeAgo(c.extractedAt) + due + ver + '</span>'
|
|
16390
|
+
+ '</div>'
|
|
16391
|
+
+ '<div style="font-size:11px;color:var(--text-secondary);margin-top:6px">' + esc(c.messageSnippet.slice(0, 300)) + '</div>'
|
|
16392
|
+
+ verdictLine
|
|
16393
|
+
+ actions
|
|
16394
|
+
+ '</div>';
|
|
16395
|
+
}
|
|
16396
|
+
html += '</div>';
|
|
16397
|
+
container.innerHTML = html;
|
|
16398
|
+
} catch (e) {
|
|
16399
|
+
var c = document.getElementById('panel-claims');
|
|
16400
|
+
if (c) c.innerHTML = '<div class="empty-state" style="color:var(--red)">Failed to load claims</div>';
|
|
16401
|
+
}
|
|
16402
|
+
}
|
|
16403
|
+
|
|
16404
|
+
async function markClaim(id, status) {
|
|
16405
|
+
var endpoint = status === 'verified' ? 'mark-verified' : status === 'failed' ? 'mark-failed' : 'dismiss';
|
|
16406
|
+
try {
|
|
16407
|
+
var res = await apiJson('POST', '/api/claims/' + encodeURIComponent(id) + '/' + endpoint, {});
|
|
16408
|
+
if (res && res.ok) {
|
|
16409
|
+
toast('Claim marked ' + status, 'success');
|
|
16410
|
+
refreshClaims();
|
|
16411
|
+
} else {
|
|
16412
|
+
toast('Failed to update claim', 'error');
|
|
16413
|
+
}
|
|
16414
|
+
} catch (e) {
|
|
16415
|
+
toast('Error: ' + String(e), 'error');
|
|
16416
|
+
}
|
|
16417
|
+
}
|
|
16418
|
+
|
|
16213
16419
|
async function applyBrokenJobFix(jobName) {
|
|
16214
16420
|
try {
|
|
16215
16421
|
// First: dry-run to get the actual diff to show in the confirm dialog
|
|
@@ -17903,6 +18109,17 @@ async function refreshSalesforce() {
|
|
|
17903
18109
|
refreshAll();
|
|
17904
18110
|
refreshTeamNav();
|
|
17905
18111
|
}
|
|
18112
|
+
// Lightweight trust-score fetch for the nav badge (any page benefits from this)
|
|
18113
|
+
try {
|
|
18114
|
+
var tr = await apiFetch('/api/claims?limit=1');
|
|
18115
|
+
var td = await tr.json();
|
|
18116
|
+
var navBadge = document.getElementById('nav-trust-score');
|
|
18117
|
+
if (navBadge && td && td.trust && td.trust.score !== null) {
|
|
18118
|
+
navBadge.textContent = Math.round(td.trust.score * 100) + '%';
|
|
18119
|
+
navBadge.style.display = '';
|
|
18120
|
+
}
|
|
18121
|
+
} catch(e) { /* non-fatal */ }
|
|
18122
|
+
|
|
17906
18123
|
// Populate agent filter from init data (avoid separate /api/office call)
|
|
17907
18124
|
if (d && d.office) {
|
|
17908
18125
|
try {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Claim tracking.
|
|
3
|
+
*
|
|
4
|
+
* Every outbound DM passes through the notification dispatcher. This
|
|
5
|
+
* module parses those messages for claims Clementine makes (promises,
|
|
6
|
+
* fixes, scheduled actions) and persists them to a SQLite table so we
|
|
7
|
+
* can verify them later and compute a rolling trust score.
|
|
8
|
+
*
|
|
9
|
+
* The bluntest answer to "you told me you fixed it twice and it wasn't
|
|
10
|
+
* fixed" — every claim is now recorded, some auto-verified, all
|
|
11
|
+
* reviewable in the dashboard.
|
|
12
|
+
*
|
|
13
|
+
* Extraction is regex-only to keep cost at $0 per DM. For nuanced
|
|
14
|
+
* claims the dashboard's manual verify/fail path covers the gap.
|
|
15
|
+
*/
|
|
16
|
+
export type ClaimType = 'scheduled' | 'fixed' | 'will_do' | 'sent' | 'added' | 'unknown';
|
|
17
|
+
export type VerifyStrategy = 'cron_run_check' | 'config_inspect' | 'manual';
|
|
18
|
+
export type ClaimStatus = 'pending' | 'verified' | 'failed' | 'expired' | 'dismissed';
|
|
19
|
+
export interface Claim {
|
|
20
|
+
id: string;
|
|
21
|
+
sessionKey: string | null;
|
|
22
|
+
messageSnippet: string;
|
|
23
|
+
claimType: ClaimType;
|
|
24
|
+
subject: string;
|
|
25
|
+
dueAt: string | null;
|
|
26
|
+
verifyStrategy: VerifyStrategy;
|
|
27
|
+
status: ClaimStatus;
|
|
28
|
+
verdict: string | null;
|
|
29
|
+
extractedAt: string;
|
|
30
|
+
verifiedAt: string | null;
|
|
31
|
+
agentSlug: string | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Extract claims from a message. Returns empty array if nothing matched.
|
|
35
|
+
* Caller supplies sessionKey for traceability. Never throws.
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractClaims(text: string, sessionKey?: string | null, agentSlug?: string | null): Omit<Claim, 'status' | 'extractedAt' | 'verifiedAt' | 'verdict'>[];
|
|
38
|
+
export declare function recordClaims(claims: Omit<Claim, 'status' | 'extractedAt' | 'verifiedAt' | 'verdict'>[]): Promise<void>;
|
|
39
|
+
export declare function listClaims(opts?: {
|
|
40
|
+
status?: ClaimStatus;
|
|
41
|
+
limit?: number;
|
|
42
|
+
sinceHours?: number;
|
|
43
|
+
}): Promise<Claim[]>;
|
|
44
|
+
export declare function setClaimStatus(id: string, status: ClaimStatus, verdict?: string): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Rolling trust score over the last N verified-or-failed claims.
|
|
47
|
+
* Ignores 'pending', 'expired', and 'dismissed' — only signal from
|
|
48
|
+
* actual verdicts. Returns null when there's not enough data (<3
|
|
49
|
+
* judged claims) to be meaningful.
|
|
50
|
+
*/
|
|
51
|
+
export declare function trustScore(lastN?: number): Promise<{
|
|
52
|
+
score: number | null;
|
|
53
|
+
verified: number;
|
|
54
|
+
failed: number;
|
|
55
|
+
total: number;
|
|
56
|
+
} | null>;
|
|
57
|
+
/**
|
|
58
|
+
* Sweep pending claims whose due_at has passed and try to verify them.
|
|
59
|
+
* Returns count of claims verified/failed. Safe to call repeatedly —
|
|
60
|
+
* only processes pending claims.
|
|
61
|
+
*/
|
|
62
|
+
export declare function verifyDueClaims(now?: number): Promise<{
|
|
63
|
+
verified: number;
|
|
64
|
+
failed: number;
|
|
65
|
+
expired: number;
|
|
66
|
+
}>;
|
|
67
|
+
//# sourceMappingURL=claim-tracker.d.ts.map
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Claim tracking.
|
|
3
|
+
*
|
|
4
|
+
* Every outbound DM passes through the notification dispatcher. This
|
|
5
|
+
* module parses those messages for claims Clementine makes (promises,
|
|
6
|
+
* fixes, scheduled actions) and persists them to a SQLite table so we
|
|
7
|
+
* can verify them later and compute a rolling trust score.
|
|
8
|
+
*
|
|
9
|
+
* The bluntest answer to "you told me you fixed it twice and it wasn't
|
|
10
|
+
* fixed" — every claim is now recorded, some auto-verified, all
|
|
11
|
+
* reviewable in the dashboard.
|
|
12
|
+
*
|
|
13
|
+
* Extraction is regex-only to keep cost at $0 per DM. For nuanced
|
|
14
|
+
* claims the dashboard's manual verify/fail path covers the gap.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import pino from 'pino';
|
|
19
|
+
import { BASE_DIR, MEMORY_DB_PATH, VAULT_DIR } from '../config.js';
|
|
20
|
+
const logger = pino({ name: 'clementine.claim-tracker' });
|
|
21
|
+
/**
|
|
22
|
+
* Parse common time expressions into an absolute ISO timestamp.
|
|
23
|
+
* Intentionally narrow — only handles shapes we're confident about.
|
|
24
|
+
* Returns null for "soon", "later", "in a bit" type phrases.
|
|
25
|
+
*/
|
|
26
|
+
function parseDueAt(expr, now = new Date()) {
|
|
27
|
+
const s = expr.trim().toLowerCase();
|
|
28
|
+
// "tomorrow at 8am" / "tomorrow at 8:30 am" / "tomorrow at 5pm"
|
|
29
|
+
const tomorrowRe = /^tomorrow(?:\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm))?$/;
|
|
30
|
+
const tm = s.match(tomorrowRe);
|
|
31
|
+
if (tm) {
|
|
32
|
+
const d = new Date(now);
|
|
33
|
+
d.setDate(d.getDate() + 1);
|
|
34
|
+
if (tm[1]) {
|
|
35
|
+
let h = Number(tm[1]);
|
|
36
|
+
const min = Number(tm[2] ?? '0');
|
|
37
|
+
if (tm[3] === 'pm' && h < 12)
|
|
38
|
+
h += 12;
|
|
39
|
+
if (tm[3] === 'am' && h === 12)
|
|
40
|
+
h = 0;
|
|
41
|
+
d.setHours(h, min, 0, 0);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
d.setHours(9, 0, 0, 0); // default to 9am if no specific time
|
|
45
|
+
}
|
|
46
|
+
return d.toISOString();
|
|
47
|
+
}
|
|
48
|
+
// "at 8am" / "at 4:30pm" / "8am" — today, roll to tomorrow if past
|
|
49
|
+
const todayRe = /^(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/;
|
|
50
|
+
const today = s.match(todayRe);
|
|
51
|
+
if (today) {
|
|
52
|
+
const d = new Date(now);
|
|
53
|
+
let h = Number(today[1]);
|
|
54
|
+
const min = Number(today[2] ?? '0');
|
|
55
|
+
if (today[3] === 'pm' && h < 12)
|
|
56
|
+
h += 12;
|
|
57
|
+
if (today[3] === 'am' && h === 12)
|
|
58
|
+
h = 0;
|
|
59
|
+
d.setHours(h, min, 0, 0);
|
|
60
|
+
if (d.getTime() < now.getTime())
|
|
61
|
+
d.setDate(d.getDate() + 1); // next occurrence
|
|
62
|
+
return d.toISOString();
|
|
63
|
+
}
|
|
64
|
+
// "in 30 minutes" / "in 2 hours"
|
|
65
|
+
const relRe = /^in\s+(\d+)\s*(min(?:ute)?s?|hours?|hrs?)$/;
|
|
66
|
+
const rel = s.match(relRe);
|
|
67
|
+
if (rel) {
|
|
68
|
+
const n = Number(rel[1]);
|
|
69
|
+
const unit = rel[2];
|
|
70
|
+
const ms = unit.startsWith('min') ? n * 60_000 : n * 3_600_000;
|
|
71
|
+
return new Date(now.getTime() + ms).toISOString();
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const PATTERNS = [
|
|
76
|
+
// "I scheduled market-leader-followup for tomorrow at 8:30am"
|
|
77
|
+
// "I've scheduled the reply-detection job for 8am tomorrow"
|
|
78
|
+
// "Scheduled the X for tomorrow at 9am"
|
|
79
|
+
{
|
|
80
|
+
type: 'scheduled',
|
|
81
|
+
re: /(?:I(?:'ve|\s+have)?\s+)?scheduled\s+(?:the\s+)?(\S[^,.!?\n]+?)(?:\s+job)?\s+(?:for|at|on)\s+([^,.!?\n]+)/i,
|
|
82
|
+
extract: (m) => {
|
|
83
|
+
const subject = m[1].trim().slice(0, 200);
|
|
84
|
+
const due = parseDueAt(m[2].trim());
|
|
85
|
+
return due ? { subject, dueAt: due } : { subject };
|
|
86
|
+
},
|
|
87
|
+
verifyStrategy: 'cron_run_check',
|
|
88
|
+
},
|
|
89
|
+
// "I fixed X" / "Fixed Y" / "I've applied the fix"
|
|
90
|
+
// Ambiguous. We look for short, declarative "fixed <noun phrase>" at the
|
|
91
|
+
// start of a sentence or after a period.
|
|
92
|
+
{
|
|
93
|
+
type: 'fixed',
|
|
94
|
+
re: /(?:^|[.!?]\s+)(?:I(?:'ve|\s+have)?\s+)?(?:fixed|resolved|applied\s+the\s+fix\s+for)\s+([\w-][\w\s:-]{2,80})/i,
|
|
95
|
+
extract: (m) => ({ subject: m[1].trim().slice(0, 200) }),
|
|
96
|
+
verifyStrategy: 'config_inspect',
|
|
97
|
+
},
|
|
98
|
+
// "I'll send the email at 4pm" / "I will run X tomorrow morning"
|
|
99
|
+
// Must have a time anchor to be meaningful.
|
|
100
|
+
{
|
|
101
|
+
type: 'will_do',
|
|
102
|
+
re: /\bI(?:'ll|\s+will)\s+(\w+\s+\S[^,.!?\n]{0,80}?)\s+(?:at|by|in|on|tomorrow|today)\s+([^,.!?\n]+)/i,
|
|
103
|
+
extract: (m) => {
|
|
104
|
+
const subject = m[1].trim().slice(0, 200);
|
|
105
|
+
const due = parseDueAt(m[2].trim());
|
|
106
|
+
return due ? { subject, dueAt: due } : { subject };
|
|
107
|
+
},
|
|
108
|
+
verifyStrategy: 'manual',
|
|
109
|
+
},
|
|
110
|
+
// "Sent email to X" / "I sent the email to..."
|
|
111
|
+
{
|
|
112
|
+
type: 'sent',
|
|
113
|
+
re: /(?:^|[.!?]\s+)(?:I(?:'ve|\s+have)?\s+)?sent\s+(?:an?\s+|the\s+)?(email|message|DM|notification)\s+to\s+([^,.!?\n]{2,80})/i,
|
|
114
|
+
extract: (m) => ({ subject: `${m[1]} to ${m[2].trim()}`.slice(0, 200) }),
|
|
115
|
+
verifyStrategy: 'manual',
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
/**
|
|
119
|
+
* Extract claims from a message. Returns empty array if nothing matched.
|
|
120
|
+
* Caller supplies sessionKey for traceability. Never throws.
|
|
121
|
+
*/
|
|
122
|
+
export function extractClaims(text, sessionKey, agentSlug) {
|
|
123
|
+
if (!text || typeof text !== 'string')
|
|
124
|
+
return [];
|
|
125
|
+
// Skip fix-verification DMs we emit ourselves — those are meta-claims that
|
|
126
|
+
// would re-enter this pipeline and create infinite loops.
|
|
127
|
+
if (text.startsWith('**[Fix verification]**'))
|
|
128
|
+
return [];
|
|
129
|
+
if (text.startsWith('🚨 **') && text.includes('cron job') && text.includes('failing'))
|
|
130
|
+
return [];
|
|
131
|
+
if (text.startsWith('⚡ **Circuit breaker') || text.startsWith('✅ **Circuit breaker'))
|
|
132
|
+
return [];
|
|
133
|
+
if (text.startsWith('🛑 **Cron auto-disabled**'))
|
|
134
|
+
return [];
|
|
135
|
+
const out = [];
|
|
136
|
+
const seenSubjects = new Set();
|
|
137
|
+
for (const p of PATTERNS) {
|
|
138
|
+
const m = text.match(p.re);
|
|
139
|
+
if (!m)
|
|
140
|
+
continue;
|
|
141
|
+
const extracted = p.extract(m);
|
|
142
|
+
if (!extracted)
|
|
143
|
+
continue;
|
|
144
|
+
// De-dup within a single message (two patterns hitting the same text)
|
|
145
|
+
const key = `${p.type}:${extracted.subject.toLowerCase()}`;
|
|
146
|
+
if (seenSubjects.has(key))
|
|
147
|
+
continue;
|
|
148
|
+
seenSubjects.add(key);
|
|
149
|
+
out.push({
|
|
150
|
+
id: randomBytes(6).toString('hex'),
|
|
151
|
+
sessionKey: sessionKey ?? null,
|
|
152
|
+
messageSnippet: text.slice(0, 400),
|
|
153
|
+
claimType: p.type,
|
|
154
|
+
subject: extracted.subject,
|
|
155
|
+
dueAt: extracted.dueAt ?? null,
|
|
156
|
+
verifyStrategy: p.verifyStrategy,
|
|
157
|
+
agentSlug: agentSlug ?? null,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
// ── Persistence ──────────────────────────────────────────────────────
|
|
163
|
+
async function getStore() {
|
|
164
|
+
const { MemoryStore } = await import('../memory/store.js');
|
|
165
|
+
const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
166
|
+
store.initialize();
|
|
167
|
+
return store;
|
|
168
|
+
}
|
|
169
|
+
export async function recordClaims(claims) {
|
|
170
|
+
if (claims.length === 0)
|
|
171
|
+
return;
|
|
172
|
+
try {
|
|
173
|
+
const store = await getStore();
|
|
174
|
+
const db = store.conn;
|
|
175
|
+
const stmt = db.prepare(`INSERT OR IGNORE INTO claims
|
|
176
|
+
(id, session_key, message_snippet, claim_type, subject, due_at, verify_strategy, status, agent_slug)
|
|
177
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`);
|
|
178
|
+
const tx = db.transaction((rows) => {
|
|
179
|
+
for (const c of rows) {
|
|
180
|
+
stmt.run(c.id, c.sessionKey, c.messageSnippet, c.claimType, c.subject, c.dueAt, c.verifyStrategy, c.agentSlug);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
tx(claims);
|
|
184
|
+
store.close();
|
|
185
|
+
logger.info({ count: claims.length, types: claims.map(c => c.claimType) }, 'Recorded claims');
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
logger.warn({ err }, 'Failed to record claims');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export async function listClaims(opts = {}) {
|
|
192
|
+
try {
|
|
193
|
+
const store = await getStore();
|
|
194
|
+
const db = store.conn;
|
|
195
|
+
const where = [];
|
|
196
|
+
const params = [];
|
|
197
|
+
if (opts.status) {
|
|
198
|
+
where.push('status = ?');
|
|
199
|
+
params.push(opts.status);
|
|
200
|
+
}
|
|
201
|
+
if (opts.sinceHours) {
|
|
202
|
+
where.push(`extracted_at >= datetime('now', ?)`);
|
|
203
|
+
params.push(`-${opts.sinceHours} hours`);
|
|
204
|
+
}
|
|
205
|
+
const sql = `SELECT id, session_key AS sessionKey, message_snippet AS messageSnippet, claim_type AS claimType,
|
|
206
|
+
subject, due_at AS dueAt, verify_strategy AS verifyStrategy, status, verdict,
|
|
207
|
+
extracted_at AS extractedAt, verified_at AS verifiedAt, agent_slug AS agentSlug
|
|
208
|
+
FROM claims
|
|
209
|
+
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
|
210
|
+
ORDER BY extracted_at DESC
|
|
211
|
+
LIMIT ?`;
|
|
212
|
+
params.push(opts.limit ?? 50);
|
|
213
|
+
const rows = db.prepare(sql).all(...params);
|
|
214
|
+
store.close();
|
|
215
|
+
return rows;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
logger.warn({ err }, 'Failed to list claims');
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export async function setClaimStatus(id, status, verdict) {
|
|
223
|
+
try {
|
|
224
|
+
const store = await getStore();
|
|
225
|
+
const db = store.conn;
|
|
226
|
+
const result = db.prepare(`UPDATE claims SET status = ?, verdict = ?, verified_at = datetime('now') WHERE id = ?`).run(status, verdict ?? null, id);
|
|
227
|
+
store.close();
|
|
228
|
+
return result.changes > 0;
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
logger.warn({ err, id }, 'Failed to set claim status');
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Rolling trust score over the last N verified-or-failed claims.
|
|
237
|
+
* Ignores 'pending', 'expired', and 'dismissed' — only signal from
|
|
238
|
+
* actual verdicts. Returns null when there's not enough data (<3
|
|
239
|
+
* judged claims) to be meaningful.
|
|
240
|
+
*/
|
|
241
|
+
export async function trustScore(lastN = 30) {
|
|
242
|
+
try {
|
|
243
|
+
const store = await getStore();
|
|
244
|
+
const db = store.conn;
|
|
245
|
+
const rows = db.prepare(`SELECT status FROM claims
|
|
246
|
+
WHERE status IN ('verified', 'failed')
|
|
247
|
+
ORDER BY extracted_at DESC
|
|
248
|
+
LIMIT ?`).all(lastN);
|
|
249
|
+
store.close();
|
|
250
|
+
const verified = rows.filter(r => r.status === 'verified').length;
|
|
251
|
+
const failed = rows.filter(r => r.status === 'failed').length;
|
|
252
|
+
const total = verified + failed;
|
|
253
|
+
if (total < 3)
|
|
254
|
+
return { score: null, verified, failed, total };
|
|
255
|
+
return { score: verified / total, verified, failed, total };
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
logger.warn({ err }, 'Failed to compute trust score');
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ── Auto-verification ────────────────────────────────────────────────
|
|
263
|
+
/**
|
|
264
|
+
* Sweep pending claims whose due_at has passed and try to verify them.
|
|
265
|
+
* Returns count of claims verified/failed. Safe to call repeatedly —
|
|
266
|
+
* only processes pending claims.
|
|
267
|
+
*/
|
|
268
|
+
export async function verifyDueClaims(now = Date.now()) {
|
|
269
|
+
const pending = await listClaims({ status: 'pending', limit: 100 });
|
|
270
|
+
let verified = 0;
|
|
271
|
+
let failed = 0;
|
|
272
|
+
let expired = 0;
|
|
273
|
+
for (const c of pending) {
|
|
274
|
+
// Only verify claims whose due time has passed
|
|
275
|
+
if (c.dueAt) {
|
|
276
|
+
const dueMs = Date.parse(c.dueAt);
|
|
277
|
+
if (Number.isFinite(dueMs) && dueMs > now)
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
// Claims without due_at: give them 24h grace period then mark expired
|
|
282
|
+
const extractedMs = Date.parse(c.extractedAt);
|
|
283
|
+
if (Number.isFinite(extractedMs) && now - extractedMs > 24 * 3600_000 && c.verifyStrategy === 'manual') {
|
|
284
|
+
await setClaimStatus(c.id, 'expired', 'No due time set and 24h elapsed — no automatic verification available.');
|
|
285
|
+
expired++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
continue; // still in grace period
|
|
289
|
+
}
|
|
290
|
+
if (c.verifyStrategy === 'cron_run_check') {
|
|
291
|
+
const verdict = await verifyCronScheduledClaim(c, now);
|
|
292
|
+
if (verdict === 'verified') {
|
|
293
|
+
await setClaimStatus(c.id, 'verified', 'Run observed at or after due time.');
|
|
294
|
+
verified++;
|
|
295
|
+
}
|
|
296
|
+
else if (verdict === 'failed') {
|
|
297
|
+
await setClaimStatus(c.id, 'failed', 'No run observed after due time (gave 1h grace).');
|
|
298
|
+
failed++;
|
|
299
|
+
}
|
|
300
|
+
// null = not ready yet, leave pending
|
|
301
|
+
}
|
|
302
|
+
// Other strategies: left pending for manual verification
|
|
303
|
+
}
|
|
304
|
+
return { verified, failed, expired };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* For a "scheduled" claim, check the cron run log. If an entry exists
|
|
308
|
+
* at or after due_at, claim is verified. If >1h past due with no run,
|
|
309
|
+
* it's failed. If not yet time or within grace, null.
|
|
310
|
+
*/
|
|
311
|
+
async function verifyCronScheduledClaim(claim, now) {
|
|
312
|
+
if (!claim.dueAt)
|
|
313
|
+
return null;
|
|
314
|
+
const dueMs = Date.parse(claim.dueAt);
|
|
315
|
+
if (!Number.isFinite(dueMs))
|
|
316
|
+
return null;
|
|
317
|
+
const graceMs = 60 * 60 * 1000; // 1h grace window
|
|
318
|
+
if (now < dueMs + 60_000)
|
|
319
|
+
return null; // not yet time
|
|
320
|
+
// Derive the job name from the subject. The subject is free-text from the
|
|
321
|
+
// DM, so we match permissively against known job names in cron/runs/.
|
|
322
|
+
const { existsSync, readdirSync, readFileSync } = await import('node:fs');
|
|
323
|
+
const runsDir = path.join(BASE_DIR, 'cron', 'runs');
|
|
324
|
+
if (!existsSync(runsDir))
|
|
325
|
+
return now > dueMs + graceMs ? 'failed' : null;
|
|
326
|
+
const subjectLower = claim.subject.toLowerCase();
|
|
327
|
+
const files = readdirSync(runsDir).filter(f => f.endsWith('.jsonl'));
|
|
328
|
+
for (const file of files) {
|
|
329
|
+
const bare = file.replace(/\.jsonl$/, '').replace(/_/g, '-').toLowerCase();
|
|
330
|
+
// Match if subject contains the job name or vice versa
|
|
331
|
+
if (!subjectLower.includes(bare) && !bare.includes(subjectLower.split(/\s+/)[0]))
|
|
332
|
+
continue;
|
|
333
|
+
try {
|
|
334
|
+
const lines = readFileSync(path.join(runsDir, file), 'utf-8').trim().split('\n').filter(Boolean);
|
|
335
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
336
|
+
const entry = JSON.parse(lines[i]);
|
|
337
|
+
const ms = Date.parse(entry.startedAt);
|
|
338
|
+
if (Number.isFinite(ms) && ms >= dueMs && ms <= dueMs + graceMs) {
|
|
339
|
+
// Found a run in the due window
|
|
340
|
+
return entry.status === 'ok' ? 'verified' : 'failed';
|
|
341
|
+
}
|
|
342
|
+
if (ms < dueMs)
|
|
343
|
+
break; // older than due — no point scanning further back
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch { /* skip malformed */ }
|
|
347
|
+
}
|
|
348
|
+
// No matching run found within grace window
|
|
349
|
+
return now > dueMs + graceMs ? 'failed' : null;
|
|
350
|
+
}
|
|
351
|
+
//# sourceMappingURL=claim-tracker.js.map
|
|
@@ -112,6 +112,15 @@ export class HeartbeatScheduler {
|
|
|
112
112
|
logger.warn({ err }, 'Failure sweep failed');
|
|
113
113
|
});
|
|
114
114
|
}).catch(err => logger.warn({ err }, 'Failure sweep import failed'));
|
|
115
|
+
// Claim verification sweep — auto-verify pending claims whose due
|
|
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 }) => {
|
|
119
|
+
if (verified + failed + expired > 0) {
|
|
120
|
+
logger.info({ verified, failed, expired }, 'Claim verification sweep complete');
|
|
121
|
+
}
|
|
122
|
+
}).catch(err => logger.warn({ err }, 'Claim verification sweep failed'));
|
|
123
|
+
}).catch(err => logger.warn({ err }, 'Claim tracker import failed'));
|
|
115
124
|
const now = new Date();
|
|
116
125
|
const hour = now.getHours();
|
|
117
126
|
// ── Nightly tasks: run regardless of active hours ─────────────────
|
|
@@ -23,6 +23,7 @@ export declare class NotificationDispatcher {
|
|
|
23
23
|
send(text: string, context?: NotificationContext): Promise<SendResult>;
|
|
24
24
|
/** Direct send without retry queueing (used by retry queue itself). */
|
|
25
25
|
private sendDirect;
|
|
26
|
+
private _trackClaims;
|
|
26
27
|
/** Stop the retry queue timer (for graceful shutdown). */
|
|
27
28
|
shutdown(): void;
|
|
28
29
|
}
|
|
@@ -90,8 +90,24 @@ export class NotificationDispatcher {
|
|
|
90
90
|
logger.error({ err, channel: name }, `Failed to send notification via ${name}`);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
// Extract and persist claims from successfully-delivered messages.
|
|
94
|
+
// Fire-and-forget — extraction errors never block delivery.
|
|
95
|
+
if (anySuccess) {
|
|
96
|
+
void this._trackClaims(capped, context);
|
|
97
|
+
}
|
|
93
98
|
return { delivered: anySuccess, channelErrors };
|
|
94
99
|
}
|
|
100
|
+
async _trackClaims(text, context) {
|
|
101
|
+
try {
|
|
102
|
+
const { extractClaims, recordClaims } = await import('./claim-tracker.js');
|
|
103
|
+
const claims = extractClaims(text, context?.sessionKey ?? null, context?.agentSlug ?? null);
|
|
104
|
+
if (claims.length > 0)
|
|
105
|
+
await recordClaims(claims);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
logger.debug({ err }, 'Claim extraction failed (non-fatal)');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
95
111
|
/** Stop the retry queue timer (for graceful shutdown). */
|
|
96
112
|
shutdown() {
|
|
97
113
|
this._retryQueue.stop();
|
package/dist/memory/store.js
CHANGED
|
@@ -418,6 +418,24 @@ export class MemoryStore {
|
|
|
418
418
|
);
|
|
419
419
|
CREATE INDEX IF NOT EXISTS idx_skill_usage_name ON skill_usage(skill_name, retrieved_at DESC);
|
|
420
420
|
CREATE INDEX IF NOT EXISTS idx_skill_usage_time ON skill_usage(retrieved_at DESC);
|
|
421
|
+
|
|
422
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
423
|
+
id TEXT PRIMARY KEY,
|
|
424
|
+
session_key TEXT,
|
|
425
|
+
message_snippet TEXT NOT NULL,
|
|
426
|
+
claim_type TEXT NOT NULL,
|
|
427
|
+
subject TEXT NOT NULL,
|
|
428
|
+
due_at TEXT,
|
|
429
|
+
verify_strategy TEXT NOT NULL,
|
|
430
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
431
|
+
verdict TEXT,
|
|
432
|
+
extracted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
433
|
+
verified_at TEXT,
|
|
434
|
+
agent_slug TEXT
|
|
435
|
+
);
|
|
436
|
+
CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status, extracted_at DESC);
|
|
437
|
+
CREATE INDEX IF NOT EXISTS idx_claims_due ON claims(due_at) WHERE status = 'pending';
|
|
438
|
+
CREATE INDEX IF NOT EXISTS idx_claims_extracted ON claims(extracted_at DESC);
|
|
421
439
|
`);
|
|
422
440
|
}
|
|
423
441
|
// ── Skill usage telemetry ─────────────────────────────────────────
|