smolcoder 0.4.2 → 0.5.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,480 @@
1
+ "use strict";
2
+ // One session = one workspace + one agent + one UI, plus the input loop and
3
+ // slash commands that drive it. Extracted from the CLI entry point so the
4
+ // terminal TUI and the web hub (many sessions side by side, one per browser
5
+ // sidebar entry) run exactly the same loop.
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.Session = exports.MODE_ORDER = exports.SLASH_COMMANDS = void 0;
41
+ exports.outputBudget = outputBudget;
42
+ exports.makeProvider = makeProvider;
43
+ exports.effortAdvice = effortAdvice;
44
+ exports.reportCompactions = reportCompactions;
45
+ exports.autoPickModel = autoPickModel;
46
+ exports.noBackendsMessage = noBackendsMessage;
47
+ exports.sessionLine = sessionLine;
48
+ exports.fmtTokens = fmtTokens;
49
+ exports.prepareModel = prepareModel;
50
+ const os = __importStar(require("os"));
51
+ const agent_1 = require("./agent");
52
+ const config_1 = require("./config");
53
+ const context_1 = require("./context");
54
+ const detect_1 = require("./detect");
55
+ const events_1 = require("./events");
56
+ const plan_1 = require("./plan");
57
+ const prompt_1 = require("./prompt");
58
+ const lmstudio_1 = require("./providers/lmstudio");
59
+ const ollama_1 = require("./providers/ollama");
60
+ const index_1 = require("./tools/index");
61
+ const shell_1 = require("./tools/shell");
62
+ const tasks_1 = require("./tools/tasks");
63
+ const util_1 = require("./util");
64
+ exports.SLASH_COMMANDS = [
65
+ { name: "models", desc: "Switch model" },
66
+ { name: "mode", desc: "Set mode (ro / edit / bypass)" },
67
+ { name: "effort", desc: "Set reasoning effort" },
68
+ { name: "plan", desc: "Show the agent's plan" },
69
+ { name: "context", desc: "Show context usage" },
70
+ { name: "compact", desc: "Compact the conversation now" },
71
+ { name: "tasks", desc: "List background tasks" },
72
+ { name: "logs", desc: "Show task output — /logs t1" },
73
+ { name: "stop", desc: "Stop a background task — /stop t1" },
74
+ { name: "clear", desc: "Reset the conversation" },
75
+ { name: "help", desc: "Show help" },
76
+ { name: "exit", desc: "Quit smolcoder (web: close this session)" },
77
+ ];
78
+ exports.MODE_ORDER = ["ro", "edit", "bypass"];
79
+ // ---- model selection helpers (shared with the headless path) --------------
80
+ /** Output budget scales with the window: big windows can afford whole-file
81
+ * writes (a single write_file's JSON must fit in the output), tiny windows
82
+ * must stay conservative. */
83
+ function outputBudget(window) {
84
+ return Math.max(1024, Math.min(16384, Math.floor(window / 4)));
85
+ }
86
+ function makeProvider(m) {
87
+ const maxOut = outputBudget(m.contextWindow);
88
+ return m.backend === "ollama"
89
+ ? new ollama_1.OllamaProvider(m.baseUrl, m.id, m.contextWindow, m.numCtx, maxOut)
90
+ : new lmstudio_1.LmStudioProvider(m.baseUrl, m.id, m.contextWindow, maxOut, m.reasoning);
91
+ }
92
+ /** One-line advice when the effective reasoning setting will be slow: LM
93
+ * Studio applies the model's own default level when none is chosen, and for
94
+ * current qwen builds that default is the maximum. */
95
+ function effortAdvice(m, effort) {
96
+ if (m.backend !== "lmstudio" || !m.reasoning?.default)
97
+ return null;
98
+ const d = m.reasoning.default;
99
+ if (effort === null && /^(high|xhigh)$/.test(d)) {
100
+ return `this model thinks at "${d}" by default on LM Studio — expect long pauses before each tool call. /effort off (or --effort off) is many times faster; /effort low or medium keeps some reasoning.`;
101
+ }
102
+ return null;
103
+ }
104
+ /** Tell the user what context management just did (both UIs; headless logs
105
+ * it to stderr so a long run's log shows when and how hard compaction hit). */
106
+ function reportCompactions(bus, ui) {
107
+ bus.on("post_compact", (report) => {
108
+ const delta = `${report?.before} → ${report?.after} tokens est.`;
109
+ if (report?.action === "evicted")
110
+ ui.status(`· freed context by dropping old tool output (${delta})`);
111
+ else if (report?.action === "compacted")
112
+ ui.status(`· compacted the conversation into hand-over notes (${delta})`);
113
+ else if (report?.action === "floor")
114
+ ui.warn(`· context is at its floor: system prompt + tools + the working tail no longer fit comfortably (${delta}). Consider a bigger context window.`);
115
+ });
116
+ }
117
+ function autoPickModel(models, wanted, remembered) {
118
+ if (wanted) {
119
+ const hit = models.find((m) => m.id === wanted) ??
120
+ models.find((m) => m.id.toLowerCase().includes(wanted.toLowerCase()));
121
+ if (hit)
122
+ return hit;
123
+ }
124
+ return (models.find((m) => m.id === remembered) ??
125
+ models.find((m) => m.backend === "ollama") ??
126
+ models.find((m) => m.loaded) ??
127
+ models[0]);
128
+ }
129
+ function noBackendsMessage() {
130
+ return (util_1.c.red("No local model backend found.") +
131
+ `\n\nsmolcoder looks for:\n` +
132
+ ` · ${util_1.c.bold("Ollama")} at http://127.0.0.1:11434 ${util_1.c.dim("(or $OLLAMA_HOST)")} — install: https://ollama.com, then: ollama pull qwen3\n` +
133
+ ` · ${util_1.c.bold("LM Studio")} at http://127.0.0.1:1234 — start its local server (Developer tab → Start Server)\n\n` +
134
+ `Start one of them and run smol again. No configuration needed.`);
135
+ }
136
+ function sessionLine(m, mode) {
137
+ return `${util_1.c.green("●")} ${m.backend} · ${util_1.c.bold(m.id)} · ctx ${m.contextWindow.toLocaleString()} · ${index_1.MODE_LABELS[mode]} mode`;
138
+ }
139
+ function fmtTokens(n) {
140
+ return n < 1000 ? String(n) : (n / 1000).toFixed(1) + "k";
141
+ }
142
+ function modeColored(mode) {
143
+ const label = index_1.MODE_LABELS[mode];
144
+ if (mode === "bypass")
145
+ return util_1.c.red(util_1.c.bold(label));
146
+ if (mode === "ro")
147
+ return util_1.c.magenta(util_1.c.bold(label));
148
+ return util_1.c.cyan(util_1.c.bold(label));
149
+ }
150
+ /** Detect backends, pick a model and resolve its context window. Returns null
151
+ * when no backend answers. `progress` gets a short label for each slow step. */
152
+ async function prepareModel(prefs, cfg, progress) {
153
+ progress?.("looking for Ollama and LM Studio");
154
+ const models = await (0, detect_1.detectAll)();
155
+ if (models.length === 0)
156
+ return null;
157
+ const chosen = autoPickModel(models, prefs.model, cfg.lastModel);
158
+ progress?.(`loading ${chosen.id}`);
159
+ return (0, detect_1.resolveContextWindow)(chosen, prefs.ctx);
160
+ }
161
+ class Session {
162
+ ui;
163
+ workspace;
164
+ chosen;
165
+ effort;
166
+ agent;
167
+ toolCtx;
168
+ taskManager;
169
+ ctxMgr;
170
+ bus = new events_1.EventBus();
171
+ shell = (0, shell_1.pickShell)();
172
+ /** Host hook: fired once when the session has shut down (/exit, ctrl+c). */
173
+ onExit = null;
174
+ agentsMd;
175
+ prefs;
176
+ help;
177
+ ended = false;
178
+ constructor(ui, opts) {
179
+ this.ui = ui;
180
+ const { workspace, chosen, prefs, cfg } = opts;
181
+ this.workspace = workspace;
182
+ this.chosen = chosen;
183
+ this.prefs = prefs;
184
+ this.help = opts.help;
185
+ const mode0 = prefs.mode ?? cfg.lastMode ?? "edit";
186
+ this.effort = prefs.effort !== undefined ? prefs.effort : (cfg.effort ?? null);
187
+ const provider = makeProvider(chosen);
188
+ provider.setEffort(this.effort);
189
+ this.taskManager = new tasks_1.TaskManager(workspace);
190
+ this.toolCtx = {
191
+ workspace,
192
+ taskManager: this.taskManager,
193
+ plan: new plan_1.Plan(),
194
+ filesTouched: new Set(),
195
+ commandsRun: [],
196
+ };
197
+ this.ctxMgr = new context_1.ContextManager(chosen.contextWindow, provider.maxOutputTokens);
198
+ this.agentsMd = (0, prompt_1.loadAgentsMd)(workspace);
199
+ // The step cap is a runaway-loop backstop, not a work limit — esc/ctrl+c
200
+ // is the user's real kill switch, so set it far above any legitimate task.
201
+ this.agent = new agent_1.Agent(provider, mode0, this.sysPrompt(mode0), this.toolCtx, this.ctxMgr, this.bus, ui, true, 1000);
202
+ ui.slashCommands = exports.SLASH_COMMANDS;
203
+ ui.hintLeft = workspace.replace(os.homedir(), "~");
204
+ ui.getStatus = () => this.statusLine();
205
+ ui.onModeCycle = () => {
206
+ const next = exports.MODE_ORDER[(exports.MODE_ORDER.indexOf(this.agent.mode) + 1) % exports.MODE_ORDER.length];
207
+ this.agent.setMode(next, this.sysPrompt(next));
208
+ this.persist();
209
+ };
210
+ ui.onCancel = () => this.agent.cancel();
211
+ ui.onExit = () => void this.shutdown();
212
+ reportCompactions(this.bus, ui);
213
+ this.persist();
214
+ }
215
+ sysPrompt(mode) {
216
+ return (0, prompt_1.buildSystemPrompt)({ workspace: this.workspace, mode, shellLabel: this.shell.label, agentsMd: this.agentsMd });
217
+ }
218
+ persist() {
219
+ (0, config_1.saveConfig)({ lastModel: this.chosen.id, lastMode: this.agent.mode, effort: this.effort });
220
+ }
221
+ /** The TUI's status row: mode · model · effort · context · plan · tasks. */
222
+ statusLine() {
223
+ const agent = this.agent;
224
+ const tasks = this.taskManager.runningSummary().length;
225
+ const plan = this.toolCtx.plan;
226
+ return (`${modeColored(agent.mode)} ${util_1.c.dim("·")} ${this.chosen.id} ${util_1.c.dim(this.chosen.backend)}` +
227
+ (this.effort || agent.provider.effortLabel()
228
+ ? ` ${util_1.c.dim("·")} ${util_1.c.yellow(agent.provider.effortLabel() ?? this.effort ?? "")}`
229
+ : "") +
230
+ ` ${util_1.c.dim("·")} ${util_1.c.dim(`${fmtTokens(agent.contextTokens())} (${agent.contextPercent()}%)`)}` +
231
+ (plan.exists
232
+ ? ` ${util_1.c.dim("·")} ${plan.currentIndex < 0
233
+ ? util_1.c.green(`plan ${plan.doneCount}/${plan.steps.length}`)
234
+ : util_1.c.cyan(`plan ${plan.doneCount}/${plan.steps.length}`)}`
235
+ : "") +
236
+ (tasks ? ` ${util_1.c.dim("·")} ${util_1.c.green(`${tasks} task${tasks > 1 ? "s" : ""}`)}` : ""));
237
+ }
238
+ /** Structured status for the web page's status bar. */
239
+ state() {
240
+ const plan = this.toolCtx.plan;
241
+ return {
242
+ mode: this.agent.mode,
243
+ model: this.chosen.id,
244
+ backend: this.chosen.backend,
245
+ effort: this.agent.provider.effortLabel() ?? this.effort,
246
+ ctxTokens: this.agent.contextTokens(),
247
+ ctxPct: this.agent.contextPercent(),
248
+ plan: plan.exists ? { steps: plan.steps, current: plan.currentIndex } : null,
249
+ tasks: this.taskManager.runningSummary().length,
250
+ workspace: this.workspace,
251
+ commands: exports.SLASH_COMMANDS,
252
+ urls: this.taskManager.recentUrls(),
253
+ };
254
+ }
255
+ /** The opening lines: backend · model · mode, workspace, AGENTS.md, advice. */
256
+ announce() {
257
+ const ui = this.ui;
258
+ ui.println(sessionLine(this.chosen, this.agent.mode));
259
+ if (this.chosen.note)
260
+ ui.warn(` ${this.chosen.note}`);
261
+ ui.status(` workspace ${this.workspace} · shell ${this.shell.label}`);
262
+ if (this.agentsMd)
263
+ ui.status(` AGENTS.md loaded (${this.agentsMd.split("\n").length} lines)`);
264
+ const advice = effortAdvice(this.chosen, this.effort);
265
+ if (advice)
266
+ ui.warn(` ${advice}`);
267
+ }
268
+ snapshot() {
269
+ return {
270
+ messages: this.agent.messages.slice(1),
271
+ plan: this.toolCtx.plan.steps.map((s) => ({ ...s })),
272
+ filesTouched: [...this.toolCtx.filesTouched],
273
+ commandsRun: [...this.toolCtx.commandsRun],
274
+ originalRequest: this.agent.originalRequest,
275
+ currentRequest: this.agent.currentRequest,
276
+ mode: this.agent.mode,
277
+ effort: this.effort,
278
+ model: this.chosen.id,
279
+ backend: this.chosen.backend,
280
+ };
281
+ }
282
+ /** Bring a saved transcript back (the system message is rebuilt for the
283
+ * current mode/workspace; approvals are deliberately not restored). */
284
+ restore(s) {
285
+ this.agent.restoreTranscript(s.messages ?? [], s.originalRequest ?? "", s.currentRequest ?? "");
286
+ this.toolCtx.plan.steps = (s.plan ?? []).map((p) => ({ text: String(p.text), done: !!p.done }));
287
+ for (const f of s.filesTouched ?? [])
288
+ this.toolCtx.filesTouched.add(f);
289
+ this.toolCtx.commandsRun.push(...(s.commandsRun ?? []));
290
+ }
291
+ /** The input loop. Returns after /exit (or after the host asked the UI to
292
+ * hand back "/exit"). */
293
+ async run() {
294
+ const { ui, agent, toolCtx, taskManager } = this;
295
+ await this.bus.emit("session_start");
296
+ for (;;) {
297
+ const input = await ui.readInput();
298
+ if (input.startsWith("/")) {
299
+ const [cmd, ...rest] = input.slice(1).split(/\s+/);
300
+ const arg = rest[0];
301
+ switch (cmd) {
302
+ case "exit":
303
+ case "quit":
304
+ case "q":
305
+ await this.shutdown();
306
+ return;
307
+ case "help":
308
+ ui.println(this.help);
309
+ break;
310
+ case "models":
311
+ case "model":
312
+ await this.switchModel();
313
+ break;
314
+ case "mode":
315
+ await this.setMode(arg);
316
+ break;
317
+ case "effort":
318
+ await this.setEffort(arg);
319
+ break;
320
+ case "plan":
321
+ if (toolCtx.plan.exists)
322
+ ui.planUpdated(toolCtx.plan);
323
+ else
324
+ ui.status("· no plan yet — the agent creates one when it starts a multi-step task");
325
+ break;
326
+ case "tasks":
327
+ ui.println(taskManager.list());
328
+ break;
329
+ case "logs":
330
+ ui.println(taskManager.logs(arg ?? "", Number(rest[1]) || 50));
331
+ break;
332
+ case "stop":
333
+ ui.println(taskManager.stop(arg ?? ""));
334
+ break;
335
+ case "compact":
336
+ ui.startSpinner("compacting");
337
+ await agent.compactNow();
338
+ ui.stopSpinner();
339
+ ui.status(`· compacted — ctx now ${agent.contextPercent()}%`);
340
+ break;
341
+ case "context":
342
+ ui.status(`· ctx ${agent.contextPercent()}% of ${this.chosen.contextWindow.toLocaleString()} tokens · ${agent.messages.length} messages`);
343
+ break;
344
+ case "clear":
345
+ agent.resetTranscript();
346
+ toolCtx.plan.reset();
347
+ ui.status("· conversation cleared");
348
+ break;
349
+ default:
350
+ ui.warn(`Unknown command /${cmd} — try /help`);
351
+ }
352
+ ui.refresh();
353
+ continue;
354
+ }
355
+ try {
356
+ await agent.runTurn(input);
357
+ }
358
+ catch (err) {
359
+ ui.error(`\n${err?.message ?? err}`);
360
+ if (String(err?.message ?? "").toLowerCase().includes("does not support tools")) {
361
+ ui.warn("This model does not support tool calling. Pick a tool-capable model with /models (e.g. qwen3, llama3.1, mistral-nemo).");
362
+ }
363
+ }
364
+ }
365
+ }
366
+ /** Idempotent: end-of-session hook, kill background tasks, close the UI,
367
+ * then tell the host. */
368
+ async shutdown() {
369
+ if (this.ended)
370
+ return;
371
+ this.ended = true;
372
+ try {
373
+ await this.bus.emit("session_end");
374
+ }
375
+ catch {
376
+ /* best effort */
377
+ }
378
+ this.taskManager.killAll();
379
+ this.ui.close();
380
+ this.onExit?.();
381
+ }
382
+ async switchModel() {
383
+ const { ui, agent } = this;
384
+ const fresh = await (0, detect_1.detectAll)();
385
+ if (!fresh.length) {
386
+ ui.error("No backends reachable right now.");
387
+ return;
388
+ }
389
+ const options = fresh.map((m) => ({
390
+ label: m.id,
391
+ hint: m.backend === "ollama"
392
+ ? "ollama"
393
+ : `lm studio${m.loaded ? ` · ctx ${m.contextWindow.toLocaleString()}` : " · not loaded"}`,
394
+ current: m.id === this.chosen.id && m.backend === this.chosen.backend,
395
+ }));
396
+ const idx = await ui.select("Select model", options);
397
+ if (idx === null)
398
+ return;
399
+ ui.startSpinner(`loading ${fresh[idx].id}`);
400
+ const next = await (0, detect_1.resolveContextWindow)(fresh[idx], this.prefs.ctx);
401
+ ui.stopSpinner();
402
+ this.chosen = next;
403
+ const p = makeProvider(next);
404
+ p.setEffort(this.effort);
405
+ agent.setProvider(p);
406
+ this.ctxMgr.setWindow(next.contextWindow, p.maxOutputTokens);
407
+ this.persist();
408
+ ui.println(sessionLine(next, agent.mode));
409
+ if (next.note)
410
+ ui.warn(` ${next.note}`);
411
+ const advice = effortAdvice(next, this.effort);
412
+ if (advice)
413
+ ui.warn(` ${advice}`);
414
+ }
415
+ async setMode(arg) {
416
+ const { ui, agent } = this;
417
+ let next = arg === "ro"
418
+ ? "ro"
419
+ : arg === "edit" || arg === "write"
420
+ ? "edit"
421
+ : arg === "bypass" || arg === "yolo"
422
+ ? "bypass"
423
+ : undefined;
424
+ if (!next) {
425
+ const idx = await ui.select("Select mode", [
426
+ { label: "read-only", hint: "read and search files only", current: agent.mode === "ro" },
427
+ {
428
+ label: "edit",
429
+ hint: "edit files; run commands inside the workspace, ask y/n for anything outside it",
430
+ current: agent.mode === "edit",
431
+ },
432
+ {
433
+ label: "bypass permissions",
434
+ hint: "full access, never asks for approval",
435
+ current: agent.mode === "bypass",
436
+ },
437
+ ]);
438
+ if (idx === null)
439
+ return;
440
+ next = exports.MODE_ORDER[idx];
441
+ }
442
+ agent.setMode(next, this.sysPrompt(next));
443
+ this.persist();
444
+ }
445
+ async setEffort(arg) {
446
+ const { ui, agent } = this;
447
+ const levels = ["default", "off", "low", "medium", "high"];
448
+ let next;
449
+ if (arg && levels.includes(arg)) {
450
+ next = arg === "default" ? null : arg;
451
+ }
452
+ else {
453
+ const idx = await ui.select("Reasoning effort", [
454
+ {
455
+ label: "default",
456
+ hint: this.chosen.reasoning?.default
457
+ ? `the model's own default (${this.chosen.reasoning.default})`
458
+ : "leave it to the model",
459
+ current: this.effort === null,
460
+ },
461
+ { label: "off", hint: "no thinking — fastest, best for long tool loops", current: this.effort === "off" },
462
+ { label: "low", hint: "brief reasoning", current: this.effort === "low" },
463
+ { label: "medium", hint: "", current: this.effort === "medium" },
464
+ { label: "high", hint: "most thorough — slow on local models", current: this.effort === "high" },
465
+ ]);
466
+ if (idx === null)
467
+ return;
468
+ next = idx === 0 ? null : levels[idx];
469
+ }
470
+ this.effort = next;
471
+ agent.provider.setEffort(this.effort);
472
+ this.persist();
473
+ const label = agent.provider.effortLabel();
474
+ ui.status(`· effort ${label ?? this.effort ?? "default"}`);
475
+ const advice = effortAdvice(this.chosen, this.effort);
476
+ if (advice)
477
+ ui.warn(` ${advice}`);
478
+ }
479
+ }
480
+ exports.Session = Session;
@@ -107,6 +107,21 @@ class TaskManager {
107
107
  .filter((t) => t.status === "running")
108
108
  .map((t) => `${t.id}: ${t.command}`);
109
109
  }
110
+ /** http://localhost-style URLs printed by running tasks — what a dev server
111
+ * announces on start — so the web UI can offer them in its browser panel. */
112
+ recentUrls() {
113
+ const out = new Set();
114
+ const re = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::\d+)?(?:\/[^\s"'<>)\]]*)?/g;
115
+ for (const t of this.tasks.values()) {
116
+ if (t.status !== "running")
117
+ continue;
118
+ for (const line of t.lines) {
119
+ for (const m of line.matchAll(re))
120
+ out.add(m[0].replace("0.0.0.0", "localhost").replace(/\/$/, ""));
121
+ }
122
+ }
123
+ return [...out].slice(0, 8);
124
+ }
110
125
  killAll() {
111
126
  for (const t of this.tasks.values()) {
112
127
  if (t.status === "running") {