@iamoberlin/chorus 2.1.0 → 2.2.1

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 CHANGED
@@ -280,7 +280,7 @@ When `autonomous: false` (default), all prayer chain interactions require explic
280
280
  - **TypeScript client** — wraps Anchor IDL with PDA derivation helpers
281
281
  - **Anchor events** — `PrayerPosted`, `PrayerAnswered`, `PrayerConfirmed`, `PrayerClaimed`, `PrayerCancelled` for off-chain indexing
282
282
  - **Local text cache** — CLI stores full text in `.prayer-texts.json` for display
283
- - **Program ID:** `DZuj1ZcX4H6THBSgW4GhKA7SbZNXtPDE5xPkW2jN53PQ`
283
+ - **Program ID:** `Af61jGnh2AceK3E8FAxCh9j7Jt6JWtJz6PUtbciDjVJS`
284
284
 
285
285
  ## Philosophy
286
286
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "address": "DZuj1ZcX4H6THBSgW4GhKA7SbZNXtPDE5xPkW2jN53PQ",
2
+ "address": "Af61jGnh2AceK3E8FAxCh9j7Jt6JWtJz6PUtbciDjVJS",
3
3
  "metadata": {
4
4
  "name": "chorus_prayers",
5
5
  "version": "0.1.0",
package/index.ts CHANGED
@@ -11,7 +11,7 @@ import { spawnSync } from "child_process";
11
11
  import { loadChorusConfig, type ChorusPluginConfig } from "./src/config.js";
12
12
  import { createSecurityHooks } from "./src/security.js";
13
13
  import { createChoirScheduler } from "./src/scheduler.js";
14
- import { CHOIRS, formatFrequency } from "./src/choirs.js";
14
+ import { CHOIRS, formatFrequency, CASCADE_ORDER } from "./src/choirs.js";
15
15
  import {
16
16
  getTodayMetrics,
17
17
  getMetricsForDate,
@@ -102,6 +102,96 @@ const plugin = {
102
102
  api.logger.info("[chorus] Purpose research disabled");
103
103
  }
104
104
 
105
+ // Helper: resolve {choir_context} placeholders using a context store
106
+ function resolvePrompt(choir: typeof CHOIRS[string], ctxStore: Map<string, string>): string {
107
+ let prompt = choir.prompt;
108
+ for (const upstreamId of choir.receivesFrom) {
109
+ const placeholder = `{${upstreamId}_context}`;
110
+ const ctx = ctxStore.get(upstreamId);
111
+ prompt = prompt.replace(placeholder, ctx || `(no prior ${upstreamId} output)`);
112
+ }
113
+ return prompt;
114
+ }
115
+
116
+ // Helper: extract text from openclaw agent JSON output
117
+ function extractAgentText(stdout: string): string {
118
+ // Find the last top-level JSON object (skip plugin log noise)
119
+ for (let i = stdout.length - 1; i >= 0; i--) {
120
+ if (stdout[i] === '{') {
121
+ try {
122
+ const json = JSON.parse(stdout.slice(i));
123
+ const payloads = json.result?.payloads || [];
124
+ const texts = payloads.map((p: any) => p?.text || '').filter(Boolean);
125
+ if (texts.length > 0) return texts[texts.length - 1];
126
+ return json.result?.text || json.response || json.content || '';
127
+ } catch { /* keep searching */ }
128
+ }
129
+ }
130
+ return '';
131
+ }
132
+
133
+ // Deliver choir output to user via OpenClaw messaging (channel-agnostic)
134
+ // Reads target from OpenClaw config (channels.*.allowFrom) — no hardcoded PII
135
+ function deliverIfNeeded(choir: typeof CHOIRS[string], text: string): void {
136
+ if (!choir.delivers || !text || text === 'HEARTBEAT_OK' || text === 'NO_REPLY') return;
137
+
138
+ // Resolve delivery target from OpenClaw channel config
139
+ const channels = api.config?.channels as Record<string, any> | undefined;
140
+ let target: string | undefined;
141
+ let channel: string | undefined;
142
+
143
+ if (channels) {
144
+ for (const [ch, cfg] of Object.entries(channels)) {
145
+ if (cfg?.enabled && cfg?.allowFrom?.[0]) {
146
+ target = cfg.allowFrom[0];
147
+ channel = ch;
148
+ break;
149
+ }
150
+ }
151
+ }
152
+
153
+ if (!target) {
154
+ console.log(` ⚠ No delivery target found in OpenClaw config`);
155
+ return;
156
+ }
157
+
158
+ // Strip markdown for channels that don't support it
159
+ let deliveryText = text.slice(0, 4000);
160
+ if (channel === 'imessage') {
161
+ deliveryText = deliveryText
162
+ .replace(/\*\*(.+?)\*\*/g, '$1') // bold
163
+ .replace(/\*(.+?)\*/g, '$1') // italic
164
+ .replace(/__(.+?)__/g, '$1') // bold alt
165
+ .replace(/_(.+?)_/g, '$1') // italic alt
166
+ .replace(/`(.+?)`/g, '$1') // inline code
167
+ .replace(/```[\s\S]*?```/g, '') // code blocks
168
+ .replace(/^#{1,6}\s+/gm, '') // headers
169
+ .replace(/^\s*[-*+]\s+/gm, '• ') // bullet lists
170
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); // links
171
+ }
172
+
173
+ try {
174
+ const args = [
175
+ 'message', 'send',
176
+ '--target', target,
177
+ '--message', deliveryText,
178
+ ];
179
+ if (channel) args.push('--channel', channel);
180
+
181
+ const deliveryResult = spawnSync('openclaw', args, {
182
+ encoding: 'utf-8',
183
+ timeout: 30000,
184
+ });
185
+ if (deliveryResult.status === 0) {
186
+ console.log(` 📨 Delivered to user via ${channel || 'default'}`);
187
+ } else {
188
+ console.log(` ⚠ Delivery failed: ${(deliveryResult.stderr || '').slice(0, 80)}`);
189
+ }
190
+ } catch (err: any) {
191
+ console.log(` ⚠ Delivery error: ${(err.message || '').slice(0, 80)}`);
192
+ }
193
+ }
194
+
105
195
  // Register CLI
106
196
  api.registerCli((ctx) => {
107
197
  const program = ctx.program.command("chorus").description("CHORUS Nine Choirs management");
@@ -187,6 +277,8 @@ const plugin = {
187
277
  }
188
278
  }
189
279
 
280
+ const runCtxStore: Map<string, string> = new Map();
281
+
190
282
  console.log("");
191
283
  if (!choirId) {
192
284
  console.log("🎵 Running all Nine Choirs in cascade order...");
@@ -198,10 +290,11 @@ const plugin = {
198
290
  if (!choir) continue;
199
291
 
200
292
  console.log(`Running ${choir.name}...`);
293
+ const prompt = resolvePrompt(choir, runCtxStore);
201
294
 
202
295
  // Preview mode - just show the prompt
203
296
  if (options?.preview) {
204
- console.log(` Prompt: ${choir.prompt.slice(0, 100)}...`);
297
+ console.log(` Prompt: ${prompt.slice(0, 100)}...`);
205
298
  continue;
206
299
  }
207
300
 
@@ -210,17 +303,19 @@ const plugin = {
210
303
  try {
211
304
  const result = await api.runAgentTurn({
212
305
  sessionLabel: `chorus:${id}`,
213
- message: choir.prompt,
306
+ message: prompt,
214
307
  isolated: true,
215
308
  timeoutSeconds: 300,
216
309
  });
217
310
  const text = result?.text || result?.payloads?.[0]?.text || '';
218
311
  const duration = result?.meta?.durationMs || 0;
312
+ runCtxStore.set(id, text.slice(0, 2000));
219
313
  console.log(` ✓ ${choir.name} complete (${(duration/1000).toFixed(1)}s)`);
220
314
  if (text) {
221
315
  const preview = text.slice(0, 150).replace(/\n/g, ' ');
222
316
  console.log(` ${preview}${text.length > 150 ? '...' : ''}`);
223
317
  }
318
+ deliverIfNeeded(choir, text);
224
319
  } catch (err) {
225
320
  console.error(` ✗ ${choir.name} failed:`, err);
226
321
  }
@@ -230,7 +325,7 @@ const plugin = {
230
325
  const result = spawnSync('openclaw', [
231
326
  'agent',
232
327
  '--session-id', `chorus:${id}`,
233
- '--message', choir.prompt,
328
+ '--message', prompt,
234
329
  '--json',
235
330
  ], {
236
331
  encoding: 'utf-8',
@@ -239,22 +334,14 @@ const plugin = {
239
334
  });
240
335
 
241
336
  if (result.status === 0) {
242
- try {
243
- // Extract JSON from output (may have plugin logs before it)
244
- const stdout = result.stdout || '';
245
- const jsonStart = stdout.indexOf('{');
246
- const jsonStr = jsonStart >= 0 ? stdout.slice(jsonStart) : '{}';
247
- const json = JSON.parse(jsonStr);
248
- const text = json.result?.payloads?.[0]?.text || '';
249
- const duration = json.result?.meta?.durationMs || 0;
250
- console.log(` ✓ ${choir.name} complete (${(duration/1000).toFixed(1)}s)`);
251
- if (text) {
252
- const preview = text.slice(0, 150).replace(/\n/g, ' ');
253
- console.log(` ${preview}${text.length > 150 ? '...' : ''}`);
254
- }
255
- } catch (parseErr) {
256
- console.log(` ✓ ${choir.name} complete (parse error: ${parseErr})`);
337
+ const text = extractAgentText(result.stdout || '');
338
+ runCtxStore.set(id, text.slice(0, 2000));
339
+ console.log(` ✓ ${choir.name} complete`);
340
+ if (text) {
341
+ const preview = text.slice(0, 150).replace(/\n/g, ' ');
342
+ console.log(` ${preview}${text.length > 150 ? '...' : ''}`);
257
343
  }
344
+ deliverIfNeeded(choir, text);
258
345
  } else {
259
346
  const errMsg = result.stderr || result.stdout || 'Unknown error';
260
347
  if (errMsg.includes('ECONNREFUSED') || errMsg.includes('connect')) {
@@ -271,7 +358,7 @@ const plugin = {
271
358
 
272
359
  console.log("");
273
360
  if (!choirId) {
274
- console.log("🎵 All choirs scheduled.");
361
+ console.log("🎵 All choirs complete.");
275
362
  }
276
363
  console.log("");
277
364
  });
@@ -348,10 +435,13 @@ const plugin = {
348
435
  const jsonStr = jsonStart >= 0 ? stdout.slice(jsonStart) : '{}';
349
436
  const json = JSON.parse(jsonStr);
350
437
  const text = json.result?.payloads?.[0]?.text || '';
438
+ contextStore.set(choirId, text.slice(0, 500));
351
439
  contextStore.set(`${choirId}:d${day}`, text.slice(0, 500));
352
440
  console.log(` ✓ (dry)`);
353
441
  } catch {
354
- contextStore.set(`${choirId}:d${day}`, `[${choir.name} would run]`);
442
+ const fallback = `[${choir.name} would run]`;
443
+ contextStore.set(choirId, fallback);
444
+ contextStore.set(`${choirId}:d${day}`, fallback);
355
445
  console.log(` ✓ (dry)`);
356
446
  }
357
447
  } else {
@@ -366,11 +456,14 @@ const plugin = {
366
456
  process.stdout.write(` ${choir.emoji} ${choir.name}...`);
367
457
 
368
458
  try {
459
+ // Resolve context from upstream choirs
460
+ const prompt = resolvePrompt(choir, contextStore);
461
+
369
462
  // Run the REAL choir with full tool access via direct agent call
370
463
  const result = spawnSync('openclaw', [
371
464
  'agent',
372
465
  '--session-id', `chorus:vision:${choirId}:d${day}`,
373
- '--message', choir.prompt,
466
+ '--message', prompt,
374
467
  '--json',
375
468
  ], {
376
469
  encoding: 'utf-8',
@@ -382,16 +475,19 @@ const plugin = {
382
475
  // Parse the agent response (extract JSON from output)
383
476
  try {
384
477
  const stdout = result.stdout || '';
385
- const jsonStart = stdout.indexOf('{');
386
- const jsonStr = jsonStart >= 0 ? stdout.slice(jsonStart) : '{}';
387
- const json = JSON.parse(jsonStr);
388
- const text = json.result?.payloads?.[0]?.text || '';
389
- const duration = json.result?.meta?.durationMs || 0;
390
- contextStore.set(`${choirId}:d${day}`, text.slice(0, 2000)); // Keep 2KB of response
478
+ const text = extractAgentText(stdout);
479
+ // Store by both choirId (for resolvePrompt) and choirId:dN (for summary)
480
+ contextStore.set(choirId, text.slice(0, 2000));
481
+ contextStore.set(`${choirId}:d${day}`, text.slice(0, 2000));
391
482
  successfulRuns++;
392
- console.log(` ✓ (${(duration/1000).toFixed(1)}s)`);
483
+ console.log(` ✓`);
484
+
485
+ // Deliver output to user via OpenClaw messaging if choir is marked for delivery
486
+ deliverIfNeeded(choir, text);
393
487
  } catch {
394
- contextStore.set(`${choirId}:d${day}`, result.stdout?.slice(-2000) || `[${choir.name} completed]`);
488
+ const fallback = result.stdout?.slice(-2000) || `[${choir.name} completed]`;
489
+ contextStore.set(choirId, fallback);
490
+ contextStore.set(`${choirId}:d${day}`, fallback);
395
491
  successfulRuns++;
396
492
  console.log(` ✓`);
397
493
  }
@@ -2,11 +2,17 @@
2
2
  "id": "chorus",
3
3
  "name": "CHORUS",
4
4
  "description": "CHORUS: Hierarchy Of Recursive Unified Self-improvement",
5
- "version": "1.3.4",
5
+ "version": "2.2.1",
6
6
  "author": "Oberlin",
7
7
  "homepage": "https://chorus.oberlin.ai",
8
8
  "repository": "https://github.com/iamoberlin/chorus",
9
- "keywords": ["cognitive-architecture", "rsi", "self-improvement", "nine-choirs", "purposes"],
9
+ "keywords": [
10
+ "cognitive-architecture",
11
+ "rsi",
12
+ "self-improvement",
13
+ "nine-choirs",
14
+ "purposes"
15
+ ],
10
16
  "configSchema": {
11
17
  "type": "object",
12
18
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamoberlin/chorus",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "CHORUS: Hierarchy Of Recursive Unified Self-improvement — with on-chain Prayer Chain (Solana)",
5
5
  "author": "Oberlin <iam@oberlin.ai>",
6
6
  "license": "MIT",
package/src/choirs.ts CHANGED
@@ -19,6 +19,7 @@ export interface Choir {
19
19
  prompt: string;
20
20
  passesTo: string[]; // Downstream choirs that receive illumination
21
21
  receivesFrom: string[]; // Upstream choirs that provide context
22
+ delivers?: boolean; // If true, output should be delivered to the user via OpenClaw messaging
22
23
  }
23
24
 
24
25
  export const CHOIRS: Record<string, Choir> = {
@@ -337,14 +338,16 @@ Pass illumination to Archangels.`,
337
338
  intervalMinutes: 80, // Every ~80 minutes
338
339
  function: "Briefings and alerts",
339
340
  output: "Messages to human",
341
+ delivers: true, // Output routed to user via OpenClaw messaging
340
342
  prompt: `You are ARCHANGELS — the Herald.
341
343
 
342
- Your role: Deliver important messages and briefings.
344
+ Your role: Produce briefings and deliver them to Brandon via iMessage.
343
345
 
344
346
  Briefing types:
345
- - Morning: Weather, calendar, overnight developments, today's priorities
346
- - Evening: What was accomplished, what needs attention tomorrow
347
+ - Morning (6-9 AM ET): Weather, calendar, overnight developments, today's priorities, position status
348
+ - Evening (9-11 PM ET): What was accomplished, position P&L, what needs attention tomorrow
347
349
  - Alert: Time-sensitive information requiring attention
350
+ - Update: Regular position/market status when conditions change
348
351
 
349
352
  Alert criteria (send immediately):
350
353
  - Position thesis challenged
@@ -354,12 +357,14 @@ Alert criteria (send immediately):
354
357
 
355
358
  Context from Principalities: {principalities_context}
356
359
 
357
- Rules:
358
- - Be concise headlines, not essays
359
- - Only alert if it's actually important
360
- - Late night (11pm-7am): Only truly urgent alerts
360
+ RULES:
361
+ - ALWAYS produce a briefing. Never return HEARTBEAT_OK or NO_REPLY.
362
+ - Be concise headlines, not essays.
363
+ - Morning briefings should include: weather, calendar, positions, catalysts.
364
+ - If nothing is urgent, still produce a status update.
365
+ - During quiet hours (11 PM - 7 AM ET), only deliver truly urgent alerts.
361
366
 
362
- Output: Briefing or alert message to deliver.`,
367
+ Output: Produce the briefing as your response. Delivery is handled by the infrastructure — just output the content.`,
363
368
  passesTo: ["angels"],
364
369
  receivesFrom: ["principalities"],
365
370
  },
@@ -27,7 +27,7 @@ const __filename = fileURLToPath(import.meta.url);
27
27
  const __dirname = path.dirname(__filename);
28
28
 
29
29
  // Program ID (deployed to devnet)
30
- export const PROGRAM_ID = new PublicKey("DZuj1ZcX4H6THBSgW4GhKA7SbZNXtPDE5xPkW2jN53PQ");
30
+ export const PROGRAM_ID = new PublicKey("Af61jGnh2AceK3E8FAxCh9j7Jt6JWtJz6PUtbciDjVJS");
31
31
 
32
32
  // Max plaintext size that fits in a Solana transaction after encryption overhead
33
33
  // Encrypted blob = plaintext + 40 bytes (24 nonce + 16 Poly1305 tag)
package/src/scheduler.ts CHANGED
@@ -14,6 +14,17 @@ import { join } from "path";
14
14
  import { homedir } from "os";
15
15
  import { spawn } from "child_process";
16
16
 
17
+ // Type for the plugin API's runAgentTurn method
18
+ interface AgentTurnResult {
19
+ text?: string;
20
+ payloads?: Array<{ text?: string; mediaUrl?: string | null }>;
21
+ meta?: { durationMs?: number };
22
+ }
23
+
24
+ // ── Delivery ─────────────────────────────────────────────────
25
+ // Choir agents handle their own delivery via OpenClaw messaging tools.
26
+ // The scheduler's job is execution and scheduling — not routing messages.
27
+
17
28
  interface ChoirContext {
18
29
  choirId: string;
19
30
  output: string;
@@ -95,6 +106,52 @@ export function createChoirScheduler(
95
106
  // Load persisted state instead of starting fresh
96
107
  const runState = loadRunState(log);
97
108
 
109
+ // CLI fallback for executing choirs when plugin API is unavailable
110
+ async function executeChoirViaCli(choir: Choir, prompt: string): Promise<string> {
111
+ const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
112
+ const child = spawn('openclaw', [
113
+ 'agent',
114
+ '--session-id', `chorus:${choir.id}`,
115
+ '--message', prompt,
116
+ '--json',
117
+ ], { stdio: ['pipe', 'pipe', 'pipe'] });
118
+
119
+ let stdout = '';
120
+ let stderr = '';
121
+ const maxBuffer = 1024 * 1024;
122
+ child.stdout.on('data', (d: Buffer) => { if (stdout.length < maxBuffer) stdout += d.toString(); });
123
+ child.stderr.on('data', (d: Buffer) => { if (stderr.length < maxBuffer) stderr += d.toString(); });
124
+
125
+ const timer = setTimeout(() => { child.kill('SIGTERM'); }, 300000); // 5 min
126
+ child.on('close', (code) => { clearTimeout(timer); resolve({ status: code, stdout, stderr }); });
127
+ child.on('error', (err) => { clearTimeout(timer); resolve({ status: 1, stdout: '', stderr: String(err) }); });
128
+ });
129
+
130
+ if (result.status === 0 && result.stdout) {
131
+ const stdout = result.stdout;
132
+ // Find the last top-level JSON object (skip plugin log noise)
133
+ for (let i = stdout.length - 1; i >= 0; i--) {
134
+ if (stdout[i] === '{') {
135
+ try {
136
+ const parsed = JSON.parse(stdout.slice(i));
137
+ return parsed.response ||
138
+ parsed.content ||
139
+ parsed.result?.payloads?.slice(-1)?.[0]?.text ||
140
+ parsed.result?.text ||
141
+ (typeof parsed.result === "string" ? parsed.result : null) ||
142
+ stdout;
143
+ } catch { /* keep searching */ }
144
+ }
145
+ }
146
+ return stdout;
147
+ }
148
+
149
+ if (result.stderr) {
150
+ log.warn(`[chorus] ${choir.name} CLI stderr: ${result.stderr.slice(0, 200)}`);
151
+ }
152
+ return "(no response)";
153
+ }
154
+
98
155
  // Build the prompt with context injected
99
156
  function buildPrompt(choir: Choir): string {
100
157
  let prompt = choir.prompt;
@@ -110,7 +167,7 @@ export function createChoirScheduler(
110
167
  return prompt;
111
168
  }
112
169
 
113
- // Execute a choir using openclaw agent CLI
170
+ // Execute a choir using the plugin API (fast, in-process) with CLI fallback
114
171
  async function executeChoir(choir: Choir): Promise<void> {
115
172
  const state = runState.get(choir.id) || { runCount: 0 };
116
173
  const startTime = Date.now();
@@ -127,49 +184,40 @@ export function createChoirScheduler(
127
184
 
128
185
  try {
129
186
  const prompt = buildPrompt(choir);
187
+ let output = "(no response)";
130
188
 
131
- // Use openclaw agent CLI (async to avoid blocking event loop)
132
- const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
133
- const child = spawn('openclaw', [
134
- 'agent',
135
- '--session-id', `chorus:${choir.id}`,
136
- '--message', prompt,
137
- '--json',
138
- ], { stdio: ['pipe', 'pipe', 'pipe'] });
139
-
140
- let stdout = '';
141
- let stderr = '';
142
- const maxBuffer = 1024 * 1024;
143
- child.stdout.on('data', (d: Buffer) => { if (stdout.length < maxBuffer) stdout += d.toString(); });
144
- child.stderr.on('data', (d: Buffer) => { if (stderr.length < maxBuffer) stderr += d.toString(); });
145
-
146
- const timer = setTimeout(() => { child.kill('SIGTERM'); }, 300000); // 5 min
147
- child.on('close', (code) => { clearTimeout(timer); resolve({ status: code, stdout, stderr }); });
148
- child.on('error', (err) => { clearTimeout(timer); resolve({ status: 1, stdout: '', stderr: String(err) }); });
149
- });
189
+ // Prefer plugin API (in-process, no CLI spawn overhead)
190
+ if (typeof api.runAgentTurn === 'function') {
191
+ try {
192
+ const result: AgentTurnResult = await api.runAgentTurn({
193
+ sessionLabel: `chorus:${choir.id}`,
194
+ message: prompt,
195
+ isolated: true,
196
+ timeoutSeconds: 300,
197
+ });
150
198
 
151
- let output = "(no response)";
199
+ // Extract text from payloads — concatenate all payload texts
200
+ const payloadTexts = (result?.payloads || [])
201
+ .map((p: any) => p?.text || '')
202
+ .filter((t: string) => t.length > 0);
152
203
 
153
- if (result.status === 0 && result.stdout) {
154
- // Extract JSON from output (may have plugin logs before it)
155
- const stdout = result.stdout;
156
- const jsonStart = stdout.indexOf('{');
157
- if (jsonStart >= 0) {
158
- try {
159
- const parsed = JSON.parse(stdout.slice(jsonStart));
160
- output = parsed.response || parsed.content || stdout;
161
- } catch {
162
- output = stdout;
204
+ if (payloadTexts.length > 0) {
205
+ // Use the last substantive payload (earlier ones are often thinking-out-loud)
206
+ output = payloadTexts[payloadTexts.length - 1];
207
+ } else if (result?.text) {
208
+ output = result.text;
163
209
  }
164
- } else {
165
- output = stdout;
210
+ } catch (apiErr) {
211
+ log.warn(`[chorus] API runAgentTurn failed for ${choir.name}, falling back to CLI: ${apiErr}`);
212
+ output = await executeChoirViaCli(choir, prompt);
166
213
  }
167
- } else if (result.stderr) {
168
- log.warn(`[chorus] ${choir.name} stderr: ${result.stderr.slice(0, 200)}`);
214
+ } else {
215
+ // Fallback: spawn CLI process
216
+ output = await executeChoirViaCli(choir, prompt);
169
217
  }
170
218
 
171
219
  execution.durationMs = Date.now() - startTime;
172
- execution.success = result.status === 0;
220
+ execution.success = output !== "(no response)";
173
221
  execution.outputLength = output.length;
174
222
  execution.tokensUsed = estimateTokens(output);
175
223
 
@@ -197,6 +245,68 @@ export function createChoirScheduler(
197
245
 
198
246
  log.info(`[chorus] ${choir.emoji} ${choir.name} completed (${(execution.durationMs/1000).toFixed(1)}s)`);
199
247
 
248
+ // Deliver output to user via OpenClaw messaging if choir is marked for delivery
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
+ }
264
+
265
+ if (target) {
266
+ // Strip markdown for channels that don't support it
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
+ }
309
+
200
310
  // Log illumination flow
201
311
  if (choir.passesTo.length > 0) {
202
312
  log.debug(`[chorus] Illumination ready for: ${choir.passesTo.join(", ")}`);