s-app-scoder-agent 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +368 -2
- package/dist/agent.js +501 -0
- package/dist/build.js +41 -0
- package/dist/config.js +117 -0
- package/dist/constants.js +69 -0
- package/dist/context.js +44 -0
- package/dist/cost.js +141 -0
- package/dist/diff.js +156 -0
- package/dist/index.js +314 -0
- package/dist/llm.js +276 -0
- package/dist/logger.js +78 -0
- package/dist/mcp.js +189 -0
- package/dist/permissions.js +298 -0
- package/dist/provider-anthropic.js +332 -0
- package/dist/retry.js +53 -0
- package/dist/session.js +143 -0
- package/dist/tools.js +361 -0
- package/dist/ui.js +96 -0
- package/dist/version.js +18 -0
- package/package.json +51 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { Agent } from "./agent.js";
|
|
7
|
+
import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
|
|
8
|
+
import { JsonlLogger } from "./logger.js";
|
|
9
|
+
import { collectUsage, formatCostReport } from "./cost.js";
|
|
10
|
+
import { getVersion as getPackageVersion } from "./version.js";
|
|
11
|
+
import * as ui from "./ui.js";
|
|
12
|
+
// Single source of truth: read version from package.json (see src/version.ts).
|
|
13
|
+
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 --version | --help
|
|
25
|
+
|
|
26
|
+
OPTIONS
|
|
27
|
+
-m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
|
|
28
|
+
--base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
|
|
29
|
+
-k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
|
|
30
|
+
--provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
|
|
31
|
+
-B, --build <cmd> Build command = stop condition (auto-detected if omitted)
|
|
32
|
+
-d, --dir <path> Working directory (default: current directory)
|
|
33
|
+
-s, --max-steps <n> Max autonomous loop iterations (default 40)
|
|
34
|
+
-y, --yes Non-interactive mode (auto-approves commands)
|
|
35
|
+
--permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
|
|
36
|
+
--no-save Disable session auto-save to .scoder/sessions/
|
|
37
|
+
--log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
|
|
38
|
+
(or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
|
|
39
|
+
--session <id> (cost) Aggregate usage of one specific session
|
|
40
|
+
--last <n> (cost) Aggregate usage of the n most recent sessions
|
|
41
|
+
-h, --help Show this help
|
|
42
|
+
-v, --version Show version
|
|
43
|
+
|
|
44
|
+
CONFIG FILE
|
|
45
|
+
.scoderrc.json in the working directory (see .scoderrc.example.json):
|
|
46
|
+
{ "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
|
|
47
|
+
"permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
|
|
48
|
+
|
|
49
|
+
EXAMPLES
|
|
50
|
+
scoder "add a divide function to src/math.ts with tests"
|
|
51
|
+
scoder --build "make test" "fix the failing test in utils"
|
|
52
|
+
scoder --permission ask "npm install express and wire up a route"
|
|
53
|
+
scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
|
|
54
|
+
scoder resume last "now add unit tests"
|
|
55
|
+
`;
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const args = { task: null, yes: false, help: false, version: false };
|
|
58
|
+
const rest = [];
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const a = argv[i];
|
|
61
|
+
const next = () => argv[++i];
|
|
62
|
+
switch (a) {
|
|
63
|
+
case "-m":
|
|
64
|
+
case "--model":
|
|
65
|
+
args.model = next();
|
|
66
|
+
break;
|
|
67
|
+
case "-k":
|
|
68
|
+
case "--api-key":
|
|
69
|
+
args.apiKey = next();
|
|
70
|
+
break;
|
|
71
|
+
case "--base-url":
|
|
72
|
+
args.baseUrl = next();
|
|
73
|
+
break;
|
|
74
|
+
case "-B":
|
|
75
|
+
case "--build":
|
|
76
|
+
args.buildCmd = next();
|
|
77
|
+
break;
|
|
78
|
+
case "-d":
|
|
79
|
+
case "--dir":
|
|
80
|
+
args.workdir = next();
|
|
81
|
+
break;
|
|
82
|
+
case "-s":
|
|
83
|
+
case "--max-steps":
|
|
84
|
+
args.maxSteps = Number(next());
|
|
85
|
+
break;
|
|
86
|
+
case "-y":
|
|
87
|
+
case "--yes":
|
|
88
|
+
args.yes = true;
|
|
89
|
+
break;
|
|
90
|
+
case "--permission": {
|
|
91
|
+
const mode = next();
|
|
92
|
+
if (mode !== "auto" && mode !== "ask" && mode !== "readonly") {
|
|
93
|
+
throw new Error(`Invalid --permission mode: ${mode} (expected auto | ask | readonly)`);
|
|
94
|
+
}
|
|
95
|
+
args.permission = mode;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case "--no-save":
|
|
99
|
+
args.noSave = true;
|
|
100
|
+
break;
|
|
101
|
+
case "--log-file":
|
|
102
|
+
args.logFile = true;
|
|
103
|
+
break;
|
|
104
|
+
case "--session":
|
|
105
|
+
args.costSession = next();
|
|
106
|
+
break;
|
|
107
|
+
case "--last": {
|
|
108
|
+
const n = Number(next());
|
|
109
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
110
|
+
throw new Error(`Invalid --last value (expected a positive integer): ${n}`);
|
|
111
|
+
}
|
|
112
|
+
args.costLast = Math.floor(n);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "--provider": {
|
|
116
|
+
const p = next();
|
|
117
|
+
if (p !== "openai" && p !== "anthropic") {
|
|
118
|
+
throw new Error(`Invalid --provider: ${p} (expected openai | anthropic)`);
|
|
119
|
+
}
|
|
120
|
+
args.provider = p;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "-h":
|
|
124
|
+
case "--help":
|
|
125
|
+
args.help = true;
|
|
126
|
+
break;
|
|
127
|
+
case "-v":
|
|
128
|
+
case "--version":
|
|
129
|
+
args.version = true;
|
|
130
|
+
break;
|
|
131
|
+
default:
|
|
132
|
+
if (a.startsWith("-")) {
|
|
133
|
+
throw new Error(`Unknown option: ${a} (see --help)`);
|
|
134
|
+
}
|
|
135
|
+
rest.push(a);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
args.task = rest.length > 0 ? rest.join(" ") : null;
|
|
139
|
+
// Subcommands: scoder list | scoder resume <id|last> [follow-up task] | scoder logs [id]
|
|
140
|
+
if (rest.length > 0 && rest[0] === "list") {
|
|
141
|
+
args.subcommand = "list";
|
|
142
|
+
args.task = null;
|
|
143
|
+
}
|
|
144
|
+
else if (rest[0] === "resume") {
|
|
145
|
+
args.subcommand = "resume";
|
|
146
|
+
args.resumeId = rest[1] ?? "last";
|
|
147
|
+
args.task = rest.length > 2 ? rest.slice(2).join(" ") : null;
|
|
148
|
+
}
|
|
149
|
+
else if (rest[0] === "logs") {
|
|
150
|
+
args.subcommand = "logs";
|
|
151
|
+
args.logsId = rest[1] ?? "last";
|
|
152
|
+
args.logsFilter = rest.length > 2 ? rest.slice(2).join(" ") : undefined;
|
|
153
|
+
}
|
|
154
|
+
else if (rest[0] === "cost") {
|
|
155
|
+
args.subcommand = "cost";
|
|
156
|
+
args.task = null;
|
|
157
|
+
}
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
async function runOnce(cfg, task) {
|
|
161
|
+
const agent = new Agent(cfg);
|
|
162
|
+
process.on("SIGINT", () => {
|
|
163
|
+
ui.warn("\nSIGINT received — finishing current tool, then stopping...");
|
|
164
|
+
agent.abort();
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
const result = await agent.run(task);
|
|
168
|
+
const pruned = pruneSessions(cfg.workdir, 7);
|
|
169
|
+
if (pruned > 0)
|
|
170
|
+
ui.info(`pruned ${pruned} old session(s)`);
|
|
171
|
+
return result.success ? 0 : 1;
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
ui.error(err.message);
|
|
175
|
+
return 2;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function repl(cfg) {
|
|
179
|
+
ui.header(`scoder interactive — model: ${cfg.model}`);
|
|
180
|
+
ui.info('Type a task and press Enter. "exit" or Ctrl+D to quit.');
|
|
181
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
|
|
182
|
+
// One shared session for the whole REPL conversation (plan P0.5 §4):
|
|
183
|
+
// every input is a follow-up turn, context carries over, saved continuously.
|
|
184
|
+
const agent = new Agent(cfg);
|
|
185
|
+
process.on("SIGINT", () => {
|
|
186
|
+
ui.warn("\nSIGINT — aborting current task...");
|
|
187
|
+
agent.abort();
|
|
188
|
+
});
|
|
189
|
+
let first = true;
|
|
190
|
+
for (;;) {
|
|
191
|
+
const line = await new Promise((res) => rl.question("you> ", res));
|
|
192
|
+
const input = line.trim();
|
|
193
|
+
if (!input)
|
|
194
|
+
continue;
|
|
195
|
+
if (input === "exit" || input === "quit")
|
|
196
|
+
break;
|
|
197
|
+
try {
|
|
198
|
+
const result = first ? await agent.run(input) : await agent.runTurn(input);
|
|
199
|
+
first = false;
|
|
200
|
+
if (!result.success)
|
|
201
|
+
ui.warn("task did not complete — try refining it");
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
ui.error(err.message);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
ui.info("bye");
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
async function main() {
|
|
211
|
+
let args;
|
|
212
|
+
try {
|
|
213
|
+
args = parseArgs(process.argv.slice(2));
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
ui.error(err.message);
|
|
217
|
+
process.exit(2);
|
|
218
|
+
}
|
|
219
|
+
if (args.version) {
|
|
220
|
+
console.log(VERSION);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (args.help) {
|
|
224
|
+
console.log(HELP);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const cfg = loadConfig({
|
|
228
|
+
model: args.model,
|
|
229
|
+
baseUrl: args.baseUrl,
|
|
230
|
+
apiKey: args.apiKey,
|
|
231
|
+
buildCmd: args.buildCmd ?? undefined,
|
|
232
|
+
workdir: args.workdir ? resolve(args.workdir) : undefined,
|
|
233
|
+
maxSteps: args.maxSteps,
|
|
234
|
+
yes: args.yes,
|
|
235
|
+
permissionMode: args.permission ?? undefined,
|
|
236
|
+
provider: args.provider ?? undefined,
|
|
237
|
+
noSave: args.noSave,
|
|
238
|
+
logFile: args.logFile,
|
|
239
|
+
});
|
|
240
|
+
// Subcommands
|
|
241
|
+
if (args.subcommand === "list") {
|
|
242
|
+
const sessions = listSessions(cfg.workdir);
|
|
243
|
+
if (sessions.length === 0) {
|
|
244
|
+
console.log("No saved sessions in this directory.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const s of sessions) {
|
|
248
|
+
console.log(`${s.id} steps=${String(s.steps).padStart(3)} ${s.updatedAt} ${s.task}`);
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (args.subcommand === "resume") {
|
|
253
|
+
const s = resolveSessionId(cfg.workdir, args.resumeId ?? "last");
|
|
254
|
+
if (!s) {
|
|
255
|
+
ui.error(`Session not found: ${args.resumeId ?? "last"}. Run \`scoder list\` to see saved sessions.`);
|
|
256
|
+
process.exit(2);
|
|
257
|
+
}
|
|
258
|
+
const agent = new Agent(cfg);
|
|
259
|
+
process.on("SIGINT", () => {
|
|
260
|
+
ui.warn("\nSIGINT received — finishing current tool, then stopping...");
|
|
261
|
+
agent.abort();
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
const result = await agent.resume(s, args.task ?? undefined);
|
|
265
|
+
process.exit(result.success ? 0 : 1);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
ui.error(err.message);
|
|
269
|
+
process.exit(2);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (args.subcommand === "logs") {
|
|
273
|
+
let id = args.logsId ?? "last";
|
|
274
|
+
if (id === "last") {
|
|
275
|
+
const sessions = listSessions(cfg.workdir);
|
|
276
|
+
if (sessions.length === 0) {
|
|
277
|
+
ui.error("No saved sessions in this directory. Run with --log-file to record logs.");
|
|
278
|
+
process.exit(2);
|
|
279
|
+
}
|
|
280
|
+
id = sessions[0].id;
|
|
281
|
+
}
|
|
282
|
+
const events = JsonlLogger.read(cfg.workdir, id);
|
|
283
|
+
if (events === null || events.length === 0) {
|
|
284
|
+
ui.error(`No log found for session ${id}. Logs require --log-file (or SCODER_LOG_FILE=1).`);
|
|
285
|
+
process.exit(2);
|
|
286
|
+
}
|
|
287
|
+
const filter = args.logsFilter?.toLowerCase();
|
|
288
|
+
for (const ev of events) {
|
|
289
|
+
const line = JSON.stringify(ev);
|
|
290
|
+
if (filter && !line.toLowerCase().includes(filter))
|
|
291
|
+
continue;
|
|
292
|
+
console.log(line);
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (args.subcommand === "cost") {
|
|
297
|
+
const sessionCosts = collectUsage(cfg.workdir, {
|
|
298
|
+
sessionId: args.costSession,
|
|
299
|
+
last: args.costLast,
|
|
300
|
+
pricingOverrides: cfg.modelPricing,
|
|
301
|
+
});
|
|
302
|
+
console.log(formatCostReport(sessionCosts));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (args.task) {
|
|
306
|
+
const code = await runOnce(cfg, args.task);
|
|
307
|
+
process.exit(code);
|
|
308
|
+
}
|
|
309
|
+
await repl(cfg);
|
|
310
|
+
}
|
|
311
|
+
main().catch((err) => {
|
|
312
|
+
ui.error(`fatal: ${err.stack ?? err.message}`);
|
|
313
|
+
process.exit(2);
|
|
314
|
+
});
|
package/dist/llm.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal OpenAI-compatible chat-completions client with function calling
|
|
3
|
+
* and optional SSE streaming.
|
|
4
|
+
*/
|
|
5
|
+
import { withRetry } from "./retry.js";
|
|
6
|
+
export class LlmError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
retryAfterMs;
|
|
9
|
+
constructor(message, status, retryAfterMs) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "LlmError";
|
|
12
|
+
if (status !== undefined)
|
|
13
|
+
this.status = status;
|
|
14
|
+
if (retryAfterMs !== undefined)
|
|
15
|
+
this.retryAfterMs = retryAfterMs;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Build an LlmError from a failed Response, carrying status + Retry-After. */
|
|
19
|
+
async function toLlmError(res, url) {
|
|
20
|
+
let body = await res.text().catch(() => "");
|
|
21
|
+
// 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***");
|
|
24
|
+
const err = new LlmError(`LLM API error ${res.status} at ${url}: ${body.slice(0, 500)}`, res.status);
|
|
25
|
+
// Only honor a REAL Retry-After value: Number("") === 0 would zero the backoff
|
|
26
|
+
const retryAfterHeader = res.headers.get("retry-after");
|
|
27
|
+
if (retryAfterHeader != null && retryAfterHeader !== "") {
|
|
28
|
+
const sec = Number(retryAfterHeader);
|
|
29
|
+
if (Number.isFinite(sec) && sec > 0)
|
|
30
|
+
err.retryAfterMs = sec * 1000;
|
|
31
|
+
}
|
|
32
|
+
return err;
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Request timeout / abort helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/** Default per-request timeout for LLM calls (a hung connection must not block the agent forever). */
|
|
38
|
+
export const LLM_REQUEST_TIMEOUT_MS = 120_000;
|
|
39
|
+
/**
|
|
40
|
+
* Combine an external signal with a request timeout.
|
|
41
|
+
* Returns a derived signal plus a cleanup fn (removes listeners).
|
|
42
|
+
* Works on Node >= 18 (no AbortSignal.any dependency).
|
|
43
|
+
*/
|
|
44
|
+
export function combinedSignal(external, timeoutMs) {
|
|
45
|
+
const ac = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => ac.abort(new DOMException("LLM request timed out", "TimeoutError")), timeoutMs);
|
|
47
|
+
const onExternal = () => ac.abort(new DOMException("aborted", "AbortError"));
|
|
48
|
+
if (external) {
|
|
49
|
+
if (external.aborted)
|
|
50
|
+
ac.abort(external.reason);
|
|
51
|
+
else
|
|
52
|
+
external.addEventListener("abort", onExternal, { once: true });
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
signal: ac.signal,
|
|
56
|
+
cleanup: () => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
external?.removeEventListener("abort", onExternal);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// SSE parsing helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const SSE_PREFIX = "data: ";
|
|
66
|
+
const SSE_DONE = "[DONE]";
|
|
67
|
+
/** Parse SSE chunks from an OpenAI-compatible stream. */
|
|
68
|
+
function parseSSELine(line) {
|
|
69
|
+
if (!line.startsWith(SSE_PREFIX))
|
|
70
|
+
return null;
|
|
71
|
+
const data = line.slice(SSE_PREFIX.length);
|
|
72
|
+
if (data === SSE_DONE)
|
|
73
|
+
return null; // [DONE] signals end
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// LlmClient
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
export class LlmClient {
|
|
80
|
+
opts;
|
|
81
|
+
streamEnabled;
|
|
82
|
+
constructor(opts) {
|
|
83
|
+
this.opts = opts;
|
|
84
|
+
this.streamEnabled = opts.stream !== false; // default true
|
|
85
|
+
}
|
|
86
|
+
get model() {
|
|
87
|
+
return this.opts.model;
|
|
88
|
+
}
|
|
89
|
+
// ── Non-streaming ──────────────────────────────────────────────────────────
|
|
90
|
+
async chat(messages, tools, opts = {}) {
|
|
91
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
92
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
93
|
+
const body = JSON.stringify({
|
|
94
|
+
model: this.opts.model,
|
|
95
|
+
messages,
|
|
96
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
97
|
+
temperature: this.opts.temperature,
|
|
98
|
+
max_tokens: this.opts.maxTokens,
|
|
99
|
+
stream: false,
|
|
100
|
+
});
|
|
101
|
+
let res;
|
|
102
|
+
try {
|
|
103
|
+
res = await withRetry(async () => {
|
|
104
|
+
const r = await fetch(url, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.apiKey}` },
|
|
107
|
+
body,
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
if (!r.ok)
|
|
111
|
+
throw await toLlmError(r, url);
|
|
112
|
+
return r;
|
|
113
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ LLM request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/3)...`));
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
cleanup();
|
|
117
|
+
}
|
|
118
|
+
const data = (await res.json());
|
|
119
|
+
const choice = data.choices?.[0]?.message;
|
|
120
|
+
if (!choice)
|
|
121
|
+
throw new LlmError("LLM API returned no message in response");
|
|
122
|
+
// Extract cached prompt tokens when the provider reports them
|
|
123
|
+
// (OpenAI-style: usage.prompt_tokens_details.cached_tokens).
|
|
124
|
+
let usage;
|
|
125
|
+
if (data.usage) {
|
|
126
|
+
usage = { ...data.usage };
|
|
127
|
+
const details = usage.prompt_tokens_details;
|
|
128
|
+
if (usage.cached_tokens === undefined && typeof details?.cached_tokens === "number") {
|
|
129
|
+
usage.cached_tokens = details.cached_tokens;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
message: {
|
|
134
|
+
role: "assistant",
|
|
135
|
+
content: choice.content ?? null,
|
|
136
|
+
stop_reason: choice.stop_reason ?? (choice.content ? "stop" : undefined),
|
|
137
|
+
tool_calls: choice.tool_calls?.map((tc) => ({
|
|
138
|
+
id: tc.id ?? "",
|
|
139
|
+
name: tc.function?.name ?? "",
|
|
140
|
+
arguments: tc.function?.arguments ?? "",
|
|
141
|
+
})),
|
|
142
|
+
},
|
|
143
|
+
usage,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// ── Streaming ───────────────────────────────────────────────────────────────
|
|
147
|
+
/**
|
|
148
|
+
* Stream chat completions, yielding incremental chunks.
|
|
149
|
+
* Falls back to non-streaming on stream-related errors.
|
|
150
|
+
*/
|
|
151
|
+
async *streamChat(messages, tools, opts = {}) {
|
|
152
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
|
153
|
+
const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
|
|
154
|
+
const bodyStr = JSON.stringify({
|
|
155
|
+
model: this.opts.model,
|
|
156
|
+
messages,
|
|
157
|
+
tools: tools.length > 0 ? tools : undefined,
|
|
158
|
+
temperature: this.opts.temperature,
|
|
159
|
+
max_tokens: this.opts.maxTokens,
|
|
160
|
+
stream: true,
|
|
161
|
+
});
|
|
162
|
+
let res;
|
|
163
|
+
try {
|
|
164
|
+
res = await withRetry(async () => {
|
|
165
|
+
const r = await fetch(url, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.apiKey}` },
|
|
168
|
+
body: bodyStr,
|
|
169
|
+
signal,
|
|
170
|
+
});
|
|
171
|
+
if (!r.ok)
|
|
172
|
+
throw await toLlmError(r, url);
|
|
173
|
+
return r;
|
|
174
|
+
}, {}, (attempt, delay, reason) => console.error(`⚠ LLM stream request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s...`));
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
cleanup(); // don't leak the 120s request timer — it would hang the process
|
|
178
|
+
throw new LlmError(`Stream fetch error after retries at ${url}: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
if (!res.body) {
|
|
181
|
+
throw new LlmError("Response body is not readable (not a streaming response)");
|
|
182
|
+
}
|
|
183
|
+
// Accumulators for tool_calls across chunks
|
|
184
|
+
const accumulatedToolCalls = [];
|
|
185
|
+
let finalResult;
|
|
186
|
+
const reader = res.body.getReader();
|
|
187
|
+
const decoder = new TextDecoder();
|
|
188
|
+
let buffer = "";
|
|
189
|
+
try {
|
|
190
|
+
while (true) {
|
|
191
|
+
const { done, value } = await reader.read();
|
|
192
|
+
if (done)
|
|
193
|
+
break;
|
|
194
|
+
buffer += decoder.decode(value, { stream: true });
|
|
195
|
+
// Process complete lines
|
|
196
|
+
const lines = buffer.split("\n");
|
|
197
|
+
buffer = lines.pop() ?? ""; // keep incomplete line in buffer
|
|
198
|
+
for (const rawLine of lines) {
|
|
199
|
+
const json = parseSSELine(rawLine);
|
|
200
|
+
if (!json)
|
|
201
|
+
continue;
|
|
202
|
+
let chunkData;
|
|
203
|
+
try {
|
|
204
|
+
chunkData = JSON.parse(json);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
continue; // skip malformed JSON lines
|
|
208
|
+
}
|
|
209
|
+
const choice = chunkData.choices?.[0];
|
|
210
|
+
if (!choice)
|
|
211
|
+
continue;
|
|
212
|
+
const delta = choice.delta;
|
|
213
|
+
// Text content delta
|
|
214
|
+
if (delta?.content != null && delta.content.length > 0) {
|
|
215
|
+
yield { contentDelta: delta.content, done: false };
|
|
216
|
+
}
|
|
217
|
+
// Tool call deltas
|
|
218
|
+
if (delta?.tool_calls) {
|
|
219
|
+
for (const tc of delta.tool_calls) {
|
|
220
|
+
const idx = tc.index ?? 0;
|
|
221
|
+
if (!accumulatedToolCalls[idx]) {
|
|
222
|
+
accumulatedToolCalls[idx] = { arguments: "" };
|
|
223
|
+
}
|
|
224
|
+
if (tc.id)
|
|
225
|
+
accumulatedToolCalls[idx].id = tc.id;
|
|
226
|
+
if (tc.function?.name)
|
|
227
|
+
accumulatedToolCalls[idx].name = tc.function.name;
|
|
228
|
+
if (tc.function?.arguments) {
|
|
229
|
+
accumulatedToolCalls[idx].arguments += tc.function.arguments;
|
|
230
|
+
}
|
|
231
|
+
yield {
|
|
232
|
+
toolCallDelta: {
|
|
233
|
+
index: idx,
|
|
234
|
+
id: tc.id,
|
|
235
|
+
name: tc.function?.name,
|
|
236
|
+
argumentsDelta: tc.function?.arguments,
|
|
237
|
+
},
|
|
238
|
+
done: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Final chunk
|
|
243
|
+
if (choice.finish_reason) {
|
|
244
|
+
// Collect final tool_calls
|
|
245
|
+
const toolCalls = accumulatedToolCalls
|
|
246
|
+
.filter((tc) => tc.name)
|
|
247
|
+
.map((tc) => ({
|
|
248
|
+
id: tc.id ?? "",
|
|
249
|
+
name: tc.name ?? "",
|
|
250
|
+
arguments: tc.arguments,
|
|
251
|
+
}));
|
|
252
|
+
finalResult = {
|
|
253
|
+
message: {
|
|
254
|
+
role: "assistant",
|
|
255
|
+
content: null,
|
|
256
|
+
stop_reason: choice.finish_reason,
|
|
257
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
258
|
+
},
|
|
259
|
+
usage: chunkData.usage,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
// Cancel the body (frees the socket) and clear the request timeout
|
|
267
|
+
try {
|
|
268
|
+
reader.cancel();
|
|
269
|
+
}
|
|
270
|
+
catch { /* already released */ }
|
|
271
|
+
reader.releaseLock();
|
|
272
|
+
cleanup();
|
|
273
|
+
}
|
|
274
|
+
yield { done: true, final: finalResult };
|
|
275
|
+
}
|
|
276
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured JSONL session logging.
|
|
3
|
+
* One JSON object per line — parseable with jq or any log consumer.
|
|
4
|
+
* Never crashes the agent: write failures are swallowed after one warning.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, appendFileSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
export class JsonlLogger {
|
|
9
|
+
filePath;
|
|
10
|
+
warned = false;
|
|
11
|
+
constructor(path) {
|
|
12
|
+
this.filePath = path;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create a logger for a session.
|
|
16
|
+
* logFile: true -> <workdir>/.scoder/logs/<sessionId>.jsonl
|
|
17
|
+
* string -> that exact path
|
|
18
|
+
* false/undefined -> disabled (no-op)
|
|
19
|
+
*/
|
|
20
|
+
static forSession(workdir, sessionId, logFile) {
|
|
21
|
+
if (logFile === undefined || logFile === false || logFile === null) {
|
|
22
|
+
return new JsonlLogger(null);
|
|
23
|
+
}
|
|
24
|
+
const path = typeof logFile === "string" && logFile.length > 0
|
|
25
|
+
? resolve(logFile)
|
|
26
|
+
: resolve(workdir, ".scoder/logs", `${sessionId}.jsonl`);
|
|
27
|
+
try {
|
|
28
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
29
|
+
return new JsonlLogger(path);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error(`⚠ logging disabled (cannot create log dir): ${err.message}`);
|
|
33
|
+
return new JsonlLogger(null);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
get enabled() {
|
|
37
|
+
return this.filePath !== null;
|
|
38
|
+
}
|
|
39
|
+
/** Read a session's JSONL log file, returning parsed events (or null if missing/unreadable). */
|
|
40
|
+
static read(workdir, sessionId) {
|
|
41
|
+
const path = resolve(workdir, ".scoder/logs", `${sessionId}.jsonl`);
|
|
42
|
+
let content;
|
|
43
|
+
try {
|
|
44
|
+
content = readFileSync(path, "utf8");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const events = [];
|
|
50
|
+
for (const line of content.split("\n")) {
|
|
51
|
+
if (!line.trim())
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
events.push(JSON.parse(line));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// skip malformed lines
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return events;
|
|
61
|
+
}
|
|
62
|
+
/** Append one event. Never throws. */
|
|
63
|
+
log(event) {
|
|
64
|
+
if (!this.filePath)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
|
|
68
|
+
appendFileSync(this.filePath, line + "\n", "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (!this.warned) {
|
|
72
|
+
this.warned = true;
|
|
73
|
+
console.error(`⚠ log write failed (${err.message}) — disabling session log`);
|
|
74
|
+
}
|
|
75
|
+
this.filePath = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|