@iamoberlin/chorus 2.0.0 → 2.2.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/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