@iamoberlin/chorus 2.2.1 → 2.4.0
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/README.md +6 -0
- package/index.ts +82 -79
- package/openclaw.plugin.json +74 -1
- package/package.json +2 -2
- package/src/behavioral-sink.ts +434 -0
- package/src/choirs.ts +51 -23
- package/src/config.ts +91 -0
- package/src/daemon.ts +18 -13
- package/src/delivery.ts +121 -0
- package/src/economics.ts +166 -0
- package/src/purpose-research.ts +137 -42
- package/src/scheduler.ts +113 -72
package/src/purpose-research.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { OpenClawPluginService, PluginLogger } from "openclaw/plugin-sdk";
|
|
9
9
|
import { loadPurposes, updatePurpose, type Purpose } from "./purposes.js";
|
|
10
10
|
import { recordExecution, type ChoirExecution } from "./metrics.js";
|
|
11
|
+
import { sortByFairness, type FairnessScore } from "./behavioral-sink.js";
|
|
11
12
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
12
13
|
import { join } from "path";
|
|
13
14
|
import { homedir } from "os";
|
|
@@ -35,6 +36,9 @@ export const DEFAULT_PURPOSE_RESEARCH_CONFIG: PurposeResearchConfig = {
|
|
|
35
36
|
checkIntervalMs: 60000,
|
|
36
37
|
};
|
|
37
38
|
|
|
39
|
+
// Guard: purposes currently running research (prevents duplicate triggers)
|
|
40
|
+
const runningPurposes = new Set<string>();
|
|
41
|
+
|
|
38
42
|
interface DailyRunTracker {
|
|
39
43
|
date: string;
|
|
40
44
|
count: number;
|
|
@@ -71,6 +75,36 @@ function countAlerts(output: string): number {
|
|
|
71
75
|
return 1;
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
function extractTextFromApiResult(result: any): string {
|
|
79
|
+
if (!result) return "";
|
|
80
|
+
const payloadText = (result.payloads || [])
|
|
81
|
+
.map((p: any) => p?.text || "")
|
|
82
|
+
.filter((t: string) => Boolean(t))
|
|
83
|
+
.pop();
|
|
84
|
+
return payloadText || result.text || result.result?.text || result.response || result.content || "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractTextFromCliStdout(stdout: string): string {
|
|
88
|
+
if (!stdout) return "";
|
|
89
|
+
|
|
90
|
+
// Find the last top-level JSON object to skip any log noise before payload.
|
|
91
|
+
for (let i = stdout.length - 1; i >= 0; i--) {
|
|
92
|
+
if (stdout[i] !== "{") continue;
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(stdout.slice(i));
|
|
95
|
+
const payloadText = (parsed.result?.payloads || [])
|
|
96
|
+
.map((p: any) => p?.text || "")
|
|
97
|
+
.filter((t: string) => Boolean(t))
|
|
98
|
+
.pop();
|
|
99
|
+
return payloadText || parsed.result?.text || parsed.text || parsed.response || parsed.content || "";
|
|
100
|
+
} catch {
|
|
101
|
+
// Keep scanning backward for valid JSON.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
|
|
74
108
|
const STATE_DIR = join(homedir(), ".chorus");
|
|
75
109
|
const STATE_FILE = join(STATE_DIR, "research-state.json");
|
|
76
110
|
|
|
@@ -160,6 +194,8 @@ export function createPurposeResearchScheduler(
|
|
|
160
194
|
if (purpose.progress >= 100) return false;
|
|
161
195
|
if (purpose.research?.enabled === false) return false;
|
|
162
196
|
if (!purpose.criteria?.length && !purpose.research?.domains?.length) return false;
|
|
197
|
+
// Guard: skip if already running (prevents duplicate triggers while agent is executing)
|
|
198
|
+
if (runningPurposes.has(purpose.id)) return false;
|
|
163
199
|
|
|
164
200
|
const lastRun = purpose.research?.lastRun ?? 0;
|
|
165
201
|
const frequency = calculateFrequency(purpose);
|
|
@@ -214,33 +250,43 @@ PURPOSE RESEARCH: ${purpose.name}
|
|
|
214
250
|
You are researching for the following purpose:
|
|
215
251
|
${purpose.description || purpose.name}
|
|
216
252
|
|
|
217
|
-
|
|
253
|
+
YOU HAVE FULL TOOL ACCESS. Use exec to run shell commands, web_search to search the web, web_fetch to read URLs. If criteria below include "run:" commands, EXECUTE THEM — do not just describe what you would do.
|
|
218
254
|
|
|
219
|
-
|
|
255
|
+
Working directory: ${WORKSPACE_PATH}
|
|
256
|
+
|
|
257
|
+
Criteria (follow these — if they say "run:", actually run the command):
|
|
220
258
|
${criteria}
|
|
221
259
|
|
|
222
260
|
Tasks:
|
|
223
|
-
1.
|
|
224
|
-
2.
|
|
225
|
-
3.
|
|
226
|
-
4.
|
|
261
|
+
1. EXECUTE any commands specified in criteria above
|
|
262
|
+
2. Use web_search for at least 2 queries on recent developments
|
|
263
|
+
3. Assess impact on purpose progress or timeline
|
|
264
|
+
4. Surface actionable insights with measurable specifics (metrics, thresholds, dates)
|
|
227
265
|
|
|
228
266
|
Alert threshold: ${alertThreshold}
|
|
229
267
|
${alertGuidance[alertThreshold]}
|
|
230
268
|
|
|
231
269
|
Output format:
|
|
232
|
-
-
|
|
270
|
+
- EXECUTED: Commands run and their results (summarized)
|
|
271
|
+
- FINDINGS: Key discoveries (bullet points with numbers)
|
|
233
272
|
- IMPACT: How this affects the purpose (progress/timeline/risk)
|
|
234
273
|
- ALERTS: Anything requiring immediate attention (or "none")
|
|
235
274
|
- NEXT: What to research next time
|
|
236
275
|
|
|
237
|
-
Your output will be saved automatically. Focus on
|
|
276
|
+
Your output will be saved automatically. Focus on actionable content, not analysis of what you could do.
|
|
238
277
|
|
|
239
|
-
CRITICAL: If sending alerts
|
|
278
|
+
CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no markdown).
|
|
240
279
|
`.trim();
|
|
241
280
|
}
|
|
242
281
|
|
|
243
|
-
async function runResearch(purpose: Purpose): Promise<
|
|
282
|
+
async function runResearch(purpose: Purpose): Promise<boolean> {
|
|
283
|
+
// Guard: prevent concurrent runs of the same purpose
|
|
284
|
+
if (runningPurposes.has(purpose.id)) {
|
|
285
|
+
log.debug(`[purpose-research] ⏭️ "${purpose.name}" already running, skipping`);
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
runningPurposes.add(purpose.id);
|
|
289
|
+
|
|
244
290
|
const startTime = Date.now();
|
|
245
291
|
log.info(`[purpose-research] 🔬 Running research for "${purpose.name}"`);
|
|
246
292
|
|
|
@@ -261,12 +307,16 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
261
307
|
if (typeof api.runAgentTurn === "function") {
|
|
262
308
|
try {
|
|
263
309
|
result = await api.runAgentTurn({
|
|
264
|
-
sessionLabel: `chorus
|
|
310
|
+
sessionLabel: `chorus-purpose-${purpose.id}`,
|
|
265
311
|
message: prompt,
|
|
266
312
|
isolated: true,
|
|
267
313
|
timeoutSeconds: config.researchTimeoutMs / 1000,
|
|
268
314
|
});
|
|
269
|
-
output = result
|
|
315
|
+
output = extractTextFromApiResult(result);
|
|
316
|
+
if (!output.trim()) {
|
|
317
|
+
log.debug(`[purpose-research] API returned empty output, falling back to CLI for "${purpose.name}"`);
|
|
318
|
+
result = null;
|
|
319
|
+
}
|
|
270
320
|
} catch (apiErr) {
|
|
271
321
|
log.debug(`[purpose-research] API runAgentTurn failed, falling back to CLI: ${apiErr}`);
|
|
272
322
|
result = null;
|
|
@@ -279,7 +329,7 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
279
329
|
result = await new Promise<any>((resolve) => {
|
|
280
330
|
const child = spawn("openclaw", [
|
|
281
331
|
"agent",
|
|
282
|
-
"--session-id", `chorus
|
|
332
|
+
"--session-id", `chorus-purpose-${purpose.id}`,
|
|
283
333
|
"--message", prompt,
|
|
284
334
|
"--json",
|
|
285
335
|
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -296,16 +346,15 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
296
346
|
});
|
|
297
347
|
|
|
298
348
|
if (result.status === 0 && result.stdout) {
|
|
299
|
-
|
|
300
|
-
const json = JSON.parse(result.stdout);
|
|
301
|
-
output = json.result?.payloads?.[0]?.text || json.response || "";
|
|
302
|
-
} catch {
|
|
303
|
-
output = result.stdout;
|
|
304
|
-
}
|
|
349
|
+
output = extractTextFromCliStdout(result.stdout);
|
|
305
350
|
} else if (result.stderr) {
|
|
306
351
|
log.error(`[purpose-research] CLI error: ${result.stderr}`);
|
|
307
352
|
}
|
|
308
353
|
}
|
|
354
|
+
|
|
355
|
+
if (!output.trim()) {
|
|
356
|
+
throw new Error("No research output produced");
|
|
357
|
+
}
|
|
309
358
|
execution.durationMs = Date.now() - startTime;
|
|
310
359
|
execution.success = true;
|
|
311
360
|
execution.outputLength = output.length;
|
|
@@ -335,24 +384,51 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
335
384
|
}
|
|
336
385
|
}
|
|
337
386
|
|
|
387
|
+
// Re-read purpose from disk to avoid overwriting fields changed during the run
|
|
388
|
+
// (e.g., frequency modified externally while agent was executing)
|
|
389
|
+
const freshPurposes = await loadPurposes();
|
|
390
|
+
const freshPurpose = freshPurposes.find((p) => p.id === purpose.id);
|
|
391
|
+
const freshResearch = freshPurpose?.research ?? purpose.research;
|
|
392
|
+
|
|
338
393
|
await updatePurpose(purpose.id, {
|
|
339
394
|
research: {
|
|
340
|
-
...
|
|
341
|
-
enabled:
|
|
395
|
+
...freshResearch,
|
|
396
|
+
enabled: freshResearch?.enabled ?? true,
|
|
342
397
|
lastRun: Date.now(),
|
|
343
|
-
runCount: (
|
|
398
|
+
runCount: (freshResearch?.runCount ?? 0) + 1,
|
|
344
399
|
},
|
|
345
400
|
});
|
|
401
|
+
dailyRuns.count++;
|
|
402
|
+
persistState();
|
|
403
|
+
return true;
|
|
346
404
|
} catch (err) {
|
|
347
405
|
execution.durationMs = Date.now() - startTime;
|
|
348
406
|
execution.success = false;
|
|
349
407
|
execution.error = String(err);
|
|
350
408
|
log.error(`[purpose-research] ✗ "${purpose.name}" failed: ${err}`);
|
|
351
|
-
}
|
|
352
409
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
410
|
+
// Update lastRun even on failure to prevent retry storms
|
|
411
|
+
// (without this, failed runs leave lastRun stale and the scheduler retries immediately)
|
|
412
|
+
try {
|
|
413
|
+
const failPurposes = await loadPurposes();
|
|
414
|
+
const failPurpose = failPurposes.find((p) => p.id === purpose.id);
|
|
415
|
+
if (failPurpose) {
|
|
416
|
+
await updatePurpose(purpose.id, {
|
|
417
|
+
research: {
|
|
418
|
+
...(failPurpose.research ?? purpose.research),
|
|
419
|
+
lastRun: Date.now(),
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
} catch (updateErr) {
|
|
424
|
+
log.error(`[purpose-research] Failed to update lastRun after error: ${updateErr}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return false;
|
|
428
|
+
} finally {
|
|
429
|
+
runningPurposes.delete(purpose.id);
|
|
430
|
+
recordExecution(execution);
|
|
431
|
+
}
|
|
356
432
|
}
|
|
357
433
|
|
|
358
434
|
async function checkAndRun(): Promise<void> {
|
|
@@ -377,21 +453,37 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
377
453
|
|
|
378
454
|
if (duePurposes.length === 0) return;
|
|
379
455
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
456
|
+
// ── Universe 25 Fairness Sort ─────────────────────────────
|
|
457
|
+
// Replaces naive deadline sort that caused purpose starvation
|
|
458
|
+
// (calibration lesson #23). Overdue purposes get exponentially
|
|
459
|
+
// increasing priority — no purpose can be crowded out.
|
|
460
|
+
const fairnessSorted = sortByFairness(
|
|
461
|
+
duePurposes.map(p => ({
|
|
462
|
+
id: p.id,
|
|
463
|
+
lastRun: p.research?.lastRun,
|
|
464
|
+
frequency: calculateFrequency(p),
|
|
465
|
+
deadline: p.deadline
|
|
466
|
+
? typeof p.deadline === "string"
|
|
467
|
+
? Date.parse(p.deadline)
|
|
468
|
+
: p.deadline
|
|
469
|
+
: undefined,
|
|
470
|
+
}))
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const selectedId = fairnessSorted[0]?.id;
|
|
474
|
+
const purpose = duePurposes.find(p => p.id === selectedId);
|
|
475
|
+
const fairness = fairnessSorted[0]?.fairness;
|
|
476
|
+
|
|
477
|
+
if (!purpose) return;
|
|
478
|
+
|
|
479
|
+
if (fairness?.starving) {
|
|
480
|
+
log.warn(
|
|
481
|
+
`[purpose-research] 🏚️ STARVATION RECOVERY: "${purpose.name}" — ` +
|
|
482
|
+
`${fairness.starvationRatio.toFixed(1)}x overdue (expected every ` +
|
|
483
|
+
`${(fairness.expectedInterval / 3600000).toFixed(1)}h, actual ` +
|
|
484
|
+
`${(fairness.actualInterval / 3600000).toFixed(1)}h). Boost: ${fairness.priorityBoost.toFixed(1)}x`
|
|
485
|
+
)
|
|
486
|
+
}
|
|
395
487
|
|
|
396
488
|
if (dailyRuns.count < config.dailyRunCap) {
|
|
397
489
|
await runResearch(purpose);
|
|
@@ -442,7 +534,10 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
442
534
|
const purposes = await loadPurposes();
|
|
443
535
|
const purpose = purposes.find((p) => p.id === purposeId);
|
|
444
536
|
if (!purpose) throw new Error(`Purpose "${purposeId}" not found`);
|
|
445
|
-
await runResearch(purpose);
|
|
537
|
+
const success = await runResearch(purpose);
|
|
538
|
+
if (!success) {
|
|
539
|
+
throw new Error(`Research run failed for "${purposeId}"`);
|
|
540
|
+
}
|
|
446
541
|
},
|
|
447
542
|
|
|
448
543
|
getStatus: () => {
|
package/src/scheduler.ts
CHANGED
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
import type { OpenClawPluginService, PluginLogger } from "openclaw/plugin-sdk";
|
|
9
9
|
import type { ChorusConfig } from "./config.js";
|
|
10
10
|
import { CHOIRS, shouldRunChoir, CASCADE_ORDER, type Choir } from "./choirs.js";
|
|
11
|
+
import { deliverChoirOutput } from "./delivery.js";
|
|
11
12
|
import { recordExecution, type ChoirExecution } from "./metrics.js";
|
|
13
|
+
import { recordCost, estimateCost } from "./economics.js";
|
|
12
14
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
13
15
|
import { join } from "path";
|
|
14
16
|
import { homedir } from "os";
|
|
15
17
|
import { spawn } from "child_process";
|
|
18
|
+
import { recordAndAssess } from "./behavioral-sink.js";
|
|
16
19
|
|
|
17
20
|
// Type for the plugin API's runAgentTurn method
|
|
18
21
|
interface AgentTurnResult {
|
|
@@ -22,8 +25,8 @@ interface AgentTurnResult {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
// ── Delivery ─────────────────────────────────────────────────
|
|
25
|
-
//
|
|
26
|
-
//
|
|
28
|
+
// Delivery is handled via shared channel adapter to keep behavior consistent
|
|
29
|
+
// across scheduler and manual CLI execution paths.
|
|
27
30
|
|
|
28
31
|
interface ChoirContext {
|
|
29
32
|
choirId: string;
|
|
@@ -41,6 +44,29 @@ interface ChoirRunState {
|
|
|
41
44
|
const CHORUS_DIR = join(homedir(), ".chorus");
|
|
42
45
|
const RUN_STATE_PATH = join(CHORUS_DIR, "run-state.json");
|
|
43
46
|
|
|
47
|
+
// ── 429 Circuit Breaker ──────────────────────────────────────
|
|
48
|
+
// Exponential backoff on rate limit errors. Skips choir execution
|
|
49
|
+
// when backing off, resets on successful runs.
|
|
50
|
+
interface CircuitBreakerState {
|
|
51
|
+
backoffUntil: number; // timestamp ms — skip all choirs until this time
|
|
52
|
+
consecutiveFailures: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MAX_BACKOFF_MS = 16 * 60 * 1000; // 16 minutes cap
|
|
56
|
+
const BASE_BACKOFF_MS = 60 * 1000; // 1 minute base
|
|
57
|
+
|
|
58
|
+
function isRateLimitError(err: unknown): boolean {
|
|
59
|
+
const msg = String(err).toLowerCase();
|
|
60
|
+
return msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests") || msg.includes("overloaded") || msg.includes("unavailable");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function computeBackoffMs(failures: number): number {
|
|
64
|
+
const ms = BASE_BACKOFF_MS * Math.pow(2, Math.min(failures - 1, 4));
|
|
65
|
+
// Add jitter: ±25%
|
|
66
|
+
const jitter = ms * 0.25 * (Math.random() * 2 - 1);
|
|
67
|
+
return Math.min(ms + jitter, MAX_BACKOFF_MS);
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
// Load persisted run state from disk
|
|
45
71
|
function loadRunState(log: PluginLogger): Map<string, ChoirRunState> {
|
|
46
72
|
const state = new Map<string, ChoirRunState>();
|
|
@@ -106,12 +132,15 @@ export function createChoirScheduler(
|
|
|
106
132
|
// Load persisted state instead of starting fresh
|
|
107
133
|
const runState = loadRunState(log);
|
|
108
134
|
|
|
135
|
+
// Circuit breaker state
|
|
136
|
+
const circuitBreaker: CircuitBreakerState = { backoffUntil: 0, consecutiveFailures: 0 };
|
|
137
|
+
|
|
109
138
|
// CLI fallback for executing choirs when plugin API is unavailable
|
|
110
139
|
async function executeChoirViaCli(choir: Choir, prompt: string): Promise<string> {
|
|
111
140
|
const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
|
|
112
141
|
const child = spawn('openclaw', [
|
|
113
142
|
'agent',
|
|
114
|
-
'--session-id', `chorus
|
|
143
|
+
'--session-id', `chorus-${choir.id}`,
|
|
115
144
|
'--message', prompt,
|
|
116
145
|
'--json',
|
|
117
146
|
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -190,7 +219,7 @@ export function createChoirScheduler(
|
|
|
190
219
|
if (typeof api.runAgentTurn === 'function') {
|
|
191
220
|
try {
|
|
192
221
|
const result: AgentTurnResult = await api.runAgentTurn({
|
|
193
|
-
sessionLabel: `chorus
|
|
222
|
+
sessionLabel: `chorus-${choir.id}`,
|
|
194
223
|
message: prompt,
|
|
195
224
|
isolated: true,
|
|
196
225
|
timeoutSeconds: 300,
|
|
@@ -208,6 +237,17 @@ export function createChoirScheduler(
|
|
|
208
237
|
output = result.text;
|
|
209
238
|
}
|
|
210
239
|
} catch (apiErr) {
|
|
240
|
+
if (isRateLimitError(apiErr)) {
|
|
241
|
+
circuitBreaker.consecutiveFailures++;
|
|
242
|
+
const backoffMs = computeBackoffMs(circuitBreaker.consecutiveFailures);
|
|
243
|
+
circuitBreaker.backoffUntil = Date.now() + backoffMs;
|
|
244
|
+
log.warn(`[chorus] ⚡ 429 RATE LIMIT on ${choir.name} — backing off ${(backoffMs/1000).toFixed(0)}s (failure #${circuitBreaker.consecutiveFailures})`);
|
|
245
|
+
execution.durationMs = Date.now() - startTime;
|
|
246
|
+
execution.success = false;
|
|
247
|
+
execution.error = "429_rate_limit_backoff";
|
|
248
|
+
recordExecution(execution);
|
|
249
|
+
return; // Skip this choir and remaining choirs will be skipped by checkAndRunChoirs
|
|
250
|
+
}
|
|
211
251
|
log.warn(`[chorus] API runAgentTurn failed for ${choir.name}, falling back to CLI: ${apiErr}`);
|
|
212
252
|
output = await executeChoirViaCli(choir, prompt);
|
|
213
253
|
}
|
|
@@ -221,17 +261,50 @@ export function createChoirScheduler(
|
|
|
221
261
|
execution.outputLength = output.length;
|
|
222
262
|
execution.tokensUsed = estimateTokens(output);
|
|
223
263
|
|
|
264
|
+
// Record economics cost (only for runs that produced output)
|
|
265
|
+
if (execution.success) {
|
|
266
|
+
const inputTokensEst = Math.ceil(prompt.length / 4);
|
|
267
|
+
const outputTokensEst = execution.tokensUsed || Math.ceil(output.length / 4);
|
|
268
|
+
recordCost({
|
|
269
|
+
choirId: choir.id,
|
|
270
|
+
timestamp: new Date().toISOString(),
|
|
271
|
+
inputTokens: inputTokensEst,
|
|
272
|
+
outputTokens: outputTokensEst,
|
|
273
|
+
model: "default",
|
|
274
|
+
inferenceMs: execution.durationMs,
|
|
275
|
+
costUsd: estimateCost(inputTokensEst, outputTokensEst),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
224
279
|
// Parse output for metrics (findings, alerts, improvements)
|
|
225
280
|
execution.findings = countFindings(output);
|
|
226
281
|
execution.alerts = countAlerts(output);
|
|
227
282
|
execution.improvements = extractImprovements(output, choir.id);
|
|
228
283
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
284
|
+
// ── Behavioral Sink Prevention ──────────────────────────
|
|
285
|
+
// Assess output quality and gate illumination to prevent
|
|
286
|
+
// degraded context from cascading downstream (Universe 25).
|
|
287
|
+
const sinkAssessment = recordAndAssess(choir.id, output);
|
|
288
|
+
|
|
289
|
+
if (sinkAssessment.quality.score < 20 && choir.id !== "angels") {
|
|
290
|
+
log.warn(`[chorus] 🪞 BEAUTIFUL ONE detected: ${choir.name} scored ${sinkAssessment.quality.score}/100 — flags: ${sinkAssessment.quality.flags.join(", ")}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Store context for downstream choirs — only if quality passes sink gate
|
|
294
|
+
if (sinkAssessment.illumination.usable) {
|
|
295
|
+
const qualityTag = sinkAssessment.illumination.degraded
|
|
296
|
+
? `\n[⚠️ CONTEXT QUALITY DEGRADED — score ${sinkAssessment.quality.score}/100]\n`
|
|
297
|
+
: "";
|
|
298
|
+
contextStore.set(choir.id, {
|
|
299
|
+
choirId: choir.id,
|
|
300
|
+
output: qualityTag + output.slice(0, 2000),
|
|
301
|
+
timestamp: new Date(),
|
|
302
|
+
});
|
|
303
|
+
} else {
|
|
304
|
+
log.warn(`[chorus] 🚫 SINK GATE: ${choir.name} output blocked from illumination chain (score ${sinkAssessment.quality.score}/100)`);
|
|
305
|
+
// Don't update context — downstream choirs keep stale but good context
|
|
306
|
+
// rather than receiving fresh but degraded context
|
|
307
|
+
}
|
|
235
308
|
|
|
236
309
|
// Update run state
|
|
237
310
|
runState.set(choir.id, {
|
|
@@ -243,69 +316,17 @@ export function createChoirScheduler(
|
|
|
243
316
|
// Persist state to disk after each run
|
|
244
317
|
saveRunState(runState, log);
|
|
245
318
|
|
|
246
|
-
|
|
319
|
+
// Reset circuit breaker on success
|
|
320
|
+
if (circuitBreaker.consecutiveFailures > 0) {
|
|
321
|
+
log.info(`[chorus] ✅ Circuit breaker reset after ${circuitBreaker.consecutiveFailures} failures`);
|
|
322
|
+
circuitBreaker.consecutiveFailures = 0;
|
|
323
|
+
circuitBreaker.backoffUntil = 0;
|
|
324
|
+
}
|
|
247
325
|
|
|
248
|
-
|
|
249
|
-
// Reads target from OpenClaw config (channels.*.allowFrom) — no hardcoded PII
|
|
250
|
-
if (choir.delivers && output && output !== "(no response)" && output !== "HEARTBEAT_OK" && output !== "NO_REPLY") {
|
|
251
|
-
const channels = api.config?.channels as Record<string, any> | undefined;
|
|
252
|
-
let target: string | undefined;
|
|
253
|
-
let channel: string | undefined;
|
|
254
|
-
|
|
255
|
-
if (channels) {
|
|
256
|
-
for (const [ch, cfg] of Object.entries(channels)) {
|
|
257
|
-
if (cfg?.enabled && cfg?.allowFrom?.[0]) {
|
|
258
|
-
target = cfg.allowFrom[0];
|
|
259
|
-
channel = ch;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
326
|
+
log.info(`[chorus] ${choir.emoji} ${choir.name} completed (${(execution.durationMs/1000).toFixed(1)}s)`);
|
|
264
327
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
let deliveryText = output.slice(0, 4000);
|
|
268
|
-
if (channel === 'imessage') {
|
|
269
|
-
deliveryText = deliveryText
|
|
270
|
-
.replace(/\*\*(.+?)\*\*/g, '$1') // bold
|
|
271
|
-
.replace(/\*(.+?)\*/g, '$1') // italic
|
|
272
|
-
.replace(/__(.+?)__/g, '$1') // bold alt
|
|
273
|
-
.replace(/_(.+?)_/g, '$1') // italic alt
|
|
274
|
-
.replace(/`(.+?)`/g, '$1') // inline code
|
|
275
|
-
.replace(/```[\s\S]*?```/g, '') // code blocks
|
|
276
|
-
.replace(/^#{1,6}\s+/gm, '') // headers
|
|
277
|
-
.replace(/^\s*[-*+]\s+/gm, '• ') // bullet lists
|
|
278
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // links
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
const args = [
|
|
283
|
-
'message', 'send',
|
|
284
|
-
'--target', target,
|
|
285
|
-
'--message', deliveryText,
|
|
286
|
-
];
|
|
287
|
-
if (channel) args.push('--channel', channel);
|
|
288
|
-
|
|
289
|
-
const deliveryProc = spawn('openclaw', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
290
|
-
|
|
291
|
-
deliveryProc.on('close', (code) => {
|
|
292
|
-
if (code === 0) {
|
|
293
|
-
log.info(`[chorus] 📨 ${choir.name} output delivered via ${channel || 'default'}`);
|
|
294
|
-
} else {
|
|
295
|
-
log.warn(`[chorus] ⚠ ${choir.name} delivery failed (exit ${code})`);
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
deliveryProc.on('error', (err) => {
|
|
300
|
-
log.warn(`[chorus] ⚠ ${choir.name} delivery error: ${err.message}`);
|
|
301
|
-
});
|
|
302
|
-
} catch (deliveryErr) {
|
|
303
|
-
log.warn(`[chorus] ⚠ ${choir.name} delivery error: ${deliveryErr}`);
|
|
304
|
-
}
|
|
305
|
-
} else {
|
|
306
|
-
log.warn(`[chorus] ⚠ No delivery target found in OpenClaw config for ${choir.name}`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
328
|
+
// Deliver output via shared adapter for consistent routing/format behavior
|
|
329
|
+
await deliverChoirOutput(api, choir, output, log);
|
|
309
330
|
|
|
310
331
|
// Log illumination flow
|
|
311
332
|
if (choir.passesTo.length > 0) {
|
|
@@ -380,10 +401,20 @@ export function createChoirScheduler(
|
|
|
380
401
|
return improvements.slice(0, 5); // Cap at 5
|
|
381
402
|
}
|
|
382
403
|
|
|
404
|
+
// Track choirs currently executing to prevent duplicate runs
|
|
405
|
+
const executing = new Set<string>();
|
|
406
|
+
|
|
383
407
|
// Check and run due choirs
|
|
384
408
|
async function checkAndRunChoirs(): Promise<void> {
|
|
385
409
|
const now = new Date();
|
|
386
410
|
|
|
411
|
+
// Circuit breaker: skip entire cycle if backing off from rate limit
|
|
412
|
+
if (Date.now() < circuitBreaker.backoffUntil) {
|
|
413
|
+
const remainingSec = ((circuitBreaker.backoffUntil - Date.now()) / 1000).toFixed(0);
|
|
414
|
+
log.debug(`[chorus] ⚡ Circuit breaker active — ${remainingSec}s remaining`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
387
418
|
// Check choirs in cascade order (important for illumination flow)
|
|
388
419
|
for (const choirId of CASCADE_ORDER) {
|
|
389
420
|
const choir = CHOIRS[choirId];
|
|
@@ -394,10 +425,20 @@ export function createChoirScheduler(
|
|
|
394
425
|
continue;
|
|
395
426
|
}
|
|
396
427
|
|
|
428
|
+
// Skip if already executing (prevents duplicate runs for slow choirs)
|
|
429
|
+
if (executing.has(choirId)) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
397
433
|
// Check if due based on interval
|
|
398
434
|
const state = runState.get(choirId);
|
|
399
435
|
if (shouldRunChoir(choir, now, state?.lastRun)) {
|
|
400
|
-
|
|
436
|
+
executing.add(choirId);
|
|
437
|
+
try {
|
|
438
|
+
await executeChoir(choir);
|
|
439
|
+
} finally {
|
|
440
|
+
executing.delete(choirId);
|
|
441
|
+
}
|
|
401
442
|
}
|
|
402
443
|
}
|
|
403
444
|
}
|