@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 +1 -1
- package/idl/chorus_prayers.json +1 -1
- package/index.ts +126 -30
- package/openclaw.plugin.json +8 -2
- package/package.json +1 -1
- package/src/choirs.ts +13 -8
- package/src/prayers/solana.ts +1 -1
- package/src/scheduler.ts +146 -36
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:** `
|
|
283
|
+
- **Program ID:** `Af61jGnh2AceK3E8FAxCh9j7Jt6JWtJz6PUtbciDjVJS`
|
|
284
284
|
|
|
285
285
|
## Philosophy
|
|
286
286
|
|
package/idl/chorus_prayers.json
CHANGED
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: ${
|
|
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:
|
|
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',
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
"id": "chorus",
|
|
3
3
|
"name": "CHORUS",
|
|
4
4
|
"description": "CHORUS: Hierarchy Of Recursive Unified Self-improvement",
|
|
5
|
-
"version": "
|
|
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": [
|
|
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
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:
|
|
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
|
-
|
|
358
|
-
-
|
|
359
|
-
-
|
|
360
|
-
-
|
|
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:
|
|
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
|
},
|
package/src/prayers/solana.ts
CHANGED
|
@@ -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("
|
|
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
|
|
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
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
}
|
|
165
|
-
|
|
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
|
|
168
|
-
|
|
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 =
|
|
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(", ")}`);
|