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.
package/dist/plan.js ADDED
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ // The agent's plan: a harness-held checklist. This is deliberately NOT a file
3
+ // and NOT model-formatted text — the harness owns the state, so rendering it
4
+ // to the user costs zero tokens, and compaction can never destroy it. For a
5
+ // small model it works as a compass: every `done` result re-states what comes
6
+ // next, and after compaction the whole checklist is re-injected verbatim.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.Plan = void 0;
9
+ class Plan {
10
+ steps = [];
11
+ get exists() {
12
+ return this.steps.length > 0;
13
+ }
14
+ get doneCount() {
15
+ return this.steps.filter((s) => s.done).length;
16
+ }
17
+ /** Index of the current (first undone) step, or -1 when all done/empty. */
18
+ get currentIndex() {
19
+ return this.steps.findIndex((s) => !s.done);
20
+ }
21
+ reset() {
22
+ this.steps = [];
23
+ }
24
+ /** Replace the plan. Steps arrive as one newline-separated string — the most
25
+ * reliable shape for small models (no arrays to mangle). */
26
+ set(stepsText) {
27
+ const lines = stepsText
28
+ .split("\n")
29
+ .map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])?\s*(?:\[.\]\s*)?/, "").trim())
30
+ .filter(Boolean)
31
+ .slice(0, 20);
32
+ if (lines.length === 0) {
33
+ return 'Error: steps is required — one step per line. Example: {"action": "set", "steps": "create index.html\\ncreate game.js\\ntest the page"}';
34
+ }
35
+ this.steps = lines.map((text) => ({ text, done: false }));
36
+ return `Plan set (${this.steps.length} steps). Current: 1. ${this.steps[0].text}`;
37
+ }
38
+ /** Mark a step done. No index = the current step. Returns a compact
39
+ * "what's next" line — cheap tokens that keep the model on course. */
40
+ markDone(stepNumber) {
41
+ if (!this.exists)
42
+ return 'Error: no plan yet. Create one first with {"action": "set", "steps": "..."}';
43
+ let idx;
44
+ if (stepNumber === undefined || stepNumber === null) {
45
+ idx = this.currentIndex;
46
+ if (idx < 0)
47
+ return "All steps are already done.";
48
+ }
49
+ else {
50
+ idx = Math.floor(stepNumber) - 1;
51
+ if (idx < 0 || idx >= this.steps.length) {
52
+ return `Error: step ${stepNumber} does not exist. The plan has ${this.steps.length} steps.`;
53
+ }
54
+ }
55
+ this.steps[idx].done = true;
56
+ const next = this.currentIndex;
57
+ return next < 0
58
+ ? `Done: ${idx + 1}. All ${this.steps.length} steps complete.`
59
+ : `Done: ${idx + 1}. Next: ${next + 1}. ${this.steps[next].text}`;
60
+ }
61
+ add(text) {
62
+ if (typeof text !== "string" || !text.trim()) {
63
+ return 'Error: text is required. Example: {"action": "add", "text": "fix the collision bug"}';
64
+ }
65
+ if (this.steps.length >= 20)
66
+ return "Error: the plan already has 20 steps — finish some first.";
67
+ this.steps.push({ text: text.trim(), done: false });
68
+ return `Added step ${this.steps.length}: ${text.trim()}`;
69
+ }
70
+ /** Compact model-facing checklist (used by action "show" and after compaction). */
71
+ modelView() {
72
+ if (!this.exists)
73
+ return "No plan set.";
74
+ return this.steps
75
+ .map((s, i) => `${i + 1}.[${s.done ? "x" : i === this.currentIndex ? ">" : " "}] ${s.text}`)
76
+ .join("\n");
77
+ }
78
+ /** One-line summary for the compaction state note. */
79
+ compactLine() {
80
+ if (!this.exists)
81
+ return null;
82
+ return `Plan (${this.doneCount}/${this.steps.length} done):\n${this.modelView()}`;
83
+ }
84
+ pendingSummary() {
85
+ return this.steps
86
+ .filter((s) => !s.done)
87
+ .map((s) => s.text)
88
+ .join("; ");
89
+ }
90
+ }
91
+ exports.Plan = Plan;
package/dist/prompt.js ADDED
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ // The whole system prompt. Two short paragraphs, ~120 tokens. Everything else
3
+ // the model needs lives in the tool schemas and in coaching error messages.
4
+ // If the workspace has an AGENTS.md, its contents ride along directly after
5
+ // the prompt (size-capped) — and because they are part of message[0], they
6
+ // survive compaction the same way the system prompt does.
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.loadAgentsMd = loadAgentsMd;
42
+ exports.buildSystemPrompt = buildSystemPrompt;
43
+ const fs = __importStar(require("fs"));
44
+ const path = __importStar(require("path"));
45
+ const AGENTS_MD_CAP_CHARS = 8000; // ~2k tokens — small-context friendly
46
+ /** Read the workspace's AGENTS.md memory file, if any. */
47
+ function loadAgentsMd(workspace) {
48
+ try {
49
+ const p = path.join(workspace, "AGENTS.md");
50
+ if (!fs.existsSync(p))
51
+ return null;
52
+ let text = fs.readFileSync(p, "utf8").trim();
53
+ if (!text)
54
+ return null;
55
+ if (text.length > AGENTS_MD_CAP_CHARS) {
56
+ text =
57
+ text.slice(0, AGENTS_MD_CAP_CHARS) +
58
+ "\n[AGENTS.md was truncated here to save context]";
59
+ }
60
+ return text;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function buildSystemPrompt(opts) {
67
+ const os = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux";
68
+ const modeLine = opts.mode === "ro"
69
+ ? "You are in read-only mode: you can read and search files but not change anything."
70
+ : opts.mode === "edit"
71
+ ? "You can read, write and edit files freely and run commands (install packages, run tests, scripts) without asking, as long as everything stays inside the workspace. A command that touches paths outside it (absolute paths, /tmp, ~, ..) asks the user for approval first — so keep scratch files inside the workspace, e.g. in a .scratch/ folder."
72
+ : "You have full access to files and commands; nothing asks the user for approval.";
73
+ return (`You are smolcoder, a coding agent working in the workspace ${opts.workspace} on ${os}. ` +
74
+ `Commands run in ${opts.shellLabel} with the workspace as the working directory. ` +
75
+ `File paths are relative to the workspace; you cannot access files outside it. ${modeLine}\n\n` +
76
+ `Work step by step: look at the relevant files before changing them, make one tool call at a time, ` +
77
+ `keep changes small and focused, and verify your work when you can. ` +
78
+ `For any task with more than one step, FIRST call the plan tool ({"action": "set"}) with your step list. ` +
79
+ `The moment a step is finished, call plan {"action": "done"} BEFORE starting the next one — the plan is your map of the task and is kept for you even when older context is dropped. ` +
80
+ `If a tool returns an error, read it carefully — it tells you how to fix the call. ` +
81
+ `When the task is done, stop and summarize briefly what you did.` +
82
+ (opts.agentsMd
83
+ ? `\n\nWorkspace instructions from AGENTS.md — follow these:\n${opts.agentsMd}`
84
+ : ""));
85
+ }
@@ -0,0 +1,326 @@
1
+ "use strict";
2
+ // LM Studio adapter — standard OpenAI-compatible /v1/chat/completions with SSE
3
+ // streaming. The context window is whatever LM Studio loaded the model with;
4
+ // we detect it and budget within it (we cannot change it per request).
5
+ //
6
+ // Reasoning is the part that decides whether LM Studio feels fast or slow.
7
+ // LM Studio's API accepts reasoning_effort none|minimal|low|medium|high|xhigh,
8
+ // but each MODEL only supports a subset (read from /api/v1/models). A value
9
+ // the model does not support is silently replaced by the model's DEFAULT —
10
+ // which for current qwen3.x builds is "xhigh", the maximum. That is how a
11
+ // harness asking for "high" ends up with 8,000-token thinking bursts per tool
12
+ // call. So: "off" is sent as "none" (measured: fully disables thinking), and
13
+ // every other level is snapped to the nearest level the model really has.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.LmStudioProvider = void 0;
16
+ exports.mapEffort = mapEffort;
17
+ const types_1 = require("./types");
18
+ function toWire(messages) {
19
+ return messages.map((m) => {
20
+ if (m.role === "assistant" && m.toolCalls?.length) {
21
+ return {
22
+ role: "assistant",
23
+ content: m.content || null,
24
+ tool_calls: m.toolCalls.map((tc) => ({
25
+ id: tc.id,
26
+ type: "function",
27
+ function: { name: tc.name, arguments: JSON.stringify(tc.args) },
28
+ })),
29
+ };
30
+ }
31
+ if (m.role === "tool") {
32
+ return { role: "tool", tool_call_id: m.toolCallId, content: m.content };
33
+ }
34
+ return { role: m.role, content: m.content };
35
+ });
36
+ }
37
+ function toWireTools(tools) {
38
+ return tools.map((t) => ({
39
+ type: "function",
40
+ function: { name: t.name, description: t.description, parameters: t.parameters },
41
+ }));
42
+ }
43
+ /** Exported for tests. Map a smolcoder effort onto LM Studio's wire value,
44
+ * respecting what the model supports. Returns undefined for "leave it to the
45
+ * backend". */
46
+ function mapEffort(effort, info) {
47
+ if (effort === null)
48
+ return undefined;
49
+ if (effort === "off")
50
+ return "none";
51
+ const wireLevels = ["low", "medium", "high", "xhigh"];
52
+ if (!info || info.allowed.length === 0)
53
+ return effort;
54
+ // Model-supported levels that the API also accepts (the model list uses
55
+ // "off"/"on" too; those are not valid wire values).
56
+ const candidates = wireLevels.filter((l) => info.allowed.includes(l));
57
+ if (candidates.length === 0)
58
+ return effort;
59
+ if (candidates.includes(effort))
60
+ return effort;
61
+ const want = types_1.EFFORT_RANK[effort];
62
+ let best = candidates[0];
63
+ let bestDist = Infinity;
64
+ for (const cnd of candidates) {
65
+ const d = Math.abs(types_1.EFFORT_RANK[cnd] - want);
66
+ // Ties go to the LOWER level: on a local model the cheaper step wins.
67
+ if (d < bestDist || (d === bestDist && types_1.EFFORT_RANK[cnd] < types_1.EFFORT_RANK[best])) {
68
+ best = cnd;
69
+ bestDist = d;
70
+ }
71
+ }
72
+ return best;
73
+ }
74
+ class LmStudioProvider {
75
+ baseUrl;
76
+ modelId;
77
+ contextWindow;
78
+ reasoning;
79
+ label;
80
+ maxOutputTokens;
81
+ effort = null;
82
+ effortUnsupported = false;
83
+ constructor(baseUrl, modelId, contextWindow, maxOutputTokens = types_1.MAX_OUTPUT_TOKENS, reasoning) {
84
+ this.baseUrl = baseUrl;
85
+ this.modelId = modelId;
86
+ this.contextWindow = contextWindow;
87
+ this.reasoning = reasoning;
88
+ this.label = `lmstudio · ${modelId}`;
89
+ this.maxOutputTokens = maxOutputTokens;
90
+ }
91
+ setEffort(effort) {
92
+ this.effort = effort;
93
+ this.effortUnsupported = false;
94
+ }
95
+ effortLabel() {
96
+ if (this.effortUnsupported)
97
+ return this.effort ? `${this.effort} (ignored by this server)` : null;
98
+ if (this.effort === null) {
99
+ return this.reasoning?.default ? `default → ${this.reasoning.default}` : null;
100
+ }
101
+ const wire = mapEffort(this.effort, this.reasoning);
102
+ if (wire && wire !== this.effort && wire !== "none")
103
+ return `${this.effort} → ${wire}`;
104
+ return null;
105
+ }
106
+ async chat(messages, tools, opts = {}) {
107
+ const effort = opts.effortOverride ?? this.effort;
108
+ let wireMessages = messages;
109
+ const base = {
110
+ model: this.modelId,
111
+ messages: toWire(wireMessages),
112
+ tools: tools.length ? toWireTools(tools) : undefined,
113
+ max_tokens: opts.maxTokens ?? this.maxOutputTokens,
114
+ };
115
+ const wireEffort = mapEffort(effort, this.reasoning);
116
+ if (wireEffort && !this.effortUnsupported) {
117
+ base.reasoning_effort = wireEffort;
118
+ }
119
+ else if (effort === "off" && /qwen/i.test(this.modelId)) {
120
+ // Older LM Studio builds without reasoning_effort: fall back to the
121
+ // qwen per-turn /no_think soft switch (older qwen3 models honor it).
122
+ wireMessages = messages.map((m) => ({ ...m }));
123
+ for (let i = wireMessages.length - 1; i >= 0; i--) {
124
+ if (wireMessages[i].role === "user") {
125
+ wireMessages[i].content = wireMessages[i].content + " /no_think";
126
+ break;
127
+ }
128
+ }
129
+ base.messages = toWire(wireMessages);
130
+ }
131
+ const started = { streaming: false };
132
+ try {
133
+ return await this.chain(base, opts, started);
134
+ }
135
+ catch (err) {
136
+ if (err?.name === "AbortError")
137
+ throw err;
138
+ if (started.streaming)
139
+ throw err; // tokens already shown — don't re-emit
140
+ const msg = String(err?.message ?? "");
141
+ // Only latch effortUnsupported on an actual param rejection (4xx naming
142
+ // it), never on a transient 5xx / dropped socket.
143
+ if (base.reasoning_effort && /returned 4\d\d/.test(msg) && /reasoning|effort/i.test(msg)) {
144
+ this.effortUnsupported = true;
145
+ delete base.reasoning_effort;
146
+ return await this.chain(base, opts, started);
147
+ }
148
+ throw err;
149
+ }
150
+ }
151
+ async chain(base, opts, started) {
152
+ try {
153
+ return await this.request({ ...base, stream: true, stream_options: { include_usage: true } }, opts, started);
154
+ }
155
+ catch (err) {
156
+ if (err?.name === "AbortError")
157
+ throw err;
158
+ if (started.streaming)
159
+ throw err;
160
+ const msg = String(err?.message ?? "");
161
+ // Only degrade the request shape on a pre-stream HTTP rejection; a
162
+ // transient error must propagate to chatWithRetry for backoff.
163
+ if (!/returned 4\d\d/.test(msg))
164
+ throw err;
165
+ // A reasoning_effort rejection must reach chat()'s handler, not be
166
+ // masked by the stream-shape fallback ladder.
167
+ if (/reasoning|effort/i.test(msg))
168
+ throw err;
169
+ try {
170
+ return await this.request({ ...base, stream: true }, opts, started);
171
+ }
172
+ catch (err2) {
173
+ if (err2?.name === "AbortError" || started.streaming)
174
+ throw err2;
175
+ if (!/returned 4\d\d/.test(String(err2?.message ?? "")))
176
+ throw err2;
177
+ return await this.request({ ...base, stream: false }, opts, started);
178
+ }
179
+ }
180
+ }
181
+ async request(body, opts, started) {
182
+ const t0 = Date.now();
183
+ const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
184
+ method: "POST",
185
+ headers: { "content-type": "application/json" },
186
+ body: JSON.stringify(body),
187
+ signal: opts.signal,
188
+ });
189
+ if (!res.ok) {
190
+ const text = await res.text().catch(() => "");
191
+ throw new Error(`LM Studio returned ${res.status}: ${text.slice(0, 300)}`);
192
+ }
193
+ if (body.stream === false) {
194
+ const data = await res.json();
195
+ const msg = data.choices?.[0]?.message ?? {};
196
+ const toolCalls = (msg.tool_calls ?? []).map((tc) => ({
197
+ id: tc.id || (0, types_1.nextCallId)(),
198
+ name: tc.function?.name ?? "",
199
+ ...(0, types_1.parseArgs)(tc.function?.arguments),
200
+ }));
201
+ if (started)
202
+ started.streaming = true;
203
+ if (msg.content)
204
+ opts.onToken?.(msg.content);
205
+ const content = msg.content ?? "";
206
+ const total = Date.now() - t0;
207
+ return {
208
+ content,
209
+ toolCalls,
210
+ promptTokens: data.usage?.prompt_tokens,
211
+ completionTokens: (0, types_1.estimateReplayTokens)(content, toolCalls),
212
+ generatedTokens: data.usage?.completion_tokens,
213
+ genTokPerSec: data.usage?.completion_tokens ? data.usage.completion_tokens / (total / 1000) : undefined,
214
+ truncated: data.choices?.[0]?.finish_reason === "length",
215
+ };
216
+ }
217
+ // SSE stream.
218
+ let content = "";
219
+ let thinking = "";
220
+ const partials = new Map();
221
+ let promptTokens;
222
+ let completionTokens;
223
+ let truncated = false;
224
+ let firstTokAt = 0;
225
+ let lastTokAt = 0;
226
+ const handleLine = (rawLine) => {
227
+ const line = rawLine.trim();
228
+ if (!line.startsWith("data:"))
229
+ return;
230
+ const payload = line.slice(5).trim();
231
+ if (payload === "[DONE]")
232
+ return;
233
+ let chunk;
234
+ try {
235
+ chunk = JSON.parse(payload);
236
+ }
237
+ catch {
238
+ return;
239
+ }
240
+ if (started)
241
+ started.streaming = true; // committed — no safe re-request now
242
+ if (chunk.usage) {
243
+ promptTokens = chunk.usage.prompt_tokens ?? promptTokens;
244
+ completionTokens = chunk.usage.completion_tokens ?? completionTokens;
245
+ }
246
+ if (chunk.choices?.[0]?.finish_reason === "length")
247
+ truncated = true;
248
+ const delta = chunk.choices?.[0]?.delta;
249
+ if (!delta)
250
+ return;
251
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
252
+ let sawToken = false;
253
+ if (typeof reasoning === "string" && reasoning) {
254
+ thinking += reasoning;
255
+ opts.onThinking?.(reasoning);
256
+ sawToken = true;
257
+ }
258
+ if (delta.content) {
259
+ content += delta.content;
260
+ opts.onToken?.(delta.content);
261
+ sawToken = true;
262
+ }
263
+ if (Array.isArray(delta.tool_calls)) {
264
+ sawToken = true;
265
+ for (const tc of delta.tool_calls) {
266
+ const idx = tc.index ?? 0;
267
+ const p = partials.get(idx) ?? { id: "", name: "", args: "" };
268
+ if (tc.id)
269
+ p.id = tc.id;
270
+ if (tc.function?.name)
271
+ p.name += tc.function.name;
272
+ if (tc.function?.arguments)
273
+ p.args += tc.function.arguments;
274
+ partials.set(idx, p);
275
+ }
276
+ }
277
+ if (sawToken) {
278
+ const now = Date.now();
279
+ if (!firstTokAt)
280
+ firstTokAt = now;
281
+ lastTokAt = now;
282
+ }
283
+ };
284
+ const reader = res.body.getReader();
285
+ const decoder = new TextDecoder();
286
+ let buffer = "";
287
+ while (true) {
288
+ const { done, value } = await reader.read();
289
+ if (done)
290
+ break;
291
+ buffer += decoder.decode(value, { stream: true });
292
+ let nl;
293
+ while ((nl = buffer.indexOf("\n")) >= 0) {
294
+ const line = buffer.slice(0, nl);
295
+ buffer = buffer.slice(nl + 1);
296
+ handleLine(line);
297
+ }
298
+ }
299
+ // Flush a final line with no trailing newline (may carry usage /
300
+ // finish_reason:"length" — losing it silently drops the anchor / truncation).
301
+ if (buffer.trim())
302
+ handleLine(buffer);
303
+ const toolCalls = [...partials.entries()]
304
+ .sort((a, b) => a[0] - b[0])
305
+ .map(([, p]) => ({
306
+ id: p.id || (0, types_1.nextCallId)(),
307
+ name: p.name,
308
+ ...(0, types_1.parseArgs)(p.args),
309
+ }));
310
+ const genMs = lastTokAt > firstTokAt ? lastTokAt - firstTokAt : 0;
311
+ return {
312
+ content,
313
+ toolCalls,
314
+ thinking: thinking || undefined,
315
+ promptTokens,
316
+ // LM Studio does not replay reasoning into the next prompt, so only the
317
+ // visible reply and tool-call JSON count toward the next request.
318
+ completionTokens: (0, types_1.estimateReplayTokens)(content, toolCalls),
319
+ generatedTokens: completionTokens,
320
+ genTokPerSec: completionTokens && genMs > 0 ? completionTokens / (genMs / 1000) : undefined,
321
+ ttftMs: firstTokAt ? firstTokAt - t0 : undefined,
322
+ truncated,
323
+ };
324
+ }
325
+ }
326
+ exports.LmStudioProvider = LmStudioProvider;