@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/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, defaultFilter } from "./salience.js";
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 pollInterval: NodeJS.Timeout | null = null;
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:daemon",
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
- // Initial poll
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
- log.info(`[daemon] 👁️ Active — polling every ${config.pollIntervalMs / 1000}s, threshold: ${config.thinkThreshold}`);
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 (pollInterval) {
266
- clearInterval(pollInterval);
267
- pollInterval = null;
270
+ if (cycleTimer) {
271
+ clearTimeout(cycleTimer);
272
+ cycleTimer = null;
268
273
  }
269
274
 
270
275
  for (const cleanup of cleanupFns) {
@@ -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
+ }
@@ -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
+ }