@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/config.ts
CHANGED
|
@@ -20,6 +20,22 @@ export interface ChorusConfig {
|
|
|
20
20
|
dailyRunCap: number;
|
|
21
21
|
defaultFrequency: number;
|
|
22
22
|
defaultMaxFrequency: number;
|
|
23
|
+
researchTimeoutMs: number;
|
|
24
|
+
checkIntervalMs: number;
|
|
25
|
+
};
|
|
26
|
+
daemon: {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
senses: {
|
|
29
|
+
inbox: boolean;
|
|
30
|
+
purposes: boolean;
|
|
31
|
+
time: boolean;
|
|
32
|
+
};
|
|
33
|
+
thinkThreshold: number;
|
|
34
|
+
pollIntervalMs: number;
|
|
35
|
+
minSleepMs: number;
|
|
36
|
+
maxSleepMs: number;
|
|
37
|
+
quietHoursStart: number;
|
|
38
|
+
quietHoursEnd: number;
|
|
23
39
|
};
|
|
24
40
|
prayers: {
|
|
25
41
|
enabled: boolean;
|
|
@@ -45,6 +61,23 @@ export interface ChorusPluginConfig {
|
|
|
45
61
|
dailyRunCap?: number;
|
|
46
62
|
defaultFrequency?: number;
|
|
47
63
|
defaultMaxFrequency?: number;
|
|
64
|
+
researchTimeoutMs?: number;
|
|
65
|
+
checkIntervalMs?: number;
|
|
66
|
+
};
|
|
67
|
+
/** Daemon config */
|
|
68
|
+
daemon?: {
|
|
69
|
+
enabled?: boolean;
|
|
70
|
+
senses?: {
|
|
71
|
+
inbox?: boolean;
|
|
72
|
+
purposes?: boolean;
|
|
73
|
+
time?: boolean;
|
|
74
|
+
};
|
|
75
|
+
thinkThreshold?: number;
|
|
76
|
+
pollIntervalMs?: number;
|
|
77
|
+
minSleepMs?: number;
|
|
78
|
+
maxSleepMs?: number;
|
|
79
|
+
quietHoursStart?: number;
|
|
80
|
+
quietHoursEnd?: number;
|
|
48
81
|
};
|
|
49
82
|
/** On-chain prayer config */
|
|
50
83
|
prayers?: {
|
|
@@ -72,6 +105,22 @@ const DEFAULT_CONFIG: ChorusConfig = {
|
|
|
72
105
|
dailyRunCap: 50,
|
|
73
106
|
defaultFrequency: 6,
|
|
74
107
|
defaultMaxFrequency: 24,
|
|
108
|
+
researchTimeoutMs: 300000,
|
|
109
|
+
checkIntervalMs: 60000,
|
|
110
|
+
},
|
|
111
|
+
daemon: {
|
|
112
|
+
enabled: true,
|
|
113
|
+
senses: {
|
|
114
|
+
inbox: true,
|
|
115
|
+
purposes: true,
|
|
116
|
+
time: true,
|
|
117
|
+
},
|
|
118
|
+
thinkThreshold: 55,
|
|
119
|
+
pollIntervalMs: 5 * 60 * 1000,
|
|
120
|
+
minSleepMs: 30 * 1000,
|
|
121
|
+
maxSleepMs: 10 * 60 * 1000,
|
|
122
|
+
quietHoursStart: 23,
|
|
123
|
+
quietHoursEnd: 7,
|
|
75
124
|
},
|
|
76
125
|
prayers: {
|
|
77
126
|
enabled: true,
|
|
@@ -146,6 +195,48 @@ export function loadChorusConfig(pluginConfig?: ChorusPluginConfig): ChorusConfi
|
|
|
146
195
|
if (pluginConfig.purposeResearch.defaultMaxFrequency !== undefined) {
|
|
147
196
|
config.purposeResearch.defaultMaxFrequency = pluginConfig.purposeResearch.defaultMaxFrequency;
|
|
148
197
|
}
|
|
198
|
+
if (pluginConfig.purposeResearch.researchTimeoutMs !== undefined) {
|
|
199
|
+
config.purposeResearch.researchTimeoutMs = pluginConfig.purposeResearch.researchTimeoutMs;
|
|
200
|
+
}
|
|
201
|
+
if (pluginConfig.purposeResearch.checkIntervalMs !== undefined) {
|
|
202
|
+
config.purposeResearch.checkIntervalMs = pluginConfig.purposeResearch.checkIntervalMs;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Daemon
|
|
207
|
+
if (pluginConfig.daemon) {
|
|
208
|
+
if (pluginConfig.daemon.enabled !== undefined) {
|
|
209
|
+
config.daemon.enabled = pluginConfig.daemon.enabled;
|
|
210
|
+
}
|
|
211
|
+
if (pluginConfig.daemon.senses) {
|
|
212
|
+
if (pluginConfig.daemon.senses.inbox !== undefined) {
|
|
213
|
+
config.daemon.senses.inbox = pluginConfig.daemon.senses.inbox;
|
|
214
|
+
}
|
|
215
|
+
if (pluginConfig.daemon.senses.purposes !== undefined) {
|
|
216
|
+
config.daemon.senses.purposes = pluginConfig.daemon.senses.purposes;
|
|
217
|
+
}
|
|
218
|
+
if (pluginConfig.daemon.senses.time !== undefined) {
|
|
219
|
+
config.daemon.senses.time = pluginConfig.daemon.senses.time;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (pluginConfig.daemon.thinkThreshold !== undefined) {
|
|
223
|
+
config.daemon.thinkThreshold = pluginConfig.daemon.thinkThreshold;
|
|
224
|
+
}
|
|
225
|
+
if (pluginConfig.daemon.pollIntervalMs !== undefined) {
|
|
226
|
+
config.daemon.pollIntervalMs = pluginConfig.daemon.pollIntervalMs;
|
|
227
|
+
}
|
|
228
|
+
if (pluginConfig.daemon.minSleepMs !== undefined) {
|
|
229
|
+
config.daemon.minSleepMs = pluginConfig.daemon.minSleepMs;
|
|
230
|
+
}
|
|
231
|
+
if (pluginConfig.daemon.maxSleepMs !== undefined) {
|
|
232
|
+
config.daemon.maxSleepMs = pluginConfig.daemon.maxSleepMs;
|
|
233
|
+
}
|
|
234
|
+
if (pluginConfig.daemon.quietHoursStart !== undefined) {
|
|
235
|
+
config.daemon.quietHoursStart = pluginConfig.daemon.quietHoursStart;
|
|
236
|
+
}
|
|
237
|
+
if (pluginConfig.daemon.quietHoursEnd !== undefined) {
|
|
238
|
+
config.daemon.quietHoursEnd = pluginConfig.daemon.quietHoursEnd;
|
|
239
|
+
}
|
|
149
240
|
}
|
|
150
241
|
|
|
151
242
|
return config;
|
package/src/daemon.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
9
9
|
import type { Signal, Sense } from "./senses.js";
|
|
10
10
|
import { ALL_SENSES } from "./senses.js";
|
|
11
|
-
import { SalienceFilter
|
|
11
|
+
import { SalienceFilter } from "./salience.js";
|
|
12
12
|
import { recordExecution, type ChoirExecution } from "./metrics.js";
|
|
13
13
|
|
|
14
14
|
export interface DaemonConfig {
|
|
@@ -55,7 +55,7 @@ export function createDaemon(
|
|
|
55
55
|
const attentionQueue: AttentionItem[] = [];
|
|
56
56
|
const cleanupFns: (() => void)[] = [];
|
|
57
57
|
|
|
58
|
-
let
|
|
58
|
+
let cycleTimer: NodeJS.Timeout | null = null;
|
|
59
59
|
let running = false;
|
|
60
60
|
|
|
61
61
|
// Get enabled senses
|
|
@@ -146,7 +146,7 @@ export function createDaemon(
|
|
|
146
146
|
|
|
147
147
|
try {
|
|
148
148
|
const result = await api.runAgentTurn?.({
|
|
149
|
-
sessionLabel: "chorus
|
|
149
|
+
sessionLabel: "chorus-daemon",
|
|
150
150
|
message: prompt,
|
|
151
151
|
isolated: true,
|
|
152
152
|
timeoutSeconds: 180,
|
|
@@ -240,11 +240,7 @@ export function createDaemon(
|
|
|
240
240
|
// Start event watchers
|
|
241
241
|
startWatchers();
|
|
242
242
|
|
|
243
|
-
|
|
244
|
-
pollSenses().catch(err => log.error(`[daemon] Initial poll failed: ${err}`));
|
|
245
|
-
|
|
246
|
-
// Periodic polling
|
|
247
|
-
pollInterval = setInterval(async () => {
|
|
243
|
+
const runCycle = async (): Promise<void> => {
|
|
248
244
|
if (!running) return;
|
|
249
245
|
|
|
250
246
|
try {
|
|
@@ -253,18 +249,27 @@ export function createDaemon(
|
|
|
253
249
|
} catch (err) {
|
|
254
250
|
log.error(`[daemon] Cycle error: ${err}`);
|
|
255
251
|
}
|
|
256
|
-
}, config.pollIntervalMs);
|
|
257
252
|
|
|
258
|
-
|
|
253
|
+
if (!running) return;
|
|
254
|
+
cycleTimer = setTimeout(() => {
|
|
255
|
+
runCycle().catch(err => log.error(`[daemon] Cycle scheduler error: ${err}`));
|
|
256
|
+
}, calculateSleepTime());
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
runCycle().catch(err => log.error(`[daemon] Initial cycle failed: ${err}`));
|
|
260
|
+
|
|
261
|
+
log.info(
|
|
262
|
+
`[daemon] 👁️ Active — adaptive sleep ${config.minSleepMs / 1000}s..${config.maxSleepMs / 1000}s, threshold: ${config.thinkThreshold}`
|
|
263
|
+
);
|
|
259
264
|
},
|
|
260
265
|
|
|
261
266
|
stop: () => {
|
|
262
267
|
running = false;
|
|
263
268
|
log.info("[daemon] Stopping...");
|
|
264
269
|
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
270
|
+
if (cycleTimer) {
|
|
271
|
+
clearTimeout(cycleTimer);
|
|
272
|
+
cycleTimer = null;
|
|
268
273
|
}
|
|
269
274
|
|
|
270
275
|
for (const cleanup of cleanupFns) {
|
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
interface DeliverableChoir {
|
|
4
|
+
name: string;
|
|
5
|
+
delivers?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface LoggerLike {
|
|
9
|
+
info?: (msg: string) => void;
|
|
10
|
+
warn?: (msg: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toPlainTextIfNeeded(text: string, channel?: string): string {
|
|
14
|
+
let deliveryText = text.slice(0, 4000);
|
|
15
|
+
if (channel !== "imessage") return deliveryText;
|
|
16
|
+
|
|
17
|
+
return deliveryText
|
|
18
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
19
|
+
.replace(/\*(.+?)\*/g, "$1")
|
|
20
|
+
.replace(/__(.+?)__/g, "$1")
|
|
21
|
+
.replace(/_(.+?)_/g, "$1")
|
|
22
|
+
.replace(/`(.+?)`/g, "$1")
|
|
23
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
24
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
25
|
+
.replace(/^\s*[-*+]\s+/gm, "• ")
|
|
26
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveDeliveryTarget(api: any): { target?: string; channel?: string } {
|
|
30
|
+
const channels = api?.config?.channels as Record<string, any> | undefined;
|
|
31
|
+
if (!channels) return {};
|
|
32
|
+
|
|
33
|
+
for (const [channel, cfg] of Object.entries(channels)) {
|
|
34
|
+
if (cfg?.enabled && cfg?.allowFrom?.[0]) {
|
|
35
|
+
return { target: cfg.allowFrom[0], channel };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function deliverChoirOutput(
|
|
43
|
+
api: any,
|
|
44
|
+
choir: DeliverableChoir,
|
|
45
|
+
text: string,
|
|
46
|
+
log?: LoggerLike
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
if (!choir.delivers || !text || text === "HEARTBEAT_OK" || text === "NO_REPLY") return;
|
|
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
|
+
|
|
75
|
+
const { target, channel } = resolveDeliveryTarget(api);
|
|
76
|
+
if (!target) {
|
|
77
|
+
log?.warn?.(`[chorus] ⚠ No delivery target found in OpenClaw config for ${choir.name}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const args = [
|
|
82
|
+
"message", "send",
|
|
83
|
+
"--target", target,
|
|
84
|
+
"--message", toPlainTextIfNeeded(text, channel),
|
|
85
|
+
];
|
|
86
|
+
if (channel) args.push("--channel", channel);
|
|
87
|
+
|
|
88
|
+
await new Promise<void>((resolve) => {
|
|
89
|
+
try {
|
|
90
|
+
const child = spawn("openclaw", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
91
|
+
let stderr = "";
|
|
92
|
+
child.stderr.on("data", (d: Buffer) => {
|
|
93
|
+
if (stderr.length < 1024) stderr += d.toString();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
child.kill("SIGTERM");
|
|
98
|
+
}, 30000);
|
|
99
|
+
|
|
100
|
+
child.on("close", (code) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
if (code === 0) {
|
|
103
|
+
log?.info?.(`[chorus] 📨 ${choir.name} output delivered via ${channel || "default"}`);
|
|
104
|
+
} else {
|
|
105
|
+
const err = stderr.trim().slice(0, 160);
|
|
106
|
+
log?.warn?.(`[chorus] ⚠ ${choir.name} delivery failed${err ? `: ${err}` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
resolve();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
child.on("error", (err: any) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
log?.warn?.(`[chorus] ⚠ ${choir.name} delivery error: ${(err?.message || "").slice(0, 160)}`);
|
|
114
|
+
resolve();
|
|
115
|
+
});
|
|
116
|
+
} catch (err: any) {
|
|
117
|
+
log?.warn?.(`[chorus] ⚠ ${choir.name} delivery error: ${(err?.message || "").slice(0, 160)}`);
|
|
118
|
+
resolve();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
package/src/economics.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Attention Economics — Phase 1: Observe
|
|
3
|
+
*
|
|
4
|
+
* Tracks inference cost and value signals per choir/purpose.
|
|
5
|
+
* No frequency adjustments yet — just measurement.
|
|
6
|
+
*
|
|
7
|
+
* Cost: captured from each choir run (tokens × model rate)
|
|
8
|
+
* Value: attributed from downstream outcomes (calibration wins, alerts acted on, etc.)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, appendFileSync, readFileSync, mkdirSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
|
|
15
|
+
const ECONOMICS_DIR = join(homedir(), ".chorus", "economics");
|
|
16
|
+
|
|
17
|
+
// Model cost rates (USD per 1K tokens)
|
|
18
|
+
const MODEL_RATES: Record<string, { input: number; output: number }> = {
|
|
19
|
+
"claude-sonnet-4": { input: 0.003, output: 0.015 },
|
|
20
|
+
"claude-opus-4": { input: 0.015, output: 0.075 },
|
|
21
|
+
"claude-haiku-3.5": { input: 0.0008, output: 0.004 },
|
|
22
|
+
default: { input: 0.003, output: 0.015 }, // assume sonnet
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── Cost Tracking ────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface RunCost {
|
|
28
|
+
choirId: string;
|
|
29
|
+
purposeId?: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
inputTokens: number;
|
|
32
|
+
outputTokens: number;
|
|
33
|
+
model: string;
|
|
34
|
+
inferenceMs: number;
|
|
35
|
+
costUsd: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureDir(): void {
|
|
39
|
+
if (!existsSync(ECONOMICS_DIR)) {
|
|
40
|
+
mkdirSync(ECONOMICS_DIR, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function recordCost(cost: RunCost): void {
|
|
45
|
+
ensureDir();
|
|
46
|
+
const line = JSON.stringify(cost) + "\n";
|
|
47
|
+
appendFileSync(join(ECONOMICS_DIR, "costs.jsonl"), line);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function estimateCost(
|
|
51
|
+
inputTokens: number,
|
|
52
|
+
outputTokens: number,
|
|
53
|
+
model: string = "default"
|
|
54
|
+
): number {
|
|
55
|
+
const rate = MODEL_RATES[model] || MODEL_RATES.default;
|
|
56
|
+
return (inputTokens / 1000) * rate.input + (outputTokens / 1000) * rate.output;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Value Tracking ───────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export type ValueKind =
|
|
62
|
+
| "thesis_win" // calibration record: prediction correct
|
|
63
|
+
| "thesis_save" // powers caught a flaw → loss avoided
|
|
64
|
+
| "config_improve" // virtues change → measurable improvement
|
|
65
|
+
| "alert_acted" // archangels alert → human acted
|
|
66
|
+
| "pnl_attribution" // research led to profitable trade
|
|
67
|
+
| "knowledge_cited"; // cherubim insight reused in later decision
|
|
68
|
+
|
|
69
|
+
export interface ValueEvent {
|
|
70
|
+
choirId: string;
|
|
71
|
+
purposeId?: string;
|
|
72
|
+
timestamp: string;
|
|
73
|
+
kind: ValueKind;
|
|
74
|
+
estimatedValueUsd: number;
|
|
75
|
+
evidence: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function recordValue(event: ValueEvent): void {
|
|
79
|
+
ensureDir();
|
|
80
|
+
const line = JSON.stringify(event) + "\n";
|
|
81
|
+
appendFileSync(join(ECONOMICS_DIR, "value.jsonl"), line);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── ROI Calculation ──────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
interface ChoirROI {
|
|
87
|
+
choirId: string;
|
|
88
|
+
windowDays: number;
|
|
89
|
+
totalCostUsd: number;
|
|
90
|
+
totalValueUsd: number;
|
|
91
|
+
roi: number; // value / cost, or Infinity if cost is 0
|
|
92
|
+
runCount: number;
|
|
93
|
+
costPerRun: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readJsonl<T>(filename: string): T[] {
|
|
97
|
+
const path = join(ECONOMICS_DIR, filename);
|
|
98
|
+
if (!existsSync(path)) return [];
|
|
99
|
+
try {
|
|
100
|
+
return readFileSync(path, "utf-8")
|
|
101
|
+
.trim()
|
|
102
|
+
.split("\n")
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map((line) => JSON.parse(line));
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function calculateROI(choirId: string, windowDays: number = 7): ChoirROI {
|
|
111
|
+
const cutoff = Date.now() - windowDays * 86400000;
|
|
112
|
+
|
|
113
|
+
const costs = readJsonl<RunCost>("costs.jsonl").filter(
|
|
114
|
+
(c) => c.choirId === choirId && new Date(c.timestamp).getTime() > cutoff
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const values = readJsonl<ValueEvent>("value.jsonl").filter(
|
|
118
|
+
(v) => v.choirId === choirId && new Date(v.timestamp).getTime() > cutoff
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const totalCost = costs.reduce((sum, c) => sum + c.costUsd, 0);
|
|
122
|
+
const totalValue = values.reduce((sum, v) => sum + v.estimatedValueUsd, 0);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
choirId,
|
|
126
|
+
windowDays,
|
|
127
|
+
totalCostUsd: totalCost,
|
|
128
|
+
totalValueUsd: totalValue,
|
|
129
|
+
roi: totalCost > 0 ? totalValue / totalCost : values.length > 0 ? Infinity : 0,
|
|
130
|
+
runCount: costs.length,
|
|
131
|
+
costPerRun: costs.length > 0 ? totalCost / costs.length : 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function calculateAllROI(windowDays: number = 7): ChoirROI[] {
|
|
136
|
+
const choirIds = [
|
|
137
|
+
"seraphim", "cherubim", "thrones",
|
|
138
|
+
"dominions", "virtues", "powers",
|
|
139
|
+
"principalities", "archangels", "angels",
|
|
140
|
+
];
|
|
141
|
+
return choirIds.map((id) => calculateROI(id, windowDays));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Summary ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export function formatEconomicsSummary(windowDays: number = 7): string {
|
|
147
|
+
const allROI = calculateAllROI(windowDays);
|
|
148
|
+
const totalCost = allROI.reduce((s, r) => s + r.totalCostUsd, 0);
|
|
149
|
+
const totalValue = allROI.reduce((s, r) => s + r.totalValueUsd, 0);
|
|
150
|
+
const totalRuns = allROI.reduce((s, r) => s + r.runCount, 0);
|
|
151
|
+
|
|
152
|
+
const lines = [
|
|
153
|
+
`Attention Economics — ${windowDays}d window`,
|
|
154
|
+
"=".repeat(50),
|
|
155
|
+
"",
|
|
156
|
+
`Total: ${totalRuns} runs | $${totalCost.toFixed(2)} cost | $${totalValue.toFixed(2)} value | ROI ${totalCost > 0 ? (totalValue / totalCost).toFixed(1) : "N/A"}x`,
|
|
157
|
+
"",
|
|
158
|
+
"Per Choir:",
|
|
159
|
+
...allROI.map((r) => {
|
|
160
|
+
const roi = r.totalCostUsd > 0 ? `${r.roi.toFixed(1)}x` : "N/A";
|
|
161
|
+
return ` ${r.choirId.padEnd(16)} ${r.runCount.toString().padStart(3)} runs $${r.totalCostUsd.toFixed(2).padStart(6)} cost $${r.totalValueUsd.toFixed(2).padStart(8)} value ${roi.padStart(6)} ROI`;
|
|
162
|
+
}),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|