s-app-scoder-agent 0.0.0-stage → 0.3.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/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
+ }
package/dist/index.js ADDED
@@ -0,0 +1,285 @@
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 { getVersion as getPackageVersion } from "./version.js";
10
+ import * as ui from "./ui.js";
11
+ // Single source of truth: read version from package.json (see src/version.ts).
12
+ const VERSION = getPackageVersion();
13
+ const HELP = `scoder — autonomous coding agent CLI
14
+
15
+ USAGE
16
+ scoder [options] "<task>" Run a task autonomously until the build passes
17
+ scoder Start interactive session (chat + tools)
18
+ scoder list List saved sessions for this directory
19
+ scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
20
+ scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
21
+ scoder --version | --help
22
+
23
+ OPTIONS
24
+ -m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
25
+ --base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
26
+ -k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
27
+ --provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
28
+ -B, --build <cmd> Build command = stop condition (auto-detected if omitted)
29
+ -d, --dir <path> Working directory (default: current directory)
30
+ -s, --max-steps <n> Max autonomous loop iterations (default 40)
31
+ -y, --yes Non-interactive mode (auto-approves commands)
32
+ --permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
33
+ --no-save Disable session auto-save to .scoder/sessions/
34
+ --log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
35
+ (or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
36
+ -h, --help Show this help
37
+ -v, --version Show version
38
+
39
+ CONFIG FILE
40
+ .scoderrc.json in the working directory (see .scoderrc.example.json):
41
+ { "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
42
+ "permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
43
+
44
+ EXAMPLES
45
+ scoder "add a divide function to src/math.ts with tests"
46
+ scoder --build "make test" "fix the failing test in utils"
47
+ scoder --permission ask "npm install express and wire up a route"
48
+ scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
49
+ scoder resume last "now add unit tests"
50
+ `;
51
+ function parseArgs(argv) {
52
+ const args = { task: null, yes: false, help: false, version: false };
53
+ const rest = [];
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ const next = () => argv[++i];
57
+ switch (a) {
58
+ case "-m":
59
+ case "--model":
60
+ args.model = next();
61
+ break;
62
+ case "-k":
63
+ case "--api-key":
64
+ args.apiKey = next();
65
+ break;
66
+ case "--base-url":
67
+ args.baseUrl = next();
68
+ break;
69
+ case "-B":
70
+ case "--build":
71
+ args.buildCmd = next();
72
+ break;
73
+ case "-d":
74
+ case "--dir":
75
+ args.workdir = next();
76
+ break;
77
+ case "-s":
78
+ case "--max-steps":
79
+ args.maxSteps = Number(next());
80
+ break;
81
+ case "-y":
82
+ case "--yes":
83
+ args.yes = true;
84
+ break;
85
+ case "--permission": {
86
+ const mode = next();
87
+ if (mode !== "auto" && mode !== "ask" && mode !== "readonly") {
88
+ throw new Error(`Invalid --permission mode: ${mode} (expected auto | ask | readonly)`);
89
+ }
90
+ args.permission = mode;
91
+ break;
92
+ }
93
+ case "--no-save":
94
+ args.noSave = true;
95
+ break;
96
+ case "--log-file":
97
+ args.logFile = true;
98
+ break;
99
+ case "--provider": {
100
+ const p = next();
101
+ if (p !== "openai" && p !== "anthropic") {
102
+ throw new Error(`Invalid --provider: ${p} (expected openai | anthropic)`);
103
+ }
104
+ args.provider = p;
105
+ break;
106
+ }
107
+ case "-h":
108
+ case "--help":
109
+ args.help = true;
110
+ break;
111
+ case "-v":
112
+ case "--version":
113
+ args.version = true;
114
+ break;
115
+ default:
116
+ if (a.startsWith("-")) {
117
+ throw new Error(`Unknown option: ${a} (see --help)`);
118
+ }
119
+ rest.push(a);
120
+ }
121
+ }
122
+ args.task = rest.length > 0 ? rest.join(" ") : null;
123
+ // Subcommands: scoder list | scoder resume <id|last> [follow-up task] | scoder logs [id]
124
+ if (rest.length > 0 && rest[0] === "list") {
125
+ args.subcommand = "list";
126
+ args.task = null;
127
+ }
128
+ else if (rest[0] === "resume") {
129
+ args.subcommand = "resume";
130
+ args.resumeId = rest[1] ?? "last";
131
+ args.task = rest.length > 2 ? rest.slice(2).join(" ") : null;
132
+ }
133
+ else if (rest[0] === "logs") {
134
+ args.subcommand = "logs";
135
+ args.logsId = rest[1] ?? "last";
136
+ args.logsFilter = rest.length > 2 ? rest.slice(2).join(" ") : undefined;
137
+ }
138
+ return args;
139
+ }
140
+ async function runOnce(cfg, task) {
141
+ const agent = new Agent(cfg);
142
+ process.on("SIGINT", () => {
143
+ ui.warn("\nSIGINT received — finishing current tool, then stopping...");
144
+ agent.abort();
145
+ });
146
+ try {
147
+ const result = await agent.run(task);
148
+ const pruned = pruneSessions(cfg.workdir, 7);
149
+ if (pruned > 0)
150
+ ui.info(`pruned ${pruned} old session(s)`);
151
+ return result.success ? 0 : 1;
152
+ }
153
+ catch (err) {
154
+ ui.error(err.message);
155
+ return 2;
156
+ }
157
+ }
158
+ async function repl(cfg) {
159
+ ui.header(`scoder interactive — model: ${cfg.model}`);
160
+ ui.info('Type a task and press Enter. "exit" or Ctrl+D to quit.');
161
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
162
+ // One shared session for the whole REPL conversation (plan P0.5 §4):
163
+ // every input is a follow-up turn, context carries over, saved continuously.
164
+ const agent = new Agent(cfg);
165
+ process.on("SIGINT", () => {
166
+ ui.warn("\nSIGINT — aborting current task...");
167
+ agent.abort();
168
+ });
169
+ let first = true;
170
+ for (;;) {
171
+ const line = await new Promise((res) => rl.question("you> ", res));
172
+ const input = line.trim();
173
+ if (!input)
174
+ continue;
175
+ if (input === "exit" || input === "quit")
176
+ break;
177
+ try {
178
+ const result = first ? await agent.run(input) : await agent.runTurn(input);
179
+ first = false;
180
+ if (!result.success)
181
+ ui.warn("task did not complete — try refining it");
182
+ }
183
+ catch (err) {
184
+ ui.error(err.message);
185
+ }
186
+ }
187
+ ui.info("bye");
188
+ process.exit(0);
189
+ }
190
+ async function main() {
191
+ let args;
192
+ try {
193
+ args = parseArgs(process.argv.slice(2));
194
+ }
195
+ catch (err) {
196
+ ui.error(err.message);
197
+ process.exit(2);
198
+ }
199
+ if (args.version) {
200
+ console.log(VERSION);
201
+ return;
202
+ }
203
+ if (args.help) {
204
+ console.log(HELP);
205
+ return;
206
+ }
207
+ const cfg = loadConfig({
208
+ model: args.model,
209
+ baseUrl: args.baseUrl,
210
+ apiKey: args.apiKey,
211
+ buildCmd: args.buildCmd ?? undefined,
212
+ workdir: args.workdir ? resolve(args.workdir) : undefined,
213
+ maxSteps: args.maxSteps,
214
+ yes: args.yes,
215
+ permissionMode: args.permission ?? undefined,
216
+ provider: args.provider ?? undefined,
217
+ noSave: args.noSave,
218
+ logFile: args.logFile,
219
+ });
220
+ // Subcommands
221
+ if (args.subcommand === "list") {
222
+ const sessions = listSessions(cfg.workdir);
223
+ if (sessions.length === 0) {
224
+ console.log("No saved sessions in this directory.");
225
+ return;
226
+ }
227
+ for (const s of sessions) {
228
+ console.log(`${s.id} steps=${String(s.steps).padStart(3)} ${s.updatedAt} ${s.task}`);
229
+ }
230
+ return;
231
+ }
232
+ if (args.subcommand === "resume") {
233
+ const s = resolveSessionId(cfg.workdir, args.resumeId ?? "last");
234
+ if (!s) {
235
+ ui.error(`Session not found: ${args.resumeId ?? "last"}. Run \`scoder list\` to see saved sessions.`);
236
+ process.exit(2);
237
+ }
238
+ const agent = new Agent(cfg);
239
+ process.on("SIGINT", () => {
240
+ ui.warn("\nSIGINT received — finishing current tool, then stopping...");
241
+ agent.abort();
242
+ });
243
+ try {
244
+ const result = await agent.resume(s, args.task ?? undefined);
245
+ process.exit(result.success ? 0 : 1);
246
+ }
247
+ catch (err) {
248
+ ui.error(err.message);
249
+ process.exit(2);
250
+ }
251
+ }
252
+ if (args.subcommand === "logs") {
253
+ let id = args.logsId ?? "last";
254
+ if (id === "last") {
255
+ const sessions = listSessions(cfg.workdir);
256
+ if (sessions.length === 0) {
257
+ ui.error("No saved sessions in this directory. Run with --log-file to record logs.");
258
+ process.exit(2);
259
+ }
260
+ id = sessions[0].id;
261
+ }
262
+ const events = JsonlLogger.read(cfg.workdir, id);
263
+ if (events === null || events.length === 0) {
264
+ ui.error(`No log found for session ${id}. Logs require --log-file (or SCODER_LOG_FILE=1).`);
265
+ process.exit(2);
266
+ }
267
+ const filter = args.logsFilter?.toLowerCase();
268
+ for (const ev of events) {
269
+ const line = JSON.stringify(ev);
270
+ if (filter && !line.toLowerCase().includes(filter))
271
+ continue;
272
+ console.log(line);
273
+ }
274
+ return;
275
+ }
276
+ if (args.task) {
277
+ const code = await runOnce(cfg, args.task);
278
+ process.exit(code);
279
+ }
280
+ await repl(cfg);
281
+ }
282
+ main().catch((err) => {
283
+ ui.error(`fatal: ${err.stack ?? err.message}`);
284
+ process.exit(2);
285
+ });