clementine-agent 1.18.162 → 1.18.163

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.
@@ -19,6 +19,7 @@ import { listAllGoals } from '../tools/shared.js';
19
19
  import { MemoryStore } from '../memory/store.js';
20
20
  import { ANTHROPIC_SKILL_NAME_PATTERN } from './skill-store.js';
21
21
  import { recordApprovalSignal, formatApprovalSignalsForHypothesizer } from './approval-signals.js';
22
+ import { clusterBrokenJobs, formatClustersForHypothesizer } from '../gateway/failure-clustering.js';
22
23
  const logger = pino({ name: 'clementine.self-improve' });
23
24
  // ── Defaults ─────────────────────────────────────────────────────────
24
25
  const DEFAULT_CONFIG = {
@@ -1102,6 +1103,18 @@ export class SelfImproveLoop {
1102
1103
  // owner has approved, away from those they've denied. Empty string for
1103
1104
  // fresh installs, which keeps the prompt clean.
1104
1105
  const approvalSignalsText = formatApprovalSignalsForHypothesizer();
1106
+ // Cross-job failure clusters (1.18.163) — when ≥3 jobs hit the same
1107
+ // normalized error pattern in 48h, surface ONE cluster summary so
1108
+ // the hypothesizer proposes a root-cause fix instead of N per-job
1109
+ // patches. Empty string when no cluster meets the threshold.
1110
+ let failureClusterText = '';
1111
+ try {
1112
+ const clusters = clusterBrokenJobs();
1113
+ failureClusterText = formatClustersForHypothesizer(clusters);
1114
+ }
1115
+ catch (err) {
1116
+ logger.warn({ err }, 'Failed to compute failure clusters — proceeding without them');
1117
+ }
1105
1118
  // ── Step 1: Analysis — identify top opportunities from metrics (no config dumps) ──
1106
1119
  const analysisPrompt = `You are Clementine's self-improvement strategist. Analyze the performance data below and identify the top 3 improvement opportunities.\n\n` +
1107
1120
  `## Recent Performance Data (last 7 days)\n` +
@@ -1119,6 +1132,7 @@ export class SelfImproveLoop {
1119
1132
  diversityConstraint +
1120
1133
  agentFocusText +
1121
1134
  soulCandidatesText +
1135
+ (failureClusterText ? `\n${failureClusterText}` : '') +
1122
1136
  (approvalSignalsText ? `\n${approvalSignalsText}` : '') +
1123
1137
  `\n## Instructions\n` +
1124
1138
  `Propose **1-3 concrete, high-impact improvements** the owner should review today — no fewer (aim for at least one actionable suggestion when data warrants it), no more (the owner reads each proposal manually and you'll overwhelm them). Rank by expected impact; drop anything below "solid idea".\n\n` +
@@ -11407,7 +11407,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
11407
11407
  res.status(500).json({ error: String(err) });
11408
11408
  }
11409
11409
  });
11410
- app.get('/api/self-improve', (_req, res) => {
11410
+ app.get('/api/self-improve', async (_req, res) => {
11411
11411
  const siDir = path.join(BASE_DIR, 'self-improve');
11412
11412
  const stateFile = path.join(siDir, 'state.json');
11413
11413
  const logFile = path.join(siDir, 'experiment-log.jsonl');
@@ -11472,7 +11472,18 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
11472
11472
  }
11473
11473
  catch { /* ignore */ }
11474
11474
  }
11475
- res.json({ state, experiments, pending, triggers, verifications });
11475
+ // 1.18.163 cross-job failure clusters (≥3 jobs hitting the same
11476
+ // normalized error pattern in 48h). Computed on demand from
11477
+ // computeBrokenJobs(); no schema, no persistence. The Self-Improve
11478
+ // tab surfaces this so the owner sees "5 jobs hit X — propose one
11479
+ // root-cause fix" instead of N per-job rows.
11480
+ let clusters = [];
11481
+ try {
11482
+ const { clusterBrokenJobs } = await import('../gateway/failure-clustering.js');
11483
+ clusters = clusterBrokenJobs();
11484
+ }
11485
+ catch { /* non-fatal — empty clusters list */ }
11486
+ res.json({ state, experiments, pending, triggers, verifications, clusters });
11476
11487
  });
11477
11488
  app.post('/api/self-improve/run', async (_req, res) => {
11478
11489
  try {
@@ -19940,6 +19951,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19940
19951
  <div class="empty-state" style="padding:14px">No active failures &mdash; nothing has tripped 3+ consecutive errors.</div>
19941
19952
  </div>
19942
19953
  </div>
19954
+ <div class="card" style="margin-top:16px" id="si-clusters-card" hidden>
19955
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
19956
+ <span>Cross-job failure clusters <span style="font-weight:normal;font-size:11px;color:var(--text-muted)">&middot; 3+ jobs hitting the same error pattern (last 48h)</span></span>
19957
+ <span class="tab-badge" id="tab-si-clusters" style="background:#a855f7;color:#fff">0</span>
19958
+ </div>
19959
+ <div class="card-body" id="si-clusters-list" style="padding:0"></div>
19960
+ </div>
19943
19961
  <div class="card" style="margin-top:16px">
19944
19962
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
19945
19963
  <span>Verifying fixes</span>
@@ -40500,6 +40518,7 @@ async function refreshSelfImprove() {
40500
40518
  const pending = d.pending || [];
40501
40519
  const triggers = d.triggers || [];
40502
40520
  const verifications = d.verifications || [];
40521
+ const clusters = d.clusters || [];
40503
40522
 
40504
40523
  // Update tab badge — combine human-attention queues so the sidebar
40505
40524
  // count reflects "things that need you to look at", not just proposals.
@@ -40537,6 +40556,36 @@ async function refreshSelfImprove() {
40537
40556
  }
40538
40557
  }
40539
40558
 
40559
+ // 1.18.163 — cross-job failure clusters (≥3 jobs hitting the same
40560
+ // normalized pattern). Hidden when the list is empty so the card
40561
+ // doesn't take up space on a healthy install.
40562
+ const clustersCard = document.getElementById('si-clusters-card');
40563
+ const clustersList = document.getElementById('si-clusters-list');
40564
+ const clustersBadge = document.getElementById('tab-si-clusters');
40565
+ if (clustersCard && clustersList) {
40566
+ if (clusters.length === 0) {
40567
+ clustersCard.hidden = true;
40568
+ } else {
40569
+ clustersCard.hidden = false;
40570
+ if (clustersBadge) clustersBadge.textContent = clusters.length;
40571
+ clustersList.innerHTML = clusters.map(function(c) {
40572
+ var rep = String(c.representative || '').slice(0, 200);
40573
+ var jobsList = (c.jobs || []).slice(0, 5).map(function(j) {
40574
+ return '<span class="badge" style="margin-right:4px;font-size:11px">' + esc(j.jobName) + ' &times;' + (j.errorCount48h || 0) + '</span>';
40575
+ }).join('');
40576
+ var more = (c.jobs && c.jobs.length > 5) ? '<span style="font-size:11px;color:var(--text-muted)">+' + (c.jobs.length - 5) + ' more</span>' : '';
40577
+ return '<div style="padding:12px;border-bottom:1px solid var(--border)">' +
40578
+ '<div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px;flex-wrap:wrap">' +
40579
+ '<div><strong>' + (c.jobs ? c.jobs.length : 0) + ' jobs</strong> &middot; ' +
40580
+ '<span style="font-size:11px;color:var(--text-muted)">' + (c.totalErrors || 0) + ' total errors (48h)</span></div>' +
40581
+ '</div>' +
40582
+ '<div style="margin-top:6px;font-size:12px;color:var(--text-secondary);font-family:ui-monospace,monospace">' + esc(rep) + '</div>' +
40583
+ '<div style="margin-top:8px">' + jobsList + ' ' + more + '</div>' +
40584
+ '</div>';
40585
+ }).join('');
40586
+ }
40587
+ }
40588
+
40540
40589
  // Pending fix verifications — auto-fixes soaking through the 3-run window.
40541
40590
  const verifyEl = document.getElementById('si-verifying-list');
40542
40591
  if (verifyEl) {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Cross-job failure clustering (1.18.163).
3
+ *
4
+ * Today the failure pipeline is per-job:
5
+ * broken-job(jobName) → classifyFailure(lastErrors) → 1 fix proposal
6
+ *
7
+ * That means when 5 different cron jobs all hit the same root cause
8
+ * (e.g. all 5 fail with "Prompt is too long"), the system generates
9
+ * 5 isolated patches instead of 1 root-cause fix. The owner sees
10
+ * 5 separate proposals in the Self-Improve tab and either approves all
11
+ * 5 (busywork) or denies them (and the underlying issue persists).
12
+ *
13
+ * This module groups recent broken jobs by *normalized error pattern*.
14
+ * When ≥3 distinct jobs hit the same cluster, the owner gets ONE
15
+ * "5 jobs all hit X — propose Y for all of them" suggestion instead of
16
+ * N separate ones.
17
+ *
18
+ * This is purely a *suggestion / presentation* layer — clusters are
19
+ * surfaced as a hint to the hypothesizer + dashboard. The existing
20
+ * per-job `failure-fix-consumer` continues to handle individual patches
21
+ * unchanged. Clustering is additive observability, not a replacement
22
+ * for per-job fixes.
23
+ *
24
+ * Reads from the existing `computeBrokenJobs()` source — no new schema,
25
+ * no new persistence, computed on demand.
26
+ */
27
+ import type { BrokenJob } from './failure-monitor.js';
28
+ /**
29
+ * Minimum distinct jobs required to form a cluster. Below this we don't
30
+ * bother — a single repeated error is just a per-job problem.
31
+ *
32
+ * 3 is conservative: 2 looks coincidental, 3 is "this is a systemic
33
+ * thing." Tunable if we get noise.
34
+ */
35
+ export declare const MIN_CLUSTER_SIZE = 3;
36
+ /**
37
+ * Normalize an error message into a clustering key.
38
+ *
39
+ * Goals:
40
+ * - "Prompt is too long (12345 tokens)" and "Prompt is too long (45678
41
+ * tokens)" should collapse to the same key.
42
+ * - Job-specific tokens (UUIDs, timestamps, paths with the job name)
43
+ * should be stripped.
44
+ * - The result should still be human-readable (we surface it in the UI).
45
+ *
46
+ * Strategy:
47
+ * 1. Lowercase + collapse whitespace
48
+ * 2. Strip ISO timestamps + UNIX epochs
49
+ * 3. Strip UUIDs and long hex tokens
50
+ * 4. Strip parenthesized numbers ("(12345 tokens)" → "(N tokens)")
51
+ * 5. Strip absolute paths
52
+ * 6. Truncate to ERROR_NORMALIZE_LEN
53
+ */
54
+ export declare function normalizeErrorMessage(raw: string): string;
55
+ export interface FailureCluster {
56
+ /** The normalized pattern key. Stable across jobs/runs. */
57
+ pattern: string;
58
+ /** A representative human-readable error message (one of the original
59
+ * uncleaned strings, picked by frequency). */
60
+ representative: string;
61
+ /** Distinct jobs hitting this cluster, sorted by error count desc. */
62
+ jobs: Array<{
63
+ jobName: string;
64
+ agentSlug?: string;
65
+ errorCount48h: number;
66
+ lastErrorAt: string | null;
67
+ }>;
68
+ /** Total errors across all jobs in the cluster (last 48h). */
69
+ totalErrors: number;
70
+ }
71
+ /**
72
+ * Group the current broken jobs by normalized error pattern. Only
73
+ * returns clusters with ≥ MIN_CLUSTER_SIZE distinct jobs. Returns
74
+ * largest clusters first (by distinct-job count, then total error
75
+ * count).
76
+ *
77
+ * Each broken job contributes UP TO 3 patterns (its `lastErrors[]`).
78
+ * A job that hits two distinct patterns counts toward both clusters
79
+ * — that's by design, since a job with two root causes really does
80
+ * need both fixes.
81
+ */
82
+ export declare function clusterBrokenJobs(jobs?: BrokenJob[]): FailureCluster[];
83
+ /**
84
+ * Render a cluster summary for the hypothesizer prompt block. Empty
85
+ * string when no clusters meet the threshold.
86
+ *
87
+ * Format:
88
+ * ### Cross-job failure clusters (last 48h)
89
+ * - "Prompt is too long (N tokens)" — 5 jobs: insight-check, outcome-grader, route-classifier, ...
90
+ * - "Reached maximum number of turns (N)" — 3 jobs: ...
91
+ * Bias one root-cause proposal toward the largest cluster instead of N per-job ones.
92
+ */
93
+ export declare function formatClustersForHypothesizer(clusters: FailureCluster[]): string;
94
+ //# sourceMappingURL=failure-clustering.d.ts.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Cross-job failure clustering (1.18.163).
3
+ *
4
+ * Today the failure pipeline is per-job:
5
+ * broken-job(jobName) → classifyFailure(lastErrors) → 1 fix proposal
6
+ *
7
+ * That means when 5 different cron jobs all hit the same root cause
8
+ * (e.g. all 5 fail with "Prompt is too long"), the system generates
9
+ * 5 isolated patches instead of 1 root-cause fix. The owner sees
10
+ * 5 separate proposals in the Self-Improve tab and either approves all
11
+ * 5 (busywork) or denies them (and the underlying issue persists).
12
+ *
13
+ * This module groups recent broken jobs by *normalized error pattern*.
14
+ * When ≥3 distinct jobs hit the same cluster, the owner gets ONE
15
+ * "5 jobs all hit X — propose Y for all of them" suggestion instead of
16
+ * N separate ones.
17
+ *
18
+ * This is purely a *suggestion / presentation* layer — clusters are
19
+ * surfaced as a hint to the hypothesizer + dashboard. The existing
20
+ * per-job `failure-fix-consumer` continues to handle individual patches
21
+ * unchanged. Clustering is additive observability, not a replacement
22
+ * for per-job fixes.
23
+ *
24
+ * Reads from the existing `computeBrokenJobs()` source — no new schema,
25
+ * no new persistence, computed on demand.
26
+ */
27
+ import pino from 'pino';
28
+ import { computeBrokenJobs } from './failure-monitor.js';
29
+ const logger = pino({ name: 'clementine.failure-clustering' });
30
+ // ── Tunables ─────────────────────────────────────────────────────────
31
+ /**
32
+ * Minimum distinct jobs required to form a cluster. Below this we don't
33
+ * bother — a single repeated error is just a per-job problem.
34
+ *
35
+ * 3 is conservative: 2 looks coincidental, 3 is "this is a systemic
36
+ * thing." Tunable if we get noise.
37
+ */
38
+ export const MIN_CLUSTER_SIZE = 3;
39
+ /** Max chars of an error message we consider when normalizing. The
40
+ * important signal is in the first ~200 chars; longer suffixes are
41
+ * usually stack traces or per-call IDs that destroy clustering. */
42
+ const ERROR_NORMALIZE_LEN = 200;
43
+ // ── Normalization ────────────────────────────────────────────────────
44
+ /**
45
+ * Normalize an error message into a clustering key.
46
+ *
47
+ * Goals:
48
+ * - "Prompt is too long (12345 tokens)" and "Prompt is too long (45678
49
+ * tokens)" should collapse to the same key.
50
+ * - Job-specific tokens (UUIDs, timestamps, paths with the job name)
51
+ * should be stripped.
52
+ * - The result should still be human-readable (we surface it in the UI).
53
+ *
54
+ * Strategy:
55
+ * 1. Lowercase + collapse whitespace
56
+ * 2. Strip ISO timestamps + UNIX epochs
57
+ * 3. Strip UUIDs and long hex tokens
58
+ * 4. Strip parenthesized numbers ("(12345 tokens)" → "(N tokens)")
59
+ * 5. Strip absolute paths
60
+ * 6. Truncate to ERROR_NORMALIZE_LEN
61
+ */
62
+ export function normalizeErrorMessage(raw) {
63
+ if (!raw)
64
+ return '';
65
+ let s = raw.toLowerCase().trim();
66
+ // ISO timestamps: 2026-05-10T14:23:00.000Z (with optional millis/tz)
67
+ s = s.replace(/\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(\.\d+)?(z|[+-]\d{2}:?\d{2})?/g, '<ts>');
68
+ // Unix epoch ms (13-digit) + sec (10-digit) — must come BEFORE plain numbers
69
+ s = s.replace(/\b\d{13}\b/g, '<ts>');
70
+ s = s.replace(/\b\d{10}\b/g, '<ts>');
71
+ // UUIDs
72
+ s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '<uuid>');
73
+ // Long hex (16+ chars, like commit SHAs / session ids)
74
+ s = s.replace(/\b[0-9a-f]{16,}\b/g, '<hex>');
75
+ // Parenthesized numbers: (12345) → (N) ; (12345 tokens) → (N tokens)
76
+ s = s.replace(/\(\s*\d[\d,_.]*\s*([a-z]*)\s*\)/g, (_m, suffix) => suffix ? `(N ${suffix})` : '(N)');
77
+ // Absolute paths — keep just the basename
78
+ s = s.replace(/\/[\w./-]+\/([\w.-]+)/g, '<path>/$1');
79
+ // Generic standalone large numbers
80
+ s = s.replace(/\b\d{4,}\b/g, '<N>');
81
+ // Collapse whitespace
82
+ s = s.replace(/\s+/g, ' ').trim();
83
+ return s.slice(0, ERROR_NORMALIZE_LEN);
84
+ }
85
+ // ── Clusterer ────────────────────────────────────────────────────────
86
+ /**
87
+ * Group the current broken jobs by normalized error pattern. Only
88
+ * returns clusters with ≥ MIN_CLUSTER_SIZE distinct jobs. Returns
89
+ * largest clusters first (by distinct-job count, then total error
90
+ * count).
91
+ *
92
+ * Each broken job contributes UP TO 3 patterns (its `lastErrors[]`).
93
+ * A job that hits two distinct patterns counts toward both clusters
94
+ * — that's by design, since a job with two root causes really does
95
+ * need both fixes.
96
+ */
97
+ export function clusterBrokenJobs(jobs) {
98
+ const source = jobs ?? computeBrokenJobs();
99
+ if (source.length === 0)
100
+ return [];
101
+ // pattern → { representative (most common raw), jobs map keyed by jobName }
102
+ const buckets = new Map();
103
+ for (const job of source) {
104
+ const seenForThisJob = new Set();
105
+ for (const raw of job.lastErrors ?? []) {
106
+ const key = normalizeErrorMessage(raw);
107
+ if (!key)
108
+ continue;
109
+ // Don't double-count this job for the same pattern even if
110
+ // lastErrors contains two near-identical messages.
111
+ if (seenForThisJob.has(key))
112
+ continue;
113
+ seenForThisJob.add(key);
114
+ let bucket = buckets.get(key);
115
+ if (!bucket) {
116
+ bucket = { representative: raw, rawCounts: new Map(), jobs: new Map() };
117
+ buckets.set(key, bucket);
118
+ }
119
+ bucket.rawCounts.set(raw, (bucket.rawCounts.get(raw) ?? 0) + 1);
120
+ // Pick the most-common raw form as the representative on the fly.
121
+ const cur = bucket.rawCounts.get(raw);
122
+ const best = bucket.rawCounts.get(bucket.representative) ?? 0;
123
+ if (cur > best)
124
+ bucket.representative = raw;
125
+ const existing = bucket.jobs.get(job.jobName);
126
+ if (existing) {
127
+ existing.errorCount48h += job.errorCount48h;
128
+ }
129
+ else {
130
+ bucket.jobs.set(job.jobName, {
131
+ jobName: job.jobName,
132
+ ...(job.agentSlug ? { agentSlug: job.agentSlug } : {}),
133
+ errorCount48h: job.errorCount48h,
134
+ lastErrorAt: job.lastErrorAt,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ const clusters = [];
140
+ for (const [pattern, bucket] of buckets) {
141
+ if (bucket.jobs.size < MIN_CLUSTER_SIZE)
142
+ continue;
143
+ const jobsArr = [...bucket.jobs.values()].sort((a, b) => b.errorCount48h - a.errorCount48h);
144
+ const totalErrors = jobsArr.reduce((acc, j) => acc + j.errorCount48h, 0);
145
+ clusters.push({
146
+ pattern,
147
+ representative: bucket.representative,
148
+ jobs: jobsArr,
149
+ totalErrors,
150
+ });
151
+ }
152
+ // Sort: distinct-job count desc, then total errors desc, then pattern asc
153
+ clusters.sort((a, b) => {
154
+ if (b.jobs.length !== a.jobs.length)
155
+ return b.jobs.length - a.jobs.length;
156
+ if (b.totalErrors !== a.totalErrors)
157
+ return b.totalErrors - a.totalErrors;
158
+ return a.pattern.localeCompare(b.pattern);
159
+ });
160
+ if (clusters.length > 0) {
161
+ logger.info({ count: clusters.length, top: clusters[0]?.pattern.slice(0, 80), topJobs: clusters[0]?.jobs.length }, 'Failure clusters detected');
162
+ }
163
+ return clusters;
164
+ }
165
+ /**
166
+ * Render a cluster summary for the hypothesizer prompt block. Empty
167
+ * string when no clusters meet the threshold.
168
+ *
169
+ * Format:
170
+ * ### Cross-job failure clusters (last 48h)
171
+ * - "Prompt is too long (N tokens)" — 5 jobs: insight-check, outcome-grader, route-classifier, ...
172
+ * - "Reached maximum number of turns (N)" — 3 jobs: ...
173
+ * Bias one root-cause proposal toward the largest cluster instead of N per-job ones.
174
+ */
175
+ export function formatClustersForHypothesizer(clusters) {
176
+ if (!clusters || clusters.length === 0)
177
+ return '';
178
+ const lines = ['### Cross-job failure clusters (last 48h)'];
179
+ for (const c of clusters.slice(0, 5)) {
180
+ const jobNames = c.jobs.slice(0, 5).map(j => j.jobName).join(', ');
181
+ const more = c.jobs.length > 5 ? `, +${c.jobs.length - 5} more` : '';
182
+ const rep = c.representative.length > 100 ? c.representative.slice(0, 100) + '…' : c.representative;
183
+ lines.push(`- "${rep}" — ${c.jobs.length} jobs (${c.totalErrors} total errors): ${jobNames}${more}`);
184
+ }
185
+ lines.push('When a cluster of 3+ jobs hits the same pattern, prefer ONE root-cause proposal ' +
186
+ '(e.g. an advisor-rule, a prompt-override at agent or global scope, or a shared ' +
187
+ 'config change) over N per-job patches.');
188
+ return lines.join('\n') + '\n\n';
189
+ }
190
+ //# sourceMappingURL=failure-clustering.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.162",
3
+ "version": "1.18.163",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",