@iamoberlin/chorus 2.3.0 → 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/index.ts +43 -0
- package/package.json +2 -2
- package/src/behavioral-sink.ts +434 -0
- package/src/delivery.ts +25 -0
- package/src/purpose-research.ts +59 -18
- package/src/scheduler.ts +26 -7
package/index.ts
CHANGED
|
@@ -644,6 +644,49 @@ const plugin = {
|
|
|
644
644
|
console.log("");
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
+
// ── Behavioral Sink Health (Universe 25) ──────────────────
|
|
648
|
+
program
|
|
649
|
+
.command("health")
|
|
650
|
+
.description("Behavioral sink health check (Universe 25 anti-collapse)")
|
|
651
|
+
.action(async () => {
|
|
652
|
+
const { getSinkHealthSummary } = await import("./src/behavioral-sink.js");
|
|
653
|
+
const health = getSinkHealthSummary();
|
|
654
|
+
console.log("");
|
|
655
|
+
console.log(health.healthy ? "🟢 CHORUS Health: HEALTHY" : "🔴 CHORUS Health: DEGRADED");
|
|
656
|
+
console.log("═".repeat(50));
|
|
657
|
+
console.log("");
|
|
658
|
+
|
|
659
|
+
if (Object.keys(health.choirHealth).length === 0) {
|
|
660
|
+
console.log(" No quality data yet. Run some choir cycles first.");
|
|
661
|
+
} else {
|
|
662
|
+
console.log(" Choir Quality (avg over last 10 runs):");
|
|
663
|
+
for (const [id, h] of Object.entries(health.choirHealth) as any) {
|
|
664
|
+
const bar = "█".repeat(Math.round(h.avgQuality / 5)) + "░".repeat(20 - Math.round(h.avgQuality / 5));
|
|
665
|
+
const trendIcon = h.trend === "improving" ? "📈" : h.trend === "declining" ? "📉" : "➡️";
|
|
666
|
+
const boAlert = h.beautifulOneCount > 0 ? ` 🪞×${h.beautifulOneCount}` : "";
|
|
667
|
+
console.log(` ${id.padEnd(16)} [${bar}] ${h.avgQuality} ${trendIcon}${boAlert}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (health.starvingPurposes.length > 0) {
|
|
672
|
+
console.log("");
|
|
673
|
+
console.log(" 🏚️ Starving Purposes:");
|
|
674
|
+
for (const p of health.starvingPurposes) {
|
|
675
|
+
console.log(` - ${p}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (health.recentIncidents.length > 0) {
|
|
680
|
+
console.log("");
|
|
681
|
+
console.log(" 🪞 Recent Beautiful One Incidents:");
|
|
682
|
+
for (const i of health.recentIncidents) {
|
|
683
|
+
console.log(` ${i.timestamp.slice(0, 16)} ${i.choirId} (score: ${i.score})`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
console.log("");
|
|
688
|
+
});
|
|
689
|
+
|
|
647
690
|
// Daemon commands
|
|
648
691
|
const daemonCmd = program.command("daemon").description("Autonomous attention daemon");
|
|
649
692
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iamoberlin/chorus",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "CHORUS: Hierarchy Of Recursive Unified Self-improvement
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"description": "CHORUS: Hierarchy Of Recursive Unified Self-improvement \u2014 with on-chain Prayer Chain (Solana)",
|
|
5
5
|
"author": "Oberlin <iam@oberlin.ai>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Behavioral Sink Prevention
|
|
3
|
+
*
|
|
4
|
+
* Inspired by John B. Calhoun's Universe 25 (1968-1972):
|
|
5
|
+
* A mouse utopia with unlimited resources collapsed not from scarcity,
|
|
6
|
+
* but from loss of purpose and structural role degradation.
|
|
7
|
+
*
|
|
8
|
+
* Three failure modes mapped to CHORUS:
|
|
9
|
+
*
|
|
10
|
+
* 1. BEAUTIFUL ONES — Mice that looked perfect but did nothing.
|
|
11
|
+
* → Choirs producing verbose but vacuous output. Busy ≠ productive.
|
|
12
|
+
*
|
|
13
|
+
* 2. BEHAVIORAL SINK — Pathological behaviors spreading contagiously.
|
|
14
|
+
* → Degraded illumination cascading through the choir hierarchy.
|
|
15
|
+
*
|
|
16
|
+
* 3. TERRITORIAL EXCLUSION — Dominant mice monopolizing resources,
|
|
17
|
+
* forcing others into withdrawal.
|
|
18
|
+
* → Purpose starvation: dominant purposes crowding out others.
|
|
19
|
+
*
|
|
20
|
+
* "Purpose prevents extinction."
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
import { homedir } from "os";
|
|
26
|
+
|
|
27
|
+
const SINK_STATE_DIR = join(homedir(), ".chorus", "behavioral-sink");
|
|
28
|
+
const SINK_STATE_PATH = join(SINK_STATE_DIR, "state.json");
|
|
29
|
+
|
|
30
|
+
// ── Beautiful Ones Detection ─────────────────────────────────
|
|
31
|
+
// Detect when choir output is present but substanceless.
|
|
32
|
+
|
|
33
|
+
export interface QualitySignal {
|
|
34
|
+
score: number; // 0-100: 0 = vacuous, 100 = highly substantive
|
|
35
|
+
actionsTaken: number; // File writes, commands run, state changes
|
|
36
|
+
novelty: number; // 0-100: how different from last N outputs
|
|
37
|
+
flags: string[]; // Human-readable quality concerns
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Analyze choir output quality. Returns a signal indicating whether
|
|
42
|
+
* the output represents real work or beautiful-one behavior.
|
|
43
|
+
*/
|
|
44
|
+
export function assessOutputQuality(
|
|
45
|
+
choirId: string,
|
|
46
|
+
output: string,
|
|
47
|
+
previousOutputs: string[] = []
|
|
48
|
+
): QualitySignal {
|
|
49
|
+
const flags: string[] = [];
|
|
50
|
+
let score = 50; // Start neutral
|
|
51
|
+
|
|
52
|
+
// ── Length check (too short = no work, too long = possibly padding)
|
|
53
|
+
if (output.length < 100) {
|
|
54
|
+
score -= 20;
|
|
55
|
+
flags.push("minimal_output");
|
|
56
|
+
} else if (output.length > 50) {
|
|
57
|
+
score += 10;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Action density: Did it actually DO things?
|
|
61
|
+
const actionPatterns = [
|
|
62
|
+
/(?:updated|wrote|created|modified|edited|deleted|committed|pushed|deployed)\s/gi,
|
|
63
|
+
/(?:wrote to|saved|appended)\s+[^\s]+/gi,
|
|
64
|
+
/(?:ran|executed|running)\s+/gi,
|
|
65
|
+
/(?:changed|set|configured|enabled|disabled)\s/gi,
|
|
66
|
+
/(?:fixed|resolved|patched|upgraded)\s/gi,
|
|
67
|
+
];
|
|
68
|
+
let actionsTaken = 0;
|
|
69
|
+
for (const pattern of actionPatterns) {
|
|
70
|
+
const matches = output.match(pattern);
|
|
71
|
+
if (matches) actionsTaken += matches.length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (actionsTaken === 0 && !["angels", "seraphim"].includes(choirId)) {
|
|
75
|
+
score -= 15;
|
|
76
|
+
flags.push("no_actions_detected");
|
|
77
|
+
} else if (actionsTaken >= 3) {
|
|
78
|
+
score += 20;
|
|
79
|
+
} else if (actionsTaken >= 1) {
|
|
80
|
+
score += 10;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Filler detection: Generic phrases that pad without substance
|
|
84
|
+
const fillerPatterns = [
|
|
85
|
+
/everything (?:looks|seems|appears) (?:good|fine|stable|normal|healthy)/gi,
|
|
86
|
+
/no (?:significant |major |notable )?(?:changes|updates|issues|concerns|findings)/gi,
|
|
87
|
+
/(?:continuing|continue) to monitor/gi,
|
|
88
|
+
/(?:all|everything) (?:is )?(?:on track|aligned|in order)/gi,
|
|
89
|
+
/nothing (?:urgent|notable|significant) to report/gi,
|
|
90
|
+
/mission (?:remains |is )?(?:clear|aligned|unchanged)/gi,
|
|
91
|
+
/no (?:drift|deviation) detected/gi,
|
|
92
|
+
];
|
|
93
|
+
let fillerCount = 0;
|
|
94
|
+
for (const pattern of fillerPatterns) {
|
|
95
|
+
const matches = output.match(pattern);
|
|
96
|
+
if (matches) fillerCount += matches.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (fillerCount >= 3) {
|
|
100
|
+
score -= 25;
|
|
101
|
+
flags.push("high_filler_ratio");
|
|
102
|
+
} else if (fillerCount >= 1) {
|
|
103
|
+
score -= 5;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Specificity: Does it contain concrete data (numbers, names, dates)?
|
|
107
|
+
const specificityPatterns = [
|
|
108
|
+
/\$[\d,]+(?:\.\d+)?/g, // Dollar amounts
|
|
109
|
+
/\d+(?:\.\d+)?%/g, // Percentages
|
|
110
|
+
/\d{4}-\d{2}-\d{2}/g, // Dates
|
|
111
|
+
/(?:PI|VIX|DXY|BTC|ETH)\s*(?:at|=|:)\s*[\d.,$]+/gi, // Market refs
|
|
112
|
+
];
|
|
113
|
+
let specificityCount = 0;
|
|
114
|
+
for (const pattern of specificityPatterns) {
|
|
115
|
+
const matches = output.match(pattern);
|
|
116
|
+
if (matches) specificityCount += matches.length;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (specificityCount >= 3) {
|
|
120
|
+
score += 15;
|
|
121
|
+
} else if (specificityCount === 0 && !["angels"].includes(choirId)) {
|
|
122
|
+
score -= 10;
|
|
123
|
+
flags.push("low_specificity");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Novelty: Is this substantially different from recent outputs?
|
|
127
|
+
let novelty = 100;
|
|
128
|
+
if (previousOutputs.length > 0) {
|
|
129
|
+
const similarities = previousOutputs.map(prev => computeSimilarity(output, prev));
|
|
130
|
+
const maxSimilarity = Math.max(...similarities);
|
|
131
|
+
|
|
132
|
+
if (maxSimilarity > 0.85) {
|
|
133
|
+
novelty = 10;
|
|
134
|
+
score -= 20;
|
|
135
|
+
flags.push("near_duplicate_output");
|
|
136
|
+
} else if (maxSimilarity > 0.65) {
|
|
137
|
+
novelty = 40;
|
|
138
|
+
score -= 10;
|
|
139
|
+
flags.push("repetitive_output");
|
|
140
|
+
} else {
|
|
141
|
+
novelty = Math.round((1 - maxSimilarity) * 100);
|
|
142
|
+
score += 5;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── HEARTBEAT_OK is fine for Angels, vacuous for everything else
|
|
147
|
+
const isHeartbeat = output.trim().toUpperCase().includes("HEARTBEAT_OK");
|
|
148
|
+
if (isHeartbeat) {
|
|
149
|
+
if (choirId === "angels") {
|
|
150
|
+
return { score: 50, actionsTaken: 0, novelty: 0, flags: ["heartbeat_ack"] };
|
|
151
|
+
} else {
|
|
152
|
+
score = 5;
|
|
153
|
+
flags.push("beautiful_one_heartbeat");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Clamp score
|
|
158
|
+
score = Math.max(0, Math.min(100, score));
|
|
159
|
+
|
|
160
|
+
return { score, actionsTaken, novelty, flags };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Simple bag-of-words Jaccard similarity.
|
|
165
|
+
* Fast enough for choir output comparison without external deps.
|
|
166
|
+
*/
|
|
167
|
+
function computeSimilarity(a: string, b: string): number {
|
|
168
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 3));
|
|
169
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 3));
|
|
170
|
+
|
|
171
|
+
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
|
172
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
173
|
+
|
|
174
|
+
let intersection = 0;
|
|
175
|
+
for (const w of wordsA) {
|
|
176
|
+
if (wordsB.has(w)) intersection++;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const union = wordsA.size + wordsB.size - intersection;
|
|
180
|
+
return union > 0 ? intersection / union : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Behavioral Sink (Illumination Quality Gate) ──────────────
|
|
184
|
+
// Prevent degraded context from cascading downstream.
|
|
185
|
+
|
|
186
|
+
export interface IlluminationAssessment {
|
|
187
|
+
sourceChoir: string;
|
|
188
|
+
quality: QualitySignal;
|
|
189
|
+
usable: boolean; // Should downstream choirs use this context?
|
|
190
|
+
degraded: boolean; // Is this a degradation from normal quality?
|
|
191
|
+
recommendation: string; // What to do
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Assess whether illumination from an upstream choir should be
|
|
196
|
+
* passed downstream, or whether it would propagate a behavioral sink.
|
|
197
|
+
*/
|
|
198
|
+
export function assessIllumination(
|
|
199
|
+
sourceChoir: string,
|
|
200
|
+
output: string,
|
|
201
|
+
historicalQuality: number[] = []
|
|
202
|
+
): IlluminationAssessment {
|
|
203
|
+
const quality = assessOutputQuality(sourceChoir, output);
|
|
204
|
+
|
|
205
|
+
// Is this a degradation from the choir's normal quality?
|
|
206
|
+
let degraded = false;
|
|
207
|
+
if (historicalQuality.length >= 3) {
|
|
208
|
+
const avgHistorical = historicalQuality.reduce((a, b) => a + b, 0) / historicalQuality.length;
|
|
209
|
+
degraded = quality.score < avgHistorical - 20;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Below 25 = do not pass downstream (sink prevention)
|
|
213
|
+
const usable = quality.score >= 25;
|
|
214
|
+
|
|
215
|
+
let recommendation: string;
|
|
216
|
+
if (!usable) {
|
|
217
|
+
recommendation = `SINK PREVENTION: ${sourceChoir} output below quality threshold (${quality.score}/100). Context NOT passed to downstream choirs. Flags: ${quality.flags.join(", ")}`;
|
|
218
|
+
} else if (degraded) {
|
|
219
|
+
recommendation = `DEGRADATION WARNING: ${sourceChoir} quality dropped from avg ${Math.round(historicalQuality.reduce((a, b) => a + b, 0) / historicalQuality.length)} to ${quality.score}. Context passed with warning.`;
|
|
220
|
+
} else {
|
|
221
|
+
recommendation = "OK";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { sourceChoir, quality, usable, degraded, recommendation };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Territorial Exclusion Prevention (Purpose Fairness) ──────
|
|
228
|
+
// Ensure no purpose monopolizes execution at the expense of others.
|
|
229
|
+
|
|
230
|
+
export interface FairnessScore {
|
|
231
|
+
purposeId: string;
|
|
232
|
+
expectedInterval: number; // ms between runs (from frequency config)
|
|
233
|
+
actualInterval: number; // ms since last run
|
|
234
|
+
starvationRatio: number; // actual / expected — >2.0 = starving
|
|
235
|
+
starving: boolean;
|
|
236
|
+
priorityBoost: number; // Multiplier to apply to scheduling priority
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Calculate starvation-aware priority for purpose scheduling.
|
|
241
|
+
* Replaces pure deadline-based sorting with fairness weighting.
|
|
242
|
+
*
|
|
243
|
+
* Universe 25 lesson: Dominant mice monopolized prime territory.
|
|
244
|
+
* Fix: overdue purposes get exponentially increasing priority.
|
|
245
|
+
*/
|
|
246
|
+
export function calculateFairness(
|
|
247
|
+
purposeId: string,
|
|
248
|
+
lastRun: number | undefined,
|
|
249
|
+
frequencyPerDay: number,
|
|
250
|
+
deadline?: number
|
|
251
|
+
): FairnessScore {
|
|
252
|
+
const expectedInterval = (24 * 60 * 60 * 1000) / Math.max(frequencyPerDay, 1);
|
|
253
|
+
const actualInterval = lastRun ? Date.now() - lastRun : expectedInterval * 10; // Never run = very overdue
|
|
254
|
+
|
|
255
|
+
const starvationRatio = actualInterval / expectedInterval;
|
|
256
|
+
const starving = starvationRatio >= 2.0;
|
|
257
|
+
|
|
258
|
+
// Priority boost: exponential with starvation ratio
|
|
259
|
+
// At 1x overdue: boost = 1 (normal)
|
|
260
|
+
// At 2x overdue: boost = 2
|
|
261
|
+
// At 4x overdue: boost = 4
|
|
262
|
+
// At 8x+ overdue: boost = 8 (capped)
|
|
263
|
+
let priorityBoost = 1;
|
|
264
|
+
if (starvationRatio > 1) {
|
|
265
|
+
priorityBoost = Math.min(starvationRatio, 8);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Deadline urgency still matters but doesn't dominate
|
|
269
|
+
if (deadline) {
|
|
270
|
+
const daysToDeadline = (deadline - Date.now()) / (24 * 60 * 60 * 1000);
|
|
271
|
+
if (daysToDeadline <= 0) {
|
|
272
|
+
priorityBoost *= 2; // Overdue deadline doubles existing boost
|
|
273
|
+
} else if (daysToDeadline <= 3) {
|
|
274
|
+
priorityBoost *= 1.5;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
purposeId,
|
|
280
|
+
expectedInterval,
|
|
281
|
+
actualInterval,
|
|
282
|
+
starvationRatio,
|
|
283
|
+
starving,
|
|
284
|
+
priorityBoost,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Sort purposes by fairness-weighted priority.
|
|
290
|
+
* Replaces the naive deadline sort that caused starvation.
|
|
291
|
+
*/
|
|
292
|
+
export function sortByFairness(purposes: Array<{
|
|
293
|
+
id: string;
|
|
294
|
+
lastRun?: number;
|
|
295
|
+
frequency: number;
|
|
296
|
+
deadline?: number;
|
|
297
|
+
}>): Array<{ id: string; fairness: FairnessScore }> {
|
|
298
|
+
const scored = purposes.map(p => ({
|
|
299
|
+
id: p.id,
|
|
300
|
+
fairness: calculateFairness(p.id, p.lastRun, p.frequency, p.deadline),
|
|
301
|
+
}));
|
|
302
|
+
|
|
303
|
+
// Sort by priorityBoost descending (most starved first)
|
|
304
|
+
scored.sort((a, b) => b.fairness.priorityBoost - a.fairness.priorityBoost);
|
|
305
|
+
return scored;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── State Persistence ────────────────────────────────────────
|
|
309
|
+
// Track quality history for degradation detection.
|
|
310
|
+
|
|
311
|
+
interface SinkState {
|
|
312
|
+
qualityHistory: Record<string, number[]>; // choirId → last N quality scores
|
|
313
|
+
recentOutputHashes: Record<string, string[]>; // choirId → last N output snippets for novelty
|
|
314
|
+
starvationAlerts: Record<string, number>; // purposeId → last alert timestamp
|
|
315
|
+
beautifulOnesDetected: Array<{
|
|
316
|
+
choirId: string;
|
|
317
|
+
timestamp: string;
|
|
318
|
+
score: number;
|
|
319
|
+
flags: string[];
|
|
320
|
+
}>;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const MAX_HISTORY = 10;
|
|
324
|
+
|
|
325
|
+
function loadSinkState(): SinkState {
|
|
326
|
+
try {
|
|
327
|
+
if (existsSync(SINK_STATE_PATH)) {
|
|
328
|
+
return JSON.parse(readFileSync(SINK_STATE_PATH, "utf-8"));
|
|
329
|
+
}
|
|
330
|
+
} catch {}
|
|
331
|
+
return {
|
|
332
|
+
qualityHistory: {},
|
|
333
|
+
recentOutputHashes: {},
|
|
334
|
+
starvationAlerts: {},
|
|
335
|
+
beautifulOnesDetected: [],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function saveSinkState(state: SinkState): void {
|
|
340
|
+
try {
|
|
341
|
+
if (!existsSync(SINK_STATE_DIR)) {
|
|
342
|
+
mkdirSync(SINK_STATE_DIR, { recursive: true });
|
|
343
|
+
}
|
|
344
|
+
writeFileSync(SINK_STATE_PATH, JSON.stringify(state, null, 2));
|
|
345
|
+
} catch {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Record a choir execution's quality and return the assessment.
|
|
350
|
+
* Call this after every choir run.
|
|
351
|
+
*/
|
|
352
|
+
export function recordAndAssess(
|
|
353
|
+
choirId: string,
|
|
354
|
+
output: string
|
|
355
|
+
): { quality: QualitySignal; illumination: IlluminationAssessment } {
|
|
356
|
+
const state = loadSinkState();
|
|
357
|
+
|
|
358
|
+
// Get previous outputs for novelty detection
|
|
359
|
+
const previousOutputs = state.recentOutputHashes[choirId] || [];
|
|
360
|
+
const historicalQuality = state.qualityHistory[choirId] || [];
|
|
361
|
+
|
|
362
|
+
// Assess quality
|
|
363
|
+
const quality = assessOutputQuality(choirId, output, previousOutputs);
|
|
364
|
+
const illumination = assessIllumination(choirId, output, historicalQuality);
|
|
365
|
+
|
|
366
|
+
// Update history
|
|
367
|
+
if (!state.qualityHistory[choirId]) state.qualityHistory[choirId] = [];
|
|
368
|
+
state.qualityHistory[choirId].push(quality.score);
|
|
369
|
+
if (state.qualityHistory[choirId].length > MAX_HISTORY) {
|
|
370
|
+
state.qualityHistory[choirId] = state.qualityHistory[choirId].slice(-MAX_HISTORY);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Store output snippet for novelty comparison (first 500 chars)
|
|
374
|
+
if (!state.recentOutputHashes[choirId]) state.recentOutputHashes[choirId] = [];
|
|
375
|
+
state.recentOutputHashes[choirId].push(output.slice(0, 500));
|
|
376
|
+
if (state.recentOutputHashes[choirId].length > MAX_HISTORY) {
|
|
377
|
+
state.recentOutputHashes[choirId] = state.recentOutputHashes[choirId].slice(-MAX_HISTORY);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Track beautiful ones incidents
|
|
381
|
+
if (quality.score < 20 && choirId !== "angels") {
|
|
382
|
+
state.beautifulOnesDetected.push({
|
|
383
|
+
choirId,
|
|
384
|
+
timestamp: new Date().toISOString(),
|
|
385
|
+
score: quality.score,
|
|
386
|
+
flags: quality.flags,
|
|
387
|
+
});
|
|
388
|
+
// Keep last 50 incidents
|
|
389
|
+
if (state.beautifulOnesDetected.length > 50) {
|
|
390
|
+
state.beautifulOnesDetected = state.beautifulOnesDetected.slice(-50);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
saveSinkState(state);
|
|
395
|
+
|
|
396
|
+
return { quality, illumination };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Get a summary of behavioral sink health across all choirs.
|
|
401
|
+
*/
|
|
402
|
+
export function getSinkHealthSummary(): {
|
|
403
|
+
healthy: boolean;
|
|
404
|
+
choirHealth: Record<string, { avgQuality: number; trend: string; beautifulOneCount: number }>;
|
|
405
|
+
starvingPurposes: string[];
|
|
406
|
+
recentIncidents: Array<{ choirId: string; timestamp: string; score: number }>;
|
|
407
|
+
} {
|
|
408
|
+
const state = loadSinkState();
|
|
409
|
+
const choirHealth: Record<string, { avgQuality: number; trend: string; beautifulOneCount: number }> = {};
|
|
410
|
+
|
|
411
|
+
let healthy = true;
|
|
412
|
+
|
|
413
|
+
for (const [choirId, scores] of Object.entries(state.qualityHistory)) {
|
|
414
|
+
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
415
|
+
const recentAvg = scores.slice(-3).reduce((a, b) => a + b, 0) / Math.min(scores.length, 3);
|
|
416
|
+
const trend = recentAvg > avg + 10 ? "improving" : recentAvg < avg - 10 ? "declining" : "stable";
|
|
417
|
+
|
|
418
|
+
const beautifulOneCount = state.beautifulOnesDetected
|
|
419
|
+
.filter(b => b.choirId === choirId)
|
|
420
|
+
.length;
|
|
421
|
+
|
|
422
|
+
choirHealth[choirId] = { avgQuality: Math.round(avg), trend, beautifulOneCount };
|
|
423
|
+
|
|
424
|
+
if (avg < 30 || trend === "declining") healthy = false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const starvingPurposes = Object.entries(state.starvationAlerts)
|
|
428
|
+
.filter(([_, ts]) => Date.now() - ts < 24 * 60 * 60 * 1000)
|
|
429
|
+
.map(([id]) => id);
|
|
430
|
+
|
|
431
|
+
const recentIncidents = state.beautifulOnesDetected.slice(-5);
|
|
432
|
+
|
|
433
|
+
return { healthy, choirHealth, starvingPurposes, recentIncidents };
|
|
434
|
+
}
|
package/src/delivery.ts
CHANGED
|
@@ -47,6 +47,31 @@ export async function deliverChoirOutput(
|
|
|
47
47
|
): Promise<void> {
|
|
48
48
|
if (!choir.delivers || !text || text === "HEARTBEAT_OK" || text === "NO_REPLY") return;
|
|
49
49
|
|
|
50
|
+
// ── Suppression Detection ──────────────────────────────────
|
|
51
|
+
// Choir agents sometimes decide NOT to send, but express that decision
|
|
52
|
+
// as output text (e.g., "NOT SENDING", "Standing by", "No alert needed").
|
|
53
|
+
// Without this gate, the deliberation itself gets delivered — the agent
|
|
54
|
+
// says "I won't message Brandon" and Brandon receives exactly that.
|
|
55
|
+
//
|
|
56
|
+
// This is calibration lesson #1 inverted: "Deciding ≠ Doing" becomes
|
|
57
|
+
// "Deciding NOT to do ≠ Not doing" when delivery is automatic.
|
|
58
|
+
const suppressionPatterns = [
|
|
59
|
+
/\bNOT\s+SEND(?:ING)?\b/i,
|
|
60
|
+
/\bSTAND(?:ING)?\s+BY\b/i,
|
|
61
|
+
/\bNO\s+ALERT\s+(?:NEEDED|REQUIRED|WARRANTED|NECESSARY)\b/i,
|
|
62
|
+
/\bSUPPRESS(?:ING|ED)?\s+(?:DELIVERY|OUTPUT|ALERT)\b/i,
|
|
63
|
+
/\bDO\s+NOT\s+(?:ALERT|NOTIFY|SEND|DELIVER)\b/i,
|
|
64
|
+
/\bNOT\s+(?:AN?\s+)?ALERT\b/i,
|
|
65
|
+
/\bNO\s+(?:ACTION|DELIVERY)\s+(?:NEEDED|REQUIRED)\b/i,
|
|
66
|
+
/\bSKIPPING\s+(?:DELIVERY|ALERT|NOTIFICATION)\b/i,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const isSuppressed = suppressionPatterns.some(p => p.test(text));
|
|
70
|
+
if (isSuppressed) {
|
|
71
|
+
log?.info?.(`[chorus] 🔇 ${choir.name} output contains suppression signal — delivery blocked`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
const { target, channel } = resolveDeliveryTarget(api);
|
|
51
76
|
if (!target) {
|
|
52
77
|
log?.warn?.(`[chorus] ⚠ No delivery target found in OpenClaw config for ${choir.name}`);
|
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";
|
|
@@ -383,12 +384,18 @@ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no mark
|
|
|
383
384
|
}
|
|
384
385
|
}
|
|
385
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
|
+
|
|
386
393
|
await updatePurpose(purpose.id, {
|
|
387
394
|
research: {
|
|
388
|
-
...
|
|
389
|
-
enabled:
|
|
395
|
+
...freshResearch,
|
|
396
|
+
enabled: freshResearch?.enabled ?? true,
|
|
390
397
|
lastRun: Date.now(),
|
|
391
|
-
runCount: (
|
|
398
|
+
runCount: (freshResearch?.runCount ?? 0) + 1,
|
|
392
399
|
},
|
|
393
400
|
});
|
|
394
401
|
dailyRuns.count++;
|
|
@@ -399,6 +406,24 @@ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no mark
|
|
|
399
406
|
execution.success = false;
|
|
400
407
|
execution.error = String(err);
|
|
401
408
|
log.error(`[purpose-research] ✗ "${purpose.name}" failed: ${err}`);
|
|
409
|
+
|
|
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
|
+
|
|
402
427
|
return false;
|
|
403
428
|
} finally {
|
|
404
429
|
runningPurposes.delete(purpose.id);
|
|
@@ -428,21 +453,37 @@ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no mark
|
|
|
428
453
|
|
|
429
454
|
if (duePurposes.length === 0) return;
|
|
430
455
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
+
}
|
|
446
487
|
|
|
447
488
|
if (dailyRuns.count < config.dailyRunCap) {
|
|
448
489
|
await runResearch(purpose);
|
package/src/scheduler.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { homedir } from "os";
|
|
17
17
|
import { spawn } from "child_process";
|
|
18
|
+
import { recordAndAssess } from "./behavioral-sink.js";
|
|
18
19
|
|
|
19
20
|
// Type for the plugin API's runAgentTurn method
|
|
20
21
|
interface AgentTurnResult {
|
|
@@ -56,7 +57,7 @@ const BASE_BACKOFF_MS = 60 * 1000; // 1 minute base
|
|
|
56
57
|
|
|
57
58
|
function isRateLimitError(err: unknown): boolean {
|
|
58
59
|
const msg = String(err).toLowerCase();
|
|
59
|
-
return msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests");
|
|
60
|
+
return msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests") || msg.includes("overloaded") || msg.includes("unavailable");
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
function computeBackoffMs(failures: number): number {
|
|
@@ -280,12 +281,30 @@ export function createChoirScheduler(
|
|
|
280
281
|
execution.alerts = countAlerts(output);
|
|
281
282
|
execution.improvements = extractImprovements(output, choir.id);
|
|
282
283
|
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
}
|
|
289
308
|
|
|
290
309
|
// Update run state
|
|
291
310
|
runState.set(choir.id, {
|