agentlas 0.4.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.
@@ -0,0 +1,451 @@
1
+ "use strict";
2
+ /*
3
+ * agentlas-api-agent: BYOK(Anthropic/OpenAI/Google) + Ollama 위에서 도는 agentlas 자체 에이전트 루프.
4
+ * openclaw가 pi-agent-core로 하는 일을 CJS로: SSE 스트리밍 + provider별 tool-use 프로토콜 + 로컬 툴 실행.
5
+ * byok.ts(electron)의 SSE 패턴을 이식했다. 외부 SDK 의존성 없음 (Node 20 fetch/ReadableStream).
6
+ *
7
+ * anthropic / openai / ollama → 스트리밍 + 툴 루프 (read/write/full 권한 게이트)
8
+ * google → 스트리밍 채팅 (툴은 후속 — 우아하게 chat-only)
9
+ */
10
+ const tools = require("./agentlas-tools.cjs");
11
+
12
+ const MAX_ITERS = 12;
13
+ const IDLE_MS = Number(process.env.AGENTLAS_IDLE_TIMEOUT_MS) || 180000;
14
+
15
+ // 스트림이 IDLE_MS 동안 한 바이트도 안 오면 중단(provider가 SSE를 열고 멈추는 hang 방지).
16
+ // 부모 signal(사용자 Ctrl-C)과 결합한다. timer는 unref이라 프로세스 종료를 막지 않는다.
17
+ function idleAbort(parentSignal, ms) {
18
+ const ctrl = new AbortController();
19
+ let timer = null;
20
+ const onParent = () => ctrl.abort();
21
+ if (parentSignal) {
22
+ if (parentSignal.aborted) ctrl.abort();
23
+ else parentSignal.addEventListener("abort", onParent, { once: true });
24
+ }
25
+ const bump = () => {
26
+ if (timer) clearTimeout(timer);
27
+ timer = setTimeout(() => ctrl.abort(), ms);
28
+ if (timer.unref) timer.unref();
29
+ };
30
+ const clear = () => {
31
+ if (timer) clearTimeout(timer);
32
+ if (parentSignal && parentSignal.removeEventListener) parentSignal.removeEventListener("abort", onParent);
33
+ };
34
+ bump();
35
+ return { signal: ctrl.signal, bump, clear };
36
+ }
37
+
38
+ async function* iterSse(resp, idle) {
39
+ if (!resp.body) return;
40
+ const reader = resp.body.getReader();
41
+ const decoder = new TextDecoder();
42
+ let buffer = "";
43
+ while (true) {
44
+ const { value, done } = await reader.read();
45
+ if (idle) idle.bump();
46
+ if (done) break;
47
+ buffer += decoder.decode(value, { stream: true });
48
+ let nl;
49
+ while ((nl = buffer.indexOf("\n")) >= 0) {
50
+ const line = buffer.slice(0, nl).trim();
51
+ buffer = buffer.slice(nl + 1);
52
+ if (line) yield line;
53
+ }
54
+ }
55
+ if (buffer.trim()) yield buffer.trim();
56
+ }
57
+
58
+ function sseData(line) {
59
+ if (!line.startsWith("data:")) return null;
60
+ const p = line.slice(5).trim();
61
+ return p === "[DONE]" ? "[DONE]" : p;
62
+ }
63
+
64
+ // ── Anthropic ────────────────────────────────────────────
65
+ async function streamAnthropic({ apiKey, model, system, messages, permission, ui, signal }) {
66
+ const body = {
67
+ model,
68
+ max_tokens: 8192,
69
+ stream: true,
70
+ system,
71
+ messages,
72
+ };
73
+ const toolDefs = tools.anthropicTools(permission);
74
+ if (toolDefs.length) body.tools = toolDefs;
75
+
76
+ const idle = idleAbort(signal, IDLE_MS);
77
+ const resp = await fetch("https://api.anthropic.com/v1/messages", {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json", "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
80
+ signal: idle.signal,
81
+ body: JSON.stringify(body),
82
+ });
83
+ if (!resp.ok) {
84
+ idle.clear();
85
+ throw new Error(`Anthropic ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 300)}`);
86
+ }
87
+
88
+ const blocks = {}; // index → {type, text, name, id, inputJson}
89
+ let stopReason = null;
90
+ let usage = null;
91
+ for await (const line of iterSse(resp, idle)) {
92
+ const data = sseData(line);
93
+ if (!data || data === "[DONE]") continue;
94
+ let ev;
95
+ try {
96
+ ev = JSON.parse(data);
97
+ } catch {
98
+ continue;
99
+ }
100
+ if (ev.type === "content_block_start") {
101
+ const cb = ev.content_block || {};
102
+ blocks[ev.index] = { type: cb.type, text: "", name: cb.name, id: cb.id, inputJson: "" };
103
+ if (cb.type === "tool_use") ui.tool(cb.name, "");
104
+ else ui.streamStart();
105
+ } else if (ev.type === "content_block_delta") {
106
+ const b = blocks[ev.index] || (blocks[ev.index] = { type: "text", text: "", inputJson: "" });
107
+ if (ev.delta.type === "text_delta") {
108
+ b.text += ev.delta.text;
109
+ ui.streamDelta(ev.delta.text);
110
+ } else if (ev.delta.type === "input_json_delta") {
111
+ b.inputJson += ev.delta.partial_json || "";
112
+ }
113
+ } else if (ev.type === "content_block_stop") {
114
+ const b = blocks[ev.index];
115
+ if (b && b.type === "tool_use") {
116
+ const arg = safeArg(b.inputJson);
117
+ if (arg) ui.info(ui.c.dim(" " + arg));
118
+ }
119
+ // 텍스트 블록 stop에서는 streamEnd를 호출하지 않는다 — 메모리 가드 중간 flush 방지(턴 끝에서만 flush).
120
+ } else if (ev.type === "message_start") {
121
+ const u = ev.message && ev.message.usage;
122
+ if (u) usage = { input_tokens: u.input_tokens || 0, output_tokens: u.output_tokens || 0 };
123
+ } else if (ev.type === "message_delta") {
124
+ if (ev.delta && ev.delta.stop_reason) stopReason = ev.delta.stop_reason;
125
+ if (ev.usage) usage = { input_tokens: (usage && usage.input_tokens) || 0, output_tokens: ev.usage.output_tokens };
126
+ }
127
+ }
128
+ ui.streamEnd();
129
+ idle.clear();
130
+
131
+ // 어셈블
132
+ const ordered = Object.keys(blocks)
133
+ .sort((a, b) => a - b)
134
+ .map((k) => blocks[k]);
135
+ const assistantContent = [];
136
+ const toolUses = [];
137
+ let text = "";
138
+ for (const b of ordered) {
139
+ if (b.type === "text" && b.text) {
140
+ assistantContent.push({ type: "text", text: b.text });
141
+ text += b.text;
142
+ } else if (b.type === "tool_use") {
143
+ let input = {};
144
+ try {
145
+ input = JSON.parse(b.inputJson || "{}");
146
+ } catch {
147
+ input = {};
148
+ }
149
+ assistantContent.push({ type: "tool_use", id: b.id, name: b.name, input });
150
+ toolUses.push({ id: b.id, name: b.name, input });
151
+ }
152
+ }
153
+ return { text, assistantContent, toolUses, stopReason, usage };
154
+ }
155
+
156
+ async function runAnthropicLoop(req) {
157
+ const { ctx, ui } = req;
158
+ const messages = req.messages.slice();
159
+ let finalText = "";
160
+ let inTok = 0, outTok = 0;
161
+ for (let i = 0; i < MAX_ITERS; i++) {
162
+ if (req.signal && req.signal.aborted) break;
163
+ const r = await streamAnthropic({
164
+ apiKey: req.apiKey,
165
+ model: req.model,
166
+ system: req.system,
167
+ messages,
168
+ permission: ctx.permission,
169
+ ui,
170
+ signal: req.signal,
171
+ });
172
+ finalText = r.text || finalText;
173
+ if (r.usage) { inTok += r.usage.input_tokens || 0; outTok += r.usage.output_tokens || 0; }
174
+ if (!r.toolUses.length) break;
175
+ messages.push({ role: "assistant", content: r.assistantContent });
176
+ const results = [];
177
+ for (const tu of r.toolUses) {
178
+ const out = tools.runTool(tu.name, tu.input, ctx);
179
+ ui.toolResult(out.content, out.ok);
180
+ results.push({ type: "tool_result", tool_use_id: tu.id, content: out.content, is_error: !out.ok });
181
+ }
182
+ messages.push({ role: "user", content: results });
183
+ }
184
+ if (inTok || outTok) ui.cost({ input_tokens: inTok, output_tokens: outTok });
185
+ return { text: finalText };
186
+ }
187
+
188
+ // ── OpenAI ───────────────────────────────────────────────
189
+ async function streamOpenAI({ apiKey, model, messages, permission, ui, signal, baseUrl }) {
190
+ const body = { model, stream: true, stream_options: { include_usage: true }, messages };
191
+ const toolDefs = tools.openaiTools(permission);
192
+ if (toolDefs.length) body.tools = toolDefs;
193
+
194
+ const idle = idleAbort(signal, IDLE_MS);
195
+ const resp = await fetch(`${baseUrl || "https://api.openai.com/v1"}/chat/completions`, {
196
+ method: "POST",
197
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
198
+ signal: idle.signal,
199
+ body: JSON.stringify(body),
200
+ });
201
+ if (!resp.ok) {
202
+ idle.clear();
203
+ throw new Error(`OpenAI ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 300)}`);
204
+ }
205
+
206
+ let text = "";
207
+ let started = false;
208
+ const toolCalls = {}; // index → {id, name, args}
209
+ let finish = null;
210
+ let usage = null;
211
+ for await (const line of iterSse(resp, idle)) {
212
+ const data = sseData(line);
213
+ if (!data || data === "[DONE]") continue;
214
+ let ev;
215
+ try {
216
+ ev = JSON.parse(data);
217
+ } catch {
218
+ continue;
219
+ }
220
+ if (ev.usage) usage = { input_tokens: ev.usage.prompt_tokens || 0, output_tokens: ev.usage.completion_tokens || 0 };
221
+ const choice = ev.choices && ev.choices[0];
222
+ if (!choice) continue;
223
+ const delta = choice.delta || {};
224
+ if (delta.content) {
225
+ if (!started) {
226
+ ui.streamStart();
227
+ started = true;
228
+ }
229
+ text += delta.content;
230
+ ui.streamDelta(delta.content);
231
+ }
232
+ if (Array.isArray(delta.tool_calls)) {
233
+ for (const tc of delta.tool_calls) {
234
+ const idx = tc.index ?? 0;
235
+ const cur = toolCalls[idx] || (toolCalls[idx] = { id: "", name: "", args: "" });
236
+ if (tc.id) cur.id = tc.id;
237
+ if (tc.function && tc.function.name) {
238
+ if (!cur.name) ui.tool(tc.function.name, "");
239
+ cur.name = tc.function.name;
240
+ }
241
+ if (tc.function && tc.function.arguments) cur.args += tc.function.arguments;
242
+ }
243
+ }
244
+ if (choice.finish_reason) finish = choice.finish_reason;
245
+ }
246
+ if (started) ui.streamEnd();
247
+ idle.clear();
248
+
249
+ const calls = Object.keys(toolCalls)
250
+ .sort((a, b) => a - b)
251
+ .map((k) => toolCalls[k])
252
+ .filter((c) => c.name);
253
+ return { text, toolCalls: calls, finish, usage };
254
+ }
255
+
256
+ async function runOpenAILoop(req) {
257
+ const { ctx, ui } = req;
258
+ const messages = req.messages.slice();
259
+ // OpenAI는 system을 messages[0]로.
260
+ if (!messages.length || messages[0].role !== "system") messages.unshift({ role: "system", content: req.system });
261
+ let finalText = "";
262
+ let inTok = 0, outTok = 0;
263
+ for (let i = 0; i < MAX_ITERS; i++) {
264
+ if (req.signal && req.signal.aborted) break;
265
+ const r = await streamOpenAI({
266
+ apiKey: req.apiKey,
267
+ model: req.model,
268
+ messages,
269
+ permission: ctx.permission,
270
+ ui,
271
+ signal: req.signal,
272
+ baseUrl: req.baseUrl,
273
+ });
274
+ finalText = r.text || finalText;
275
+ if (r.usage) { inTok += r.usage.input_tokens || 0; outTok += r.usage.output_tokens || 0; }
276
+ if (!r.toolCalls.length) break;
277
+ messages.push({
278
+ role: "assistant",
279
+ content: r.text || null,
280
+ tool_calls: r.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })),
281
+ });
282
+ for (const c of r.toolCalls) {
283
+ let args = {};
284
+ try {
285
+ args = JSON.parse(c.args || "{}");
286
+ } catch {
287
+ args = {};
288
+ }
289
+ const arg = safeArgObj(args);
290
+ if (arg) ui.info(ui.c.dim(" " + arg));
291
+ const out = tools.runTool(c.name, args, ctx);
292
+ ui.toolResult(out.content, out.ok);
293
+ messages.push({ role: "tool", tool_call_id: c.id, content: out.content });
294
+ }
295
+ }
296
+ if (inTok || outTok) ui.cost({ input_tokens: inTok, output_tokens: outTok });
297
+ return { text: finalText };
298
+ }
299
+
300
+ // ── Ollama (openai 스타일 tools, /api/chat) ──────────────
301
+ async function runOllamaLoop(req) {
302
+ const { ctx, ui } = req;
303
+ const host = process.env.OLLAMA_HOST || "http://127.0.0.1:11434";
304
+ const messages = req.messages.slice();
305
+ if (!messages.length || messages[0].role !== "system") messages.unshift({ role: "system", content: req.system });
306
+ const toolDefs = tools.openaiTools(ctx.permission);
307
+ let finalText = "";
308
+ for (let i = 0; i < MAX_ITERS; i++) {
309
+ if (req.signal && req.signal.aborted) break;
310
+ const idle = idleAbort(req.signal, IDLE_MS);
311
+ let resp;
312
+ try {
313
+ resp = await fetch(`${host}/api/chat`, {
314
+ method: "POST",
315
+ headers: { "content-type": "application/json" },
316
+ signal: idle.signal,
317
+ body: JSON.stringify({ model: req.model, stream: true, messages, tools: toolDefs.length ? toolDefs : undefined }),
318
+ });
319
+ } catch (e) {
320
+ idle.clear();
321
+ throw new Error(`Ollama connection failed (${host}) — is 'ollama serve' running? ${e.message}`);
322
+ }
323
+ if (!resp.ok) {
324
+ idle.clear();
325
+ throw new Error(`Ollama ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
326
+ }
327
+ let text = "";
328
+ let started = false;
329
+ let toolCalls = [];
330
+ for await (const line of iterSse(resp, idle)) {
331
+ let ev;
332
+ try {
333
+ ev = JSON.parse(line);
334
+ } catch {
335
+ continue;
336
+ }
337
+ const msg = ev.message || {};
338
+ if (msg.content) {
339
+ if (!started) {
340
+ ui.streamStart();
341
+ started = true;
342
+ }
343
+ text += msg.content;
344
+ ui.streamDelta(msg.content);
345
+ }
346
+ if (Array.isArray(msg.tool_calls)) toolCalls = toolCalls.concat(msg.tool_calls);
347
+ }
348
+ if (started) ui.streamEnd();
349
+ idle.clear();
350
+ finalText = text || finalText;
351
+ if (!toolCalls.length) break;
352
+ messages.push({ role: "assistant", content: text, tool_calls: toolCalls });
353
+ for (const c of toolCalls) {
354
+ const fn = c.function || {};
355
+ const args = typeof fn.arguments === "string" ? safeParse(fn.arguments) : fn.arguments || {};
356
+ ui.tool(fn.name || "tool", safeArgObj(args));
357
+ const out = tools.runTool(fn.name, args, ctx);
358
+ ui.toolResult(out.content, out.ok);
359
+ // Ollama tool 결과는 tool_call_id가 없으므로 tool_name으로 상관관계를 보존 (병렬 호출 시 중요)
360
+ messages.push({ role: "tool", tool_name: fn.name, content: out.content });
361
+ }
362
+ }
363
+ return { text: finalText };
364
+ }
365
+
366
+ // ── Google (chat-only 스트리밍) ──────────────────────────
367
+ async function runGoogleChat(req) {
368
+ const { ui } = req;
369
+ const contents = [];
370
+ for (const m of req.messages) {
371
+ if (m.role === "user") contents.push({ role: "user", parts: [{ text: textOf(m.content) }] });
372
+ else if (m.role === "assistant") contents.push({ role: "model", parts: [{ text: textOf(m.content) }] });
373
+ }
374
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${req.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(req.apiKey)}`;
375
+ const idle = idleAbort(req.signal, IDLE_MS);
376
+ const resp = await fetch(url, {
377
+ method: "POST",
378
+ headers: { "content-type": "application/json" },
379
+ signal: idle.signal,
380
+ body: JSON.stringify({ systemInstruction: { parts: [{ text: req.system }] }, contents }),
381
+ });
382
+ if (!resp.ok) {
383
+ idle.clear();
384
+ throw new Error(`Google ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 300)}`);
385
+ }
386
+ let text = "";
387
+ let started = false;
388
+ for await (const line of iterSse(resp, idle)) {
389
+ const data = sseData(line);
390
+ if (!data || data === "[DONE]") continue;
391
+ let ev;
392
+ try {
393
+ ev = JSON.parse(data);
394
+ } catch {
395
+ continue;
396
+ }
397
+ const t = ev.candidates && ev.candidates[0] && ev.candidates[0].content && ev.candidates[0].content.parts && ev.candidates[0].content.parts[0] && ev.candidates[0].content.parts[0].text;
398
+ if (t) {
399
+ if (!started) {
400
+ ui.streamStart();
401
+ started = true;
402
+ }
403
+ text += t;
404
+ ui.streamDelta(t);
405
+ }
406
+ }
407
+ if (started) ui.streamEnd();
408
+ idle.clear();
409
+ return { text };
410
+ }
411
+
412
+ // ── 엔트리 ───────────────────────────────────────────────
413
+ // req = { backend, model, apiKey, system, messages([{role,content}]), ctx({cwd,permission}), ui, signal }
414
+ async function runApiTurn(req) {
415
+ switch (req.backend) {
416
+ case "anthropic":
417
+ return runAnthropicLoop(req);
418
+ case "openai":
419
+ return runOpenAILoop(req);
420
+ case "upstage":
421
+ return runOpenAILoop({ ...req, baseUrl: "https://api.upstage.ai/v1" });
422
+ case "ollama":
423
+ return runOllamaLoop(req);
424
+ case "google":
425
+ return runGoogleChat(req);
426
+ default:
427
+ throw new Error(`unsupported backend: ${req.backend}`);
428
+ }
429
+ }
430
+
431
+ function safeParse(s) {
432
+ try {
433
+ return JSON.parse(s);
434
+ } catch {
435
+ return {};
436
+ }
437
+ }
438
+ function safeArg(json) {
439
+ return safeArgObj(safeParse(json || "{}"));
440
+ }
441
+ function safeArgObj(o) {
442
+ if (!o || typeof o !== "object") return "";
443
+ return o.file_path || o.path || o.command || o.pattern || o.query || "";
444
+ }
445
+ function textOf(content) {
446
+ if (typeof content === "string") return content;
447
+ if (Array.isArray(content)) return content.map((b) => (b.type === "text" ? b.text : "")).join("");
448
+ return "";
449
+ }
450
+
451
+ module.exports = { runApiTurn, MAX_ITERS };
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ /*
3
+ * Agentlas terminal splash — small dinosaur mascot (Chrome-dino style) + wordmark + status.
4
+ */
5
+ const path = require("node:path");
6
+ const os = require("node:os");
7
+
8
+ // Small T-Rex (side view, facing right) — eye is ●.
9
+ const DINO_ART = [
10
+ " ▟████▙",
11
+ " █● ▜█▙",
12
+ " ▖ ████████",
13
+ " ▜█▄▄▄▄▄███",
14
+ " ▀▀▀█▌ █▌",
15
+ ];
16
+
17
+ function readVersion() {
18
+ try {
19
+ return require(path.join(__dirname, "..", "package.json")).version || "";
20
+ } catch {
21
+ return "";
22
+ }
23
+ }
24
+
25
+ function shorten(p) {
26
+ if (!p) return "";
27
+ const home = os.homedir();
28
+ return p.startsWith(home) ? "~" + p.slice(home.length) : p;
29
+ }
30
+
31
+ // Just the mascot lines (used by the onboarding wizard header).
32
+ function renderMascot(ui) {
33
+ const c = ui.c;
34
+ if (!ui.enabled) {
35
+ ui.line(" Agentlas");
36
+ return;
37
+ }
38
+ for (let i = 0; i < DINO_ART.length; i++) {
39
+ const row = DINO_ART[i];
40
+ ui.line(" " + (i === 1 ? c.text(row).split("●").join(c.emerald("●")) : c.text(row)));
41
+ }
42
+ }
43
+
44
+ function stripAnsi(s) {
45
+ return String(s || "").replace(/\x1b\[[0-9;]*m/g, "");
46
+ }
47
+
48
+ function fit(value, width) {
49
+ let s = stripAnsi(value);
50
+ if (s.length > width) {
51
+ if (width <= 1) return "…";
52
+ s = s.slice(0, Math.max(0, width - 1)) + "…";
53
+ }
54
+ return s + " ".repeat(Math.max(0, width - s.length));
55
+ }
56
+
57
+ function row(ui, width, text) {
58
+ const inner = Math.max(10, width - 4);
59
+ ui.line(ui.c.faint("│ ") + ui.c.text(fit(text, inner)) + ui.c.faint(" │"));
60
+ }
61
+
62
+ function renderStatusCard(ctx, opts = {}) {
63
+ const ui = ctx.ui;
64
+ const c = ui.c;
65
+ const cols = ui.out.columns || 80;
66
+ const width = Math.max(54, Math.min(cols - 2, 78));
67
+ const version = ctx.version || readVersion();
68
+ const subject = ctx.subjectLabel || "Pick an agent, choose a company, or type a task";
69
+ const permission = ctx.permission || "write";
70
+ const runtime = ctx.runtimeLabel || "(not configured)";
71
+ const cwd = ctx.cwd ? shorten(ctx.cwd) : process.cwd();
72
+
73
+ ui.line("");
74
+ ui.line(c.faint("╭" + "─".repeat(width - 2) + "╮"));
75
+ row(ui, width, `>_ Agentlas${version ? " (v" + version + ")" : ""}`);
76
+ row(ui, width, "");
77
+ row(ui, width, `model: ${runtime}`);
78
+ row(ui, width, `agent: ${subject}`);
79
+ row(ui, width, `directory: ${cwd}`);
80
+ row(ui, width, `permissions: ${permission}`);
81
+ ui.line(c.faint("╰" + "─".repeat(width - 2) + "╯"));
82
+ if (!opts.noTip) {
83
+ ui.line(
84
+ " " +
85
+ c.bold(c.text("Tip:")) +
86
+ c.dim(" Type ") +
87
+ c.faint("/help") +
88
+ c.dim(" for commands, ") +
89
+ c.faint("/status") +
90
+ c.dim(" for session state, ") +
91
+ c.faint("/exit") +
92
+ c.dim(" to quit."),
93
+ );
94
+ }
95
+ }
96
+
97
+ // Main splash. ctx = { ui, version, runtimeLabel, subjectLabel, permission, cwd }
98
+ function renderBanner(ctx) {
99
+ renderStatusCard(ctx);
100
+ ctx.ui.line("");
101
+ }
102
+
103
+ // runtime · subject · permission · working folder
104
+ function renderStatus(ctx) {
105
+ renderStatusCard(ctx, { noTip: true });
106
+ }
107
+
108
+ module.exports = { renderBanner, renderStatus, renderMascot, readVersion, shorten, DINO_ART, fit };
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /*
3
+ * Runtime capability registry (core) + capability-aware auto-routing.
4
+ *
5
+ * User-verified (2026-05): images are produced by the native CLIs themselves —
6
+ * codex → Imagen, gemini → nano-banana (Gemini 2.5 Flash Image), claude → NO image gen.
7
+ * BYOK: openai (gpt-image) and google (imagen) can do images; anthropic/ollama cannot.
8
+ *
9
+ * So a multi-LLM team = each agent runs on a runtime whose capabilities match its job:
10
+ * an image/design agent auto-routes to gemini/codex, a coding agent to claude/codex.
11
+ */
12
+
13
+ // keyed by runtime "spec": cli kind (claude-code|codex|gemini) or api backend (anthropic|openai|google|ollama)
14
+ const RUNTIME_CAPS = {
15
+ "claude-code": { code: true, image: false, label: "claude" },
16
+ codex: { code: true, image: true, label: "codex" }, // Imagen
17
+ gemini: { code: true, image: true, label: "gemini" }, // nano-banana
18
+ anthropic: { code: true, image: false, label: "anthropic" },
19
+ openai: { code: true, image: true, label: "openai" }, // gpt-image
20
+ google: { code: true, image: true, label: "google" }, // imagen
21
+ ollama: { code: true, image: false, label: "ollama" },
22
+ upstage: { code: true, image: false, label: "solar" }, // Upstage Solar — Korean sovereign LLM (OpenAI-compatible)
23
+ };
24
+
25
+ const CLI_KINDS = ["claude-code", "codex", "gemini"];
26
+
27
+ function capsFor(spec) {
28
+ return RUNTIME_CAPS[spec] || { code: true, image: false, label: spec || "?" };
29
+ }
30
+
31
+ // runtime object ⇄ spec string
32
+ function specOf(rt) {
33
+ if (!rt) return "";
34
+ return rt.mode === "cli" ? rt.kind : rt.backend;
35
+ }
36
+ function runtimeFromSpec(spec) {
37
+ return CLI_KINDS.includes(spec) ? { mode: "cli", kind: spec } : { mode: "api", backend: spec, model: null };
38
+ }
39
+
40
+ // Does this agent's job involve generating/handling images?
41
+ const IMAGE_HINTS = [
42
+ /image/i, /이미지/, /그림/, /\bdesign\b/i, /디자인/, /쇼핑몰/, /상품\s*(사진|이미지|상세)/, /상세\s*페이지/,
43
+ /thumbnail/i, /썸네일/, /banner/i, /배너/, /poster/i, /포스터/, /visual/i, /비주얼/, /illustrat/i, /일러스트/,
44
+ /로고/, /\blogo\b/i, /사진/, /photo/i, /nano-?banana/i, /imagen/i, /이미지\s*생성/, /그래픽/, /graphic/i,
45
+ ];
46
+ // 빌더/메타/조율/거버넌스 역할은 (이미지 에이전트를 *만들* 수는 있어도) 스스로 이미지를 생산하지 않는다.
47
+ // 이런 역할이 system_prompt에 "이미지/디자인"을 언급한다는 이유로 gemini로 끌려가면 코드/빌드 품질이 떨어진다.
48
+ const NON_IMAGE_ROLES = new Set(["meta", "builder", "orchestrator", "pm", "curator", "governance"]);
49
+ function needsImage(agent) {
50
+ if (!agent) return false;
51
+ if (NON_IMAGE_ROLES.has(String(agent.role || "").toLowerCase())) return false;
52
+ const hay = `${agent.name || ""} ${agent.name_en || ""} ${agent.tagline || ""} ${agent.tagline_en || ""} ${agent.system_prompt || ""}`;
53
+ return IMAGE_HINTS.some((re) => re.test(hay));
54
+ }
55
+
56
+ // Auto-pick a runtime spec for an agent given installed CLI kinds and the session default spec.
57
+ // Image agents route to an installed image-capable runtime; otherwise keep the session default.
58
+ function autoRuntimeFor(agent, { installedKinds, activeSpec }) {
59
+ if (needsImage(agent)) {
60
+ if (capsFor(activeSpec).image) return activeSpec;
61
+ for (const k of ["gemini", "codex"]) if ((installedKinds || []).includes(k)) return k;
62
+ }
63
+ return activeSpec;
64
+ }
65
+
66
+ // short capability badge for display
67
+ function badge(spec) {
68
+ const c = capsFor(spec);
69
+ return c.image ? "🖼" : "";
70
+ }
71
+
72
+ module.exports = { RUNTIME_CAPS, CLI_KINDS, capsFor, specOf, runtimeFromSpec, needsImage, autoRuntimeFor, badge };