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.
- package/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -435
- package/dist/agent.js +829 -168
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +216 -45
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +148 -11
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +537 -24
- package/dist/tui/app.js +615 -0
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +222 -0
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +342 -0
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/ui.js +43 -3
- package/dist/watch.js +52 -0
- 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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
scoder
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
scoder
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
--
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
--
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
--
|
|
43
|
-
--
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|