s-app-scoder-agent 0.3.0 → 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/dist/agent.js +67 -44
- package/dist/build.js +3 -6
- package/dist/config.js +26 -2
- package/dist/constants.js +69 -0
- package/dist/cost.js +141 -0
- package/dist/index.js +29 -0
- package/dist/llm.js +11 -1
- package/dist/permissions.js +24 -3
- package/dist/provider-anthropic.js +14 -4
- package/dist/tools.js +7 -2
- package/package.json +4 -2
package/dist/agent.js
CHANGED
|
@@ -43,6 +43,8 @@ export class Agent {
|
|
|
43
43
|
sessionCreatedAt;
|
|
44
44
|
currentTask = "";
|
|
45
45
|
lastBuildResult = null;
|
|
46
|
+
/** Accumulated token usage across the current run (session state). */
|
|
47
|
+
usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
|
|
46
48
|
mcp = null;
|
|
47
49
|
/** Aborted when abort() is called — cancels in-flight LLM requests (SIGINT). */
|
|
48
50
|
runSignal = new AbortController();
|
|
@@ -90,6 +92,55 @@ export class Agent {
|
|
|
90
92
|
// terminate its child process immediately (long builds must be interruptible).
|
|
91
93
|
this.ctx.signal = this.runSignal.signal;
|
|
92
94
|
}
|
|
95
|
+
/** Push one tool-result message back into the conversation. */
|
|
96
|
+
pushToolResult(tc, content) {
|
|
97
|
+
this.messages.push({
|
|
98
|
+
role: "tool",
|
|
99
|
+
tool_call_id: tc.id,
|
|
100
|
+
name: tc.name,
|
|
101
|
+
content,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Handle a rejected tool call (unknown tool, policy denial, user denial):
|
|
106
|
+
* render the failure, log the event, and push an ERROR tool message so the
|
|
107
|
+
* LLM can react. Replaces the previously duplicated denied/deny/unknown blocks.
|
|
108
|
+
*/
|
|
109
|
+
rejectToolCall(step, tc, output, logExtra) {
|
|
110
|
+
ui.toolResult(tc.name, false, output);
|
|
111
|
+
this.logger.log({ type: "tool", step, tool: tc.name, ok: false, ...logExtra });
|
|
112
|
+
this.pushToolResult(tc, output);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Record usage of one LLM call: render token counts, log an `llm_usage`
|
|
116
|
+
* event, and accumulate totals into session state.
|
|
117
|
+
*/
|
|
118
|
+
recordLlmUsage(usage) {
|
|
119
|
+
if (!usage)
|
|
120
|
+
return;
|
|
121
|
+
const input = usage.prompt_tokens ?? 0;
|
|
122
|
+
const output = usage.completion_tokens ?? 0;
|
|
123
|
+
const cached = usage.cached_tokens ?? 0;
|
|
124
|
+
this.usageTotals.calls += 1;
|
|
125
|
+
this.usageTotals.inputTokens += input;
|
|
126
|
+
this.usageTotals.outputTokens += output;
|
|
127
|
+
this.usageTotals.cachedTokens += cached;
|
|
128
|
+
ui.info(`tokens: in=${usage.prompt_tokens ?? "?"} out=${usage.completion_tokens ?? "?"}`);
|
|
129
|
+
this.logger.log({
|
|
130
|
+
type: "llm_usage",
|
|
131
|
+
model: this.client.model,
|
|
132
|
+
provider: this.config.provider,
|
|
133
|
+
usage: {
|
|
134
|
+
input_tokens: input,
|
|
135
|
+
output_tokens: output,
|
|
136
|
+
cached_tokens: cached,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** Reset per-run usage accumulation (called when a fresh run starts). */
|
|
141
|
+
resetUsageTotals() {
|
|
142
|
+
this.usageTotals = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
|
|
143
|
+
}
|
|
93
144
|
/**
|
|
94
145
|
* One LLM turn: streaming if enabled (renders deltas live), with automatic
|
|
95
146
|
* fallback to non-streaming when the provider/stream fails.
|
|
@@ -171,6 +222,7 @@ export class Agent {
|
|
|
171
222
|
ui.info(`model=${this.client.model} workdir=${this.config.workdir} build="${buildCmd}" maxSteps=${this.config.maxSteps}`);
|
|
172
223
|
this.currentTask = task;
|
|
173
224
|
this.resetRunSignal();
|
|
225
|
+
this.resetUsageTotals();
|
|
174
226
|
this.messages = [
|
|
175
227
|
{
|
|
176
228
|
role: "system",
|
|
@@ -199,6 +251,7 @@ export class Agent {
|
|
|
199
251
|
const buildCmd = this.buildCmd;
|
|
200
252
|
this.currentTask = `${this.currentTask}\n${task}`.trim();
|
|
201
253
|
this.resetRunSignal();
|
|
254
|
+
this.resetUsageTotals();
|
|
202
255
|
this.messages.push({ role: "user", content: task });
|
|
203
256
|
try {
|
|
204
257
|
await this.connectMcp();
|
|
@@ -217,6 +270,7 @@ export class Agent {
|
|
|
217
270
|
this.sessionCreatedAt = session.createdAt;
|
|
218
271
|
this.currentTask = session.task;
|
|
219
272
|
this.resetRunSignal();
|
|
273
|
+
this.resetUsageTotals();
|
|
220
274
|
this.messages = session.messages ?? [];
|
|
221
275
|
ui.header(`RESUME ${session.id}`);
|
|
222
276
|
ui.info(`previous task: ${session.task} (${session.steps} step(s) done)`);
|
|
@@ -262,19 +316,13 @@ export class Agent {
|
|
|
262
316
|
});
|
|
263
317
|
if (this.aborted)
|
|
264
318
|
break;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.logger.log({ type: "step", step: steps, usage });
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
this.logger.log({ type: "step", step: steps });
|
|
271
|
-
}
|
|
319
|
+
this.recordLlmUsage(usage);
|
|
320
|
+
this.logger.log({ type: "step", step: steps });
|
|
272
321
|
// No tool calls => assistant produced a text answer
|
|
273
322
|
if (!message.tool_calls || message.tool_calls.length === 0) {
|
|
274
323
|
const text = message.content ?? "";
|
|
275
324
|
this.messages.push({ role: "assistant", content: text });
|
|
276
325
|
// If the assistant says it's done without building, force a build check.
|
|
277
|
-
// If the assistant says it's done without building, force a build check.
|
|
278
326
|
if (claimsCompletion(text) && steps < this.config.maxSteps) {
|
|
279
327
|
ui.info("agent claims completion -> verifying with build");
|
|
280
328
|
lastBuild = await this.checkBuild(buildCmd);
|
|
@@ -311,24 +359,12 @@ export class Agent {
|
|
|
311
359
|
args = tc.arguments ? JSON.parse(tc.arguments) : {};
|
|
312
360
|
}
|
|
313
361
|
catch {
|
|
314
|
-
this.
|
|
315
|
-
role: "tool",
|
|
316
|
-
tool_call_id: tc.id,
|
|
317
|
-
name: tc.name,
|
|
318
|
-
content: `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`,
|
|
319
|
-
});
|
|
362
|
+
this.pushToolResult(tc, `ERROR: invalid JSON arguments: ${tc.arguments.slice(0, 200)}`);
|
|
320
363
|
continue;
|
|
321
364
|
}
|
|
322
365
|
ui.toolCall(tc.name, args);
|
|
323
366
|
if (!tool && !(this.mcp?.hasTool(tc.name))) {
|
|
324
|
-
|
|
325
|
-
this.logger.log({ type: "tool", step: steps, tool: tc.name, ok: false, error: "unknown tool" });
|
|
326
|
-
this.messages.push({
|
|
327
|
-
role: "tool",
|
|
328
|
-
tool_call_id: tc.id,
|
|
329
|
-
name: tc.name,
|
|
330
|
-
content: `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`,
|
|
331
|
-
});
|
|
367
|
+
this.rejectToolCall(steps, tc, `ERROR: unknown tool "${tc.name}". Available: ${AGENT_TOOLS.map((t) => t.spec.function.name).join(", ")}${this.mcp ? " + MCP tools (mcp__<server>__<tool>)" : ""}`, { error: "unknown tool" });
|
|
332
368
|
continue;
|
|
333
369
|
}
|
|
334
370
|
let result;
|
|
@@ -341,14 +377,7 @@ export class Agent {
|
|
|
341
377
|
ok: false,
|
|
342
378
|
output: `ERROR: command denied by permission policy (${decision.reason}): ${String(args.command).slice(0, 200)}`,
|
|
343
379
|
};
|
|
344
|
-
|
|
345
|
-
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: decision.reason });
|
|
346
|
-
this.messages.push({
|
|
347
|
-
role: "tool",
|
|
348
|
-
tool_call_id: tc.id,
|
|
349
|
-
name: tc.name,
|
|
350
|
-
content: result.output,
|
|
351
|
-
});
|
|
380
|
+
this.rejectToolCall(steps, tc, result.output, { args, denied: decision.reason });
|
|
352
381
|
continue;
|
|
353
382
|
}
|
|
354
383
|
if (decision.decision === "ask") {
|
|
@@ -358,14 +387,7 @@ export class Agent {
|
|
|
358
387
|
ok: false,
|
|
359
388
|
output: `ERROR: user denied permission to run: ${String(args.command).slice(0, 200)}`,
|
|
360
389
|
};
|
|
361
|
-
|
|
362
|
-
this.logger.log({ type: "tool", step: steps, tool: tc.name, args, ok: false, denied: "user" });
|
|
363
|
-
this.messages.push({
|
|
364
|
-
role: "tool",
|
|
365
|
-
tool_call_id: tc.id,
|
|
366
|
-
name: tc.name,
|
|
367
|
-
content: result.output,
|
|
368
|
-
});
|
|
390
|
+
this.rejectToolCall(steps, tc, result.output, { args, denied: "user" });
|
|
369
391
|
continue;
|
|
370
392
|
}
|
|
371
393
|
}
|
|
@@ -406,12 +428,7 @@ export class Agent {
|
|
|
406
428
|
}
|
|
407
429
|
ui.toolResult(tc.name, result.ok, result.output);
|
|
408
430
|
stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
|
|
409
|
-
this.
|
|
410
|
-
role: "tool",
|
|
411
|
-
tool_call_id: tc.id,
|
|
412
|
-
name: tc.name,
|
|
413
|
-
content: summariseOutput(result.output),
|
|
414
|
-
});
|
|
431
|
+
this.pushToolResult(tc, summariseOutput(result.output));
|
|
415
432
|
}
|
|
416
433
|
// After a build-command run, treat success as the terminal condition.
|
|
417
434
|
// Match the FULL build command (not just its first word): matching "npm"
|
|
@@ -456,6 +473,12 @@ export class Agent {
|
|
|
456
473
|
lastBuild: lastBuild
|
|
457
474
|
? { success: lastBuild.success, command: lastBuild.command, output: lastBuild.output.slice(0, 10_000) }
|
|
458
475
|
: null,
|
|
476
|
+
usage: {
|
|
477
|
+
llmCalls: this.usageTotals.calls,
|
|
478
|
+
inputTokens: this.usageTotals.inputTokens,
|
|
479
|
+
outputTokens: this.usageTotals.outputTokens,
|
|
480
|
+
cachedTokens: this.usageTotals.cachedTokens,
|
|
481
|
+
},
|
|
459
482
|
}, this.sessionCreatedAt);
|
|
460
483
|
}
|
|
461
484
|
/** Explicitly verify the build (independent of what the LLM claims). */
|
package/dist/build.js
CHANGED
|
@@ -10,8 +10,9 @@ export function detectBuildCommand(workdir) {
|
|
|
10
10
|
if (pkg.scripts?.build)
|
|
11
11
|
return "npm run build";
|
|
12
12
|
}
|
|
13
|
-
catch {
|
|
14
|
-
// fall through
|
|
13
|
+
catch (err) {
|
|
14
|
+
// malformed package.json -> fall through to the other detectors
|
|
15
|
+
console.error(`⚠ cannot parse package.json (${err.message}) — build command detection degraded`);
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
if (existsSync(resolve(workdir, "Makefile")))
|
|
@@ -26,10 +27,6 @@ export function detectBuildCommand(workdir) {
|
|
|
26
27
|
return "npx tsc --noEmit";
|
|
27
28
|
return null;
|
|
28
29
|
}
|
|
29
|
-
/**
|
|
30
|
-
* Run the build command. This is the agent's stop condition:
|
|
31
|
-
* exit code 0 == task complete.
|
|
32
|
-
*/
|
|
33
30
|
/**
|
|
34
31
|
* Run the build command. This is the agent's stop condition:
|
|
35
32
|
* exit code 0 == task complete.
|
package/dist/config.js
CHANGED
|
@@ -11,8 +11,9 @@ function loadRcFile(workdir) {
|
|
|
11
11
|
try {
|
|
12
12
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
13
13
|
}
|
|
14
|
-
catch {
|
|
15
|
-
// malformed rc file -> ignore
|
|
14
|
+
catch (err) {
|
|
15
|
+
// malformed rc file -> ignore it, but tell the user why their config is not applied
|
|
16
|
+
console.error(`⚠ ignoring malformed config file ${name}: ${err.message}`);
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
}
|
|
@@ -28,6 +29,28 @@ function parseList(raw) {
|
|
|
28
29
|
return [];
|
|
29
30
|
return raw.filter((x) => typeof x === "string");
|
|
30
31
|
}
|
|
32
|
+
/** Validate the user-supplied pricing table; drop malformed entries with a warning. */
|
|
33
|
+
function parsePricing(raw) {
|
|
34
|
+
if (raw === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof raw !== "object" || raw === null) {
|
|
37
|
+
console.error("⚠ ignoring invalid modelPricing in config (expected an object)");
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const key of Object.keys(raw)) {
|
|
42
|
+
const p = raw[key];
|
|
43
|
+
if (typeof p === "object" && p !== null &&
|
|
44
|
+
typeof p.input === "number" && Number.isFinite(p.input) &&
|
|
45
|
+
typeof p.output === "number" && Number.isFinite(p.output)) {
|
|
46
|
+
out[key] = { input: p.input, output: p.output };
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.error(`⚠ ignoring invalid modelPricing entry "${key}" (expected { input: number, output: number })`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
31
54
|
export function loadConfig(overrides = {}) {
|
|
32
55
|
const workdir = overrides.workdir ?? process.cwd();
|
|
33
56
|
const rc = loadRcFile(workdir);
|
|
@@ -74,6 +97,7 @@ export function loadConfig(overrides = {}) {
|
|
|
74
97
|
stream: overrides.stream ?? (process.env.SCODER_STREAM !== "0" ? true : false),
|
|
75
98
|
permissionMode: overrides.permissionMode ??
|
|
76
99
|
parsePermissionMode(process.env.SCODER_PERMISSION ?? rc.permission ?? rc.permissions?.mode ?? "auto"),
|
|
100
|
+
modelPricing: parsePricing(rc.modelPricing),
|
|
77
101
|
};
|
|
78
102
|
// Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
|
|
79
103
|
if (rc.apiKey && !process.env.SCODER_NO_APIKEY_WARNING) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central constants: model pricing for cost estimation.
|
|
3
|
+
*
|
|
4
|
+
* Prices are USD per 1M tokens (input / output), approximate list prices.
|
|
5
|
+
* They can be overridden per-project via the rc file:
|
|
6
|
+
*
|
|
7
|
+
* .scoderrc.json:
|
|
8
|
+
* { "modelPricing": { "my-model": { "input": 1.0, "output": 2.0 } } }
|
|
9
|
+
*
|
|
10
|
+
* Keys are matched as a PREFIX of the model name (case-insensitive), so
|
|
11
|
+
* "gpt-4o-mini" also covers "gpt-4o-mini-2024-07-18". More specific keys
|
|
12
|
+
* (longer prefix) win.
|
|
13
|
+
*/
|
|
14
|
+
/** Fallback price when a model is unknown (deliberately conservative, not zero). */
|
|
15
|
+
export const DEFAULT_MODEL_PRICING = { input: 0.5, output: 1.5 };
|
|
16
|
+
/** Approximate list prices, USD per 1M tokens. Longest matching prefix wins. */
|
|
17
|
+
export const MODEL_PRICING = {
|
|
18
|
+
// OpenAI
|
|
19
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
20
|
+
"gpt-4o": { input: 2.5, output: 10 },
|
|
21
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6 },
|
|
22
|
+
"gpt-4.1": { input: 2, output: 8 },
|
|
23
|
+
"gpt-4-turbo": { input: 10, output: 30 },
|
|
24
|
+
"gpt-4": { input: 30, output: 60 },
|
|
25
|
+
"gpt-3.5-turbo": { input: 0.5, output: 1.5 },
|
|
26
|
+
"o1": { input: 15, output: 60 },
|
|
27
|
+
"o1-mini": { input: 1.1, output: 4.4 },
|
|
28
|
+
"o3-mini": { input: 1.1, output: 4.4 },
|
|
29
|
+
"o3": { input: 2, output: 8 },
|
|
30
|
+
"o4-mini": { input: 1.1, output: 4.4 },
|
|
31
|
+
// Anthropic
|
|
32
|
+
"claude-opus-4": { input: 15, output: 75 },
|
|
33
|
+
"claude-sonnet-4": { input: 3, output: 15 },
|
|
34
|
+
"claude-haiku-4": { input: 0.8, output: 4 },
|
|
35
|
+
"claude-3-7-sonnet": { input: 3, output: 15 },
|
|
36
|
+
"claude-3-5-sonnet": { input: 3, output: 15 },
|
|
37
|
+
"claude-3-5-haiku": { input: 0.8, output: 4 },
|
|
38
|
+
"claude-3-haiku": { input: 0.25, output: 1.25 },
|
|
39
|
+
"claude-3-opus": { input: 15, output: 75 },
|
|
40
|
+
// Popular local/OpenAI-compatible providers (mostly $0 or nominal)
|
|
41
|
+
"llama3": { input: 0, output: 0 },
|
|
42
|
+
"qwen": { input: 0, output: 0 },
|
|
43
|
+
"deepseek-chat": { input: 0.27, output: 1.1 },
|
|
44
|
+
"deepseek-reasoner": { input: 0.55, output: 2.19 },
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the price table for a model, honouring user overrides.
|
|
48
|
+
* `overrides` maps model-name prefixes to pricing and always wins over the
|
|
49
|
+
* built-in table. Longest matching prefix wins (case-insensitive).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveModelPricing(model, overrides = {}) {
|
|
52
|
+
const m = model.toLowerCase();
|
|
53
|
+
let best = null;
|
|
54
|
+
// Built-in table first (longest prefix wins), then overrides — an override
|
|
55
|
+
// with the SAME prefix length replaces the built-in entry.
|
|
56
|
+
for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
|
|
57
|
+
const k = key.toLowerCase();
|
|
58
|
+
if (m.startsWith(k) && (!best || k.length > best.key.length)) {
|
|
59
|
+
best = { key: k, pricing };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [key, pricing] of Object.entries(overrides)) {
|
|
63
|
+
const k = key.toLowerCase();
|
|
64
|
+
if (m.startsWith(k) && (!best || k.length >= best.key.length)) {
|
|
65
|
+
best = { key: k, pricing };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return best ? best.pricing : DEFAULT_MODEL_PRICING;
|
|
69
|
+
}
|
package/dist/cost.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage & cost tracking (enterprise feature).
|
|
3
|
+
*
|
|
4
|
+
* Every LLM call writes an `llm_usage` event into the session JSONL log:
|
|
5
|
+
* { ts, type: "llm_usage", model, provider, usage: { input_tokens, output_tokens, cached_tokens } }
|
|
6
|
+
*
|
|
7
|
+
* `scoder cost` reads those logs and aggregates tokens + estimated cost.
|
|
8
|
+
* All logic here is pure/filesystem-only so it stays unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { resolveModelPricing } from "./constants.js";
|
|
13
|
+
/** Extract `llm_usage` events from a list of raw log events. */
|
|
14
|
+
export function extractUsageEvents(sessionId, events) {
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const ev of events) {
|
|
17
|
+
if (ev.type !== "llm_usage")
|
|
18
|
+
continue;
|
|
19
|
+
const usage = (ev.usage ?? {});
|
|
20
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
21
|
+
out.push({
|
|
22
|
+
ts: typeof ev.ts === "string" ? ev.ts : undefined,
|
|
23
|
+
sessionId,
|
|
24
|
+
model: typeof ev.model === "string" ? ev.model : "unknown",
|
|
25
|
+
provider: typeof ev.provider === "string" ? ev.provider : undefined,
|
|
26
|
+
inputTokens: num(usage.input_tokens ?? usage.prompt_tokens),
|
|
27
|
+
outputTokens: num(usage.output_tokens ?? usage.completion_tokens),
|
|
28
|
+
cachedTokens: num(usage.cached_tokens),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Cost of one usage event in USD (cached tokens are billed like input tokens here — provider-specific discounts are approximated by the price table). */
|
|
34
|
+
export function estimateEventCost(ev, overrides = {}) {
|
|
35
|
+
const p = resolveModelPricing(ev.model, overrides);
|
|
36
|
+
return (ev.inputTokens / 1_000_000) * p.input + (ev.outputTokens / 1_000_000) * p.output;
|
|
37
|
+
}
|
|
38
|
+
/** Aggregate usage events into totals + estimated cost. */
|
|
39
|
+
export function aggregateUsage(events, overrides = {}) {
|
|
40
|
+
const agg = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, costUsd: 0 };
|
|
41
|
+
for (const ev of events) {
|
|
42
|
+
agg.calls += 1;
|
|
43
|
+
agg.inputTokens += ev.inputTokens;
|
|
44
|
+
agg.outputTokens += ev.outputTokens;
|
|
45
|
+
agg.cachedTokens += ev.cachedTokens;
|
|
46
|
+
agg.costUsd += estimateEventCost(ev, overrides);
|
|
47
|
+
}
|
|
48
|
+
agg.costUsd = Math.round(agg.costUsd * 10_000) / 10_000; // round to $0.0001
|
|
49
|
+
return agg;
|
|
50
|
+
}
|
|
51
|
+
/** Parse a JSONL log file into raw events (null if missing/unreadable). */
|
|
52
|
+
export function readJsonlLog(path) {
|
|
53
|
+
let content;
|
|
54
|
+
try {
|
|
55
|
+
content = readFileSync(path, "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const events = [];
|
|
61
|
+
for (const line of content.split("\n")) {
|
|
62
|
+
if (!line.trim())
|
|
63
|
+
continue;
|
|
64
|
+
try {
|
|
65
|
+
events.push(JSON.parse(line));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// skip malformed lines
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return events;
|
|
72
|
+
}
|
|
73
|
+
/** List all session log files, newest id first (ids sort chronologically). */
|
|
74
|
+
export function listLogSessions(workdir) {
|
|
75
|
+
const dir = resolve(workdir, ".scoder/logs");
|
|
76
|
+
if (!existsSync(dir))
|
|
77
|
+
return [];
|
|
78
|
+
try {
|
|
79
|
+
return readdirSync(dir)
|
|
80
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
81
|
+
.map((f) => f.slice(0, -".jsonl".length))
|
|
82
|
+
.sort()
|
|
83
|
+
.reverse();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Load all usage events of a single session (or [] if no log). */
|
|
90
|
+
export function loadSessionUsage(workdir, sessionId) {
|
|
91
|
+
const path = resolve(workdir, ".scoder/logs", `${sessionId}.jsonl`);
|
|
92
|
+
const events = readJsonlLog(path);
|
|
93
|
+
return events ? extractUsageEvents(sessionId, events) : [];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Collect usage for a selection of sessions:
|
|
97
|
+
* - explicit id -> that session
|
|
98
|
+
* - "last n" -> n most recent session logs
|
|
99
|
+
* - all -> every session log in the workdir
|
|
100
|
+
*/
|
|
101
|
+
export function collectUsage(workdir, opts = {}) {
|
|
102
|
+
let ids;
|
|
103
|
+
if (opts.sessionId) {
|
|
104
|
+
ids = [opts.sessionId];
|
|
105
|
+
}
|
|
106
|
+
else if (typeof opts.last === "number") {
|
|
107
|
+
const all = listLogSessions(workdir);
|
|
108
|
+
ids = all.slice(0, Math.max(0, opts.last));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
ids = listLogSessions(workdir);
|
|
112
|
+
}
|
|
113
|
+
return ids.map((id) => ({
|
|
114
|
+
sessionId: id,
|
|
115
|
+
aggregate: aggregateUsage(loadSessionUsage(workdir, id), opts.pricingOverrides),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
/** Render a human-readable cost report (also used by tests to assert output). */
|
|
119
|
+
export function formatCostReport(sessionCosts) {
|
|
120
|
+
if (sessionCosts.length === 0) {
|
|
121
|
+
return "No LLM usage recorded. Run with --log-file (or SCODER_LOG_FILE=1) to record usage.";
|
|
122
|
+
}
|
|
123
|
+
const grand = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, costUsd: 0 };
|
|
124
|
+
const lines = ["COST REPORT"];
|
|
125
|
+
for (const sc of sessionCosts) {
|
|
126
|
+
const a = sc.aggregate;
|
|
127
|
+
grand.calls += a.calls;
|
|
128
|
+
grand.inputTokens += a.inputTokens;
|
|
129
|
+
grand.outputTokens += a.outputTokens;
|
|
130
|
+
grand.cachedTokens += a.cachedTokens;
|
|
131
|
+
grand.costUsd += a.costUsd;
|
|
132
|
+
lines.push(` ${sc.sessionId} calls=${a.calls} in=${a.inputTokens.toLocaleString("en-US")} out=${a.outputTokens.toLocaleString("en-US")} cached=${a.cachedTokens.toLocaleString("en-US")} cost≈$${a.costUsd.toFixed(4)}`);
|
|
133
|
+
}
|
|
134
|
+
grand.costUsd = Math.round(grand.costUsd * 10_000) / 10_000;
|
|
135
|
+
lines.push(`TOTAL (${sessionCosts.length} session(s)) calls=${grand.calls} in=${grand.inputTokens.toLocaleString("en-US")} out=${grand.outputTokens.toLocaleString("en-US")} cached=${grand.cachedTokens.toLocaleString("en-US")} cost≈$${grand.costUsd.toFixed(4)}`);
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
/** Join a path under the workdir's log dir (exported for tests). */
|
|
139
|
+
export function logFilePath(workdir, sessionId) {
|
|
140
|
+
return join(workdir, ".scoder/logs", `${sessionId}.jsonl`);
|
|
141
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { loadConfig } from "./config.js";
|
|
|
6
6
|
import { Agent } from "./agent.js";
|
|
7
7
|
import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
|
|
8
8
|
import { JsonlLogger } from "./logger.js";
|
|
9
|
+
import { collectUsage, formatCostReport } from "./cost.js";
|
|
9
10
|
import { getVersion as getPackageVersion } from "./version.js";
|
|
10
11
|
import * as ui from "./ui.js";
|
|
11
12
|
// Single source of truth: read version from package.json (see src/version.ts).
|
|
@@ -18,6 +19,8 @@ USAGE
|
|
|
18
19
|
scoder list List saved sessions for this directory
|
|
19
20
|
scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
|
|
20
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
|
|
21
24
|
scoder --version | --help
|
|
22
25
|
|
|
23
26
|
OPTIONS
|
|
@@ -33,6 +36,8 @@ OPTIONS
|
|
|
33
36
|
--no-save Disable session auto-save to .scoder/sessions/
|
|
34
37
|
--log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
|
|
35
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
|
|
36
41
|
-h, --help Show this help
|
|
37
42
|
-v, --version Show version
|
|
38
43
|
|
|
@@ -96,6 +101,17 @@ function parseArgs(argv) {
|
|
|
96
101
|
case "--log-file":
|
|
97
102
|
args.logFile = true;
|
|
98
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
|
+
}
|
|
99
115
|
case "--provider": {
|
|
100
116
|
const p = next();
|
|
101
117
|
if (p !== "openai" && p !== "anthropic") {
|
|
@@ -135,6 +151,10 @@ function parseArgs(argv) {
|
|
|
135
151
|
args.logsId = rest[1] ?? "last";
|
|
136
152
|
args.logsFilter = rest.length > 2 ? rest.slice(2).join(" ") : undefined;
|
|
137
153
|
}
|
|
154
|
+
else if (rest[0] === "cost") {
|
|
155
|
+
args.subcommand = "cost";
|
|
156
|
+
args.task = null;
|
|
157
|
+
}
|
|
138
158
|
return args;
|
|
139
159
|
}
|
|
140
160
|
async function runOnce(cfg, task) {
|
|
@@ -273,6 +293,15 @@ async function main() {
|
|
|
273
293
|
}
|
|
274
294
|
return;
|
|
275
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
|
+
}
|
|
276
305
|
if (args.task) {
|
|
277
306
|
const code = await runOnce(cfg, args.task);
|
|
278
307
|
process.exit(code);
|
package/dist/llm.js
CHANGED
|
@@ -119,6 +119,16 @@ export class LlmClient {
|
|
|
119
119
|
const choice = data.choices?.[0]?.message;
|
|
120
120
|
if (!choice)
|
|
121
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
|
+
}
|
|
122
132
|
return {
|
|
123
133
|
message: {
|
|
124
134
|
role: "assistant",
|
|
@@ -130,7 +140,7 @@ export class LlmClient {
|
|
|
130
140
|
arguments: tc.function?.arguments ?? "",
|
|
131
141
|
})),
|
|
132
142
|
},
|
|
133
|
-
usage
|
|
143
|
+
usage,
|
|
134
144
|
};
|
|
135
145
|
}
|
|
136
146
|
// ── Streaming ───────────────────────────────────────────────────────────────
|
package/dist/permissions.js
CHANGED
|
@@ -10,9 +10,30 @@ import { createInterface } from "node:readline";
|
|
|
10
10
|
// Catastrophic command patterns — always denied, no exceptions
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
12
12
|
const CATASTROPHIC_PATTERNS = [
|
|
13
|
+
// rm -rf (original) — kept for backwards compatibility
|
|
13
14
|
/rm\s+-rf\s+[/~"]|(?:rm\s+-rf\s+\/\*)/i,
|
|
14
|
-
/
|
|
15
|
+
// rm with whitespace/flag variants: "rm -rf /", "rm -fr /", "rm -r -f /",
|
|
16
|
+
// "rm --recursive --force /", "rm -rvf /" … i.e. any rm followed by one or
|
|
17
|
+
// more option flags and then a target at filesystem root or home.
|
|
18
|
+
// Does NOT match common safe commands: "rm -rf node_modules", "rm file.txt",
|
|
19
|
+
// "rm ./build" (target must START with / or ~).
|
|
20
|
+
/\brm\s+(?:-{1,2}[\w-]+\s+)+["']?[/~](?:\s|$|\/)/,
|
|
21
|
+
// rm with a single flag token containing both r/R and f (e.g. -fr, -Rf, -rvf)
|
|
22
|
+
// targeting / or ~ — covered above, but also catch "rm -fr --no-preserve-root".
|
|
23
|
+
/\brm\s+[^|;&>]*--no-preserve-root/i,
|
|
24
|
+
// base64/sh -c wrappers used to smuggle destructive commands past filters:
|
|
25
|
+
// echo cm0gLXJmIC8= | base64 -d | sh
|
|
26
|
+
// sh -c "rm -rf /"
|
|
27
|
+
/base64\s+(?:-d|-D|--decode)\b[^|;&]*\|\s*(?:ba|z|da)?sh\b/i,
|
|
28
|
+
/\b(?:ba|z|da)?sh\s+-c\s+["'][^"']*\brm\s+[^"']*\s-[a-zA-Z]*[rf]/i,
|
|
29
|
+
// Filesystem destruction
|
|
30
|
+
/\bmkfs(?:\.[a-z0-9]+)?\b/i,
|
|
15
31
|
/dd\s+.*of=\//i,
|
|
32
|
+
/chmod\s+-R\s+0[77][07][07]\s+[/~]/i,
|
|
33
|
+
// chmod -R 777 on / or ~ (any flag spelling: -R, -r, -Rf, --recursive)
|
|
34
|
+
/\bchmod\s+(?:-{1,2}[\w-]+\s+)+[0-7]{3,4}\s+["']?[/~](?:\s|$|\/)/,
|
|
35
|
+
// Redirect raw writes to block devices: > /dev/sda, > /dev/nvme0n1, dd > disk...
|
|
36
|
+
/>\s*\/dev\/(?:sd[a-z]+|hd[a-z]+|nvme\d+n\d+(?:p\d+)?|disk\d|rdisk\d)/i,
|
|
16
37
|
/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/i,
|
|
17
38
|
// curl/wget pipe-to-shell: match any content between command and '| sh'/'| bash'
|
|
18
39
|
// Handles -qO-, -X POST, etc. flags; URL-like patterns must contain '/' or '.'
|
|
@@ -20,9 +41,9 @@ const CATASTROPHIC_PATTERNS = [
|
|
|
20
41
|
/curl\s+.*[/.].*\s*\|\s*bash\b/i,
|
|
21
42
|
/wget\s+.*[/.].*\s*\|\s*sh\b/i,
|
|
22
43
|
/wget\s+.*[/.].*\s*\|\s*bash\b/i,
|
|
23
|
-
/sudo
|
|
44
|
+
// sudo rm with any recursive/force flag combo: "sudo rm -rf x", "sudo rm -fr /usr"
|
|
45
|
+
/sudo\s+rm\s+[^|;&]*-\w*[rR]/i,
|
|
24
46
|
/\bshutdown\b|\breboot\b|\binit\s+[06]\b/i,
|
|
25
|
-
/chmod\s+-R\s+0[77][07][07]\s+[/~]/i,
|
|
26
47
|
/>\s*\/dev\/sda/i,
|
|
27
48
|
/\|\s*nc\s+-e\s+/i,
|
|
28
49
|
/\|\s*bash\s+-i/i,
|
|
@@ -72,16 +72,20 @@ export function fromAnthropicResponse(data) {
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
const usage = {
|
|
76
|
+
prompt_tokens: data.usage?.input_tokens,
|
|
77
|
+
completion_tokens: data.usage?.output_tokens,
|
|
78
|
+
};
|
|
79
|
+
if (typeof data.usage?.cache_read_input_tokens === "number") {
|
|
80
|
+
usage.cached_tokens = data.usage.cache_read_input_tokens;
|
|
81
|
+
}
|
|
75
82
|
return {
|
|
76
83
|
message: {
|
|
77
84
|
role: "assistant",
|
|
78
85
|
content: text.length > 0 ? text : null,
|
|
79
86
|
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
80
87
|
},
|
|
81
|
-
usage
|
|
82
|
-
prompt_tokens: data.usage?.input_tokens,
|
|
83
|
-
completion_tokens: data.usage?.output_tokens,
|
|
84
|
-
},
|
|
88
|
+
usage,
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
// ---------------------------------------------------------------------------
|
|
@@ -216,6 +220,9 @@ export class AnthropicClient {
|
|
|
216
220
|
prompt_tokens: ev.message.usage.input_tokens,
|
|
217
221
|
completion_tokens: ev.message.usage.output_tokens,
|
|
218
222
|
};
|
|
223
|
+
if (typeof ev.message.usage.cache_read_input_tokens === "number") {
|
|
224
|
+
usage.cached_tokens = ev.message.usage.cache_read_input_tokens;
|
|
225
|
+
}
|
|
219
226
|
}
|
|
220
227
|
return null;
|
|
221
228
|
}
|
|
@@ -252,6 +259,9 @@ export class AnthropicClient {
|
|
|
252
259
|
prompt_tokens: usage?.prompt_tokens ?? ev.usage.input_tokens,
|
|
253
260
|
completion_tokens: ev.usage.output_tokens ?? usage?.completion_tokens,
|
|
254
261
|
};
|
|
262
|
+
if (usage?.cached_tokens === undefined && typeof ev.usage.cache_read_input_tokens === "number") {
|
|
263
|
+
usage = { ...usage, cached_tokens: ev.usage.cache_read_input_tokens };
|
|
264
|
+
}
|
|
255
265
|
}
|
|
256
266
|
return null;
|
|
257
267
|
}
|
package/dist/tools.js
CHANGED
|
@@ -25,8 +25,13 @@ function safePath(workdir, p) {
|
|
|
25
25
|
try {
|
|
26
26
|
realWorkdir = realpathSync(workdir);
|
|
27
27
|
}
|
|
28
|
-
catch {
|
|
29
|
-
// workdir must exist; keep lexical fallback
|
|
28
|
+
catch (err) {
|
|
29
|
+
// workdir must exist; keep lexical fallback for a transient ENOENT,
|
|
30
|
+
// but surface unexpected failures instead of silently swallowing them.
|
|
31
|
+
const code = err.code;
|
|
32
|
+
if (code !== "ENOENT") {
|
|
33
|
+
throw new Error(`Cannot resolve working directory "${workdir}": ${err.message}`, { cause: err });
|
|
34
|
+
}
|
|
30
35
|
}
|
|
31
36
|
// Walk up to the nearest existing ancestor, then verify containment.
|
|
32
37
|
let probe = abs;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s-app-scoder-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Autonomous coding agent CLI - similar to opencode/cline/claude. Runs tasks until build succeeds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
12
|
+
"prepare": "npm run build",
|
|
13
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test",
|
|
12
14
|
"start": "node dist/index.js",
|
|
13
15
|
"dev": "tsc --watch",
|
|
14
16
|
"typecheck": "tsc --noEmit",
|
|
@@ -48,4 +50,4 @@
|
|
|
48
50
|
"typescript": "^5.6.0",
|
|
49
51
|
"typescript-eslint": "^8.72.1"
|
|
50
52
|
}
|
|
51
|
-
}
|
|
53
|
+
}
|