lazyclaw 3.99.20 → 3.99.21

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
@@ -81,6 +81,36 @@ lazyclaw onboard --non-interactive --provider nim \
81
81
 
82
82
  Need a vendor that's **not** built-in? `+ Add a custom OpenAI-compatible endpoint…` inside the setup picker (or `lazyclaw providers add <name> --base-url <url>`) still works for vLLM / LM Studio / private gateways / anything else that speaks the OpenAI v1 wire format.
83
83
 
84
+ ### `orchestrator` — multi-agent dispatch as a provider
85
+
86
+ `orchestrator` is a synthetic provider that composes the others. A chat message hitting `PROVIDERS.orchestrator` triggers a three-phase pipeline instead of a single 1:1 call:
87
+
88
+ 1. **PLAN** — the *planner* provider decomposes the request into 2–5 parallel subtasks (JSON-only system prompt; fences / prose tolerated).
89
+ 2. **EXECUTE** — each subtask is dispatched round-robin across the *workers*. Replies stream inline so you watch progress in real time.
90
+ 3. **SYNTHESIS** — the planner re-enters with every worker's output and writes the final user-facing answer.
91
+
92
+ Configure in `~/.lazyclaw/config.json`:
93
+
94
+ ```json
95
+ {
96
+ "provider": "orchestrator",
97
+ "orchestrator": {
98
+ "planner": "claude-cli:claude-opus-4-7",
99
+ "workers": [
100
+ "claude-cli:claude-sonnet-4-6",
101
+ "openai:gpt-4o",
102
+ "gemini:gemini-2.5-pro",
103
+ "nim:meta/llama-3.1-405b-instruct"
104
+ ],
105
+ "maxSubtasks": 5
106
+ }
107
+ }
108
+ ```
109
+
110
+ Then `lazyclaw chat` (or any other entry point that ends up calling a provider — `lazyclaw agent`, the daemon's `POST /agent` / `POST /chat`, the dashboard chat tab) routes through the orchestrator. Each worker's api-key is resolved through the same chain a direct chat would use (`authProfiles` → `customProviders` → built-in env var → legacy `cfg['api-key']`).
111
+
112
+ Defaults fall back gracefully: `planner` defaults to `cfg.provider`/`cfg.model`, `workers` defaults to `[planner]` (single-agent chain, still benefits from plan + synthesis structure). Self-recursion (`planner: "orchestrator"`) is rejected up front.
113
+
84
114
  ## Launcher (no-arg `lazyclaw`)
85
115
 
86
116
  Running `lazyclaw` with no subcommand drops into an arrow-key launcher with every subcommand laid out as a menu. Navigation:
package/cli.mjs CHANGED
@@ -707,6 +707,18 @@ async function ensureRegistry() {
707
707
  _registryMod.registerCustomProviders(readConfig());
708
708
  }
709
709
  } catch { /* never let a malformed cfg.customProviders block startup */ }
710
+ // Wire the orchestrator's live cfg + auth-key resolver. We do this on
711
+ // every ensureRegistry() call (cheap — just replaces the closure) so a
712
+ // mid-session config edit (custom provider added, env var exported)
713
+ // takes effect on the next orchestrator turn without a restart.
714
+ try {
715
+ if (typeof _registryMod.registerOrchestrator === 'function') {
716
+ _registryMod.registerOrchestrator({
717
+ cfgGetter: readConfig,
718
+ keyResolver: _resolveAuthKey,
719
+ });
720
+ }
721
+ } catch { /* defensive */ }
710
722
  return _registryMod;
711
723
  }
712
724
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.20",
3
+ "version": "3.99.21",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,273 @@
1
+ // Orchestrator provider — "openclaw-style" multi-agent dispatch.
2
+ //
3
+ // A user message arriving at PROVIDERS.orchestrator is NOT forwarded
4
+ // 1:1 to a single backend. Instead the provider performs three phases:
5
+ //
6
+ // 1. PLAN — the configured planner provider decomposes the task
7
+ // into 2–5 self-contained subtasks (JSON shape).
8
+ // 2. EXECUTE — each subtask is dispatched to a worker provider
9
+ // (round-robin over cfg.orchestrator.workers). Workers
10
+ // stream their replies; the orchestrator surfaces them
11
+ // inline so the user can watch progress.
12
+ // 3. SYNTHESIS — the planner re-enters with all subtask outputs and
13
+ // produces the final answer.
14
+ //
15
+ // Provider/model spec is "<provider>:<model>" (same shape as the chat
16
+ // REPL's `/model anthropic/claude-opus-4-7` after normalisation). When
17
+ // the model part is omitted, the worker's defaultModel from
18
+ // PROVIDER_INFO is used.
19
+ //
20
+ // Config (~/.lazyclaw/config.json):
21
+ // {
22
+ // "orchestrator": {
23
+ // "planner": "claude-cli:claude-opus-4-7",
24
+ // "workers": [
25
+ // "claude-cli:claude-sonnet-4-6",
26
+ // "openai:gpt-4o",
27
+ // "gemini:gemini-2.5-pro"
28
+ // ],
29
+ // "maxSubtasks": 5, // optional, default 5
30
+ // "concurrency": 0 // optional, 0 = sequential (visible streaming)
31
+ // }
32
+ // }
33
+ //
34
+ // Defaults: planner = the user's currently configured `cfg.provider`
35
+ // (so `lazyclaw onboard --provider claude-cli` works without any extra
36
+ // step), workers = [planner] (degenerates to a single-agent chain that
37
+ // still benefits from plan + synthesis structure).
38
+
39
+ import { PROVIDERS, PROVIDER_INFO } from './registry.mjs';
40
+
41
+ function _parseSpec(spec) {
42
+ if (!spec || typeof spec !== 'string') return { provider: '', model: '' };
43
+ const colon = spec.indexOf(':');
44
+ if (colon < 0) return { provider: spec.trim(), model: '' };
45
+ return { provider: spec.slice(0, colon).trim(), model: spec.slice(colon + 1).trim() };
46
+ }
47
+
48
+ function _lookupProvider(spec) {
49
+ const { provider, model } = _parseSpec(spec);
50
+ const prov = PROVIDERS[provider];
51
+ if (!prov) return null;
52
+ const info = PROVIDER_INFO[provider] || {};
53
+ return {
54
+ name: provider,
55
+ model: model || info.defaultModel || '',
56
+ prov,
57
+ info,
58
+ };
59
+ }
60
+
61
+ function _bestPlanArray(text) {
62
+ // Planners sometimes wrap the JSON in prose / code fences. Try the
63
+ // raw response first, then the largest [...] / [...]-shaped span.
64
+ const tryParse = (s) => {
65
+ try { return JSON.parse(s); } catch { return null; }
66
+ };
67
+ let arr = tryParse(text);
68
+ if (Array.isArray(arr)) return arr;
69
+ // Strip ```json fences
70
+ const fence = text.match(/```(?:json)?\s*([\s\S]+?)```/);
71
+ if (fence) {
72
+ arr = tryParse(fence[1].trim());
73
+ if (Array.isArray(arr)) return arr;
74
+ }
75
+ // Largest [...] substring
76
+ const start = text.indexOf('[');
77
+ const end = text.lastIndexOf(']');
78
+ if (start >= 0 && end > start) {
79
+ arr = tryParse(text.slice(start, end + 1));
80
+ if (Array.isArray(arr)) return arr;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ const PLANNER_SYSTEM = `You are an orchestrator that decomposes a user request into independent subtasks for parallel worker agents.
86
+
87
+ Rules:
88
+ - Output ONLY a JSON array. No prose, no markdown, no code fences.
89
+ - Each entry has shape { "id": <int>, "task": "<one-sentence imperative>", "rationale": "<why this is a useful slice>" }.
90
+ - 2 to 5 subtasks. Each must be doable WITHOUT seeing the others' outputs (parallel-safe).
91
+ - If the request is genuinely atomic (e.g. "say hi"), return a single-element array.
92
+ - Do not add a synthesis / merge step — that runs separately after workers complete.
93
+ - Subtasks must be self-contained: include any context a worker needs to act on the task alone.`;
94
+
95
+ const SYNTHESIS_SYSTEM = `You are an orchestrator producing the final answer for the user.
96
+
97
+ You receive: (1) the user's original request, (2) the subtask plan you produced, (3) each worker's response.
98
+
99
+ Rules:
100
+ - Synthesize a single coherent answer. Distill — do not echo each worker verbatim.
101
+ - Cite worker findings briefly when they meaningfully diverge ("Worker A found …, Worker B confirmed").
102
+ - If a worker failed, acknowledge it but do not let it block the rest of the answer.
103
+ - Match the tone and length the user implied (one-line question → one-line answer; deep dive → deep dive).
104
+ - No JSON; this is the human-facing reply.`;
105
+
106
+ /**
107
+ * Build an orchestrator provider. The chat REPL / agent / daemon path
108
+ * treats it like any other provider — the `sendMessage` async iterable
109
+ * yields markdown chunks describing plan + subtasks + synthesis.
110
+ *
111
+ * @param {Object} [opts]
112
+ * @param {() => Record<string, unknown>} [opts.cfgGetter] reads ~/.lazyclaw/config.json
113
+ * @param {(cfg, provider) => string} [opts.keyResolver] returns api-key for a worker provider (mirrors cli.mjs::_resolveAuthKey)
114
+ */
115
+ export function makeOrchestratorProvider(opts = {}) {
116
+ const cfgGetter = typeof opts.cfgGetter === 'function' ? opts.cfgGetter : () => ({});
117
+ const keyResolver = typeof opts.keyResolver === 'function' ? opts.keyResolver : () => '';
118
+
119
+ return {
120
+ name: 'orchestrator',
121
+ async *sendMessage(messages, callerOpts = {}) {
122
+ const cfg = cfgGetter() || {};
123
+ const o = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
124
+ const fallbackSpec = cfg.provider && cfg.provider !== 'orchestrator'
125
+ ? `${cfg.provider}${cfg.model ? ':' + cfg.model : ''}`
126
+ : 'claude-cli';
127
+ const plannerSpec = String(o.planner || fallbackSpec);
128
+ const workerSpecs = Array.isArray(o.workers) && o.workers.length
129
+ ? o.workers.map(String)
130
+ : [plannerSpec];
131
+ const maxSubtasks = Number.isFinite(o.maxSubtasks) && o.maxSubtasks > 0 ? Math.min(10, o.maxSubtasks) : 5;
132
+
133
+ const planner = _lookupProvider(plannerSpec);
134
+ if (!planner) {
135
+ yield `⚠ orchestrator: planner provider "${plannerSpec}" is not registered. ` +
136
+ `Set cfg.orchestrator.planner to a valid "provider:model" (e.g. "claude-cli:claude-opus-4-7").\n`;
137
+ return;
138
+ }
139
+ // Self-recursion guard: a misconfigured cfg.orchestrator.planner =
140
+ // "orchestrator" would otherwise spin forever, with each call
141
+ // dispatching back to itself.
142
+ if (planner.name === 'orchestrator') {
143
+ yield `⚠ orchestrator: planner cannot be "orchestrator" — set cfg.orchestrator.planner to a real provider (e.g. "claude-cli:claude-opus-4-7").\n`;
144
+ return;
145
+ }
146
+ const workers = workerSpecs.map(_lookupProvider).filter(Boolean).filter(w => w.name !== 'orchestrator');
147
+ if (workers.length === 0) {
148
+ yield `⚠ orchestrator: no usable workers (cfg.orchestrator.workers is empty, all unknown, or only references "orchestrator" itself).\n`;
149
+ return;
150
+ }
151
+
152
+ const userText = (() => {
153
+ // Most recent user message becomes the orchestration target. We
154
+ // pass earlier turns as context to the planner only — workers
155
+ // see a self-contained subtask string, not chat history.
156
+ for (let i = messages.length - 1; i >= 0; i--) {
157
+ if (messages[i].role === 'user') return String(messages[i].content || '');
158
+ }
159
+ return '';
160
+ })();
161
+
162
+ // ── Phase 1: PLAN ───────────────────────────────────────────────
163
+ yield `## 🦞 Orchestrator\n\n`;
164
+ yield `Planner: \`${planner.name}${planner.model ? ':' + planner.model : ''}\` · Workers: ${workers.map(w => `\`${w.name}${w.model ? ':' + w.model : ''}\``).join(', ')}\n\n`;
165
+ yield `### 1. Planning\n\n`;
166
+
167
+ const plannerMessages = [
168
+ { role: 'system', content: PLANNER_SYSTEM },
169
+ ...messages.filter(m => m.role === 'user' || m.role === 'assistant'),
170
+ ];
171
+ let planRaw = '';
172
+ try {
173
+ for await (const chunk of planner.prov.sendMessage(plannerMessages, {
174
+ apiKey: keyResolver(cfg, planner.name),
175
+ model: planner.model || undefined,
176
+ signal: callerOpts.signal,
177
+ maxTokens: 1024,
178
+ })) {
179
+ planRaw += String(chunk);
180
+ }
181
+ } catch (e) {
182
+ yield `⚠ planner error: ${e?.message || String(e)}\n\n`;
183
+ // Fallback: hand the user message to the first worker directly.
184
+ const w = workers[0];
185
+ yield `Falling back to direct call on \`${w.name}${w.model ? ':' + w.model : ''}\`:\n\n`;
186
+ for await (const chunk of w.prov.sendMessage(messages, {
187
+ apiKey: keyResolver(cfg, w.name),
188
+ model: w.model || undefined,
189
+ signal: callerOpts.signal,
190
+ })) yield String(chunk);
191
+ return;
192
+ }
193
+
194
+ const plan = _bestPlanArray(planRaw);
195
+ if (!plan || plan.length === 0) {
196
+ yield `⚠ planner returned no parseable JSON plan. Raw output:\n\n\`\`\`\n${planRaw.trim().slice(0, 800)}\n\`\`\`\n\nFalling back to single-shot on \`${planner.name}${planner.model ? ':' + planner.model : ''}\`:\n\n`;
197
+ for await (const chunk of planner.prov.sendMessage(messages, {
198
+ apiKey: keyResolver(cfg, planner.name),
199
+ model: planner.model || undefined,
200
+ signal: callerOpts.signal,
201
+ })) yield String(chunk);
202
+ return;
203
+ }
204
+ const trimmed = plan.slice(0, maxSubtasks).map((p, i) => ({
205
+ id: Number.isFinite(p?.id) ? p.id : i + 1,
206
+ task: String(p?.task || '').trim(),
207
+ rationale: String(p?.rationale || '').trim(),
208
+ })).filter(p => p.task);
209
+ if (trimmed.length === 0) {
210
+ yield `⚠ plan parsed but contained no usable subtasks. Falling back.\n\n`;
211
+ for await (const chunk of planner.prov.sendMessage(messages, {
212
+ apiKey: keyResolver(cfg, planner.name),
213
+ model: planner.model || undefined,
214
+ signal: callerOpts.signal,
215
+ })) yield String(chunk);
216
+ return;
217
+ }
218
+
219
+ for (const p of trimmed) {
220
+ yield `${p.id}. **${p.task}**${p.rationale ? ` _— ${p.rationale}_` : ''}\n`;
221
+ }
222
+ yield `\n`;
223
+
224
+ // ── Phase 2: EXECUTE ────────────────────────────────────────────
225
+ yield `### 2. Executing ${trimmed.length} subtask${trimmed.length === 1 ? '' : 's'}\n\n`;
226
+ const results = [];
227
+ for (let i = 0; i < trimmed.length; i++) {
228
+ const sub = trimmed[i];
229
+ const worker = workers[i % workers.length];
230
+ yield `**Subtask ${sub.id}** \`${worker.name}${worker.model ? ':' + worker.model : ''}\` — ${sub.task}\n\n`;
231
+ let res = '';
232
+ try {
233
+ for await (const chunk of worker.prov.sendMessage([{ role: 'user', content: sub.task }], {
234
+ apiKey: keyResolver(cfg, worker.name),
235
+ model: worker.model || undefined,
236
+ signal: callerOpts.signal,
237
+ })) {
238
+ const s = String(chunk);
239
+ res += s;
240
+ yield s;
241
+ }
242
+ results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: res, error: null });
243
+ } catch (e) {
244
+ const msg = e?.message || String(e);
245
+ yield `\n⚠ worker error: ${msg}\n`;
246
+ results.push({ ...sub, worker: `${worker.name}${worker.model ? ':' + worker.model : ''}`, result: '', error: msg });
247
+ }
248
+ yield `\n\n---\n\n`;
249
+ }
250
+
251
+ // ── Phase 3: SYNTHESIS ──────────────────────────────────────────
252
+ yield `### 3. Synthesis\n\n`;
253
+ const synthUser = [
254
+ `Original request:\n${userText}`,
255
+ `\nSubtask plan and worker outputs:`,
256
+ ...results.map(r => `\n#### Subtask ${r.id} — ${r.task}\nWorker: ${r.worker}\n${r.error ? `Error: ${r.error}` : r.result.trim()}`),
257
+ `\nNow write the final answer for the user.`,
258
+ ].join('\n');
259
+ try {
260
+ for await (const chunk of planner.prov.sendMessage([
261
+ { role: 'system', content: SYNTHESIS_SYSTEM },
262
+ { role: 'user', content: synthUser },
263
+ ], {
264
+ apiKey: keyResolver(cfg, planner.name),
265
+ model: planner.model || undefined,
266
+ signal: callerOpts.signal,
267
+ })) yield String(chunk);
268
+ } catch (e) {
269
+ yield `⚠ synthesis error: ${e?.message || String(e)}. Worker outputs above are the final material — please review them directly.\n`;
270
+ }
271
+ },
272
+ };
273
+ }
@@ -13,6 +13,7 @@ import { ollamaProvider } from './ollama.mjs';
13
13
  import { geminiProvider } from './gemini.mjs';
14
14
  import { claudeCliProvider } from './claude_cli.mjs';
15
15
  import { makeOpenAICompatProvider, fetchOpenAICompatModels } from './openai_compat.mjs';
16
+ import { makeOrchestratorProvider } from './orchestrator.mjs';
16
17
 
17
18
  /**
18
19
  * @typedef {{ role: 'user'|'assistant'|'system', content: string }} ChatMessage
@@ -51,6 +52,7 @@ export const mockProvider = {
51
52
 
52
53
  export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
53
54
  export { makeOpenAICompatProvider, fetchOpenAICompatModels };
55
+ export { makeOrchestratorProvider };
54
56
 
55
57
  // Built-in OpenAI-compatible vendors. Same wire format → one factory call
56
58
  // each. The picker treats these like first-class providers so users don't
@@ -229,6 +231,13 @@ export const PROVIDERS = {
229
231
  mock: mockProvider,
230
232
  };
231
233
 
234
+ // Orchestrator — multi-agent dispatcher that composes other providers.
235
+ // Registered upfront with no cfg/keyResolver so a bare process can list
236
+ // it via `lazyclaw providers list`; `registerOrchestrator(...)` from
237
+ // cli.mjs::ensureRegistry wires in the live cfg + auth-key resolver so
238
+ // sendMessage can reach env vars / authProfiles / customProviders.
239
+ PROVIDERS.orchestrator = makeOrchestratorProvider();
240
+
232
241
  // Wire each OpenAI-compat builtin into PROVIDERS as a callable provider.
233
242
  // Insertion is between Tier 2 (anthropic) and Tier 4 (ollama) by reordering
234
243
  // the keys after the loop runs — JS objects honour insertion order and
@@ -348,6 +357,21 @@ export const PROVIDER_INFO = {
348
357
  },
349
358
  };
350
359
 
360
+ // Orchestrator metadata. Composes other providers; the planner/workers
361
+ // each carry their own keys (or none for claude-cli / ollama / mock),
362
+ // so the orchestrator itself reports requiresApiKey: false. The setup
363
+ // picker treats it as a CLI/Local-family entry — no api-key prompt.
364
+ PROVIDER_INFO.orchestrator = {
365
+ name: 'orchestrator',
366
+ label: 'Orchestrator (multi-agent)',
367
+ requiresApiKey: false,
368
+ docs: 'Orchestrator — decomposes the user message into 2-5 parallel subtasks, dispatches each to a worker provider, then synthesizes the answers. Configure cfg.orchestrator = { planner: "provider:model", workers: ["provider:model", ...], maxSubtasks?: 5 }. Composes any registered provider — Claude / OpenAI / Gemini / NIM / Groq / local Ollama / custom OpenAI-compat endpoints.',
369
+ endpoint: '(composes other providers)',
370
+ defaultModel: 'orchestrator',
371
+ suggestedModels: ['orchestrator'],
372
+ composite: true,
373
+ };
374
+
351
375
  // Mirror the OpenAI-compat builtins into PROVIDER_INFO so picker / docs /
352
376
  // `lazyclaw providers info` see them with the same shape as the hand-written
353
377
  // entries above.
@@ -370,6 +394,16 @@ for (const [name, def] of Object.entries(OPENAI_COMPAT_BUILTINS)) {
370
394
  };
371
395
  }
372
396
 
397
+ /**
398
+ * Re-register PROVIDERS.orchestrator with a live config getter + auth-key
399
+ * resolver, so each phase's worker call can pick up env vars / authProfiles
400
+ * / customProviders. Called from cli.mjs::ensureRegistry on every entry
401
+ * — idempotent (overwrites the previous registration in place).
402
+ */
403
+ export function registerOrchestrator({ cfgGetter, keyResolver } = {}) {
404
+ PROVIDERS.orchestrator = makeOrchestratorProvider({ cfgGetter, keyResolver });
405
+ }
406
+
373
407
  /**
374
408
  * Resolve an api-key for a built-in OpenAI-compatible provider from the
375
409
  * environment, scanning {envKey} then any {altEnvKeys}. Returns '' when
@@ -422,6 +456,7 @@ export function parseProviderModel(s) {
422
456
  // `makeOpenAICompatProvider` — overriding is well-defined.
423
457
  const RESERVED_PROVIDER_NAMES = new Set([
424
458
  'mock', 'claude-cli', 'anthropic', 'openai', 'gemini', 'ollama',
459
+ 'orchestrator',
425
460
  '__add_custom__', '__custom_model__', '__fetch_models__',
426
461
  ]);
427
462