smolcoder-plus 1.0.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/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/agent.js +748 -0
- package/dist/attachments.js +158 -0
- package/dist/config.js +87 -0
- package/dist/context.js +498 -0
- package/dist/detect.js +474 -0
- package/dist/events.js +24 -0
- package/dist/history.js +9 -0
- package/dist/hosts.js +107 -0
- package/dist/index.js +391 -0
- package/dist/logo.js +48 -0
- package/dist/netscan.js +159 -0
- package/dist/network.js +193 -0
- package/dist/plan.js +102 -0
- package/dist/prompt.js +84 -0
- package/dist/providers/lmstudio.js +347 -0
- package/dist/providers/ollama.js +269 -0
- package/dist/providers/scheduler.js +57 -0
- package/dist/providers/transport.js +86 -0
- package/dist/providers/types.js +62 -0
- package/dist/sandbox.js +207 -0
- package/dist/session.js +639 -0
- package/dist/tools/check.js +193 -0
- package/dist/tools/fs-tools.js +431 -0
- package/dist/tools/index.js +260 -0
- package/dist/tools/search-worker.js +34 -0
- package/dist/tools/shell.js +186 -0
- package/dist/tools/tasks.js +147 -0
- package/dist/tools/web-search.js +155 -0
- package/dist/tui/editor.js +134 -0
- package/dist/tui/keys.js +145 -0
- package/dist/tui/tui.js +723 -0
- package/dist/ui.js +226 -0
- package/dist/util.js +91 -0
- package/dist/verification.js +71 -0
- package/dist/web/channel.js +260 -0
- package/dist/web/client.js +1010 -0
- package/dist/web/hub.js +952 -0
- package/dist/web/page.js +87 -0
- package/dist/web/store.js +199 -0
- package/dist/web/styles.js +333 -0
- package/dist/web/terminal.js +190 -0
- package/package.json +49 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
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.HOSTS_ROW = exports.FIND_ROW = 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.backendLabel = backendLabel;
|
|
48
|
+
exports.sessionLine = sessionLine;
|
|
49
|
+
exports.fmtTokens = fmtTokens;
|
|
50
|
+
exports.prepareModel = prepareModel;
|
|
51
|
+
exports.modelOptions = modelOptions;
|
|
52
|
+
exports.setupWithoutLocalModels = setupWithoutLocalModels;
|
|
53
|
+
exports.suggestTitle = suggestTitle;
|
|
54
|
+
exports.cleanTitle = cleanTitle;
|
|
55
|
+
const os = __importStar(require("os"));
|
|
56
|
+
const agent_1 = require("./agent");
|
|
57
|
+
const config_1 = require("./config");
|
|
58
|
+
const context_1 = require("./context");
|
|
59
|
+
const detect_1 = require("./detect");
|
|
60
|
+
const events_1 = require("./events");
|
|
61
|
+
const network_1 = require("./network");
|
|
62
|
+
const plan_1 = require("./plan");
|
|
63
|
+
const prompt_1 = require("./prompt");
|
|
64
|
+
const lmstudio_1 = require("./providers/lmstudio");
|
|
65
|
+
const ollama_1 = require("./providers/ollama");
|
|
66
|
+
const index_1 = require("./tools/index");
|
|
67
|
+
const shell_1 = require("./tools/shell");
|
|
68
|
+
const tasks_1 = require("./tools/tasks");
|
|
69
|
+
const ui_1 = require("./ui");
|
|
70
|
+
const util_1 = require("./util");
|
|
71
|
+
exports.SLASH_COMMANDS = [
|
|
72
|
+
{ name: "models", desc: "Switch model · add models from other machines" },
|
|
73
|
+
{ name: "mode", desc: "Set mode (ro / edit / bypass)" },
|
|
74
|
+
{ name: "effort", desc: "Set reasoning effort" },
|
|
75
|
+
{ name: "plan", desc: "Show the agent's plan" },
|
|
76
|
+
{ name: "context", desc: "Show context usage" },
|
|
77
|
+
{ name: "compact", desc: "Compact the conversation now" },
|
|
78
|
+
{ name: "tasks", desc: "List background tasks" },
|
|
79
|
+
{ name: "logs", desc: "Show task output — /logs t1" },
|
|
80
|
+
{ name: "stop", desc: "Stop a background task — /stop t1" },
|
|
81
|
+
{ name: "clear", desc: "Reset the conversation" },
|
|
82
|
+
{ name: "help", desc: "Show help" },
|
|
83
|
+
{ name: "exit", desc: "Quit smolcoder (web: close this session)" },
|
|
84
|
+
];
|
|
85
|
+
exports.MODE_ORDER = ["ro", "edit", "bypass"];
|
|
86
|
+
// ---- model selection helpers (shared with the headless path) --------------
|
|
87
|
+
/** Output budget scales with the window: big windows can afford whole-file
|
|
88
|
+
* writes (a single write_file's JSON must fit in the output), tiny windows
|
|
89
|
+
* must stay conservative. */
|
|
90
|
+
function outputBudget(window) {
|
|
91
|
+
return Math.max(128, Math.min(8192, Math.floor(window / 4)));
|
|
92
|
+
}
|
|
93
|
+
function makeProvider(m) {
|
|
94
|
+
const maxOut = outputBudget(m.contextWindow);
|
|
95
|
+
return m.backend === "ollama"
|
|
96
|
+
? new ollama_1.OllamaProvider(m.baseUrl, m.id, m.contextWindow, m.numCtx, maxOut, m.vision)
|
|
97
|
+
: new lmstudio_1.LmStudioProvider(m.baseUrl, m.id, m.contextWindow, maxOut, m.reasoning, m.vision);
|
|
98
|
+
}
|
|
99
|
+
/** One-line advice when the effective reasoning setting will be slow: LM
|
|
100
|
+
* Studio applies the model's own default level when none is chosen, and for
|
|
101
|
+
* current qwen builds that default is the maximum. */
|
|
102
|
+
function effortAdvice(m, effort) {
|
|
103
|
+
if (m.backend !== "lmstudio" || !m.reasoning?.default)
|
|
104
|
+
return null;
|
|
105
|
+
const d = m.reasoning.default;
|
|
106
|
+
if (effort === null && /^(high|xhigh)$/.test(d)) {
|
|
107
|
+
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.`;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/** Tell the user what context management just did (both UIs; headless logs
|
|
112
|
+
* it to stderr so a long run's log shows when and how hard compaction hit). */
|
|
113
|
+
function reportCompactions(bus, ui) {
|
|
114
|
+
bus.on("post_compact", (report) => {
|
|
115
|
+
const delta = `${report?.before} → ${report?.after} tokens est.`;
|
|
116
|
+
if (report?.action === "evicted")
|
|
117
|
+
ui.status(`· pruned context (${delta})`);
|
|
118
|
+
else if (report?.action === "compacted")
|
|
119
|
+
ui.status(`· compacted the conversation into hand-over notes (${delta})`);
|
|
120
|
+
else if (report?.action === "floor")
|
|
121
|
+
ui.warn(`· context is at its floor: system prompt + tools + the working tail no longer fit comfortably (${delta}). Consider a bigger context window.`);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** `url` says which server the wanted (or else the remembered) model was on;
|
|
125
|
+
* it only breaks ties between machines that serve the same model id. */
|
|
126
|
+
function autoPickModel(models, wanted, remembered, url) {
|
|
127
|
+
if (wanted) {
|
|
128
|
+
const hit = models.find((m) => m.id === wanted && m.baseUrl === url) ??
|
|
129
|
+
models.find((m) => m.id === wanted) ??
|
|
130
|
+
models.find((m) => m.id.toLowerCase().includes(wanted.toLowerCase()));
|
|
131
|
+
if (hit)
|
|
132
|
+
return hit;
|
|
133
|
+
throw new Error(`Model "${wanted}" was not found on the selected backend. Use /models to choose an available model.`);
|
|
134
|
+
}
|
|
135
|
+
// With nothing remembered, a model on this computer beats one across the network.
|
|
136
|
+
const local = models.filter((m) => !m.host);
|
|
137
|
+
const pool = local.length ? local : models;
|
|
138
|
+
return (models.find((m) => m.id === remembered && m.baseUrl === url) ??
|
|
139
|
+
models.find((m) => m.id === remembered) ??
|
|
140
|
+
pool.find((m) => m.backend === "ollama") ??
|
|
141
|
+
pool.find((m) => m.loaded) ??
|
|
142
|
+
pool[0]);
|
|
143
|
+
}
|
|
144
|
+
function noBackendsMessage() {
|
|
145
|
+
return (util_1.c.red("No usable model found.") +
|
|
146
|
+
`\n\nsmolcoder connects to a running model server; it does not scan installed apps or drives.\n` +
|
|
147
|
+
` · ${util_1.c.bold("Ollama")}: start the app (or run: ollama serve), then check: ollama list\n` +
|
|
148
|
+
` Found on this computer, at ${util_1.c.dim("$OLLAMA_HOST")}, and in Docker containers that publish its port.\n` +
|
|
149
|
+
` If the list is empty, run: ollama pull qwen3\n` +
|
|
150
|
+
` · ${util_1.c.bold("LM Studio")}: load a model and start Local Server in the Developer tab (any port).\n` +
|
|
151
|
+
` · ${util_1.c.bold("Another machine")}: start smol in a terminal or with --web and choose "Find models on another machine".\n\n` +
|
|
152
|
+
`Then run smol again.`);
|
|
153
|
+
}
|
|
154
|
+
/** "ollama" on this computer, "ollama @ gpu-box" across the network. */
|
|
155
|
+
function backendLabel(m) {
|
|
156
|
+
return m.host ? `${m.backend} @ ${m.host}` : m.backend;
|
|
157
|
+
}
|
|
158
|
+
function sessionLine(m, mode) {
|
|
159
|
+
return `${util_1.c.green("●")} ${backendLabel(m)} · ${util_1.c.bold(m.id)} · ctx ${m.contextWindow.toLocaleString()} · ${index_1.MODE_LABELS[mode]} mode`;
|
|
160
|
+
}
|
|
161
|
+
function fmtTokens(n) {
|
|
162
|
+
return n < 1000 ? String(n) : (n / 1000).toFixed(1) + "k";
|
|
163
|
+
}
|
|
164
|
+
function modeColored(mode) {
|
|
165
|
+
const label = index_1.MODE_LABELS[mode];
|
|
166
|
+
if (mode === "bypass")
|
|
167
|
+
return util_1.c.red(util_1.c.bold(label));
|
|
168
|
+
if (mode === "ro")
|
|
169
|
+
return util_1.c.magenta(util_1.c.bold(label));
|
|
170
|
+
return util_1.c.cyan(util_1.c.bold(label));
|
|
171
|
+
}
|
|
172
|
+
/** Detect backends, pick a model and resolve its context window. Returns null
|
|
173
|
+
* when no backend answers. `progress` gets a short label for each slow step. */
|
|
174
|
+
async function prepareModel(prefs, cfg, progress) {
|
|
175
|
+
progress?.("looking for Ollama and LM Studio");
|
|
176
|
+
const url = prefs.model ? prefs.baseUrl : cfg.lastModelUrl;
|
|
177
|
+
// Without --model the remembered one wins anyway, so stop looking the moment
|
|
178
|
+
// it shows up instead of waiting out a network host that is switched off.
|
|
179
|
+
const until = !prefs.model && cfg.lastModel
|
|
180
|
+
? (m) => m.id === cfg.lastModel && (!url || m.baseUrl === url) && (!prefs.backend || m.backend === prefs.backend)
|
|
181
|
+
: undefined;
|
|
182
|
+
const models = (await (0, detect_1.detectAll)({ hosts: cfg.hosts, until })).filter((m) => !prefs.backend || m.backend === prefs.backend);
|
|
183
|
+
if (models.length === 0)
|
|
184
|
+
return null;
|
|
185
|
+
const chosen = autoPickModel(models, prefs.model, cfg.lastModel, url);
|
|
186
|
+
progress?.(`loading ${chosen.id}`);
|
|
187
|
+
return (0, detect_1.resolveContextWindow)(chosen, prefs.ctx);
|
|
188
|
+
}
|
|
189
|
+
/** Picker rows for a model list: this computer first, then each network host. */
|
|
190
|
+
function modelOptions(models, current) {
|
|
191
|
+
return models.map((m) => ({
|
|
192
|
+
label: m.id,
|
|
193
|
+
hint: (m.backend === "ollama" ? "ollama" : `lm studio${m.loaded ? ` · ctx ${m.contextWindow.toLocaleString()}` : " · not loaded"}`) +
|
|
194
|
+
(m.host ? ` · ${m.host}` : ""),
|
|
195
|
+
current: !!current && m.id === current.id && m.backend === current.backend && m.baseUrl === current.baseUrl,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
exports.FIND_ROW = { label: "+ Find models on another machine…", hint: "search my network or enter an address" };
|
|
199
|
+
exports.HOSTS_ROW = { label: "Network hosts…", hint: "rename, remove or re-find added machines" };
|
|
200
|
+
/** Nothing answered on this computer. Rather than giving up, offer to look on
|
|
201
|
+
* the network — with both UIs' own pickers, before a session exists. Returns
|
|
202
|
+
* a ready model, or null when the user backs out. */
|
|
203
|
+
async function setupWithoutLocalModels(ui, prefs) {
|
|
204
|
+
for (;;) {
|
|
205
|
+
const pick = await ui.select("No model server found on this computer", [
|
|
206
|
+
{ label: "Find models on another machine", hint: "search my network or enter an address" },
|
|
207
|
+
{ label: "Look again", hint: "after starting Ollama or LM Studio here" },
|
|
208
|
+
]);
|
|
209
|
+
if (pick === null)
|
|
210
|
+
return null;
|
|
211
|
+
if (pick === 0 && !(await (0, network_1.findModelsOnNetwork)(ui)))
|
|
212
|
+
continue;
|
|
213
|
+
ui.startSpinner("looking for Ollama and LM Studio");
|
|
214
|
+
const cfg = (0, config_1.loadConfig)();
|
|
215
|
+
const models = (await (0, detect_1.detectAll)({ hosts: cfg.hosts })).filter((m) => !prefs.backend || m.backend === prefs.backend);
|
|
216
|
+
ui.stopSpinner();
|
|
217
|
+
if (!models.length) {
|
|
218
|
+
ui.warn("Still no model server answering.");
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// A machine was just added by hand: let the user say which of its models
|
|
222
|
+
// to load instead of pulling the first one into its memory.
|
|
223
|
+
const idx = models.length === 1 ? 0 : await ui.select("Select model", modelOptions(models));
|
|
224
|
+
if (idx === null)
|
|
225
|
+
continue;
|
|
226
|
+
ui.startSpinner(`loading ${models[idx].id}`);
|
|
227
|
+
try {
|
|
228
|
+
return await (0, detect_1.resolveContextWindow)(models[idx], prefs.ctx);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
ui.stopSpinner();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// ---- session titles ---------------------------------------------------------
|
|
236
|
+
/** Ask the model for a short session name from the first exchange. One cheap
|
|
237
|
+
* call with thinking off and a tiny output cap; null when the reply is not
|
|
238
|
+
* usable, in which case the caller keeps its fallback (the first message). */
|
|
239
|
+
async function suggestTitle(messages, provider) {
|
|
240
|
+
const first = messages.find((m) => m.role === "user" && !m.compactNote);
|
|
241
|
+
if (!first)
|
|
242
|
+
return null;
|
|
243
|
+
const reply = [...messages].reverse().find((m) => m.role === "assistant" && m.content.trim());
|
|
244
|
+
try {
|
|
245
|
+
const res = await provider.chat([
|
|
246
|
+
{
|
|
247
|
+
role: "system",
|
|
248
|
+
content: "You name coding sessions. Reply with only the title: three to six words, plain text, no quotes, no trailing period.",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
role: "user",
|
|
252
|
+
content: `First request:\n${(0, util_1.truncateEnd)(first.content, 600)}\n\n` +
|
|
253
|
+
(reply ? `Reply excerpt:\n${(0, util_1.truncateEnd)(reply.content, 400)}\n\n` : "") +
|
|
254
|
+
"Title:",
|
|
255
|
+
},
|
|
256
|
+
], [], { effortOverride: "off", maxTokens: 30, timeoutMs: 15_000, background: true });
|
|
257
|
+
return cleanTitle(res.content);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Exported for tests: normalize a model-written title. */
|
|
264
|
+
function cleanTitle(raw) {
|
|
265
|
+
let t = String(raw ?? "").replace(/<think>[\s\S]*?(<\/think>|$)/gi, "");
|
|
266
|
+
t = t.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
|
267
|
+
t = t
|
|
268
|
+
.replace(/^(title|session( name)?)\s*:\s*/i, "")
|
|
269
|
+
.replace(/^["'`“”*#\s]+|["'`“”*.\s]+$/g, "")
|
|
270
|
+
.replace(/\s+/g, " ");
|
|
271
|
+
if (t.length < 3 || /[<>{}]/.test(t))
|
|
272
|
+
return null;
|
|
273
|
+
if (t.length > 60) {
|
|
274
|
+
t = t.slice(0, 60);
|
|
275
|
+
const cut = t.lastIndexOf(" ");
|
|
276
|
+
if (cut > 20)
|
|
277
|
+
t = t.slice(0, cut);
|
|
278
|
+
t = t.replace(/[\s,;:.-]+$/, "");
|
|
279
|
+
}
|
|
280
|
+
return t || null;
|
|
281
|
+
}
|
|
282
|
+
class Session {
|
|
283
|
+
ui;
|
|
284
|
+
workspace;
|
|
285
|
+
chosen;
|
|
286
|
+
effort;
|
|
287
|
+
agent;
|
|
288
|
+
toolCtx;
|
|
289
|
+
taskManager;
|
|
290
|
+
ctxMgr;
|
|
291
|
+
bus = new events_1.EventBus();
|
|
292
|
+
shell = (0, shell_1.pickShell)();
|
|
293
|
+
/** Host hook: fired once when the session has shut down (/exit, ctrl+c). */
|
|
294
|
+
onExit = null;
|
|
295
|
+
/** Host hook: fired after each completed user turn (the web hub names the
|
|
296
|
+
* session after the first one). */
|
|
297
|
+
onTurnDone = null;
|
|
298
|
+
agentsMd;
|
|
299
|
+
prefs;
|
|
300
|
+
help;
|
|
301
|
+
ended = false;
|
|
302
|
+
constructor(ui, opts) {
|
|
303
|
+
this.ui = ui;
|
|
304
|
+
const { workspace, chosen, prefs, cfg } = opts;
|
|
305
|
+
this.workspace = workspace;
|
|
306
|
+
this.chosen = chosen;
|
|
307
|
+
this.prefs = prefs;
|
|
308
|
+
this.help = opts.help;
|
|
309
|
+
const mode0 = prefs.mode ?? cfg.lastMode ?? "edit";
|
|
310
|
+
this.effort = prefs.effort !== undefined ? prefs.effort : (cfg.effort ?? null);
|
|
311
|
+
const provider = makeProvider(chosen);
|
|
312
|
+
provider.setEffort(this.effort);
|
|
313
|
+
this.taskManager = new tasks_1.TaskManager(workspace);
|
|
314
|
+
this.toolCtx = {
|
|
315
|
+
workspace,
|
|
316
|
+
taskManager: this.taskManager,
|
|
317
|
+
plan: new plan_1.Plan(),
|
|
318
|
+
filesTouched: new Set(),
|
|
319
|
+
commandsRun: [],
|
|
320
|
+
};
|
|
321
|
+
this.ctxMgr = new context_1.ContextManager(chosen.contextWindow, provider.maxOutputTokens);
|
|
322
|
+
this.agentsMd = (0, prompt_1.loadAgentsMd)(workspace);
|
|
323
|
+
// The step cap is a runaway-loop backstop, not a work limit — esc/ctrl+c
|
|
324
|
+
// is the user's real kill switch, so set it far above any legitimate task.
|
|
325
|
+
this.agent = new agent_1.Agent(provider, mode0, this.sysPrompt(mode0), this.toolCtx, this.ctxMgr, this.bus, ui, true, 1000);
|
|
326
|
+
ui.slashCommands = exports.SLASH_COMMANDS;
|
|
327
|
+
ui.hintLeft = workspace.replace(os.homedir(), "~");
|
|
328
|
+
ui.getStatus = () => this.statusLine();
|
|
329
|
+
ui.onModeCycle = () => {
|
|
330
|
+
const next = exports.MODE_ORDER[(exports.MODE_ORDER.indexOf(this.agent.mode) + 1) % exports.MODE_ORDER.length];
|
|
331
|
+
this.agent.setMode(next, this.sysPrompt(next));
|
|
332
|
+
this.persist();
|
|
333
|
+
};
|
|
334
|
+
ui.onCancel = () => this.agent.cancel();
|
|
335
|
+
ui.onExit = () => void this.shutdown();
|
|
336
|
+
reportCompactions(this.bus, ui);
|
|
337
|
+
this.bus.on("context_update", () => ui.refresh());
|
|
338
|
+
this.persist();
|
|
339
|
+
}
|
|
340
|
+
sysPrompt(mode) {
|
|
341
|
+
return (0, prompt_1.buildSystemPrompt)({ workspace: this.workspace, mode, shellLabel: this.shell.label, agentsMd: this.agentsMd });
|
|
342
|
+
}
|
|
343
|
+
persist() {
|
|
344
|
+
(0, config_1.updateConfig)({ lastModel: this.chosen.id, lastModelUrl: this.chosen.baseUrl, lastMode: this.agent.mode, effort: this.effort });
|
|
345
|
+
}
|
|
346
|
+
/** The TUI's status row: mode · model · effort · context · plan · tasks. */
|
|
347
|
+
statusLine() {
|
|
348
|
+
const agent = this.agent;
|
|
349
|
+
const tasks = this.taskManager.runningSummary().length;
|
|
350
|
+
const plan = this.toolCtx.plan;
|
|
351
|
+
return (`${modeColored(agent.mode)} ${util_1.c.dim("·")} ${this.chosen.id} ${util_1.c.dim(backendLabel(this.chosen))}` +
|
|
352
|
+
(this.effort || agent.provider.effortLabel()
|
|
353
|
+
? ` ${util_1.c.dim("·")} ${util_1.c.yellow(agent.provider.effortLabel() ?? this.effort ?? "")}`
|
|
354
|
+
: "") +
|
|
355
|
+
` ${util_1.c.dim("·")} ${util_1.c.dim(`${fmtTokens(agent.contextTokens())} (${agent.contextPercent()}%)`)}` +
|
|
356
|
+
(plan.exists
|
|
357
|
+
? ` ${util_1.c.dim("·")} ${plan.currentIndex < 0
|
|
358
|
+
? util_1.c.green(`plan ${plan.doneCount}/${plan.steps.length}`)
|
|
359
|
+
: util_1.c.cyan(`plan ${plan.doneCount}/${plan.steps.length}`)}`
|
|
360
|
+
: "") +
|
|
361
|
+
(tasks ? ` ${util_1.c.dim("·")} ${util_1.c.green(`${tasks} task${tasks > 1 ? "s" : ""}`)}` : ""));
|
|
362
|
+
}
|
|
363
|
+
/** Structured status for the web page's status bar. */
|
|
364
|
+
state() {
|
|
365
|
+
const plan = this.toolCtx.plan;
|
|
366
|
+
return {
|
|
367
|
+
mode: this.agent.mode,
|
|
368
|
+
model: this.chosen.id,
|
|
369
|
+
backend: this.chosen.backend,
|
|
370
|
+
host: this.chosen.host,
|
|
371
|
+
vision: this.chosen.vision,
|
|
372
|
+
effort: this.agent.provider.effortLabel() ?? this.effort,
|
|
373
|
+
ctxTokens: this.agent.contextTokens(),
|
|
374
|
+
ctxPct: this.agent.contextPercent(),
|
|
375
|
+
context: this.agent.contextBudget(),
|
|
376
|
+
outcome: this.agent.outcome,
|
|
377
|
+
lastError: this.agent.lastError,
|
|
378
|
+
plan: plan.exists ? { steps: plan.steps, current: plan.currentIndex } : null,
|
|
379
|
+
tasks: this.taskManager.runningSummary().length,
|
|
380
|
+
workspace: this.workspace,
|
|
381
|
+
commands: exports.SLASH_COMMANDS,
|
|
382
|
+
urls: this.taskManager.recentUrls(),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/** The opening lines: backend · model · mode, workspace, AGENTS.md, advice. */
|
|
386
|
+
announce() {
|
|
387
|
+
const ui = this.ui;
|
|
388
|
+
if (this.chosen.note)
|
|
389
|
+
ui.warn(` ${this.chosen.note}`);
|
|
390
|
+
const advice = effortAdvice(this.chosen, this.effort);
|
|
391
|
+
if (advice)
|
|
392
|
+
ui.warn(` ${advice}`);
|
|
393
|
+
}
|
|
394
|
+
snapshot() {
|
|
395
|
+
return {
|
|
396
|
+
messages: this.agent.messages.slice(1),
|
|
397
|
+
plan: this.toolCtx.plan.steps.map((s) => ({ ...s })),
|
|
398
|
+
filesTouched: [...this.toolCtx.filesTouched],
|
|
399
|
+
commandsRun: [...this.toolCtx.commandsRun],
|
|
400
|
+
originalRequest: this.agent.originalRequest,
|
|
401
|
+
currentRequest: this.agent.currentRequest,
|
|
402
|
+
mode: this.agent.mode,
|
|
403
|
+
effort: this.effort,
|
|
404
|
+
model: this.chosen.id,
|
|
405
|
+
backend: this.chosen.backend,
|
|
406
|
+
baseUrl: this.chosen.baseUrl,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/** A model-written name for this session, or null to keep the fallback. */
|
|
410
|
+
suggestTitle() {
|
|
411
|
+
return suggestTitle(this.agent.messages, this.agent.provider);
|
|
412
|
+
}
|
|
413
|
+
/** Bring a saved transcript back (the system message is rebuilt for the
|
|
414
|
+
* current mode/workspace; approvals are deliberately not restored). */
|
|
415
|
+
restore(s) {
|
|
416
|
+
this.agent.restoreTranscript(s.messages ?? [], s.originalRequest ?? "", s.currentRequest ?? "");
|
|
417
|
+
this.toolCtx.plan.steps = (s.plan ?? []).map((p) => ({ text: String(p.text), done: !!p.done,
|
|
418
|
+
...(typeof p.note === "string" ? { note: p.note.slice(0, 1000) } : {}) }));
|
|
419
|
+
for (const f of s.filesTouched ?? [])
|
|
420
|
+
this.toolCtx.filesTouched.add(f);
|
|
421
|
+
this.toolCtx.commandsRun.push(...(s.commandsRun ?? []));
|
|
422
|
+
}
|
|
423
|
+
/** The input loop. Returns after /exit (or after the host asked the UI to
|
|
424
|
+
* hand back "/exit"). */
|
|
425
|
+
async run() {
|
|
426
|
+
const { ui, agent, toolCtx, taskManager } = this;
|
|
427
|
+
await this.bus.emit("session_start");
|
|
428
|
+
for (;;) {
|
|
429
|
+
const raw = await ui.readInput();
|
|
430
|
+
const input = typeof raw === "string" ? raw : raw.text;
|
|
431
|
+
const attachments = typeof raw === "string" ? [] : raw.attachments;
|
|
432
|
+
if (!attachments.length && input.startsWith("/")) {
|
|
433
|
+
const [cmd, ...rest] = input.slice(1).split(/\s+/);
|
|
434
|
+
const arg = rest[0];
|
|
435
|
+
switch (cmd) {
|
|
436
|
+
case "exit":
|
|
437
|
+
case "quit":
|
|
438
|
+
case "q":
|
|
439
|
+
await this.shutdown();
|
|
440
|
+
return;
|
|
441
|
+
case "help":
|
|
442
|
+
ui.println(this.help);
|
|
443
|
+
break;
|
|
444
|
+
case "models":
|
|
445
|
+
case "model":
|
|
446
|
+
await this.switchModel();
|
|
447
|
+
break;
|
|
448
|
+
case "mode":
|
|
449
|
+
await this.setMode(arg);
|
|
450
|
+
break;
|
|
451
|
+
case "effort":
|
|
452
|
+
await this.setEffort(arg);
|
|
453
|
+
break;
|
|
454
|
+
case "plan":
|
|
455
|
+
if (toolCtx.plan.exists)
|
|
456
|
+
ui.println((0, ui_1.renderPlan)(toolCtx.plan));
|
|
457
|
+
else
|
|
458
|
+
ui.status("· no plan yet — the agent creates one when it starts a multi-step task");
|
|
459
|
+
break;
|
|
460
|
+
case "tasks":
|
|
461
|
+
ui.println(taskManager.list());
|
|
462
|
+
break;
|
|
463
|
+
case "logs":
|
|
464
|
+
ui.println(taskManager.logs(arg ?? "", Number(rest[1]) || 50));
|
|
465
|
+
break;
|
|
466
|
+
case "stop":
|
|
467
|
+
ui.println(taskManager.stop(arg ?? ""));
|
|
468
|
+
break;
|
|
469
|
+
case "compact":
|
|
470
|
+
ui.startSpinner("compacting");
|
|
471
|
+
try {
|
|
472
|
+
await agent.compactNow();
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
ui.error(String(err?.message ?? err));
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
ui.stopSpinner();
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
case "context":
|
|
482
|
+
const budget = agent.contextBudget();
|
|
483
|
+
ui.status(`Context ${budget.prompt.toLocaleString()} / ${budget.window.toLocaleString()} tokens (${budget.source})\nReply reserve ${budget.reserve.toLocaleString()} · available ${budget.available.toLocaleString()} · ${agent.messages.length} messages · ${agent.tools.length} tools`);
|
|
484
|
+
break;
|
|
485
|
+
case "clear":
|
|
486
|
+
agent.resetTranscript();
|
|
487
|
+
toolCtx.plan.reset();
|
|
488
|
+
ui.status("· conversation cleared");
|
|
489
|
+
break;
|
|
490
|
+
default:
|
|
491
|
+
ui.warn(`Unknown command /${cmd} — try /help`);
|
|
492
|
+
}
|
|
493
|
+
ui.refresh();
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
await agent.runTurn(input, attachments);
|
|
498
|
+
this.onTurnDone?.();
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
ui.error(`\n${err?.message ?? err}`);
|
|
502
|
+
if (String(err?.message ?? "").toLowerCase().includes("does not support tools")) {
|
|
503
|
+
ui.warn("This model does not support tool calling. Pick a tool-capable model with /models (e.g. qwen3, llama3.1, mistral-nemo).");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/** Idempotent: end-of-session hook, kill background tasks, close the UI,
|
|
509
|
+
* then tell the host. */
|
|
510
|
+
async shutdown() {
|
|
511
|
+
if (this.ended)
|
|
512
|
+
return;
|
|
513
|
+
this.ended = true;
|
|
514
|
+
this.agent.cancel();
|
|
515
|
+
try {
|
|
516
|
+
await this.bus.emit("session_end");
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
/* best effort */
|
|
520
|
+
}
|
|
521
|
+
this.taskManager.killAll();
|
|
522
|
+
this.ui.close();
|
|
523
|
+
this.onExit?.();
|
|
524
|
+
}
|
|
525
|
+
async switchModel() {
|
|
526
|
+
const { ui, agent } = this;
|
|
527
|
+
let fresh;
|
|
528
|
+
let idx;
|
|
529
|
+
// The picker is also where other machines are added and managed; after
|
|
530
|
+
// either, look again and reopen it so their models are right there.
|
|
531
|
+
for (;;) {
|
|
532
|
+
const hosts = (0, config_1.loadConfig)().hosts ?? [];
|
|
533
|
+
ui.startSpinner("looking for models");
|
|
534
|
+
fresh = await (0, detect_1.detectAll)({ hosts });
|
|
535
|
+
ui.stopSpinner();
|
|
536
|
+
if (!fresh.length)
|
|
537
|
+
ui.warn("No model server is answering right now.");
|
|
538
|
+
const rows = [...modelOptions(fresh, this.chosen), exports.FIND_ROW, ...(hosts.length ? [exports.HOSTS_ROW] : [])];
|
|
539
|
+
idx = await ui.select("Select model", rows);
|
|
540
|
+
if (idx === null)
|
|
541
|
+
return;
|
|
542
|
+
if (idx < fresh.length)
|
|
543
|
+
break;
|
|
544
|
+
if (rows[idx] === exports.FIND_ROW)
|
|
545
|
+
await (0, network_1.findModelsOnNetwork)(ui);
|
|
546
|
+
else
|
|
547
|
+
await (0, network_1.manageHosts)(ui);
|
|
548
|
+
}
|
|
549
|
+
let next;
|
|
550
|
+
ui.startSpinner(`loading ${fresh[idx].id}`);
|
|
551
|
+
try {
|
|
552
|
+
next = await (0, detect_1.resolveContextWindow)(fresh[idx], this.prefs.ctx);
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
ui.error(String(err?.message ?? err));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
ui.stopSpinner();
|
|
560
|
+
}
|
|
561
|
+
this.chosen = next;
|
|
562
|
+
const p = makeProvider(next);
|
|
563
|
+
p.setEffort(this.effort);
|
|
564
|
+
agent.setProvider(p);
|
|
565
|
+
this.ctxMgr.setWindow(next.contextWindow, p.maxOutputTokens);
|
|
566
|
+
this.persist();
|
|
567
|
+
ui.println(sessionLine(next, agent.mode));
|
|
568
|
+
if (next.note)
|
|
569
|
+
ui.warn(` ${next.note}`);
|
|
570
|
+
const advice = effortAdvice(next, this.effort);
|
|
571
|
+
if (advice)
|
|
572
|
+
ui.warn(` ${advice}`);
|
|
573
|
+
}
|
|
574
|
+
async setMode(arg) {
|
|
575
|
+
const { ui, agent } = this;
|
|
576
|
+
let next = arg === "ro"
|
|
577
|
+
? "ro"
|
|
578
|
+
: arg === "edit" || arg === "write"
|
|
579
|
+
? "edit"
|
|
580
|
+
: arg === "bypass" || arg === "yolo"
|
|
581
|
+
? "bypass"
|
|
582
|
+
: undefined;
|
|
583
|
+
if (!next) {
|
|
584
|
+
const idx = await ui.select("Select mode", [
|
|
585
|
+
{ label: "read-only", hint: "read and search files only", current: agent.mode === "ro" },
|
|
586
|
+
{
|
|
587
|
+
label: "edit",
|
|
588
|
+
hint: "edit files; run commands inside the workspace, ask y/n for anything outside it",
|
|
589
|
+
current: agent.mode === "edit",
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
label: "bypass permissions",
|
|
593
|
+
hint: "full access, never asks for approval",
|
|
594
|
+
current: agent.mode === "bypass",
|
|
595
|
+
},
|
|
596
|
+
]);
|
|
597
|
+
if (idx === null)
|
|
598
|
+
return;
|
|
599
|
+
next = exports.MODE_ORDER[idx];
|
|
600
|
+
}
|
|
601
|
+
agent.setMode(next, this.sysPrompt(next));
|
|
602
|
+
this.persist();
|
|
603
|
+
}
|
|
604
|
+
async setEffort(arg) {
|
|
605
|
+
const { ui, agent } = this;
|
|
606
|
+
const levels = ["default", "off", "low", "medium", "high"];
|
|
607
|
+
let next;
|
|
608
|
+
if (arg && levels.includes(arg)) {
|
|
609
|
+
next = arg === "default" ? null : arg;
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
const idx = await ui.select("Reasoning effort", [
|
|
613
|
+
{
|
|
614
|
+
label: "default",
|
|
615
|
+
hint: this.chosen.reasoning?.default
|
|
616
|
+
? `the model's own default (${this.chosen.reasoning.default})`
|
|
617
|
+
: "leave it to the model",
|
|
618
|
+
current: this.effort === null,
|
|
619
|
+
},
|
|
620
|
+
{ label: "off", hint: "no thinking — fastest, best for long tool loops", current: this.effort === "off" },
|
|
621
|
+
{ label: "low", hint: "brief reasoning", current: this.effort === "low" },
|
|
622
|
+
{ label: "medium", hint: "", current: this.effort === "medium" },
|
|
623
|
+
{ label: "high", hint: "most thorough — slow on local models", current: this.effort === "high" },
|
|
624
|
+
]);
|
|
625
|
+
if (idx === null)
|
|
626
|
+
return;
|
|
627
|
+
next = idx === 0 ? null : levels[idx];
|
|
628
|
+
}
|
|
629
|
+
this.effort = next;
|
|
630
|
+
agent.provider.setEffort(this.effort);
|
|
631
|
+
this.persist();
|
|
632
|
+
const label = agent.provider.effortLabel();
|
|
633
|
+
ui.status(`· effort ${label ?? this.effort ?? "default"}`);
|
|
634
|
+
const advice = effortAdvice(this.chosen, this.effort);
|
|
635
|
+
if (advice)
|
|
636
|
+
ui.warn(` ${advice}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
exports.Session = Session;
|