@iamoberlin/chorus 2.2.1 → 2.3.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/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,96 @@
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
+ const { target, channel } = resolveDeliveryTarget(api);
51
+ if (!target) {
52
+ log?.warn?.(`[chorus] ⚠ No delivery target found in OpenClaw config for ${choir.name}`);
53
+ return;
54
+ }
55
+
56
+ const args = [
57
+ "message", "send",
58
+ "--target", target,
59
+ "--message", toPlainTextIfNeeded(text, channel),
60
+ ];
61
+ if (channel) args.push("--channel", channel);
62
+
63
+ await new Promise<void>((resolve) => {
64
+ try {
65
+ const child = spawn("openclaw", args, { stdio: ["ignore", "pipe", "pipe"] });
66
+ let stderr = "";
67
+ child.stderr.on("data", (d: Buffer) => {
68
+ if (stderr.length < 1024) stderr += d.toString();
69
+ });
70
+
71
+ const timer = setTimeout(() => {
72
+ child.kill("SIGTERM");
73
+ }, 30000);
74
+
75
+ child.on("close", (code) => {
76
+ clearTimeout(timer);
77
+ if (code === 0) {
78
+ log?.info?.(`[chorus] 📨 ${choir.name} output delivered via ${channel || "default"}`);
79
+ } else {
80
+ const err = stderr.trim().slice(0, 160);
81
+ log?.warn?.(`[chorus] ⚠ ${choir.name} delivery failed${err ? `: ${err}` : ""}`);
82
+ }
83
+ resolve();
84
+ });
85
+
86
+ child.on("error", (err: any) => {
87
+ clearTimeout(timer);
88
+ log?.warn?.(`[chorus] ⚠ ${choir.name} delivery error: ${(err?.message || "").slice(0, 160)}`);
89
+ resolve();
90
+ });
91
+ } catch (err: any) {
92
+ log?.warn?.(`[chorus] ⚠ ${choir.name} delivery error: ${(err?.message || "").slice(0, 160)}`);
93
+ resolve();
94
+ }
95
+ });
96
+ }
@@ -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
+ }
@@ -35,6 +35,9 @@ export const DEFAULT_PURPOSE_RESEARCH_CONFIG: PurposeResearchConfig = {
35
35
  checkIntervalMs: 60000,
36
36
  };
37
37
 
38
+ // Guard: purposes currently running research (prevents duplicate triggers)
39
+ const runningPurposes = new Set<string>();
40
+
38
41
  interface DailyRunTracker {
39
42
  date: string;
40
43
  count: number;
@@ -71,6 +74,36 @@ function countAlerts(output: string): number {
71
74
  return 1;
72
75
  }
73
76
 
77
+ function extractTextFromApiResult(result: any): string {
78
+ if (!result) return "";
79
+ const payloadText = (result.payloads || [])
80
+ .map((p: any) => p?.text || "")
81
+ .filter((t: string) => Boolean(t))
82
+ .pop();
83
+ return payloadText || result.text || result.result?.text || result.response || result.content || "";
84
+ }
85
+
86
+ function extractTextFromCliStdout(stdout: string): string {
87
+ if (!stdout) return "";
88
+
89
+ // Find the last top-level JSON object to skip any log noise before payload.
90
+ for (let i = stdout.length - 1; i >= 0; i--) {
91
+ if (stdout[i] !== "{") continue;
92
+ try {
93
+ const parsed = JSON.parse(stdout.slice(i));
94
+ const payloadText = (parsed.result?.payloads || [])
95
+ .map((p: any) => p?.text || "")
96
+ .filter((t: string) => Boolean(t))
97
+ .pop();
98
+ return payloadText || parsed.result?.text || parsed.text || parsed.response || parsed.content || "";
99
+ } catch {
100
+ // Keep scanning backward for valid JSON.
101
+ }
102
+ }
103
+
104
+ return "";
105
+ }
106
+
74
107
  const STATE_DIR = join(homedir(), ".chorus");
75
108
  const STATE_FILE = join(STATE_DIR, "research-state.json");
76
109
 
@@ -160,6 +193,8 @@ export function createPurposeResearchScheduler(
160
193
  if (purpose.progress >= 100) return false;
161
194
  if (purpose.research?.enabled === false) return false;
162
195
  if (!purpose.criteria?.length && !purpose.research?.domains?.length) return false;
196
+ // Guard: skip if already running (prevents duplicate triggers while agent is executing)
197
+ if (runningPurposes.has(purpose.id)) return false;
163
198
 
164
199
  const lastRun = purpose.research?.lastRun ?? 0;
165
200
  const frequency = calculateFrequency(purpose);
@@ -214,33 +249,43 @@ PURPOSE RESEARCH: ${purpose.name}
214
249
  You are researching for the following purpose:
215
250
  ${purpose.description || purpose.name}
216
251
 
217
- Search domains: ${domains}
252
+ 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.
253
+
254
+ Working directory: ${WORKSPACE_PATH}
218
255
 
219
- Success criteria to inform research:
256
+ Criteria (follow these if they say "run:", actually run the command):
220
257
  ${criteria}
221
258
 
222
259
  Tasks:
223
- 1. Search for recent developments relevant to this purpose
224
- 2. Assess impact on purpose progress or timeline
225
- 3. Flag anything that challenges or validates current assumptions
226
- 4. Note actionable insights
260
+ 1. EXECUTE any commands specified in criteria above
261
+ 2. Use web_search for at least 2 queries on recent developments
262
+ 3. Assess impact on purpose progress or timeline
263
+ 4. Surface actionable insights with measurable specifics (metrics, thresholds, dates)
227
264
 
228
265
  Alert threshold: ${alertThreshold}
229
266
  ${alertGuidance[alertThreshold]}
230
267
 
231
268
  Output format:
232
- - FINDINGS: Key discoveries (bullet points)
269
+ - EXECUTED: Commands run and their results (summarized)
270
+ - FINDINGS: Key discoveries (bullet points with numbers)
233
271
  - IMPACT: How this affects the purpose (progress/timeline/risk)
234
272
  - ALERTS: Anything requiring immediate attention (or "none")
235
273
  - NEXT: What to research next time
236
274
 
237
- Your output will be saved automatically. Focus on the research content.
275
+ Your output will be saved automatically. Focus on actionable content, not analysis of what you could do.
238
276
 
239
- CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
277
+ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no markdown).
240
278
  `.trim();
241
279
  }
242
280
 
243
- async function runResearch(purpose: Purpose): Promise<void> {
281
+ async function runResearch(purpose: Purpose): Promise<boolean> {
282
+ // Guard: prevent concurrent runs of the same purpose
283
+ if (runningPurposes.has(purpose.id)) {
284
+ log.debug(`[purpose-research] ⏭️ "${purpose.name}" already running, skipping`);
285
+ return false;
286
+ }
287
+ runningPurposes.add(purpose.id);
288
+
244
289
  const startTime = Date.now();
245
290
  log.info(`[purpose-research] 🔬 Running research for "${purpose.name}"`);
246
291
 
@@ -261,12 +306,16 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
261
306
  if (typeof api.runAgentTurn === "function") {
262
307
  try {
263
308
  result = await api.runAgentTurn({
264
- sessionLabel: `chorus:purpose:${purpose.id}`,
309
+ sessionLabel: `chorus-purpose-${purpose.id}`,
265
310
  message: prompt,
266
311
  isolated: true,
267
312
  timeoutSeconds: config.researchTimeoutMs / 1000,
268
313
  });
269
- output = result?.response || "";
314
+ output = extractTextFromApiResult(result);
315
+ if (!output.trim()) {
316
+ log.debug(`[purpose-research] API returned empty output, falling back to CLI for "${purpose.name}"`);
317
+ result = null;
318
+ }
270
319
  } catch (apiErr) {
271
320
  log.debug(`[purpose-research] API runAgentTurn failed, falling back to CLI: ${apiErr}`);
272
321
  result = null;
@@ -279,7 +328,7 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
279
328
  result = await new Promise<any>((resolve) => {
280
329
  const child = spawn("openclaw", [
281
330
  "agent",
282
- "--session-id", `chorus:purpose:${purpose.id}`,
331
+ "--session-id", `chorus-purpose-${purpose.id}`,
283
332
  "--message", prompt,
284
333
  "--json",
285
334
  ], { stdio: ['pipe', 'pipe', 'pipe'] });
@@ -296,16 +345,15 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
296
345
  });
297
346
 
298
347
  if (result.status === 0 && result.stdout) {
299
- try {
300
- const json = JSON.parse(result.stdout);
301
- output = json.result?.payloads?.[0]?.text || json.response || "";
302
- } catch {
303
- output = result.stdout;
304
- }
348
+ output = extractTextFromCliStdout(result.stdout);
305
349
  } else if (result.stderr) {
306
350
  log.error(`[purpose-research] CLI error: ${result.stderr}`);
307
351
  }
308
352
  }
353
+
354
+ if (!output.trim()) {
355
+ throw new Error("No research output produced");
356
+ }
309
357
  execution.durationMs = Date.now() - startTime;
310
358
  execution.success = true;
311
359
  execution.outputLength = output.length;
@@ -343,16 +391,19 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
343
391
  runCount: (purpose.research?.runCount ?? 0) + 1,
344
392
  },
345
393
  });
394
+ dailyRuns.count++;
395
+ persistState();
396
+ return true;
346
397
  } catch (err) {
347
398
  execution.durationMs = Date.now() - startTime;
348
399
  execution.success = false;
349
400
  execution.error = String(err);
350
401
  log.error(`[purpose-research] ✗ "${purpose.name}" failed: ${err}`);
402
+ return false;
403
+ } finally {
404
+ runningPurposes.delete(purpose.id);
405
+ recordExecution(execution);
351
406
  }
352
-
353
- recordExecution(execution);
354
- dailyRuns.count++;
355
- persistState();
356
407
  }
357
408
 
358
409
  async function checkAndRun(): Promise<void> {
@@ -442,7 +493,10 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
442
493
  const purposes = await loadPurposes();
443
494
  const purpose = purposes.find((p) => p.id === purposeId);
444
495
  if (!purpose) throw new Error(`Purpose "${purposeId}" not found`);
445
- await runResearch(purpose);
496
+ const success = await runResearch(purpose);
497
+ if (!success) {
498
+ throw new Error(`Research run failed for "${purposeId}"`);
499
+ }
446
500
  },
447
501
 
448
502
  getStatus: () => {