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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/dist/agent.js +748 -0
  4. package/dist/attachments.js +158 -0
  5. package/dist/config.js +87 -0
  6. package/dist/context.js +498 -0
  7. package/dist/detect.js +474 -0
  8. package/dist/events.js +24 -0
  9. package/dist/history.js +9 -0
  10. package/dist/hosts.js +107 -0
  11. package/dist/index.js +391 -0
  12. package/dist/logo.js +48 -0
  13. package/dist/netscan.js +159 -0
  14. package/dist/network.js +193 -0
  15. package/dist/plan.js +102 -0
  16. package/dist/prompt.js +84 -0
  17. package/dist/providers/lmstudio.js +347 -0
  18. package/dist/providers/ollama.js +269 -0
  19. package/dist/providers/scheduler.js +57 -0
  20. package/dist/providers/transport.js +86 -0
  21. package/dist/providers/types.js +62 -0
  22. package/dist/sandbox.js +207 -0
  23. package/dist/session.js +639 -0
  24. package/dist/tools/check.js +193 -0
  25. package/dist/tools/fs-tools.js +431 -0
  26. package/dist/tools/index.js +260 -0
  27. package/dist/tools/search-worker.js +34 -0
  28. package/dist/tools/shell.js +186 -0
  29. package/dist/tools/tasks.js +147 -0
  30. package/dist/tools/web-search.js +155 -0
  31. package/dist/tui/editor.js +134 -0
  32. package/dist/tui/keys.js +145 -0
  33. package/dist/tui/tui.js +723 -0
  34. package/dist/ui.js +226 -0
  35. package/dist/util.js +91 -0
  36. package/dist/verification.js +71 -0
  37. package/dist/web/channel.js +260 -0
  38. package/dist/web/client.js +1010 -0
  39. package/dist/web/hub.js +952 -0
  40. package/dist/web/page.js +87 -0
  41. package/dist/web/store.js +199 -0
  42. package/dist/web/styles.js +333 -0
  43. package/dist/web/terminal.js +190 -0
  44. package/package.json +49 -0
package/dist/detect.js ADDED
@@ -0,0 +1,474 @@
1
+ "use strict";
2
+ // Zero-config backend detection. Build a list of places a model server could
3
+ // be — loopback on the usual ports, $OLLAMA_HOST, the port LM Studio says it
4
+ // serves on, ports published by Docker containers, the host machine when we
5
+ // run inside WSL or a container, and network hosts the user added — then ask
6
+ // each one what it is. The ANSWER decides the backend, never the port, so
7
+ // Ollama and LM Studio are found the same way wherever they listen.
8
+ //
9
+ // The two backends are asymmetric on context windows:
10
+ // - Ollama: WE choose the window (num_ctx is a per-request option). Read the
11
+ // model's true maximum from /api/show and set num_ctx explicitly, because
12
+ // Ollama's defaults vary by version and silently truncate the prompt.
13
+ // - LM Studio: the window is fixed when the model is loaded in LM Studio's
14
+ // UI. We READ it from /api/v1/models (or the older /api/v0) and adapt.
15
+ // The same endpoint tells us which reasoning levels the model supports
16
+ // and which one it defaults to — that default is often the MAXIMUM.
17
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ var desc = Object.getOwnPropertyDescriptor(m, k);
20
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
+ desc = { enumerable: true, get: function() { return m[k]; } };
22
+ }
23
+ Object.defineProperty(o, k2, desc);
24
+ }) : (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ o[k2] = m[k];
27
+ }));
28
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
30
+ }) : function(o, v) {
31
+ o["default"] = v;
32
+ });
33
+ var __importStar = (this && this.__importStar) || (function () {
34
+ var ownKeys = function(o) {
35
+ ownKeys = Object.getOwnPropertyNames || function (o) {
36
+ var ar = [];
37
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
+ return ar;
39
+ };
40
+ return ownKeys(o);
41
+ };
42
+ return function (mod) {
43
+ if (mod && mod.__esModule) return mod;
44
+ var result = {};
45
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
+ __setModuleDefault(result, mod);
47
+ return result;
48
+ };
49
+ })();
50
+ Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.ollamaBaseUrls = ollamaBaseUrls;
52
+ exports.lmStudioBaseUrls = lmStudioBaseUrls;
53
+ exports.parseLmStudioServerPort = parseLmStudioServerPort;
54
+ exports.readLmStudioPort = readLmStudioPort;
55
+ exports.parseDockerBaseUrls = parseDockerBaseUrls;
56
+ exports.parseDefaultGateway = parseDefaultGateway;
57
+ exports.parseLmStudioV1 = parseLmStudioV1;
58
+ exports.identifyServer = identifyServer;
59
+ exports.groupServers = groupServers;
60
+ exports.probeHosts = probeHosts;
61
+ exports.detectAll = detectAll;
62
+ exports.resolveContextWindow = resolveContextWindow;
63
+ const child_process_1 = require("child_process");
64
+ const fs = __importStar(require("fs"));
65
+ const os = __importStar(require("os"));
66
+ const path = __importStar(require("path"));
67
+ const hosts_1 = require("./hosts");
68
+ const util_1 = require("./util");
69
+ const DEFAULT_OLLAMA_BASE = `http://127.0.0.1:${hosts_1.OLLAMA_PORT}`;
70
+ const LOCAL_PROBE_TIMEOUT_MS = 3000;
71
+ // An added machine that is switched off must not hold up startup for long.
72
+ const NETWORK_PROBE_TIMEOUT_MS = 1500;
73
+ const DOCKER_DISCOVERY_TIMEOUT_MS = 2000;
74
+ const LOOPBACK_RE = /^(https?:\/\/)(localhost|127(?:\.\d+){3}|\[::1\])(?=[:/]|$)/i;
75
+ function normalizeOllamaBase(env) {
76
+ let base;
77
+ if (!env)
78
+ base = DEFAULT_OLLAMA_BASE;
79
+ else if (env.startsWith("http://") || env.startsWith("https://"))
80
+ base = env.replace(/\/$/, "");
81
+ else
82
+ base = `http://${env.replace(/\/$/, "")}`;
83
+ // OLLAMA_HOST=0.0.0.0 is the documented way to expose the SERVER on the LAN,
84
+ // but as a CLIENT connect address 0.0.0.0/:: fails on Windows (WSAEADDRNOTAVAIL)
85
+ // and would make detection silently return no models. Rewrite to loopback.
86
+ base = base.replace(/^(https?:\/\/)(0\.0\.0\.0|\[::\]|::)(?=[:/]|$)/, "$1127.0.0.1");
87
+ // A bare local OLLAMA_HOST is common in desktop environment settings. Ollama
88
+ // means port 11434 there, while fetch would otherwise try port 80.
89
+ if (/^https?:\/\/(localhost|127(?:\.\d+){3}|\[::1\])$/i.test(base))
90
+ base += `:${hosts_1.OLLAMA_PORT}`;
91
+ return base;
92
+ }
93
+ function loopbackAliases(base) {
94
+ const match = base.match(LOOPBACK_RE);
95
+ if (!match)
96
+ return [base];
97
+ const tail = base.slice(match[0].length);
98
+ return [match[2], "127.0.0.1", "localhost", "[::1]"]
99
+ .map((host) => `${match[1]}${host}${tail}`)
100
+ .filter((url, i, urls) => urls.indexOf(url) === i);
101
+ }
102
+ /** Probe the configured endpoint first, then every loopback spelling. A stale
103
+ * OLLAMA_HOST does not hide a healthy local or Docker-published server. */
104
+ function ollamaBaseUrls(env) {
105
+ const primary = normalizeOllamaBase(env);
106
+ const candidates = [...loopbackAliases(primary), ...loopbackAliases(DEFAULT_OLLAMA_BASE)];
107
+ return candidates.filter((url, i) => candidates.indexOf(url) === i);
108
+ }
109
+ /** Loopback spellings for LM Studio: the port its settings file names (it is
110
+ * changeable in the app), then the default. */
111
+ function lmStudioBaseUrls(configuredPort) {
112
+ const ports = [configuredPort, hosts_1.LMSTUDIO_PORT].filter((p, i, all) => !!p && all.indexOf(p) === i);
113
+ return ports.flatMap((port) => loopbackAliases(`http://127.0.0.1:${port}`));
114
+ }
115
+ /** Exported for tests: the port in LM Studio's http-server-config.json. */
116
+ function parseLmStudioServerPort(text) {
117
+ try {
118
+ const port = JSON.parse(text)?.port;
119
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : undefined;
120
+ }
121
+ catch {
122
+ return undefined;
123
+ }
124
+ }
125
+ /** LM Studio keeps its live server settings in its home folder, which the
126
+ * pointer file relocates when the user moved it. Missing or unreadable just
127
+ * means we fall back to the default port. */
128
+ function readLmStudioPort(home = os.homedir()) {
129
+ const homes = [];
130
+ try {
131
+ const pointed = fs.readFileSync(path.join(home, ".lmstudio-home-pointer"), "utf8").trim();
132
+ if (pointed)
133
+ homes.push(pointed);
134
+ }
135
+ catch {
136
+ /* not relocated */
137
+ }
138
+ homes.push(path.join(home, ".lmstudio"), path.join(home, ".cache", "lm-studio"));
139
+ for (const dir of homes) {
140
+ try {
141
+ const port = parseLmStudioServerPort(fs.readFileSync(path.join(dir, ".internal", "http-server-config.json"), "utf8"));
142
+ if (port)
143
+ return port;
144
+ }
145
+ catch {
146
+ /* try the next location */
147
+ }
148
+ }
149
+ return undefined;
150
+ }
151
+ /** Parse `docker ps --format {{.Ports}}` and return host endpoints that publish
152
+ * a container's model-server port. Unpublished/expose-only ports are
153
+ * intentionally ignored because the CLI cannot reach them from the host. */
154
+ function parseDockerBaseUrls(output, containerPorts = [hosts_1.OLLAMA_PORT, hosts_1.LMSTUDIO_PORT]) {
155
+ const urls = [];
156
+ const mapping = /(\[[^\]]+\]|(?:\d{1,3}\.){3}\d{1,3}|localhost):(\d+)->(\d+)\/tcp\b/gi;
157
+ for (const match of output.matchAll(mapping)) {
158
+ if (!containerPorts.includes(Number(match[3])))
159
+ continue;
160
+ let host = match[1].toLowerCase();
161
+ if (host === "0.0.0.0")
162
+ host = "127.0.0.1";
163
+ else if (host === "[::]")
164
+ host = "[::1]";
165
+ urls.push(`http://${host}:${match[2]}`);
166
+ }
167
+ return urls.filter((url, i) => urls.indexOf(url) === i);
168
+ }
169
+ function containerPublishedUrls(containerPorts) {
170
+ const ask = (cli) => new Promise((resolve) => {
171
+ (0, child_process_1.execFile)(cli, ["ps", "--format", "{{.Ports}}"], { encoding: "utf8", timeout: DOCKER_DISCOVERY_TIMEOUT_MS, windowsHide: true }, (err, stdout) => resolve(err ? null : parseDockerBaseUrls(stdout, containerPorts)));
172
+ });
173
+ // Podman prints the same port format; only ask it when Docker is absent.
174
+ return ask("docker").then((urls) => urls ?? ask("podman")).then((urls) => urls ?? []);
175
+ }
176
+ /** Exported for tests: the default gateway in /proc/net/route (little-endian hex). */
177
+ function parseDefaultGateway(procNetRoute) {
178
+ for (const line of procNetRoute.split("\n").slice(1)) {
179
+ const cols = line.trim().split(/\s+/);
180
+ if (cols.length < 3 || cols[1] !== "00000000" || !/^[0-9a-f]{8}$/i.test(cols[2]) || cols[2] === "00000000")
181
+ continue;
182
+ const hex = cols[2];
183
+ return [6, 4, 2, 0].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(".");
184
+ }
185
+ return undefined;
186
+ }
187
+ /** Inside WSL or a container, "this computer" includes the machine hosting
188
+ * us: loopback does not reach a model server running there. */
189
+ function hostMachineUrls(ports) {
190
+ if (process.platform !== "linux")
191
+ return [];
192
+ const read = (file) => {
193
+ try {
194
+ return fs.readFileSync(file, "utf8");
195
+ }
196
+ catch {
197
+ return "";
198
+ }
199
+ };
200
+ const wsl = !!process.env.WSL_DISTRO_NAME || /microsoft/i.test(read("/proc/version"));
201
+ const container = fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv");
202
+ if (!wsl && !container)
203
+ return [];
204
+ const names = [parseDefaultGateway(read("/proc/net/route")), container ? "host.docker.internal" : undefined];
205
+ return names.filter((n) => !!n).flatMap((name) => ports.map((port) => `http://${name}:${port}`));
206
+ }
207
+ // ---- asking a server what it is ---------------------------------------------
208
+ const NOT_LOADED_NOTE = "not loaded yet — LM Studio will load it on first use, likely at a small default context. For longer sessions, load it in LM Studio with a bigger context first.";
209
+ const LMSTUDIO_JIT_GUESS = 4096; // LM Studio's usual default when a model is JIT-loaded
210
+ const DEFAULT_LMSTUDIO_BASE = `http://127.0.0.1:${hosts_1.LMSTUDIO_PORT}`;
211
+ /** LM Studio labels vision models "vlm" and text-only ones "llm". */
212
+ function visionOf(type) {
213
+ return type === "vlm" ? true : type === "llm" ? false : undefined;
214
+ }
215
+ function parseOllamaTags(data, base) {
216
+ if (!data || !Array.isArray(data.models) || !data.models.every((m) => typeof m?.name === "string"))
217
+ return null;
218
+ return data.models.map((m) => ({
219
+ id: m.name,
220
+ backend: "ollama",
221
+ baseUrl: base,
222
+ contextWindow: 0, // resolved lazily via /api/show when the model is chosen
223
+ }));
224
+ }
225
+ /** Exported for tests: parse LM Studio's /api/v1/models listing. */
226
+ function parseLmStudioV1(data, base = DEFAULT_LMSTUDIO_BASE) {
227
+ if (!data || !Array.isArray(data.models))
228
+ return null;
229
+ return data.models
230
+ .filter((m) => m.type === "llm" || m.type === "vlm" || m.type === undefined)
231
+ .map((m) => {
232
+ const inst = Array.isArray(m.loaded_instances) ? m.loaded_instances[0] : undefined;
233
+ const loaded = !!inst;
234
+ const max = typeof m.max_context_length === "number" ? m.max_context_length : undefined;
235
+ const loadedCtx = typeof inst?.config?.context_length === "number" ? inst.config.context_length : undefined;
236
+ const r = m.capabilities?.reasoning;
237
+ const reasoning = r && Array.isArray(r.allowed_options)
238
+ ? { allowed: r.allowed_options.map(String), default: r.default ? String(r.default) : undefined }
239
+ : undefined;
240
+ return {
241
+ id: String(inst?.id ?? m.key),
242
+ backend: "lmstudio",
243
+ baseUrl: base,
244
+ contextWindow: loaded && loadedCtx ? loadedCtx : Math.min(max ?? LMSTUDIO_JIT_GUESS, LMSTUDIO_JIT_GUESS),
245
+ maxContext: max,
246
+ loaded,
247
+ reasoning,
248
+ vision: visionOf(m.type),
249
+ note: loaded && loadedCtx ? undefined : NOT_LOADED_NOTE,
250
+ };
251
+ });
252
+ }
253
+ function parseLmStudioV0(data, base) {
254
+ if (!data || !Array.isArray(data.data))
255
+ return null;
256
+ return data.data
257
+ .filter((m) => m.type === "llm" || m.type === "vlm" || m.type === undefined)
258
+ .map((m) => {
259
+ const loaded = m.state === "loaded";
260
+ const max = typeof m.max_context_length === "number" ? m.max_context_length : undefined;
261
+ const loadedCtx = typeof m.loaded_context_length === "number" ? m.loaded_context_length : undefined;
262
+ let contextWindow;
263
+ let note;
264
+ if (loaded && loadedCtx) {
265
+ contextWindow = loadedCtx;
266
+ }
267
+ else {
268
+ contextWindow = Math.min(max ?? LMSTUDIO_JIT_GUESS, LMSTUDIO_JIT_GUESS);
269
+ note = NOT_LOADED_NOTE;
270
+ }
271
+ return {
272
+ id: m.id,
273
+ backend: "lmstudio",
274
+ baseUrl: base,
275
+ contextWindow,
276
+ maxContext: max,
277
+ loaded,
278
+ vision: visionOf(m.type),
279
+ note,
280
+ };
281
+ });
282
+ }
283
+ /** Ask one address whether it is Ollama or LM Studio. null means neither (or
284
+ * nothing there). Both native listings are requested together so a dead
285
+ * address costs one timeout, not one per backend. */
286
+ async function identifyServer(base, timeoutMs = NETWORK_PROBE_TIMEOUT_MS) {
287
+ const [tags, v1] = await Promise.all([
288
+ (0, util_1.probeJson)(`${base}/api/tags`, timeoutMs),
289
+ (0, util_1.probeJson)(`${base}/api/v1/models`, timeoutMs),
290
+ ]);
291
+ if (!tags.reached && !v1.reached)
292
+ return null;
293
+ // LM Studio first: its listing names models by "key", which nothing else does.
294
+ const lmKeyed = Array.isArray(v1.data?.models) && v1.data.models.length > 0 && v1.data.models.every((m) => typeof m?.key === "string");
295
+ if (lmKeyed)
296
+ return { backend: "lmstudio", baseUrl: base, models: parseLmStudioV1(v1.data, base) ?? [] };
297
+ const ollama = parseOllamaTags(tags.data, base);
298
+ if (ollama)
299
+ return { backend: "ollama", baseUrl: base, models: ollama };
300
+ const lmV1 = parseLmStudioV1(v1.data, base);
301
+ if (lmV1)
302
+ return { backend: "lmstudio", baseUrl: base, models: lmV1 };
303
+ const v0 = parseLmStudioV0(await (0, util_1.tryFetchJson)(`${base}/api/v0/models`, undefined, timeoutMs), base);
304
+ if (v0)
305
+ return { backend: "lmstudio", baseUrl: base, models: v0 };
306
+ // Older LM Studio builds: fall back to the OpenAI-compat listing (no context info).
307
+ const compat = await (0, util_1.tryFetchJson)(`${base}/v1/models`, undefined, timeoutMs);
308
+ if (compat && Array.isArray(compat.data)) {
309
+ const models = compat.data
310
+ .filter((m) => !String(m.id).includes("embed"))
311
+ .map((m) => ({
312
+ id: m.id,
313
+ backend: "lmstudio",
314
+ baseUrl: base,
315
+ contextWindow: LMSTUDIO_JIT_GUESS,
316
+ note: "context window unknown (older LM Studio) — assuming 4096 to be safe.",
317
+ }));
318
+ return { backend: "lmstudio", baseUrl: base, models };
319
+ }
320
+ return null;
321
+ }
322
+ /** Exported for tests. Loopback spellings of the same port are one server;
323
+ * every other URL is its own. Order of first appearance is kept. */
324
+ function groupServers(urls, timeoutMs, host) {
325
+ const groups = new Map();
326
+ for (const url of urls) {
327
+ const loop = url.match(LOOPBACK_RE);
328
+ const key = loop ? `loopback${url.slice(loop[0].length)}` : url;
329
+ const group = groups.get(key) ?? { urls: [], timeoutMs, host };
330
+ if (!group.urls.includes(url))
331
+ group.urls.push(url);
332
+ groups.set(key, group);
333
+ }
334
+ return [...groups.values()];
335
+ }
336
+ async function probeGroup(group) {
337
+ for (const url of group.urls) {
338
+ const info = await identifyServer(url, group.timeoutMs);
339
+ if (info)
340
+ return { ...info, models: info.models.map((m) => ({ ...m, host: group.host })) };
341
+ }
342
+ return null;
343
+ }
344
+ /** What each saved host is serving right now (for the hosts manager). */
345
+ async function probeHosts(hosts) {
346
+ return Promise.all(hosts.map(async (host) => {
347
+ const found = await Promise.all(groupServers((0, hosts_1.hostUrls)(host), NETWORK_PROBE_TIMEOUT_MS, (0, hosts_1.hostLabel)(host)).map(probeGroup));
348
+ return { host, servers: found.filter((s) => !!s) };
349
+ }));
350
+ }
351
+ async function detectAll(opts = {}) {
352
+ const lmPort = readLmStudioPort();
353
+ const ports = [hosts_1.OLLAMA_PORT, lmPort ?? hosts_1.LMSTUDIO_PORT, hosts_1.LMSTUDIO_PORT].filter((p, i, all) => all.indexOf(p) === i);
354
+ const local = groupServers([...ollamaBaseUrls(process.env.OLLAMA_HOST), ...lmStudioBaseUrls(lmPort), ...hostMachineUrls(ports)], LOCAL_PROBE_TIMEOUT_MS);
355
+ const covered = new Set(local.flatMap((g) => g.urls));
356
+ // One slot per source, in display order: this computer, its containers,
357
+ // then each added host. Slots finish at different times.
358
+ const slots = [
359
+ ...local.map((g) => probeGroup(g).then((s) => s?.models ?? [])),
360
+ containerPublishedUrls(ports).then(async (urls) => {
361
+ const groups = groupServers(urls.filter((u) => !covered.has(u)), LOCAL_PROBE_TIMEOUT_MS);
362
+ return (await Promise.all(groups.map(probeGroup))).flatMap((s) => s?.models ?? []);
363
+ }),
364
+ ...(opts.hosts ?? []).flatMap((host) => groupServers((0, hosts_1.hostUrls)(host), NETWORK_PROBE_TIMEOUT_MS, (0, hosts_1.hostLabel)(host)).map((g) => probeGroup(g).then((s) => s?.models ?? []))),
365
+ ];
366
+ const done = slots.map(() => undefined);
367
+ const merged = () => {
368
+ const seen = new Set();
369
+ return done.flatMap((models) => models ?? []).filter((m) => {
370
+ const key = `${m.baseUrl}|${m.id}`;
371
+ return seen.has(key) ? false : (seen.add(key), true);
372
+ });
373
+ };
374
+ return new Promise((resolve) => {
375
+ let left = slots.length;
376
+ slots.forEach((slot, i) => slot
377
+ .catch(() => [])
378
+ .then((models) => {
379
+ done[i] = models;
380
+ if (--left === 0 || (opts.until && models.some(opts.until)))
381
+ resolve(merged());
382
+ }));
383
+ });
384
+ }
385
+ const DEFAULT_OLLAMA_CTX_CAP = 32768; // avoid surprise VRAM blowups on huge-window models
386
+ /**
387
+ * Resolve the context window we will actually budget against for a chosen model.
388
+ *
389
+ * Ollama: by default we respect the SERVER's configured context (the Ollama
390
+ * app's Context Length setting / OLLAMA_CONTEXT_LENGTH) and never send
391
+ * num_ctx. To learn the effective value we preload the model and read
392
+ * context_length from /api/ps. Only two cases send an explicit num_ctx: a
393
+ * --ctx override, or an old Ollama whose /api/ps doesn't report context (where
394
+ * the tiny silent default is the classic footgun).
395
+ */
396
+ async function resolveContextWindow(model, ctxOverride) {
397
+ if (ctxOverride !== undefined && (!Number.isSafeInteger(ctxOverride) || ctxOverride < 1024))
398
+ throw new Error("Context window must be a whole number of at least 1024 tokens.");
399
+ if (model.backend === "ollama") {
400
+ const info = await (0, util_1.tryFetchJson)(`${model.baseUrl}/api/show`, {
401
+ method: "POST",
402
+ headers: { "content-type": "application/json" },
403
+ body: JSON.stringify({ model: model.id }),
404
+ });
405
+ // Newer Ollama lists what the model can do; "vision" means it takes images.
406
+ if (Array.isArray(info?.capabilities))
407
+ model = { ...model, vision: info.capabilities.includes("vision") };
408
+ let max;
409
+ const mi = info?.model_info;
410
+ if (mi && typeof mi === "object") {
411
+ for (const key of Object.keys(mi)) {
412
+ if (key.endsWith(".context_length") && typeof mi[key] === "number") {
413
+ max = mi[key];
414
+ break;
415
+ }
416
+ }
417
+ }
418
+ if (ctxOverride) {
419
+ const window = Math.min(ctxOverride, max ?? ctxOverride);
420
+ return { ...model, maxContext: max, contextWindow: window, numCtx: window };
421
+ }
422
+ // Preload the model (documented no-op chat), then read the effective
423
+ // context the server actually allocated.
424
+ await (0, util_1.tryFetchJson)(`${model.baseUrl}/api/chat`, {
425
+ method: "POST",
426
+ headers: { "content-type": "application/json" },
427
+ body: JSON.stringify({ model: model.id, messages: [] }),
428
+ }, 180_000);
429
+ const ps = await (0, util_1.tryFetchJson)(`${model.baseUrl}/api/ps`, undefined, 3000);
430
+ // Match the CHOSEN model only — never fall back to models[0]. If our
431
+ // preload failed (e.g. too big for VRAM) but a different model is still
432
+ // resident, models[0] would anchor the budget to the wrong window.
433
+ const entry = ps?.models?.find((m) => m.name === model.id || m.model === model.id);
434
+ if (typeof entry?.context_length === "number" && entry.context_length > 0) {
435
+ return {
436
+ ...model,
437
+ maxContext: max,
438
+ contextWindow: entry.context_length,
439
+ numCtx: undefined, // respect the server's configuration
440
+ };
441
+ }
442
+ // Older Ollama: no visibility into the server default, which is tiny and
443
+ // silently truncates — set num_ctx explicitly ourselves.
444
+ const window = Math.min(max ?? DEFAULT_OLLAMA_CTX_CAP, DEFAULT_OLLAMA_CTX_CAP);
445
+ return {
446
+ ...model,
447
+ maxContext: max,
448
+ contextWindow: window,
449
+ numCtx: window,
450
+ note: `older Ollama — setting the context to ${window.toLocaleString()} explicitly (adjust with --ctx).`,
451
+ };
452
+ }
453
+ if (model.loaded === false && ctxOverride) {
454
+ const desired = Math.min(ctxOverride, model.maxContext ?? ctxOverride);
455
+ const response = await fetch(`${model.baseUrl}/api/v1/models/load`, {
456
+ method: "POST", headers: { "content-type": "application/json" },
457
+ body: JSON.stringify({ model: model.id, context_length: desired, echo_load_config: true }),
458
+ signal: AbortSignal.timeout(180_000),
459
+ });
460
+ if (!response.ok)
461
+ throw new Error(`LM Studio could not load ${model.id} with ${desired} context tokens (${response.status}). Load the model in LM Studio or reduce --ctx.`);
462
+ const loaded = await response.json();
463
+ const actual = loaded.load_config?.context_length;
464
+ if (!Number.isSafeInteger(actual) || actual < 1024 || !loaded.instance_id)
465
+ throw new Error("LM Studio did not confirm the loaded context. Load the model in LM Studio and select it again.");
466
+ return { ...model, id: loaded.instance_id, loaded: true, contextWindow: Math.min(desired, actual), note: undefined };
467
+ }
468
+ // LM Studio: an already-loaded model stays resident; an override only shrinks our budget
469
+ // (we cannot change what LM Studio allocated).
470
+ if (ctxOverride && ctxOverride < model.contextWindow) {
471
+ return { ...model, contextWindow: ctxOverride };
472
+ }
473
+ return model;
474
+ }
package/dist/events.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ // Internal lifecycle event bus. Handlers run sequentially and may be async,
3
+ // so a pre_request handler can finish compaction before the request goes out.
4
+ // Not user-configurable in v1 by design — this is the spine that compaction,
5
+ // the context meter, and logging hang off. User-facing hooks can come later.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.EventBus = void 0;
8
+ class EventBus {
9
+ handlers = new Map();
10
+ on(event, handler) {
11
+ const list = this.handlers.get(event) ?? [];
12
+ list.push(handler);
13
+ this.handlers.set(event, list);
14
+ }
15
+ async emit(event, payload) {
16
+ const list = this.handlers.get(event);
17
+ if (!list)
18
+ return;
19
+ for (const h of list) {
20
+ await h(payload);
21
+ }
22
+ }
23
+ }
24
+ exports.EventBus = EventBus;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isHistoryPlaceholder = isHistoryPlaceholder;
4
+ // Recognize the exact legacy compaction marker. It is history metadata, never
5
+ // a replacement for source code. Keep this narrow so ordinary documentation
6
+ // and comments containing examples remain writable.
7
+ function isHistoryPlaceholder(value) {
8
+ return /^\[\d+ characters already applied to [^\r\n]+\. Read the file for current code\.\]$/.test(value.trim());
9
+ }
package/dist/hosts.js ADDED
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ // Network hosts: other machines that serve models. People add them from the
3
+ // model picker by searching the network or typing an address, so everything
4
+ // here accepts loose input ("192.168.1.50", "gpu-box.local", "box:1234",
5
+ // "https://llm.example.com") and works out the rest.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.LMSTUDIO_PORT = exports.OLLAMA_PORT = void 0;
8
+ exports.parseAddress = parseAddress;
9
+ exports.hostUrls = hostUrls;
10
+ exports.hostLabel = hostLabel;
11
+ exports.addHost = addHost;
12
+ exports.removeHost = removeHost;
13
+ exports.renameHost = renameHost;
14
+ exports.isPrivateHost = isPrivateHost;
15
+ exports.OLLAMA_PORT = 11434;
16
+ exports.LMSTUDIO_PORT = 1234;
17
+ /** Parse what someone typed into the address box. Throws a plain-English
18
+ * error for input that cannot be a server address. */
19
+ function parseAddress(input) {
20
+ let text = String(input ?? "").trim();
21
+ if (!text)
22
+ throw new Error("Type the address of the machine that runs your models.");
23
+ if (/\s/.test(text))
24
+ throw new Error("An address cannot contain spaces.");
25
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(text);
26
+ if (schemeMatch && !/^https?$/i.test(schemeMatch[1]))
27
+ throw new Error("Only http:// and https:// addresses work here.");
28
+ if (schemeMatch && text.length === schemeMatch[0].length)
29
+ throw new Error("The address needs a host name or IP.");
30
+ text = text.replace(/\/+$/, "");
31
+ // A bare IPv6 address needs brackets before it can carry a port.
32
+ if (!schemeMatch && !text.startsWith("[") && (text.match(/:/g) ?? []).length > 1)
33
+ text = `[${text}]`;
34
+ let url;
35
+ try {
36
+ url = new URL(schemeMatch ? text : `http://${text}`);
37
+ }
38
+ catch {
39
+ throw new Error(`"${input.trim()}" is not an address I can connect to. Try an IP like 192.168.1.50 or a name like gpu-box.local.`);
40
+ }
41
+ if (url.username || url.password)
42
+ throw new Error("Leave user names and passwords out of the address.");
43
+ if (!url.hostname)
44
+ throw new Error("The address needs a host name or IP.");
45
+ if (/^(0\.0\.0\.0|\[::\])$/.test(url.hostname))
46
+ throw new Error("0.0.0.0 is what a server listens on, not where to reach it. Use that machine's IP or name.");
47
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
48
+ const pathPart = url.pathname.replace(/\/+$/, "");
49
+ // URL drops a default port ("box:80"), so look at the text for one too.
50
+ const portTyped = /:\d+$/.test(text.replace(/^\[[^\]]*\]/, ""));
51
+ if (schemeMatch || portTyped || pathPart) {
52
+ const base = `${url.protocol}//${url.host}${pathPart}`;
53
+ return { address: base, hostname, urls: [base] };
54
+ }
55
+ return { address: url.hostname, hostname, urls: [`http://${url.host}:${exports.OLLAMA_PORT}`, `http://${url.host}:${exports.LMSTUDIO_PORT}`] };
56
+ }
57
+ /** Server URLs for a saved host; empty when the entry is unusable. */
58
+ function hostUrls(host) {
59
+ try {
60
+ return parseAddress(host.address).urls;
61
+ }
62
+ catch {
63
+ return [];
64
+ }
65
+ }
66
+ function hostLabel(host) {
67
+ if (host.name)
68
+ return host.name;
69
+ try {
70
+ return parseAddress(host.address).hostname;
71
+ }
72
+ catch {
73
+ return host.address;
74
+ }
75
+ }
76
+ function sameAddress(a, b) {
77
+ return a.trim().toLowerCase() === b.trim().toLowerCase();
78
+ }
79
+ /** Add a host, or update the name of one that is already there. */
80
+ function addHost(hosts, host) {
81
+ const at = hosts.findIndex((h) => sameAddress(h.address, host.address));
82
+ if (at < 0)
83
+ return [...hosts, host];
84
+ return hosts.map((h, i) => (i === at ? { ...h, ...(host.name ? { name: host.name } : {}) } : h));
85
+ }
86
+ function removeHost(hosts, address) {
87
+ return hosts.filter((h) => !sameAddress(h.address, address));
88
+ }
89
+ function renameHost(hosts, address, name) {
90
+ const clean = name.replace(/\s+/g, " ").trim().slice(0, 40);
91
+ return hosts.map((h) => (sameAddress(h.address, address) ? (clean ? { ...h, name: clean } : { address: h.address }) : h));
92
+ }
93
+ /** True for addresses that stay inside a home or office network (and for
94
+ * names, which we cannot judge). Used to warn before sending code over plain
95
+ * http to somewhere on the internet. */
96
+ function isPrivateHost(hostname) {
97
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
98
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
99
+ if (v4) {
100
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
101
+ return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
102
+ }
103
+ if (h.includes(":"))
104
+ return h === "::1" || /^f[cd]/.test(h) || /^fe[89ab]/.test(h);
105
+ // Single-label and .local/.lan/.home names resolve inside the network.
106
+ return !h.includes(".") || /\.(local|lan|home|internal|localdomain|home\.arpa|ts\.net)$/.test(h);
107
+ }