s-app-scoder-agent 0.5.0 → 0.7.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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +599 -435
  4. package/dist/agent.js +829 -168
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +409 -0
  11. package/dist/index.js +216 -45
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +148 -11
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +97 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +28 -1
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +537 -24
  30. package/dist/tui/app.js +615 -0
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +145 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +222 -0
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +101 -0
  44. package/dist/tui/state.js +342 -0
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/ui.js +43 -3
  48. package/dist/watch.js +52 -0
  49. package/package.json +18 -5
package/dist/index.js CHANGED
@@ -8,53 +8,65 @@ import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
8
8
  import { JsonlLogger } from "./logger.js";
9
9
  import { collectUsage, formatCostReport } from "./cost.js";
10
10
  import { getVersion as getPackageVersion } from "./version.js";
11
+ import { dispatchCommand } from "./tui/command-registry.js";
11
12
  import * as ui from "./ui.js";
12
13
  // Single source of truth: read version from package.json (see src/version.ts).
13
14
  const VERSION = getPackageVersion();
14
- const HELP = `scoder — autonomous coding agent CLI
15
-
16
- USAGE
17
- scoder [options] "<task>" Run a task autonomously until the build passes
18
- scoder Start interactive session (chat + tools)
19
- scoder list List saved sessions for this directory
20
- scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
21
- scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
22
- scoder cost [--session <id>] [--last <n>]
23
- Token usage & estimated cost from JSONL logs
24
- scoder serve [--port <n>] [--host <addr>]
25
- Run as an HTTP server: POST tasks, stream
26
- progress via Server-Sent Events (see below)
27
- scoder --version | --help
28
-
29
- OPTIONS
30
- -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
31
- --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
32
- -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
33
- --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
34
- -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
35
- -d, --dir <path> Working directory (default: current directory)
36
- -s, --max-steps <n> Max autonomous loop iterations (default 40)
37
- -y, --yes Non-interactive mode (auto-approves commands)
38
- --permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
39
- --no-save Disable session auto-save to .scoder/sessions/
40
- --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
41
- (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
42
- --session <id> (cost) Aggregate usage of one specific session
43
- --last <n> (cost) Aggregate usage of the n most recent sessions
44
- -h, --help Show this help
45
- -v, --version Show version
46
-
47
- CONFIG FILE
48
- .scoderrc.json in the working directory (see .scoderrc.example.json):
49
- { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
50
- "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
51
-
52
- EXAMPLES
53
- scoder "add a divide function to src/math.ts with tests"
54
- scoder --build "make test" "fix the failing test in utils"
55
- scoder --permission ask "npm install express and wire up a route"
56
- scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
57
- scoder resume last "now add unit tests"
15
+ // Safety net: a stray promise rejection (e.g. a provider socket aborting after
16
+ // its consumer already moved on) must log — never hard-crash the agent.
17
+ process.on("unhandledRejection", (reason) => {
18
+ const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
19
+ ui.warn(`background error (ignored): ${msg.slice(0, 200)}`);
20
+ });
21
+ const HELP = `scoder — autonomous coding agent CLI
22
+
23
+ USAGE
24
+ scoder [options] "<task>" Run a task autonomously until the build passes
25
+ scoder Start interactive session (chat + tools)
26
+ scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
27
+ scoder init First-run wizard: writes .scoderrc.json + tests the connection
28
+ scoder list List saved sessions for this directory
29
+ scoder models [provider] [--refresh]
30
+ Model catalog (context window, pricing) —
31
+ synced from models.dev, cached 24h
32
+ scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
33
+ scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
34
+ scoder cost [--session <id>] [--last <n>]
35
+ Token usage & estimated cost from JSONL logs
36
+ scoder serve [--port <n>] [--host <addr>]
37
+ Run as an HTTP server: POST tasks, stream
38
+ progress via Server-Sent Events (see below)
39
+ scoder --version | --help
40
+
41
+ OPTIONS
42
+ -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
43
+ --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
44
+ -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
45
+ --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
46
+ -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
47
+ -d, --dir <path> Working directory (default: current directory)
48
+ -s, --max-steps <n> Max autonomous loop iterations (default 40)
49
+ -y, --yes Non-interactive mode (auto-approves commands)
50
+ --permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
51
+ --no-save Disable session auto-save to .scoder/sessions/
52
+ --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
53
+ (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
54
+ --session <id> (cost) Aggregate usage of one specific session
55
+ --last <n> (cost) Aggregate usage of the n most recent sessions
56
+ -h, --help Show this help
57
+ -v, --version Show version
58
+
59
+ CONFIG FILE
60
+ .scoderrc.json in the working directory (see .scoderrc.example.json):
61
+ { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
62
+ "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
63
+
64
+ EXAMPLES
65
+ scoder "add a divide function to src/math.ts with tests"
66
+ scoder --build "make test" "fix the failing test in utils"
67
+ scoder --permission ask "npm install express and wire up a route"
68
+ scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
69
+ scoder resume last "now add unit tests"
58
70
  `;
59
71
  function parseArgs(argv) {
60
72
  const args = { task: null, yes: false, help: false, version: false };
@@ -173,6 +185,22 @@ function parseArgs(argv) {
173
185
  args.subcommand = "serve";
174
186
  args.task = null;
175
187
  }
188
+ else if (rest[0] === "tui") {
189
+ // scoder tui [initial-task] — full-screen Ink interface
190
+ args.subcommand = "tui";
191
+ args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
192
+ }
193
+ else if (rest[0] === "models") {
194
+ // scoder models [provider] [--refresh] — models.dev catalog (I-02)
195
+ args.subcommand = "models";
196
+ args.modelsProvider = rest[1] && !rest[1].startsWith("--") ? rest[1] : undefined;
197
+ args.modelsRefresh = rest.includes("--refresh");
198
+ args.task = null;
199
+ }
200
+ else if (rest[0] === "init") {
201
+ args.subcommand = "init";
202
+ args.task = null;
203
+ }
176
204
  return args;
177
205
  }
178
206
  async function runOnce(cfg, task) {
@@ -193,13 +221,89 @@ async function runOnce(cfg, task) {
193
221
  return 2;
194
222
  }
195
223
  }
224
+ /**
225
+ * `scoder init` — first-run wizard: asks for the essentials, writes
226
+ * .scoderrc.json, then offers a live connection test against the provider.
227
+ */
228
+ export async function runInit() {
229
+ const { existsSync, writeFileSync } = await import("node:fs");
230
+ const rcPath = resolve(process.cwd(), ".scoderrc.json");
231
+ ui.banner("scoder init — configure this project", [
232
+ ["file", rcPath],
233
+ ["note", "answers are saved to .scoderrc.json (env vars still override)"],
234
+ ]);
235
+ if (existsSync(rcPath)) {
236
+ const rl0 = createInterface({ input: process.stdin, output: process.stdout });
237
+ const overwrite = await new Promise((res) => rl0.question(`.scoderrc.json already exists — overwrite? [y/N] `, res));
238
+ rl0.close();
239
+ if (!/^y(es)?$/i.test(overwrite.trim())) {
240
+ ui.info("kept the existing .scoderrc.json");
241
+ return;
242
+ }
243
+ }
244
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
245
+ const ask = (q, def) => new Promise((res) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, (a) => res(a.trim() || def)));
246
+ // I-03: probe local engines first — suggest when found (no auto-switch when
247
+ // a cloud key exists; user always decides).
248
+ ui.info("probing local engines (ollama :11434, lmstudio :1234, vllm :8000)…");
249
+ const { discoverLocalEngines } = await import("./provider-registry.js");
250
+ const locals = await discoverLocalEngines();
251
+ for (const e of locals) {
252
+ ui.info(` found ${e.provider.id} at ${e.provider.baseUrl} — ${e.models.length} model(s), e.g. ${e.models[0]}`);
253
+ }
254
+ const suggestBase = locals[0]?.provider.baseUrl ?? "https://api.openai.com/v1";
255
+ const suggestModel = locals[0]?.models[0] ?? "gpt-4o-mini";
256
+ const baseUrl = await ask("API base URL (OpenAI-compatible)", suggestBase);
257
+ const apiKey = await ask("API key (SCODER_API_KEY/OPENAI_API_KEY also work at runtime)", "");
258
+ const model = await ask("Model", suggestModel);
259
+ const buildCmd = await ask("Build command (empty = auto-detect)", "");
260
+ const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
261
+ rl.close();
262
+ const provider = /api\.anthropic\.com/.test(baseUrl) || /^claude-/i.test(model) ? "anthropic" : "openai";
263
+ const rc = {
264
+ model,
265
+ baseUrl,
266
+ provider,
267
+ ...(buildCmd ? { buildCmd } : {}),
268
+ permissions: { mode: ["auto", "ask", "readonly"].includes(permission) ? permission : "auto" },
269
+ };
270
+ writeFileSync(rcPath, JSON.stringify(rc, null, 2) + "\n");
271
+ ui.success(`wrote ${rcPath} (provider: ${provider})`);
272
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
273
+ const doTest = await new Promise((res) => rl2.question("Test the connection now? [Y/n] ", res));
274
+ rl2.close();
275
+ if (!/^y(es)?$/i.test(doTest.trim()) && doTest.trim() !== "") {
276
+ ui.info("skipped connection test");
277
+ return;
278
+ }
279
+ if (!apiKey) {
280
+ ui.warn("no API key provided — set SCODER_API_KEY/OPENAI_API_KEY before running tasks");
281
+ return;
282
+ }
283
+ ui.info(`connecting to ${baseUrl} (${provider}) …`);
284
+ try {
285
+ const { LlmClient } = await import("./llm.js");
286
+ const { AnthropicClient } = await import("./provider-anthropic.js");
287
+ const client = provider === "anthropic"
288
+ ? new AnthropicClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 })
289
+ : new LlmClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 });
290
+ const res = await client.chat([{ role: "user", content: "Reply with exactly: OK" }], []);
291
+ const reply = (res.message.content ?? "").trim();
292
+ ui.success(`connection OK — model replied: ${JSON.stringify(reply)}`);
293
+ }
294
+ catch (err) {
295
+ ui.error(`connection test failed: ${err.message}`);
296
+ process.exitCode = 2;
297
+ }
298
+ }
196
299
  async function repl(cfg) {
197
300
  ui.header(`scoder interactive — model: ${cfg.model}`);
198
301
  ui.info('Type a task and press Enter. "exit" or Ctrl+D to quit.');
199
302
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
200
303
  // One shared session for the whole REPL conversation (plan P0.5 §4):
201
304
  // every input is a follow-up turn, context carries over, saved continuously.
202
- const agent = new Agent(cfg);
305
+ // /clear swaps in a fresh Agent (the /clear branch reassigns this).
306
+ let agent = new Agent(cfg);
203
307
  process.on("SIGINT", () => {
204
308
  ui.warn("\nSIGINT — aborting current task...");
205
309
  agent.abort();
@@ -212,6 +316,37 @@ async function repl(cfg) {
212
316
  continue;
213
317
  if (input === "exit" || input === "quit")
214
318
  break;
319
+ // Slash commands — registry dùng chung với TUI (T1). Command riêng của
320
+ // TUI (/review /undo /theme /diff turn-scope) do TUI tự xử lý trước.
321
+ const host = {
322
+ agent: () => agent,
323
+ config: cfg,
324
+ replaceAgent: (next) => {
325
+ agent.abort();
326
+ agent = next ?? new Agent(cfg);
327
+ first = true;
328
+ },
329
+ resumeAndRun: async (s) => {
330
+ agent.abort();
331
+ agent = new Agent(cfg);
332
+ first = false;
333
+ await agent.resume(s);
334
+ return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
335
+ },
336
+ };
337
+ const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
338
+ if (level === "error")
339
+ ui.error(text);
340
+ else
341
+ console.log(text);
342
+ });
343
+ if (handled) {
344
+ if (outcome?.quit)
345
+ break;
346
+ if (outcome?.notice)
347
+ ui.info(outcome.notice);
348
+ continue;
349
+ }
215
350
  try {
216
351
  const result = first ? await agent.run(input) : await agent.runTurn(input);
217
352
  first = false;
@@ -256,6 +391,10 @@ async function main() {
256
391
  logFile: args.logFile,
257
392
  });
258
393
  // Subcommands
394
+ if (args.subcommand === "init") {
395
+ await runInit();
396
+ return;
397
+ }
259
398
  if (args.subcommand === "list") {
260
399
  const sessions = listSessions(cfg.workdir);
261
400
  if (sessions.length === 0) {
@@ -320,6 +459,38 @@ async function main() {
320
459
  console.log(formatCostReport(sessionCosts));
321
460
  return;
322
461
  }
462
+ if (args.subcommand === "models") {
463
+ const { loadCatalog, cacheAgeHours } = await import("./model-catalog.js");
464
+ const filter = args.modelsProvider ?? null;
465
+ const { models, source } = await loadCatalog(args.modelsRefresh === true);
466
+ const age = cacheAgeHours();
467
+ const ageStr = source === "fallback" ? "offline fallback snapshot" : age < 0 ? "?" : `${age.toFixed(1)}h ago`;
468
+ console.log(`models catalog — source: ${source} (updated ${ageStr}), ${Object.keys(models).length} entries\n`);
469
+ const rows = Object.values(models)
470
+ .filter((m) => !filter || m.provider === filter || m.id.includes(filter))
471
+ .sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id))
472
+ .slice(0, 60);
473
+ for (const m of rows) {
474
+ const flags = [m.supportCache ? "cache" : null, m.supportReasoning ? "reasoning" : null].filter(Boolean).join(",");
475
+ console.log(` ${m.provider}/${m.id} ctx=${(m.contextWindow / 1000).toFixed(0)}k${m.pricing ? ` $${m.pricing.input}/$${m.pricing.output} per 1M` : ""}${flags ? ` [${flags}]` : ""}`);
476
+ }
477
+ if (rows.length === 0)
478
+ console.log(` (no models match "${filter}")`);
479
+ return;
480
+ }
481
+ if (args.subcommand === "tui") {
482
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
483
+ ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
484
+ await repl(cfg);
485
+ return;
486
+ }
487
+ if (!cfg.apiKey) {
488
+ ui.warn("No API key configured — tasks will fail until SCODER_API_KEY (or OPENAI_API_KEY) is set.");
489
+ }
490
+ const { runTui } = await import("./tui/app.js");
491
+ await runTui(cfg, args.task);
492
+ return;
493
+ }
323
494
  if (args.subcommand === "serve") {
324
495
  const { startServer } = await import("./server.js");
325
496
  if (!cfg.apiKey) {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * M2: JSON-Schema subset validator — không dependency. Hỗ trợ: type,
3
+ * required, properties, enum, items, additionalProperties=false khi khai báo.
4
+ * Trả về mảng lỗi (rỗng = hợp lệ). Dùng để validate MCP tool args trước
5
+ * tools/call — lỗi local, không gọi server.
6
+ */
7
+ const TYPE_CHECKS = {
8
+ string: (v) => typeof v === "string",
9
+ number: (v) => typeof v === "number" && Number.isFinite(v),
10
+ integer: (v) => typeof v === "number" && Number.isInteger(v),
11
+ boolean: (v) => typeof v === "boolean",
12
+ object: (v) => typeof v === "object" && v !== null && !Array.isArray(v),
13
+ array: (v) => Array.isArray(v),
14
+ null: (v) => v === null,
15
+ };
16
+ /** Validate value theo schema subset. path dùng cho error message. */
17
+ export function validateSchema(value, schema, path = "args") {
18
+ if (!schema || typeof schema !== "object")
19
+ return [];
20
+ const errors = [];
21
+ const type = schema.type;
22
+ if (type !== undefined) {
23
+ const types = Array.isArray(type) ? type : [type];
24
+ const ok = types.some((t) => TYPE_CHECKS[t]?.(value));
25
+ if (!ok) {
26
+ errors.push(`${path}: expected ${types.join("|")}, got ${jsonType(value)}`);
27
+ return errors; // sai type — không kiểm tra sâu hơn
28
+ }
29
+ }
30
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => e === value)) {
31
+ errors.push(`${path}: must be one of ${schema.enum.map((e) => JSON.stringify(e)).join(", ")}`);
32
+ }
33
+ if (TYPE_CHECKS.object(value)) {
34
+ const obj = value;
35
+ const props = (schema.properties ?? {});
36
+ for (const req of schema.required ?? []) {
37
+ if (!(req in obj))
38
+ errors.push(`${path}: missing required property "${req}"`);
39
+ }
40
+ for (const [k, v] of Object.entries(obj)) {
41
+ if (props[k])
42
+ errors.push(...validateSchema(v, props[k], `${path}.${k}`));
43
+ else if (schema.additionalProperties === false)
44
+ errors.push(`${path}: unexpected property "${k}"`);
45
+ }
46
+ }
47
+ if (Array.isArray(value) && schema.items) {
48
+ value.forEach((item, i) => errors.push(...validateSchema(item, schema.items, `${path}[${i}]`)));
49
+ }
50
+ return errors;
51
+ }
52
+ function jsonType(v) {
53
+ if (v === null)
54
+ return "null";
55
+ if (Array.isArray(v))
56
+ return "array";
57
+ return typeof v;
58
+ }
package/dist/llm.js CHANGED
@@ -3,6 +3,29 @@
3
3
  * and optional SSE streaming.
4
4
  */
5
5
  import { withRetry } from "./retry.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Secrets redaction (H-13) — shared by LLM error bodies, MCP tool output,
8
+ // compaction summaries and snapshot logs. Never log raw credentials.
9
+ // ---------------------------------------------------------------------------
10
+ const REDACTION_PATTERNS = [
11
+ [/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***"],
12
+ [/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***"],
13
+ [/sk-ant-[^\s,&\]]+/gi, "sk-ant-***REDACTED***"],
14
+ [/ghp_[A-Za-z0-9]{20,}/g, "ghp_***REDACTED***"],
15
+ [/github_pat_[A-Za-z0-9_]{20,}/g, "github_pat_***REDACTED***"],
16
+ [/xox[bap]-[A-Za-z0-9-]{10,}/g, "xox***REDACTED***"],
17
+ [/AKIA[0-9A-Z]{16}/g, "AKIA***REDACTED***"],
18
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, "***PRIVATE-KEY***"],
19
+ [/\b(api[_-]?key|token|secret|password)["'\s:=]+[^\s"'&]{8,}/gi, "$1=***REDACTED***"],
20
+ ];
21
+ /** Redact known secret shapes from arbitrary tool/LLM/log output. */
22
+ export function redactSecrets(text) {
23
+ let out = text;
24
+ for (const [rx, replacement] of REDACTION_PATTERNS) {
25
+ out = out.replace(rx, replacement);
26
+ }
27
+ return out;
28
+ }
6
29
  export class LlmError extends Error {
7
30
  status;
8
31
  retryAfterMs;
@@ -19,8 +42,7 @@ export class LlmError extends Error {
19
42
  async function toLlmError(res, url) {
20
43
  let body = await res.text().catch(() => "");
21
44
  // Redact Bearer token and raw key patterns to avoid leaking secrets in logs/errors
22
- body = body.replace(/Bearer\s+[^\s,&\]]+/gi, "Bearer ***REDACTED***");
23
- body = body.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***REDACTED***");
45
+ body = redactSecrets(body);
24
46
  const err = new LlmError(`LLM API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
25
47
  // Only honor a REAL Retry-After value: Number("") === 0 would zero the backoff
26
48
  const retryAfterHeader = res.headers.get("retry-after");
@@ -53,6 +75,8 @@ export function combinedSignal(external, timeoutMs) {
53
75
  }
54
76
  return {
55
77
  signal: ac.signal,
78
+ /** Clear only the request timer — keep the SIGINT listener alive. */
79
+ stopTimer: () => clearTimeout(timer),
56
80
  cleanup: () => {
57
81
  clearTimeout(timer);
58
82
  external?.removeEventListener("abort", onExternal);
@@ -76,9 +100,34 @@ function parseSSELine(line) {
76
100
  // ---------------------------------------------------------------------------
77
101
  // LlmClient
78
102
  // ---------------------------------------------------------------------------
103
+ /**
104
+ * Serialize conversation history to the OpenAI wire format. Internal ToolCall
105
+ * shape is `{id, name, arguments}`; the API expects
106
+ * `{id, type: "function", function: {name, arguments}}`. Some providers
107
+ * (Upstage/solar) reject assistant tool_calls in the internal shape with a
108
+ * misleading "Invalid function name: ''" error — always normalize here.
109
+ */
110
+ function toWireMessages(messages) {
111
+ return messages.map((m) => {
112
+ if (m.role === "assistant" && m.tool_calls) {
113
+ return {
114
+ role: m.role,
115
+ content: m.content,
116
+ tool_calls: m.tool_calls.map((tc) => ({
117
+ id: tc.id,
118
+ type: "function",
119
+ function: { name: tc.name, arguments: tc.arguments },
120
+ })),
121
+ };
122
+ }
123
+ return m;
124
+ });
125
+ }
79
126
  export class LlmClient {
80
127
  opts;
81
128
  streamEnabled;
129
+ /** Last interrupted stream's accumulated tool calls (H-09 continuation). */
130
+ lastPartialToolCalls = [];
82
131
  constructor(opts) {
83
132
  this.opts = opts;
84
133
  this.streamEnabled = opts.stream !== false; // default true
@@ -86,13 +135,20 @@ export class LlmClient {
86
135
  get model() {
87
136
  return this.opts.model;
88
137
  }
138
+ /** T-04: runtime hot-swap — next request uses the new model id. */
139
+ setModel(model) {
140
+ this.opts = { ...this.opts, model };
141
+ }
142
+ get timeoutMs() {
143
+ return this.opts.requestTimeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
144
+ }
89
145
  // ── Non-streaming ──────────────────────────────────────────────────────────
90
146
  async chat(messages, tools, opts = {}) {
91
147
  const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
92
- const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
148
+ const { signal, cleanup } = combinedSignal(opts.signal, this.timeoutMs);
93
149
  const body = JSON.stringify({
94
150
  model: this.opts.model,
95
- messages,
151
+ messages: toWireMessages(messages),
96
152
  tools: tools.length > 0 ? tools : undefined,
97
153
  temperature: this.opts.temperature,
98
154
  max_tokens: this.opts.maxTokens,
@@ -112,10 +168,21 @@ export class LlmClient {
112
168
  return r;
113
169
  }, {}, (attempt, delay, reason) => console.error(`⚠ LLM request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
114
170
  }
171
+ catch (err) {
172
+ cleanup(); // timer must not survive a failed request
173
+ throw err;
174
+ }
175
+ let data;
176
+ try {
177
+ // Keep the timeout alive through the body read — a hung body must also
178
+ // surface as a timeout, not an eternal await.
179
+ const pending = res.json();
180
+ pending.catch(() => { }); // no-op catch for the abort path; awaited below
181
+ data = (await pending);
182
+ }
115
183
  finally {
116
184
  cleanup();
117
185
  }
118
- const data = (await res.json());
119
186
  const choice = data.choices?.[0]?.message;
120
187
  if (!choice)
121
188
  throw new LlmError("LLM API returned no message in response");
@@ -134,6 +201,7 @@ export class LlmClient {
134
201
  role: "assistant",
135
202
  content: choice.content ?? null,
136
203
  stop_reason: choice.stop_reason ?? (choice.content ? "stop" : undefined),
204
+ reasoning: typeof choice.reasoning_content === "string" && choice.reasoning_content ? choice.reasoning_content : undefined,
137
205
  tool_calls: choice.tool_calls?.map((tc) => ({
138
206
  id: tc.id ?? "",
139
207
  name: tc.function?.name ?? "",
@@ -150,14 +218,17 @@ export class LlmClient {
150
218
  */
151
219
  async *streamChat(messages, tools, opts = {}) {
152
220
  const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
153
- const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
221
+ const { signal, cleanup, stopTimer } = combinedSignal(opts.signal, this.timeoutMs);
154
222
  const bodyStr = JSON.stringify({
155
223
  model: this.opts.model,
156
- messages,
224
+ messages: toWireMessages(messages),
157
225
  tools: tools.length > 0 ? tools : undefined,
158
226
  temperature: this.opts.temperature,
159
227
  max_tokens: this.opts.maxTokens,
160
228
  stream: true,
229
+ // OpenAI-compatible providers only include usage in streaming responses
230
+ // when this flag is set — without it token accounting silently zeros out.
231
+ stream_options: { include_usage: true },
161
232
  });
162
233
  let res;
163
234
  try {
@@ -180,15 +251,47 @@ export class LlmClient {
180
251
  if (!res.body) {
181
252
  throw new LlmError("Response body is not readable (not a streaming response)");
182
253
  }
254
+ // Headers received — the WHOLE-request timeout has served its purpose.
255
+ // A long stream is normal (big models can stream for minutes), so from
256
+ // here on we enforce an IDLE timeout instead: reset on every received
257
+ // chunk; only a truly silent connection for idleTimeoutMs kills the read.
258
+ // (The old whole-request timer fired mid-stream on long generations —
259
+ // "LLM stream timed out while reading the response".) SIGINT still aborts
260
+ // through the same signal — stopTimer keeps the external listener alive.
261
+ stopTimer();
262
+ const idleTimeoutMs = opts.idleTimeoutMs ?? this.timeoutMs;
263
+ const readWithIdleTimeout = async () => {
264
+ const ac = new AbortController();
265
+ const t = setTimeout(() => ac.abort(new DOMException(`stream idle for ${idleTimeoutMs / 1000}s`, "TimeoutError")), idleTimeoutMs);
266
+ try {
267
+ return await Promise.race([
268
+ reader.read(),
269
+ new Promise((_, rej) => ac.signal.addEventListener("abort", () => rej(ac.signal.reason), { once: true })),
270
+ ]);
271
+ }
272
+ finally {
273
+ clearTimeout(t);
274
+ }
275
+ };
183
276
  // Accumulators for tool_calls across chunks
184
277
  const accumulatedToolCalls = [];
185
278
  let finalResult;
279
+ let finalUsage;
186
280
  const reader = res.body.getReader();
187
281
  const decoder = new TextDecoder();
188
282
  let buffer = "";
283
+ // Reset the partial-call record for this stream (H-09 continuation).
284
+ this.lastPartialToolCalls = [];
285
+ // I-05: accumulate reasoning deltas for the final message.
286
+ let reasoningAccum = "";
189
287
  try {
190
288
  while (true) {
191
- const { done, value } = await reader.read();
289
+ // Guard the pending read against a late abort (e.g. the idle timeout
290
+ // firing while the consumer paused at a yield): attaching a no-op
291
+ // catch prevents an unhandled rejection from crashing the process.
292
+ const pending = readWithIdleTimeout();
293
+ pending.catch(() => { });
294
+ const { done, value } = await pending;
192
295
  if (done)
193
296
  break;
194
297
  buffer += decoder.decode(value, { stream: true });
@@ -206,6 +309,10 @@ export class LlmClient {
206
309
  catch {
207
310
  continue; // skip malformed JSON lines
208
311
  }
312
+ // Usage-only final chunk: many providers send usage in a chunk with
313
+ // an empty choices array — must not be skipped.
314
+ if (chunkData.usage)
315
+ finalUsage = { ...chunkData.usage };
209
316
  const choice = chunkData.choices?.[0];
210
317
  if (!choice)
211
318
  continue;
@@ -214,6 +321,11 @@ export class LlmClient {
214
321
  if (delta?.content != null && delta.content.length > 0) {
215
322
  yield { contentDelta: delta.content, done: false };
216
323
  }
324
+ // I-05: reasoning_content delta (DeepSeek/OpenRouter style)
325
+ if (delta?.reasoning_content) {
326
+ reasoningAccum += delta.reasoning_content;
327
+ yield { reasoningDelta: delta.reasoning_content, done: false };
328
+ }
217
329
  // Tool call deltas
218
330
  if (delta?.tool_calls) {
219
331
  for (const tc of delta.tool_calls) {
@@ -255,6 +367,7 @@ export class LlmClient {
255
367
  content: null,
256
368
  stop_reason: choice.finish_reason,
257
369
  tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
370
+ reasoning: reasoningAccum || undefined,
258
371
  },
259
372
  usage: chunkData.usage,
260
373
  };
@@ -262,15 +375,39 @@ export class LlmClient {
262
375
  }
263
376
  }
264
377
  }
378
+ catch (err) {
379
+ // Normalize the abort timeout into LlmError so callers handle it as a
380
+ // normal provider failure instead of a stray DOMException.
381
+ const e = err;
382
+ if (e?.name === "TimeoutError" || /TimeoutError/.test(e?.message ?? "")) {
383
+ throw new LlmError(`LLM stream timed out (${(e?.message ?? "").includes("idle") ? `no data for ${e.message.replace(/^.*idle for /, "")}` : "while reading the response"})`);
384
+ }
385
+ throw err;
386
+ }
265
387
  finally {
266
- // Cancel the body (frees the socket) and clear the request timeout
388
+ // H-09: record complete tool calls accumulated so far, for continuation.
389
+ this.lastPartialToolCalls = accumulatedToolCalls
390
+ .filter((tc) => tc.name)
391
+ .map((tc) => ({ id: tc.id ?? "", name: tc.name, arguments: tc.arguments }));
392
+ // Cancel the body (frees the socket) and clear the request timeout.
393
+ // cancel() returns a promise — a rejection after abort must be swallowed
394
+ // or it becomes an unhandled rejection that kills the process.
267
395
  try {
268
- reader.cancel();
396
+ const c = reader.cancel();
397
+ if (c && typeof c.catch === "function")
398
+ c.catch(() => { });
269
399
  }
270
400
  catch { /* already released */ }
271
- reader.releaseLock();
401
+ try {
402
+ reader.releaseLock();
403
+ }
404
+ catch { /* lock already released */ }
272
405
  cleanup();
273
406
  }
407
+ // Attach usage collected from a usage-only chunk (choices: []) if the
408
+ // finish chunk itself didn't carry it.
409
+ if (finalResult && !finalResult.usage && finalUsage)
410
+ finalResult.usage = finalUsage;
274
411
  yield { done: true, final: finalResult };
275
412
  }
276
413
  }