smolcoder 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,264 @@
1
+ "use strict";
2
+ // Ollama adapter — uses the NATIVE /api/chat endpoint, not the OpenAI-compat
3
+ // one, because only the native API lets us set num_ctx per request, pass
4
+ // thinking traces back, and read real timings (prompt/eval durations).
5
+ //
6
+ // Prompt-size savings that matter on a long tool loop with a thinking model:
7
+ // reasoning traces from assistant messages BEFORE the current user turn are
8
+ // not sent back (the qwen3-family templates drop them anyway); only the
9
+ // current turn's traces travel, which is what tool-call loops need.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.OllamaProvider = void 0;
12
+ exports.toWire = toWire;
13
+ const types_1 = require("./types");
14
+ /** Exported for tests. */
15
+ function toWire(messages) {
16
+ const keepThinkingFrom = (0, types_1.lastUserIndex)(messages);
17
+ return messages.map((m, i) => {
18
+ if (m.role === "assistant" && m.toolCalls?.length) {
19
+ return {
20
+ role: "assistant",
21
+ content: m.content ?? "",
22
+ ...(m.thinking && i > keepThinkingFrom ? { thinking: m.thinking } : {}),
23
+ tool_calls: m.toolCalls.map((tc) => ({
24
+ function: { name: tc.name, arguments: tc.args },
25
+ })),
26
+ };
27
+ }
28
+ if (m.role === "tool") {
29
+ return { role: "tool", content: m.content, tool_name: m.toolName };
30
+ }
31
+ return { role: m.role, content: m.content };
32
+ });
33
+ }
34
+ function toWireTools(tools) {
35
+ return tools.map((t) => ({
36
+ type: "function",
37
+ function: { name: t.name, description: t.description, parameters: t.parameters },
38
+ }));
39
+ }
40
+ /** Keep the model resident between tool calls and while the user reads or
41
+ * approves. Ollama's own default (5 min) unloads mid-session on any longer
42
+ * pause, and a reload of a 17 GB model costs 10-20 s plus a cold cache. */
43
+ const KEEP_ALIVE = process.env.SMOLCODER_KEEP_ALIVE || "30m";
44
+ class OllamaProvider {
45
+ baseUrl;
46
+ modelId;
47
+ contextWindow;
48
+ numCtx;
49
+ label;
50
+ maxOutputTokens;
51
+ effort = null;
52
+ thinkUnsupported = false;
53
+ /** null = unknown, tried lazily; false = this model only takes a boolean. */
54
+ levelsSupported = null;
55
+ constructor(baseUrl, modelId, contextWindow,
56
+ /** Explicit num_ctx to send; undefined = respect the server's configured context. */
57
+ numCtx, maxOutputTokens = types_1.MAX_OUTPUT_TOKENS) {
58
+ this.baseUrl = baseUrl;
59
+ this.modelId = modelId;
60
+ this.contextWindow = contextWindow;
61
+ this.numCtx = numCtx;
62
+ this.label = `ollama · ${modelId}`;
63
+ this.maxOutputTokens = maxOutputTokens;
64
+ // Only gpt-oss is documented to take levels; everything else gets a
65
+ // boolean straight away instead of a wasted probe request.
66
+ if (!/gpt-oss/i.test(modelId))
67
+ this.levelsSupported = false;
68
+ }
69
+ setEffort(effort) {
70
+ this.effort = effort;
71
+ this.thinkUnsupported = false;
72
+ }
73
+ effortLabel() {
74
+ if (this.effort === null || this.effort === "off")
75
+ return null;
76
+ if (this.thinkUnsupported)
77
+ return `${this.effort} (model has no thinking switch)`;
78
+ if (this.levelsSupported === false)
79
+ return `${this.effort} → thinking on`;
80
+ return null;
81
+ }
82
+ /** Ollama's think param: boolean for most reasoning models; gpt-oss accepts levels. */
83
+ thinkParam(effort) {
84
+ if (effort === null || this.thinkUnsupported)
85
+ return undefined;
86
+ if (effort === "off")
87
+ return false;
88
+ return this.levelsSupported === false ? true : effort;
89
+ }
90
+ async chat(messages, tools, opts = {}) {
91
+ const effort = opts.effortOverride ?? this.effort;
92
+ const makeBody = (stream, think) => ({
93
+ model: this.modelId,
94
+ messages: toWire(messages),
95
+ tools: tools.length ? toWireTools(tools) : undefined,
96
+ stream,
97
+ keep_alive: KEEP_ALIVE,
98
+ ...(think !== undefined ? { think } : {}),
99
+ options: {
100
+ ...(this.numCtx ? { num_ctx: this.numCtx } : {}),
101
+ num_predict: opts.maxTokens ?? this.maxOutputTokens,
102
+ },
103
+ });
104
+ let think = this.thinkParam(effort);
105
+ const started = { streaming: false };
106
+ try {
107
+ return await this.request(makeBody(true, think), opts, started);
108
+ }
109
+ catch (err) {
110
+ if (err?.name === "AbortError")
111
+ throw err;
112
+ // Never re-request after tokens were already streamed to the UI — that
113
+ // double-emits. Let agent.ts's chatWithRetry handle mid-stream failures.
114
+ if (started.streaming)
115
+ throw err;
116
+ const msg = String(err?.message ?? "");
117
+ const paramRejected = /returned 4\d\d/.test(msg) && /think/i.test(msg);
118
+ // Only treat the think param as unsupported on an actual param rejection;
119
+ // a transient 5xx/network error must NOT permanently disable reasoning.
120
+ if (think !== undefined && paramRejected) {
121
+ if (typeof think === "string") {
122
+ // Levels rejected — this model takes a boolean. Same intent: on.
123
+ this.levelsSupported = false;
124
+ think = true;
125
+ try {
126
+ return await this.request(makeBody(true, think), opts, started);
127
+ }
128
+ catch (err2) {
129
+ if (err2?.name === "AbortError" || started.streaming)
130
+ throw err2;
131
+ const msg2 = String(err2?.message ?? "");
132
+ if (!(/returned 4\d\d/.test(msg2) && /think/i.test(msg2)))
133
+ throw err2;
134
+ }
135
+ }
136
+ this.thinkUnsupported = true;
137
+ think = undefined;
138
+ return await this.request(makeBody(true, undefined), opts, started);
139
+ }
140
+ // Older Ollama versions reject stream+tools together; retry non-streaming
141
+ // once, but only for a pre-stream rejection (not a transient error).
142
+ if (/returned 4\d\d/.test(msg)) {
143
+ return await this.request(makeBody(false, think), opts, started);
144
+ }
145
+ throw err;
146
+ }
147
+ }
148
+ async request(body, opts, started) {
149
+ const t0 = Date.now();
150
+ const res = await fetch(`${this.baseUrl}/api/chat`, {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json" },
153
+ body: JSON.stringify(body),
154
+ signal: opts.signal,
155
+ });
156
+ if (!res.ok) {
157
+ const text = await res.text().catch(() => "");
158
+ throw new Error(`Ollama returned ${res.status}: ${text.slice(0, 300)}`);
159
+ }
160
+ let content = "";
161
+ let thinking = "";
162
+ const toolCalls = [];
163
+ let promptTokens;
164
+ let completionTokens;
165
+ let promptTokPerSec;
166
+ let genTokPerSec;
167
+ let truncated = false;
168
+ let firstTokAt = 0;
169
+ const handleChunk = (chunk) => {
170
+ if (started)
171
+ started.streaming = true; // committed — no safe re-request now
172
+ const msg = chunk.message;
173
+ if (msg?.thinking) {
174
+ thinking += msg.thinking;
175
+ opts.onThinking?.(msg.thinking);
176
+ if (!firstTokAt)
177
+ firstTokAt = Date.now();
178
+ }
179
+ if (msg?.content) {
180
+ content += msg.content;
181
+ opts.onToken?.(msg.content);
182
+ if (!firstTokAt)
183
+ firstTokAt = Date.now();
184
+ }
185
+ if (Array.isArray(msg?.tool_calls)) {
186
+ if (!firstTokAt)
187
+ firstTokAt = Date.now();
188
+ for (const tc of msg.tool_calls) {
189
+ const fn = tc.function ?? {};
190
+ toolCalls.push({
191
+ id: (0, types_1.nextCallId)(),
192
+ name: fn.name ?? "",
193
+ ...(0, types_1.parseArgs)(fn.arguments),
194
+ });
195
+ }
196
+ }
197
+ if (chunk.done) {
198
+ if (typeof chunk.prompt_eval_count === "number")
199
+ promptTokens = chunk.prompt_eval_count;
200
+ if (typeof chunk.eval_count === "number")
201
+ completionTokens = chunk.eval_count;
202
+ if (typeof chunk.prompt_eval_duration === "number" && chunk.prompt_eval_duration > 0 && promptTokens) {
203
+ promptTokPerSec = promptTokens / (chunk.prompt_eval_duration / 1e9);
204
+ }
205
+ if (typeof chunk.eval_duration === "number" && chunk.eval_duration > 0 && completionTokens) {
206
+ genTokPerSec = completionTokens / (chunk.eval_duration / 1e9);
207
+ }
208
+ if (chunk.done_reason === "length")
209
+ truncated = true;
210
+ }
211
+ };
212
+ if (body.stream === false) {
213
+ handleChunk(await res.json());
214
+ }
215
+ else {
216
+ // NDJSON stream: one JSON object per line.
217
+ const reader = res.body.getReader();
218
+ const decoder = new TextDecoder();
219
+ let buffer = "";
220
+ while (true) {
221
+ const { done, value } = await reader.read();
222
+ if (done)
223
+ break;
224
+ buffer += decoder.decode(value, { stream: true });
225
+ let nl;
226
+ while ((nl = buffer.indexOf("\n")) >= 0) {
227
+ const line = buffer.slice(0, nl).trim();
228
+ buffer = buffer.slice(nl + 1);
229
+ if (!line)
230
+ continue;
231
+ try {
232
+ handleChunk(JSON.parse(line));
233
+ }
234
+ catch {
235
+ /* partial/garbled line — skip */
236
+ }
237
+ }
238
+ }
239
+ if (buffer.trim()) {
240
+ try {
241
+ handleChunk(JSON.parse(buffer.trim()));
242
+ }
243
+ catch {
244
+ /* ignore */
245
+ }
246
+ }
247
+ }
248
+ return {
249
+ content,
250
+ toolCalls,
251
+ thinking: thinking || undefined,
252
+ promptTokens,
253
+ // Ollama replays this turn's thinking into the next prompt, so the full
254
+ // eval count is what the next request carries.
255
+ completionTokens,
256
+ generatedTokens: completionTokens,
257
+ promptTokPerSec,
258
+ genTokPerSec,
259
+ ttftMs: firstTokAt ? firstTokAt - t0 : undefined,
260
+ truncated,
261
+ };
262
+ }
263
+ }
264
+ exports.OllamaProvider = OllamaProvider;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // One internal message/tool shape; each provider adapts it to its wire format.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.MAX_OUTPUT_TOKENS = exports.EFFORT_RANK = void 0;
5
+ exports.nextCallId = nextCallId;
6
+ exports.parseArgs = parseArgs;
7
+ exports.estimateReplayTokens = estimateReplayTokens;
8
+ exports.lastUserIndex = lastUserIndex;
9
+ /** Rank of every reasoning level any backend knows about, used to map a
10
+ * requested effort onto whatever a specific model actually supports. */
11
+ exports.EFFORT_RANK = {
12
+ none: 0,
13
+ off: 0,
14
+ minimal: 1,
15
+ low: 2,
16
+ medium: 3,
17
+ high: 4,
18
+ xhigh: 5,
19
+ on: 3,
20
+ };
21
+ exports.MAX_OUTPUT_TOKENS = 2048;
22
+ let callCounter = 0;
23
+ function nextCallId() {
24
+ return `call_${++callCounter}`;
25
+ }
26
+ function parseArgs(raw) {
27
+ if (raw && typeof raw === "object")
28
+ return { args: raw };
29
+ if (typeof raw === "string") {
30
+ try {
31
+ const parsed = JSON.parse(raw);
32
+ if (parsed && typeof parsed === "object")
33
+ return { args: parsed, rawArgs: raw };
34
+ return { args: {}, rawArgs: raw, parseError: "arguments were not a JSON object" };
35
+ }
36
+ catch (e) {
37
+ return { args: {}, rawArgs: raw, parseError: `invalid JSON: ${e.message}` };
38
+ }
39
+ }
40
+ return { args: {} };
41
+ }
42
+ /** Tokens the visible reply + tool-call JSON will occupy when replayed in the
43
+ * next prompt (~4 chars/token, corrected by real usage on the next response). */
44
+ function estimateReplayTokens(content, toolCalls) {
45
+ let chars = (content ?? "").length;
46
+ for (const tc of toolCalls)
47
+ chars += tc.name.length + (tc.rawArgs ?? JSON.stringify(tc.args)).length + 12;
48
+ return Math.ceil(chars / 4);
49
+ }
50
+ /** Index of the most recent plain user message (the current turn's start).
51
+ * Reasoning traces from assistant messages BEFORE it are dropped from the
52
+ * wire — that is what the Qwen3-family chat templates do themselves, and it
53
+ * is the biggest single saving on a long tool loop with a thinking model. */
54
+ function lastUserIndex(messages) {
55
+ for (let i = messages.length - 1; i >= 0; i--) {
56
+ if (messages[i].role === "user" && !messages[i].compactNote)
57
+ return i;
58
+ }
59
+ return -1;
60
+ }
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ // Workspace containment for file tools. Every path a tool touches must resolve
3
+ // inside the workspace root — including through symlinks, which is why we
4
+ // realpath the deepest EXISTING ancestor before comparing.
5
+ //
6
+ // Commands (run_command / task) cannot be contained this way. Edit mode scans
7
+ // the command text instead (commandEscapesWorkspace, below) and asks the user
8
+ // only when something reaches outside; bypass mode never asks.
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.SandboxError = void 0;
44
+ exports.resolveInWorkspace = resolveInWorkspace;
45
+ exports.relPath = relPath;
46
+ exports.commandEscapesWorkspace = commandEscapesWorkspace;
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
49
+ class SandboxError extends Error {
50
+ }
51
+ exports.SandboxError = SandboxError;
52
+ const isWin = process.platform === "win32";
53
+ function normalizeForCompare(p) {
54
+ return isWin ? p.toLowerCase() : p;
55
+ }
56
+ function resolveInWorkspace(root, userPath) {
57
+ if (typeof userPath !== "string" || userPath.trim() === "") {
58
+ throw new SandboxError("path is required (relative to the workspace, e.g. \"src/app.js\").");
59
+ }
60
+ const abs = path.resolve(root, userPath);
61
+ // realpath the deepest existing ancestor to defeat symlink escapes
62
+ let existing = abs;
63
+ while (!fs.existsSync(existing)) {
64
+ const parent = path.dirname(existing);
65
+ if (parent === existing)
66
+ break;
67
+ existing = parent;
68
+ }
69
+ let realExisting;
70
+ let realRoot;
71
+ try {
72
+ realExisting = fs.realpathSync(existing);
73
+ realRoot = fs.realpathSync(root);
74
+ }
75
+ catch {
76
+ throw new SandboxError(`cannot resolve path "${userPath}".`);
77
+ }
78
+ const rel = path.relative(normalizeForCompare(realRoot), normalizeForCompare(realExisting));
79
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
80
+ throw new SandboxError(`"${userPath}" is outside the workspace. Only files inside ${root} can be accessed. Use a relative path like "src/app.js".`);
81
+ }
82
+ return abs;
83
+ }
84
+ function relPath(root, abs) {
85
+ return path.relative(root, abs).split(path.sep).join("/") || ".";
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Command containment (edit mode). A shell command cannot be sandboxed the way
89
+ // a file path can, so this is a best-effort scan of the command text for
90
+ // anything that reaches outside the workspace: absolute paths that resolve
91
+ // elsewhere, `..` climbing past the root, home-directory shortcuts, temp-dir
92
+ // variables, and package-manager global installs. A clean in-tree command
93
+ // (`npm install`, `node script.mjs`, `a && b`) runs without asking; a flagged
94
+ // one goes to the y/n prompt with the reason shown. False positives cost one
95
+ // prompt, false negatives are the accepted limit of a text scan.
96
+ const HOME_TOKENS = /^(~|\$HOME|\$\{HOME\}|%USERPROFILE%|%HOMEPATH%)([\\/]|$)/i;
97
+ const TEMP_TOKENS = /^(\$TMPDIR|\$\{TMPDIR\}|\$TEMP|\$TMP|%TEMP%|%TMP%|\$\{TEMP\}|\$\{TMP\})([\\/]|$)/i;
98
+ const GLOBAL_INSTALL = /\b(npm|pnpm|yarn|bun)\b[^;&|]*\s(-g|--global|global)(\s|$)/;
99
+ /** MSYS roots Git Bash maps to real places; any other one-segment `/word` on
100
+ * Windows is a command switch (`taskkill /pid`, `dir /s`), not a path. */
101
+ const MSYS_ROOTS = /^\/(tmp|usr|etc|bin|home|mnt|dev|proc|var|opt|root)(\/|$)/i;
102
+ /** Split a command into candidate path tokens: whitespace-separated words with
103
+ * quotes stripped, the value side of `--flag=value` and `VAR=value`, and the
104
+ * target of `>`/`<` redirections written without a space. */
105
+ function pathCandidates(command) {
106
+ const out = [];
107
+ for (const raw of command.split(/\s+/)) {
108
+ if (!raw)
109
+ continue;
110
+ let tok = raw.replace(/^[<>]+|^[\d]?[<>]+&?/, "").replace(/^["'`]+|["'`,;)]+$/g, "");
111
+ if (!tok)
112
+ continue;
113
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(tok))
114
+ continue; // URLs
115
+ const eq = tok.indexOf("=");
116
+ if (eq > 0 && /^[-\w.]+$/.test(tok.slice(0, eq)))
117
+ tok = tok.slice(eq + 1);
118
+ if (tok)
119
+ out.push(tok);
120
+ }
121
+ return out;
122
+ }
123
+ /** Git Bash writes C:\x as /c/x; map that back so it compares like a Windows
124
+ * path. A bare `/f` with nothing after it is left alone — that is a command
125
+ * switch far more often than the root of drive F:. */
126
+ function fromMsys(tok) {
127
+ const m = /^\/([a-zA-Z])(\/.*)$/.exec(tok);
128
+ if (m && isWin)
129
+ return `${m[1].toUpperCase()}:${m[2].replace(/\//g, "\\")}`;
130
+ return tok;
131
+ }
132
+ function isAbsoluteLike(tok) {
133
+ if (/^[a-zA-Z]:[\\/]/.test(tok))
134
+ return true; // C:\ or C:/
135
+ if (/^\\\\/.test(tok))
136
+ return true; // UNC
137
+ return tok.startsWith("/");
138
+ }
139
+ function insideWorkspace(root, abs) {
140
+ let realRoot = root;
141
+ try {
142
+ realRoot = fs.realpathSync(root);
143
+ }
144
+ catch {
145
+ /* compare against the given root */
146
+ }
147
+ const rel = path.relative(normalizeForCompare(realRoot), normalizeForCompare(path.resolve(abs)));
148
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
149
+ }
150
+ /**
151
+ * Why a command would reach outside the workspace, or null when every path it
152
+ * mentions stays inside it. Only the reason text is returned; the caller
153
+ * decides whether that means "ask" or "refuse".
154
+ */
155
+ function commandEscapesWorkspace(command, root) {
156
+ if (GLOBAL_INSTALL.test(command))
157
+ return "installs a package globally, outside the workspace";
158
+ for (const tok of pathCandidates(command)) {
159
+ if (HOME_TOKENS.test(tok))
160
+ return `uses the home directory (${tok})`;
161
+ if (TEMP_TOKENS.test(tok))
162
+ return `uses the system temp directory (${tok})`;
163
+ const t = fromMsys(tok);
164
+ if (isAbsoluteLike(t)) {
165
+ // On Windows under Git Bash, a bare POSIX path like /tmp or /usr/bin is
166
+ // an MSYS path — never inside a Windows workspace.
167
+ const posixOnWin = isWin && t.startsWith("/");
168
+ if (posixOnWin && !MSYS_ROOTS.test(t))
169
+ continue; // a /switch, not a path
170
+ if (posixOnWin || !insideWorkspace(root, t))
171
+ return `reaches outside the workspace (${tok})`;
172
+ continue;
173
+ }
174
+ if (/(^|[\\/])\.\.([\\/]|$)/.test(t) && !insideWorkspace(root, path.resolve(root, t))) {
175
+ return `climbs above the workspace (${tok})`;
176
+ }
177
+ }
178
+ return null;
179
+ }