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.
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ const MAX_MESSAGES = 120; // keep history under control for long runs
2
+ /**
3
+ * Trim message history to stay within token budget.
4
+ * Strategy: keep system prompt + last N messages, compact older user/assistant pairs.
5
+ */
6
+ export function trimHistory(messages) {
7
+ if (messages.length <= MAX_MESSAGES)
8
+ return messages;
9
+ // Keep system + last MAX_MESSAGES-1
10
+ const system = messages[0].role === "system" ? messages[0] : null;
11
+ const rest = system ? messages.slice(1) : messages;
12
+ const trimmed = rest.slice(-(MAX_MESSAGES - 1));
13
+ if (system) {
14
+ return [system, ...trimmed];
15
+ }
16
+ return trimmed;
17
+ }
18
+ /**
19
+ * Strict heuristic: does the assistant's plain-text message claim the task is
20
+ * finished? Deliberately narrow — bare words like "done" or "finished" appear
21
+ * in almost any sentence ("I'm not done yet", "package.json is finished with"),
22
+ * which previously triggered spurious build checks. Requires an explicit
23
+ * completion phrase instead.
24
+ */
25
+ export function claimsCompletion(text) {
26
+ return /\b(?:task\s+(?:is\s+)?(?:now\s+)?(?:complete[d]?|done|finished)|all\s+(?:done|finished)|work\s+is\s+(?:now\s+)?(?:complete[d]?|done|finished)|tests?\s+(?:all\s+)?pass(?:ed|ing)?\s*[-—]\s*(?:task|everything)\s+complete[d]?)\b/i.test(text);
27
+ }
28
+ /**
29
+ * Summarize a long output to keep context lean.
30
+ * For very long tool outputs, keep beginning + error lines + end.
31
+ */
32
+ export function summariseOutput(output, maxChars = 4000) {
33
+ if (output.length <= maxChars)
34
+ return output;
35
+ const lines = output.split("\n");
36
+ if (lines.length <= 30)
37
+ return output.slice(0, maxChars) + "\n... [truncated]";
38
+ const keepStart = Math.min(10, Math.floor(lines.length * 0.1));
39
+ const keepEnd = Math.min(10, Math.floor(lines.length * 0.1));
40
+ const snippet = lines.slice(0, keepStart).join("\n") +
41
+ `\n... [${lines.length - keepStart - keepEnd} lines omitted] ...\n` +
42
+ lines.slice(-keepEnd).join("\n");
43
+ return snippet.slice(0, maxChars) + "\n... [truncated]";
44
+ }
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/diff.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Minimal unified-diff generator (LCS-based, zero dependencies).
3
+ * Large inputs fall back to a summary (truncated: true).
4
+ */
5
+ const MAX_DIFF_LINES = 2000; // per side; beyond this, summarize only
6
+ const MAX_DIFF_TEXT_LINES = 120; // cap rendered diff lines
7
+ /** LCS-based line diff producing ops in order. */
8
+ function computeOps(a, b) {
9
+ const n = a.length;
10
+ const m = b.length;
11
+ // dp[i][j] = LCS length of a[i..] vs b[j..]
12
+ const dp = [];
13
+ for (let i = 0; i <= n; i++)
14
+ dp[i] = new Uint32Array(m + 1);
15
+ for (let i = n - 1; i >= 0; i--) {
16
+ for (let j = m - 1; j >= 0; j--) {
17
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
18
+ }
19
+ }
20
+ const ops = [];
21
+ let i = 0;
22
+ let j = 0;
23
+ while (i < n && j < m) {
24
+ if (a[i] === b[j]) {
25
+ ops.push({ type: " ", line: a[i] });
26
+ i++;
27
+ j++;
28
+ }
29
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
30
+ ops.push({ type: "-", line: a[i] });
31
+ i++;
32
+ }
33
+ else {
34
+ ops.push({ type: "+", line: b[j] });
35
+ j++;
36
+ }
37
+ }
38
+ while (i < n) {
39
+ ops.push({ type: "-", line: a[i] });
40
+ i++;
41
+ }
42
+ while (j < m) {
43
+ ops.push({ type: "+", line: b[j] });
44
+ j++;
45
+ }
46
+ return ops;
47
+ }
48
+ function splitLines(content) {
49
+ const lines = content.split("\n");
50
+ // Drop the artificial trailing empty element caused by a final newline
51
+ if (lines.length > 0 && content.endsWith("\n"))
52
+ lines.pop();
53
+ return lines;
54
+ }
55
+ /**
56
+ * Unified diff between old and new file content.
57
+ * oldContent === null means the file is new (all additions).
58
+ */
59
+ export function unifiedDiff(path, oldContent, newContent) {
60
+ if (oldContent === newContent) {
61
+ return { text: "", added: 0, removed: 0, truncated: false };
62
+ }
63
+ const a = oldContent === null ? [] : splitLines(oldContent);
64
+ const b = splitLines(newContent);
65
+ if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) {
66
+ return { text: "", added: b.length, removed: a.length, truncated: true };
67
+ }
68
+ const ops = computeOps(a, b);
69
+ const added = ops.filter((o) => o.type === "+").length;
70
+ const removed = ops.filter((o) => o.type === "-").length;
71
+ if (added === 0 && removed === 0)
72
+ return { text: "", added: 0, removed: 0, truncated: false };
73
+ const header = `--- a/${path}\n+++ b/${path}\n`;
74
+ const hunks = buildHunks(ops, 3);
75
+ let text = header + hunks.join("\n");
76
+ let truncated = false;
77
+ const textLines = text.split("\n");
78
+ if (textLines.length > MAX_DIFF_TEXT_LINES) {
79
+ // Cap rendered diff lines; report counts instead of the full diff
80
+ const kept = textLines.slice(0, MAX_DIFF_TEXT_LINES).join("\n");
81
+ text = `${kept}\n... [diff truncated: +${added} -${removed} lines total]`;
82
+ truncated = true;
83
+ }
84
+ return { text, added, removed, truncated };
85
+ }
86
+ /** Build unified-diff hunks with `context` lines of context around changes. */
87
+ function buildHunks(ops, context) {
88
+ // Indices of changed ops
89
+ const changed = [];
90
+ for (let i = 0; i < ops.length; i++) {
91
+ if (ops[i].type !== " ")
92
+ changed.push(i);
93
+ }
94
+ if (changed.length === 0)
95
+ return [];
96
+ // Prefix counts: lines in old/new file before op index i
97
+ const prefixA = new Int32Array(ops.length + 1); // counts "-" and " "
98
+ const prefixB = new Int32Array(ops.length + 1); // counts "+" and " "
99
+ for (let i = 0; i < ops.length; i++) {
100
+ prefixA[i + 1] = prefixA[i] + (ops[i].type === "+" ? 0 : 1);
101
+ prefixB[i + 1] = prefixB[i] + (ops[i].type === "-" ? 0 : 1);
102
+ }
103
+ // Group change indices into hunks (merge when gap <= 2*context)
104
+ const groups = [];
105
+ let gs = changed[0];
106
+ let prev = changed[0];
107
+ for (let k = 1; k < changed.length; k++) {
108
+ if (changed[k] - prev > context * 2) {
109
+ groups.push([gs, prev]);
110
+ gs = changed[k];
111
+ }
112
+ prev = changed[k];
113
+ }
114
+ groups.push([gs, prev]);
115
+ const hunks = [];
116
+ for (const [gStart, gEnd] of groups) {
117
+ const start = Math.max(0, gStart - context);
118
+ const end = Math.min(ops.length - 1, gEnd + context);
119
+ let aCount = 0;
120
+ let bCount = 0;
121
+ const body = [];
122
+ for (let k = start; k <= end; k++) {
123
+ const op = ops[k];
124
+ body.push(op.type + op.line);
125
+ if (op.type === "-")
126
+ aCount++;
127
+ else if (op.type === "+")
128
+ bCount++;
129
+ else {
130
+ aCount++;
131
+ bCount++;
132
+ }
133
+ }
134
+ // 1-based start lines; for empty side use the "line before" convention
135
+ const aStart = aCount > 0 ? prefixA[start] + 1 : prefixA[start];
136
+ const bStart = bCount > 0 ? prefixB[start] + 1 : prefixB[start];
137
+ const header = `@@ -${aStart},${aCount} +${bStart},${bCount} @@`;
138
+ hunks.push(header + "\n" + body.join("\n"));
139
+ }
140
+ return hunks;
141
+ }
142
+ /** Count added/removed lines for a summary like "+12 -3". */
143
+ export function diffCounts(oldContent, newContent) {
144
+ if (oldContent === newContent)
145
+ return { added: 0, removed: 0 };
146
+ const a = oldContent === null ? [] : splitLines(oldContent);
147
+ const b = splitLines(newContent);
148
+ if (a.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES) {
149
+ return { added: b.length, removed: a.length };
150
+ }
151
+ const ops = computeOps(a, b);
152
+ return {
153
+ added: ops.filter((o) => o.type === "+").length,
154
+ removed: ops.filter((o) => o.type === "-").length,
155
+ };
156
+ }