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/llm.js ADDED
@@ -0,0 +1,266 @@
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
+ return {
123
+ message: {
124
+ role: "assistant",
125
+ content: choice.content ?? null,
126
+ stop_reason: choice.stop_reason ?? (choice.content ? "stop" : undefined),
127
+ tool_calls: choice.tool_calls?.map((tc) => ({
128
+ id: tc.id ?? "",
129
+ name: tc.function?.name ?? "",
130
+ arguments: tc.function?.arguments ?? "",
131
+ })),
132
+ },
133
+ usage: data.usage,
134
+ };
135
+ }
136
+ // ── Streaming ───────────────────────────────────────────────────────────────
137
+ /**
138
+ * Stream chat completions, yielding incremental chunks.
139
+ * Falls back to non-streaming on stream-related errors.
140
+ */
141
+ async *streamChat(messages, tools, opts = {}) {
142
+ const url = `${this.opts.baseUrl.replace(/\/$/, "")}/chat/completions`;
143
+ const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
144
+ const bodyStr = JSON.stringify({
145
+ model: this.opts.model,
146
+ messages,
147
+ tools: tools.length > 0 ? tools : undefined,
148
+ temperature: this.opts.temperature,
149
+ max_tokens: this.opts.maxTokens,
150
+ stream: true,
151
+ });
152
+ let res;
153
+ try {
154
+ res = await withRetry(async () => {
155
+ const r = await fetch(url, {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.opts.apiKey}` },
158
+ body: bodyStr,
159
+ signal,
160
+ });
161
+ if (!r.ok)
162
+ throw await toLlmError(r, url);
163
+ return r;
164
+ }, {}, (attempt, delay, reason) => console.error(`⚠ LLM stream request failed (${reason}), retrying in ${(delay / 1000).toFixed(1)}s...`));
165
+ }
166
+ catch (err) {
167
+ cleanup(); // don't leak the 120s request timer — it would hang the process
168
+ throw new LlmError(`Stream fetch error after retries at ${url}: ${err.message}`);
169
+ }
170
+ if (!res.body) {
171
+ throw new LlmError("Response body is not readable (not a streaming response)");
172
+ }
173
+ // Accumulators for tool_calls across chunks
174
+ const accumulatedToolCalls = [];
175
+ let finalResult;
176
+ const reader = res.body.getReader();
177
+ const decoder = new TextDecoder();
178
+ let buffer = "";
179
+ try {
180
+ while (true) {
181
+ const { done, value } = await reader.read();
182
+ if (done)
183
+ break;
184
+ buffer += decoder.decode(value, { stream: true });
185
+ // Process complete lines
186
+ const lines = buffer.split("\n");
187
+ buffer = lines.pop() ?? ""; // keep incomplete line in buffer
188
+ for (const rawLine of lines) {
189
+ const json = parseSSELine(rawLine);
190
+ if (!json)
191
+ continue;
192
+ let chunkData;
193
+ try {
194
+ chunkData = JSON.parse(json);
195
+ }
196
+ catch {
197
+ continue; // skip malformed JSON lines
198
+ }
199
+ const choice = chunkData.choices?.[0];
200
+ if (!choice)
201
+ continue;
202
+ const delta = choice.delta;
203
+ // Text content delta
204
+ if (delta?.content != null && delta.content.length > 0) {
205
+ yield { contentDelta: delta.content, done: false };
206
+ }
207
+ // Tool call deltas
208
+ if (delta?.tool_calls) {
209
+ for (const tc of delta.tool_calls) {
210
+ const idx = tc.index ?? 0;
211
+ if (!accumulatedToolCalls[idx]) {
212
+ accumulatedToolCalls[idx] = { arguments: "" };
213
+ }
214
+ if (tc.id)
215
+ accumulatedToolCalls[idx].id = tc.id;
216
+ if (tc.function?.name)
217
+ accumulatedToolCalls[idx].name = tc.function.name;
218
+ if (tc.function?.arguments) {
219
+ accumulatedToolCalls[idx].arguments += tc.function.arguments;
220
+ }
221
+ yield {
222
+ toolCallDelta: {
223
+ index: idx,
224
+ id: tc.id,
225
+ name: tc.function?.name,
226
+ argumentsDelta: tc.function?.arguments,
227
+ },
228
+ done: false,
229
+ };
230
+ }
231
+ }
232
+ // Final chunk
233
+ if (choice.finish_reason) {
234
+ // Collect final tool_calls
235
+ const toolCalls = accumulatedToolCalls
236
+ .filter((tc) => tc.name)
237
+ .map((tc) => ({
238
+ id: tc.id ?? "",
239
+ name: tc.name ?? "",
240
+ arguments: tc.arguments,
241
+ }));
242
+ finalResult = {
243
+ message: {
244
+ role: "assistant",
245
+ content: null,
246
+ stop_reason: choice.finish_reason,
247
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
248
+ },
249
+ usage: chunkData.usage,
250
+ };
251
+ }
252
+ }
253
+ }
254
+ }
255
+ finally {
256
+ // Cancel the body (frees the socket) and clear the request timeout
257
+ try {
258
+ reader.cancel();
259
+ }
260
+ catch { /* already released */ }
261
+ reader.releaseLock();
262
+ cleanup();
263
+ }
264
+ yield { done: true, final: finalResult };
265
+ }
266
+ }
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
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Minimal MCP (Model Context Protocol) client for stdio servers.
3
+ *
4
+ * Implements JSON-RPC 2.0 over stdin/stdout with line-delimited messages.
5
+ * Connects to configured stdio servers, exposes their tools as
6
+ * `mcp__<server>__<tool>` prefixed names in the agent's tool registry.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { CLIENT_NAME, getVersion } from "./version.js";
10
+ export const MCP_TOOL_PREFIX = "mcp__";
11
+ export function mcpToolKey(serverName, toolName) {
12
+ return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
13
+ }
14
+ /** Expand {env:VAR} references in config env values. */
15
+ function expandEnv(env) {
16
+ const out = {};
17
+ for (const [k, v] of Object.entries(env ?? {})) {
18
+ out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => process.env[n] ?? "");
19
+ }
20
+ return out;
21
+ }
22
+ // ── JSON-RPC stdio connection ─────────────────────────────────────────────────
23
+ export class McpConnection {
24
+ name;
25
+ cfg;
26
+ proc = null;
27
+ nextId = 1;
28
+ pending = new Map();
29
+ buf = "";
30
+ constructor(name, cfg) {
31
+ this.name = name;
32
+ this.cfg = cfg;
33
+ }
34
+ get alive() {
35
+ return this.proc !== null && this.proc.exitCode === null;
36
+ }
37
+ async connect(timeoutMs = 15_000) {
38
+ const p = spawn(this.cfg.command, this.cfg.args ?? [], {
39
+ stdio: ["pipe", "pipe", "pipe"],
40
+ env: { ...process.env, ...expandEnv(this.cfg.env) },
41
+ });
42
+ this.proc = p;
43
+ p.stderr?.on("data", () => { });
44
+ p.on("error", (e) => this.drain(new Error(`spawn: ${e.message}`)));
45
+ p.on("exit", () => this.drain(new Error("process exited")));
46
+ p.stdout?.on("data", (c) => this.onChunk(c));
47
+ // MCP handshake: initialize + notifications/initialized
48
+ await this.rpc("initialize", {
49
+ protocolVersion: "2024-11-05",
50
+ capabilities: {},
51
+ clientInfo: { name: CLIENT_NAME, version: getVersion() },
52
+ }, timeoutMs);
53
+ this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
54
+ }
55
+ async listTools(timeoutMs = 120_000) {
56
+ const res = await this.rpc("tools/list", {}, timeoutMs);
57
+ return (res.tools ?? []).filter((t) => typeof t.name === "string");
58
+ }
59
+ async callTool(name, args, timeoutMs = 120_000) {
60
+ const res = await this.rpc("tools/call", { name, arguments: args }, timeoutMs);
61
+ const isErr = res.isError === true;
62
+ const parts = (res.content ?? []).map((c) => c.text ?? "").filter(Boolean);
63
+ return { ok: !isErr, output: parts.join("\n") || "(empty result)" };
64
+ }
65
+ kill() {
66
+ this.drain(new Error("disconnected"));
67
+ if (this.proc) {
68
+ try {
69
+ this.proc.kill();
70
+ }
71
+ catch { /* already dead */ }
72
+ this.proc = null;
73
+ }
74
+ }
75
+ // ── internals ─────────────────────────────────────────────────────────────
76
+ send(msg) {
77
+ if (!this.proc?.stdin?.writable)
78
+ return;
79
+ try {
80
+ this.proc.stdin.write(JSON.stringify(msg) + "\n");
81
+ }
82
+ catch { /* ignore */ }
83
+ }
84
+ async rpc(method, params, timeoutMs) {
85
+ if (!this.alive)
86
+ throw new Error(`MCP server "${this.name}" not connected`);
87
+ const id = this.nextId++;
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => {
90
+ this.pending.delete(id);
91
+ reject(new Error(`MCP "${method}" timed out after ${(timeoutMs / 1000).toFixed(0)}s`));
92
+ }, timeoutMs);
93
+ this.pending.set(id, {
94
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
95
+ reject: (e) => { clearTimeout(timer); reject(e); },
96
+ });
97
+ this.send({ jsonrpc: "2.0", id, method, params });
98
+ });
99
+ }
100
+ onChunk(chunk) {
101
+ this.buf += chunk.toString("utf8");
102
+ let i;
103
+ while ((i = this.buf.indexOf("\n")) !== -1) {
104
+ const line = this.buf.slice(0, i).trim();
105
+ this.buf = this.buf.slice(i + 1);
106
+ if (!line)
107
+ continue;
108
+ let msg;
109
+ try {
110
+ msg = JSON.parse(line);
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ if (msg.id === undefined)
116
+ continue;
117
+ const pending = this.pending.get(msg.id);
118
+ if (!pending)
119
+ continue;
120
+ this.pending.delete(msg.id);
121
+ if (msg.error)
122
+ pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
123
+ else
124
+ pending.resolve(msg.result ?? {});
125
+ }
126
+ }
127
+ drain(err) {
128
+ for (const [, p] of this.pending)
129
+ p.reject(err);
130
+ this.pending.clear();
131
+ }
132
+ }
133
+ export class McpManager {
134
+ configs;
135
+ conns = new Map();
136
+ tools = new Map();
137
+ constructor(configs) {
138
+ this.configs = configs;
139
+ }
140
+ /** Connect all servers; skip failures with a warning. Returns discovered tool count. */
141
+ async connectAll() {
142
+ await Promise.allSettled(Object.entries(this.configs).map(async ([name, cfg]) => {
143
+ const conn = new McpConnection(name, cfg);
144
+ await conn.connect();
145
+ const defs = await conn.listTools();
146
+ for (const def of defs) {
147
+ const key = mcpToolKey(name, def.name);
148
+ if (this.tools.has(key))
149
+ throw new Error(`Duplicate MCP tool: ${key}`);
150
+ this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
151
+ }
152
+ this.conns.set(name, conn);
153
+ })).then((results) => {
154
+ results.forEach((r, i) => {
155
+ const name = Object.keys(this.configs)[i];
156
+ if (r.status === "rejected") {
157
+ console.error(`⚠ MCP server "${name}" unavailable: ${String(r.reason?.message).slice(0, 160)}`);
158
+ }
159
+ else {
160
+ const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
161
+ console.error(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"}`);
162
+ }
163
+ });
164
+ });
165
+ return this.tools.size;
166
+ }
167
+ hasTool(name) {
168
+ return this.tools.has(name);
169
+ }
170
+ getTool(name) {
171
+ return this.tools.get(name);
172
+ }
173
+ /** Convert all discovered MCP tools to OpenAI function-calling specs. */
174
+ toToolSpecs() {
175
+ return [...this.tools.values()].map(({ conn: _, serverName, toolName, def }) => ({
176
+ type: "function",
177
+ function: {
178
+ name: mcpToolKey(serverName, toolName),
179
+ description: def.description ?? `MCP tool "${toolName}" from server "${serverName}"`,
180
+ parameters: def.inputSchema ?? { type: "object", properties: {} },
181
+ },
182
+ }));
183
+ }
184
+ killAll() {
185
+ for (const c of this.conns.values())
186
+ c.kill();
187
+ this.conns.clear();
188
+ }
189
+ }