atom-agent 0.3.0 → 1.1.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 (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +83 -32
  3. package/dist/App.js +2178 -318
  4. package/dist/adapters.js +146 -15
  5. package/dist/agent/gates.js +153 -0
  6. package/dist/agent/loop-guard.js +184 -0
  7. package/dist/agent/loop.js +908 -0
  8. package/dist/agent/normalize.js +144 -0
  9. package/dist/agent/types.js +1 -0
  10. package/dist/auth.js +2 -1
  11. package/dist/cli.js +68 -6
  12. package/dist/compact.js +6 -48
  13. package/dist/config.js +171 -0
  14. package/dist/context-manager.js +564 -0
  15. package/dist/kilo.js +343 -0
  16. package/dist/local-discovery.js +308 -0
  17. package/dist/policy.js +286 -0
  18. package/dist/prompt-cache.js +99 -0
  19. package/dist/providers.js +183 -2
  20. package/dist/rollback.js +21 -0
  21. package/dist/scheduler.js +247 -0
  22. package/dist/session.js +35 -3
  23. package/dist/skills.js +214 -43
  24. package/dist/snapshots.js +57 -2
  25. package/dist/system.js +8 -1
  26. package/dist/telemetry-dashboard.js +589 -0
  27. package/dist/telemetry-server.js +301 -0
  28. package/dist/telemetry.js +1056 -0
  29. package/dist/tools/dir-cache.js +207 -0
  30. package/dist/tools/filesystem.js +149 -0
  31. package/dist/tools/fingerprints.js +33 -0
  32. package/dist/tools/overflow.js +76 -0
  33. package/dist/tools/read-cache.js +160 -0
  34. package/dist/tools/registry.js +802 -0
  35. package/dist/tools/search.js +242 -0
  36. package/dist/tools/shared.js +31 -0
  37. package/dist/tools/shell.js +273 -0
  38. package/dist/tools/todo.js +191 -0
  39. package/dist/tools/web.js +454 -0
  40. package/dist/tools.js +17 -1863
  41. package/dist/ui/activity.js +51 -0
  42. package/dist/ui/diff-panel.js +55 -0
  43. package/dist/ui/diff-view.js +112 -0
  44. package/dist/ui/diff.js +422 -0
  45. package/dist/ui/errors.js +129 -0
  46. package/dist/ui/highlight.js +120 -0
  47. package/dist/ui/input-model.js +115 -0
  48. package/dist/ui/input.js +40 -0
  49. package/dist/ui/live-tail.js +15 -0
  50. package/dist/ui/markdown.js +525 -0
  51. package/dist/ui/modals.js +47 -0
  52. package/dist/ui/palette.js +70 -0
  53. package/dist/ui/pickers.js +32 -0
  54. package/dist/ui/side-by-side.js +144 -0
  55. package/dist/ui/status-bar.js +75 -0
  56. package/dist/ui/theme.js +128 -0
  57. package/dist/ui/todo-panel.js +30 -0
  58. package/dist/ui/tool-inspector.js +59 -0
  59. package/dist/ui/transcript.js +128 -0
  60. package/dist/zen.js +145 -666
  61. package/package.json +1 -1
package/dist/kilo.js ADDED
@@ -0,0 +1,343 @@
1
+ // Kilo Gateway provider (ATOM's default provider).
2
+ //
3
+ // Kilo exposes an OpenAI-compatible surface:
4
+ // GET {base}/models
5
+ // POST {base}/chat/completions
6
+ // Chat/streaming/tool-call wire behavior is the shared OpenAI-chat path in
7
+ // src/zen.ts — this module owns ONLY Kilo-specific concerns:
8
+ // - endpoint constants + header construction (auth is optional: free
9
+ // `:free` models work anonymously, so no Authorization header is sent
10
+ // when no key is configured)
11
+ // - dynamic catalog parsing into ATOM's normalized model representation
12
+ // - free-model detection (`:free` suffix, incl. the `kilo-auto/free`
13
+ // dynamic routing model)
14
+ // - TTL-cached discovery (manual refresh clears it)
15
+ // - provider-specific error normalization (concise, actionable, key-free)
16
+ //
17
+ // Nothing here is imported by other providers: no Kilo behavior leaks.
18
+ export const KILO_BASE_URL = "https://api.kilo.ai/api/gateway";
19
+ export const KILO_CHAT_ENDPOINT = `${KILO_BASE_URL}/chat/completions`;
20
+ export const KILO_MODELS_URL = `${KILO_BASE_URL}/models`;
21
+ // Env var names in precedence order (mirrors the ProviderDef.envVars shape;
22
+ // the registry entry is the single declaration point).
23
+ export const KILO_ENV_VARS = ["KILO_API_KEY"];
24
+ // Dynamic routing model: Kilo picks a free model server-side. Preferred
25
+ // whenever the user has no API key and the live catalog exposes it — but
26
+ // never assumed present (the catalog is authoritative, see
27
+ // preferFreeKiloModel).
28
+ export const KILO_AUTO_MODEL = "kilo-auto/free";
29
+ // Offline placeholder only: the live /models list is authoritative. Kept to
30
+ // one routing id (not a catalog) so nothing here goes stale as Kilo changes
31
+ // its models.
32
+ export const KILO_FALLBACK_MODELS = [KILO_AUTO_MODEL];
33
+ // Discovery cache TTL: 5 minutes. The App also keeps successful lists in its
34
+ // session cache; this TTL bounds gateway hits across provider switches while
35
+ // staying fresh enough for a changing catalog.
36
+ export const KILO_MODELS_TTL_MS = 5 * 60 * 1000;
37
+ // A model is free when its id carries the `:free` suffix (Kilo's free-model
38
+ // convention) or names the free routing slot (`<provider>/free`, e.g.
39
+ // `kilo-auto/free`), or its catalog pricing is explicitly zero. Unknown
40
+ // pricing is NOT free — never assume.
41
+ export function isFreeKiloModel(id) {
42
+ if (id.endsWith(":free"))
43
+ return true;
44
+ if (/\/free$/.test(id))
45
+ return true;
46
+ return false;
47
+ }
48
+ function finiteCount(value) {
49
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
50
+ ? Math.floor(value)
51
+ : undefined;
52
+ }
53
+ function nonEmptyString(value) {
54
+ return typeof value === "string" && value.length > 0 ? value : undefined;
55
+ }
56
+ function entryId(entry) {
57
+ if (typeof entry === "string")
58
+ return entry || null;
59
+ if (typeof entry !== "object" || entry === null)
60
+ return null;
61
+ const e = entry;
62
+ const id = e["id"] ?? e["name"];
63
+ return typeof id === "string" && id.length > 0 ? id : null;
64
+ }
65
+ function entryRecord(entry) {
66
+ if (typeof entry !== "object" || entry === null)
67
+ return null;
68
+ return entry;
69
+ }
70
+ function parseContextLength(e) {
71
+ for (const key of [
72
+ "context_length",
73
+ "contextLength",
74
+ "max_context_length",
75
+ "maxContextLength",
76
+ "context_window",
77
+ "contextWindow",
78
+ ]) {
79
+ const hit = finiteCount(e[key]);
80
+ if (hit !== undefined)
81
+ return hit;
82
+ }
83
+ return undefined;
84
+ }
85
+ function parseCapabilities(e) {
86
+ const raw = e["capabilities"] ?? e["supported_parameters"] ?? e["supportedParameters"];
87
+ if (!Array.isArray(raw))
88
+ return undefined;
89
+ const out = raw.filter((v) => typeof v === "string" && v.length > 0);
90
+ return out.length > 0 ? out : undefined;
91
+ }
92
+ function parsePricing(e) {
93
+ const raw = e["pricing"];
94
+ if (typeof raw !== "object" || raw === null)
95
+ return undefined;
96
+ const p = raw;
97
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
98
+ // Kilo/OpenRouter-style pricing may be a decimal string ("0") — accept it.
99
+ const coerce = (v) => {
100
+ if (typeof v === "number")
101
+ return num(v);
102
+ if (typeof v === "string" && v.trim().length > 0) {
103
+ const n = Number(v.trim());
104
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
105
+ }
106
+ return undefined;
107
+ };
108
+ const prompt = coerce(p["prompt"]) ?? coerce(p["input"]);
109
+ const completion = coerce(p["completion"]) ?? coerce(p["output"]);
110
+ if (prompt === undefined && completion === undefined)
111
+ return undefined;
112
+ return {
113
+ pricing: {
114
+ ...(prompt !== undefined ? { prompt } : {}),
115
+ ...(completion !== undefined ? { completion } : {}),
116
+ },
117
+ zero: (prompt ?? 0) === 0 && (completion ?? 0) === 0,
118
+ };
119
+ }
120
+ function parseToolsSupported(e) {
121
+ for (const key of [
122
+ "tools_supported",
123
+ "toolsSupported",
124
+ "supports_tools",
125
+ "supportsTools",
126
+ "tool_support",
127
+ "toolSupport",
128
+ ]) {
129
+ if (typeof e[key] === "boolean")
130
+ return e[key];
131
+ }
132
+ // Some catalogs advertise function calling via supported parameters.
133
+ const caps = parseCapabilities(e);
134
+ if (caps?.some((c) => /tool|function/i.test(c)))
135
+ return true;
136
+ return undefined;
137
+ }
138
+ // Parse one catalog entry into the normalized representation. Returns null
139
+ // when the entry carries no usable model id.
140
+ export function parseKiloModelEntry(entry) {
141
+ const id = entryId(entry);
142
+ if (!id)
143
+ return null;
144
+ const e = entryRecord(entry);
145
+ const slash = id.indexOf("/");
146
+ const info = {
147
+ id,
148
+ displayName: nonEmptyString(e?.["display_name"] ?? e?.["displayName"] ?? e?.["name"]) ?? id,
149
+ ...(slash > 0 ? { provider: id.slice(0, slash) } : {}),
150
+ free: isFreeKiloModel(id),
151
+ };
152
+ if (!e)
153
+ return info;
154
+ const contextLength = parseContextLength(e);
155
+ if (contextLength !== undefined)
156
+ info.contextLength = contextLength;
157
+ const capabilities = parseCapabilities(e);
158
+ if (capabilities !== undefined)
159
+ info.capabilities = capabilities;
160
+ const pricing = parsePricing(e);
161
+ if (pricing !== undefined) {
162
+ info.pricing = pricing.pricing;
163
+ if (pricing.zero)
164
+ info.free = true;
165
+ }
166
+ const toolsSupported = parseToolsSupported(e);
167
+ if (toolsSupported !== undefined)
168
+ info.toolsSupported = toolsSupported;
169
+ return info;
170
+ }
171
+ function catalogEntries(data) {
172
+ try {
173
+ const entries = Array.isArray(data)
174
+ ? data
175
+ : data?.data;
176
+ if (!Array.isArray(entries) || entries.length === 0)
177
+ return null;
178
+ return entries;
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ // Full normalized catalog parse. Malformed payloads yield an empty list
185
+ // (callers fall back to KILO_FALLBACK_MODELS) — never throws.
186
+ export function parseKiloModelInfos(data) {
187
+ const entries = catalogEntries(data);
188
+ if (!entries)
189
+ return [];
190
+ const out = [];
191
+ for (const entry of entries) {
192
+ try {
193
+ const info = parseKiloModelEntry(entry);
194
+ if (info)
195
+ out.push(info);
196
+ }
197
+ catch {
198
+ // One bad entry must not drop the whole catalog.
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+ // Picker-compatible id list from a catalog payload. ANY failure (malformed
204
+ // payload, zero usable ids) returns the fallback — never throws.
205
+ export function parseKiloModelsList(data, fallback = KILO_FALLBACK_MODELS) {
206
+ const infos = parseKiloModelInfos(data);
207
+ if (infos.length === 0)
208
+ return [...fallback];
209
+ return infos.map((m) => m.id);
210
+ }
211
+ // Default pick for a fresh session without an API key: the exposed
212
+ // `kilo-auto/free` routing model when present, else the first `:free` model,
213
+ // else the first live id, else the offline fallback. Never assumes a
214
+ // specific free model exists beyond what the catalog exposes.
215
+ export function preferFreeKiloModel(models, fallback = KILO_AUTO_MODEL) {
216
+ if (models.length === 0)
217
+ return fallback;
218
+ const auto = models.find((m) => m === KILO_AUTO_MODEL);
219
+ if (auto)
220
+ return auto;
221
+ const free = models.find((m) => isFreeKiloModel(m));
222
+ if (free)
223
+ return free;
224
+ return models[0];
225
+ }
226
+ // ---- Headers (auth optional) ----
227
+ // Free `:free` models work anonymously: no Authorization header is sent when
228
+ // no key is configured (never an empty `Bearer `). No Kilo-specific,
229
+ // identity-spoofing, or bypass headers are ever sent — plain
230
+ // OpenAI-compatible auth only.
231
+ export function kiloHeaders(apiKey) {
232
+ const headers = {
233
+ "Content-Type": "application/json",
234
+ };
235
+ if (apiKey)
236
+ headers["Authorization"] = `Bearer ${apiKey}`;
237
+ return headers;
238
+ }
239
+ const kiloModelsCache = new Map();
240
+ function cacheKey(hasKey) {
241
+ // Anonymous and authenticated catalogs can differ (paid models appear
242
+ // with a key), so they cache separately.
243
+ return hasKey ? "auth" : "anon";
244
+ }
245
+ function readKiloCache(hasKey, now = Date.now()) {
246
+ const slot = kiloModelsCache.get(cacheKey(hasKey));
247
+ if (!slot)
248
+ return undefined;
249
+ if (now - slot.at > KILO_MODELS_TTL_MS) {
250
+ kiloModelsCache.delete(cacheKey(hasKey));
251
+ return undefined;
252
+ }
253
+ return slot.status;
254
+ }
255
+ // Manual refresh path: clears the discovery cache so the next fetch hits
256
+ // `/models` again (used by `/models refresh` and provider switches).
257
+ export function clearKiloModelsCache() {
258
+ kiloModelsCache.clear();
259
+ }
260
+ // GET the live catalog. Anonymous when apiKey is "" (free models only);
261
+ // authenticated otherwise. ANY failure returns the offline fallback with
262
+ // ok:false (callers keep failures uncached) — never throws.
263
+ export async function fetchKiloModelsWithStatus(apiKey) {
264
+ const hasKey = apiKey.length > 0;
265
+ const cached = readKiloCache(hasKey);
266
+ if (cached)
267
+ return cached;
268
+ const fallback = {
269
+ models: [...KILO_FALLBACK_MODELS],
270
+ infos: [],
271
+ ok: false,
272
+ };
273
+ try {
274
+ const res = await fetch(KILO_MODELS_URL, { headers: kiloHeaders(apiKey) });
275
+ if (!res.ok)
276
+ return fallback;
277
+ let data;
278
+ try {
279
+ data = await res.json();
280
+ }
281
+ catch {
282
+ return fallback;
283
+ }
284
+ const infos = parseKiloModelInfos(data);
285
+ if (infos.length === 0)
286
+ return fallback;
287
+ const status = {
288
+ models: infos.map((m) => m.id),
289
+ infos,
290
+ ok: true,
291
+ };
292
+ kiloModelsCache.set(cacheKey(hasKey), { at: Date.now(), status });
293
+ return status;
294
+ }
295
+ catch {
296
+ return fallback;
297
+ }
298
+ }
299
+ // ---- Error normalization ----
300
+ // Concise, actionable TUI errors. Never includes the API key, raw bodies, or
301
+ // HTTP dumps (callers truncate bodies before this point). `hasKey` selects
302
+ // the anonymous vs authenticated wording for 429s.
303
+ export function kiloErrorMessage(status, hasKey) {
304
+ switch (status) {
305
+ case 401:
306
+ return "Kilo: API key is invalid.";
307
+ case 403:
308
+ return "Kilo: access forbidden for this model or key.";
309
+ case 404:
310
+ return "Kilo: model is unavailable.";
311
+ case 429:
312
+ return hasKey
313
+ ? "Kilo: rate limit reached — wait a moment, then resend."
314
+ : "Kilo: anonymous free-model rate limit reached.";
315
+ case 500:
316
+ case 502:
317
+ case 503:
318
+ case 504:
319
+ return "Kilo: gateway temporarily unavailable.";
320
+ default:
321
+ return `Kilo: request failed (HTTP ${status}).`;
322
+ }
323
+ }
324
+ // Reframe a chat-POST failure thrown by the shared OpenAI-chat path
325
+ // (`Kilo HTTP {status}: ...`) into the normalized message above, preserving
326
+ // non-HTTP failures (network throws, truncation, empty replies) for their
327
+ // own handling. Never throws; unknown shapes pass through untouched.
328
+ export function normalizeKiloChatError(error, apiKey) {
329
+ if (!(error instanceof Error))
330
+ return error;
331
+ const m = /^Kilo HTTP (\d+):/.exec(error.message);
332
+ if (!m)
333
+ return error;
334
+ const status = Number(m[1]);
335
+ if (!Number.isFinite(status))
336
+ return error;
337
+ return new Error(kiloErrorMessage(status, apiKey.length > 0));
338
+ }
339
+ // Human drift-guard for the TUI: connection-level failures (no HTTP status
340
+ // at all) get the gateway wording instead of a bare fetch throw.
341
+ export function kiloNetworkErrorMessage() {
342
+ return "Kilo: gateway temporarily unavailable.";
343
+ }
@@ -0,0 +1,308 @@
1
+ // Local LLM auto-discovery: Ollama, LM Studio, llama.cpp (llama-server).
2
+ //
3
+ // On startup ATOM probes the three standard local endpoints in parallel
4
+ // with short timeouts, normalizes whatever models each runtime exposes,
5
+ // and hands the lists to the existing provider/model registry — local
6
+ // models then ride the normal openai-chat path (chatCompletionForProvider
7
+ // + runLoopWithChat), never a parallel agent implementation.
8
+ //
9
+ // Endpoint facts (verified 2026-09-09 against ollama/ollama + llama.cpp docs):
10
+ // - Ollama: native GET {base}/api/tags -> {models:[{name,model,size,digest,
11
+ // details:{format,family,families,parameter_size,quantization_level}}]};
12
+ // OpenAI-compatible {base}/v1/chat/completions (streaming, tools, vision,
13
+ // reasoning) + {base}/v1/models as fallback listing. api_key ignored.
14
+ // - LM Studio: OpenAI-compatible {base}/v1/models -> {data:[{id,...}]}.
15
+ // - llama-server: GET {base}/v1/models -> {object:"list",data:[{id,
16
+ // owned_by:"llamacpp",meta:{...n_ctx_train...}|null}]} — always a single
17
+ // element (the loaded model); id defaults to the model file path unless
18
+ // --alias is set. Bearer auth accepted-and-ignored.
19
+ //
20
+ // Honesty rules: capabilities stay unknown (undefined) unless a response
21
+ // exposes them (llama-server meta.n_ctx_train -> contextLength). Nothing is
22
+ // fabricated. Unreachable/malformed servers yield ok:false, never throw —
23
+ // discovery failures are isolated per provider and silent by default (the
24
+ // TUI surfaces them only via /models).
25
+ //
26
+ // Concurrency: createLocalDiscovery() owns one in-flight promise per scope
27
+ // ("all" or one provider id), so overlapping refresh calls share work and
28
+ // never race the snapshot. Results live in memory only — refresh
29
+ // re-discovers, disappeared servers flip to ok:false with empty models.
30
+ import { getProvider, LOCAL_PROVIDER_IDS, normalizeBaseURL, } from "./providers.js";
31
+ // Short by design: a dead local server must not delay startup.
32
+ export const DISCOVERY_TIMEOUT_MS = 2000;
33
+ export function discoveryBaseURL(provider, override) {
34
+ const stored = (override ?? "").trim();
35
+ if (stored)
36
+ return normalizeBaseURL(stored);
37
+ const def = getProvider(provider);
38
+ const envVar = def?.local?.baseURLEnvVar;
39
+ const fromEnv = envVar ? (process.env[envVar] ?? "").trim() : "";
40
+ if (fromEnv)
41
+ return normalizeBaseURL(fromEnv);
42
+ return def?.local?.defaultBaseURL ?? "";
43
+ }
44
+ function initialResult(provider) {
45
+ return {
46
+ provider,
47
+ baseURL: discoveryBaseURL(provider),
48
+ ok: false,
49
+ models: [],
50
+ };
51
+ }
52
+ export function emptyLocalSnapshot() {
53
+ return {
54
+ results: {
55
+ ollama: initialResult("ollama"),
56
+ lmstudio: initialResult("lmstudio"),
57
+ llamacpp: initialResult("llamacpp"),
58
+ },
59
+ version: 0,
60
+ };
61
+ }
62
+ // One-line human summary for /models output (counts only, no transcript spam).
63
+ export function summarizeLocalSnapshot(snap) {
64
+ const parts = LOCAL_PROVIDER_IDS.map((id) => {
65
+ const r = snap.results[id];
66
+ const name = getProvider(id)?.name ?? id;
67
+ if (!r.ok)
68
+ return `${name}: unavailable`;
69
+ return `${name}: ${r.models.length} model${r.models.length === 1 ? "" : "s"}`;
70
+ });
71
+ return `local models — ${parts.join(" · ")}`;
72
+ }
73
+ // ---- HTTP plumbing (short timeout, never hangs) ---------------------------
74
+ function timeoutSignal(ms) {
75
+ try {
76
+ const withTimeout = AbortSignal;
77
+ if (typeof withTimeout.timeout === "function")
78
+ return withTimeout.timeout(ms);
79
+ }
80
+ catch {
81
+ // fall through: no timeout support, plain fetch
82
+ }
83
+ return undefined;
84
+ }
85
+ async function fetchJson(url, deps) {
86
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
87
+ const timeoutMs = deps.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
88
+ const signal = timeoutSignal(timeoutMs);
89
+ let res;
90
+ try {
91
+ if (signal) {
92
+ res = await fetchImpl(url, {
93
+ method: "GET",
94
+ headers: { Accept: "application/json" },
95
+ signal,
96
+ });
97
+ }
98
+ else {
99
+ // No AbortSignal.timeout on this runtime: manual race so a dead
100
+ // server still cannot hang discovery past the timeout.
101
+ let timer;
102
+ try {
103
+ res = await Promise.race([
104
+ fetchImpl(url, { method: "GET", headers: { Accept: "application/json" } }),
105
+ new Promise((_, reject) => {
106
+ timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
107
+ }),
108
+ ]);
109
+ }
110
+ finally {
111
+ if (timer !== undefined)
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+ }
116
+ catch (e) {
117
+ throw new Error(e instanceof Error ? e.message : "fetch failed");
118
+ }
119
+ if (!res.ok)
120
+ throw new Error(`HTTP ${res.status}`);
121
+ try {
122
+ return (await res.json());
123
+ }
124
+ catch {
125
+ throw new Error("invalid JSON");
126
+ }
127
+ }
128
+ // ---- Pure response parsers (null = unusable shape) -------------------------
129
+ function asRecord(v) {
130
+ return typeof v === "object" && v !== null && !Array.isArray(v)
131
+ ? v
132
+ : null;
133
+ }
134
+ function finiteNumber(v) {
135
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
136
+ }
137
+ function modelIdOf(entry) {
138
+ if (typeof entry === "string")
139
+ return entry || null;
140
+ const o = asRecord(entry);
141
+ if (!o)
142
+ return null;
143
+ const id = o["id"] ?? o["name"];
144
+ return typeof id === "string" && id.length > 0 ? id : null;
145
+ }
146
+ // Ollama native listing: {models:[{name|model,size,details:{...}}]}.
147
+ export function parseOllamaTagsPayload(data) {
148
+ try {
149
+ const o = asRecord(data);
150
+ const list = o?.["models"];
151
+ if (!Array.isArray(list))
152
+ return null;
153
+ const out = [];
154
+ for (const entry of list) {
155
+ const r = asRecord(entry);
156
+ const raw = r ? (r["name"] ?? r["model"]) : null;
157
+ if (typeof raw !== "string" || raw.length === 0)
158
+ continue;
159
+ const m = { id: raw, capabilities: {} };
160
+ const size = r ? finiteNumber(r["size"]) : undefined;
161
+ if (size !== undefined && size > 0)
162
+ m.sizeBytes = size;
163
+ const details = r ? asRecord(r["details"]) : null;
164
+ if (details) {
165
+ const family = details["family"];
166
+ if (typeof family === "string" && family.length > 0)
167
+ m.family = family;
168
+ const params = details["parameter_size"];
169
+ if (typeof params === "string" && params.length > 0)
170
+ m.parameterSize = params;
171
+ }
172
+ out.push(m);
173
+ }
174
+ return out;
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ // OpenAI-shape listing: {data:[{id,meta?}]}. Used by LM Studio, llama-server,
181
+ // and as the Ollama fallback. llama-server meta.n_ctx_train (when a finite
182
+ // number) becomes contextLength; everything else stays unknown.
183
+ export function parseOpenAIModelsPayload(data) {
184
+ try {
185
+ const o = asRecord(data);
186
+ const list = o?.["data"];
187
+ if (!Array.isArray(list))
188
+ return null;
189
+ const out = [];
190
+ for (const entry of list) {
191
+ const id = modelIdOf(entry);
192
+ if (!id)
193
+ continue;
194
+ const m = { id, capabilities: {} };
195
+ const meta = asRecord(entry) ? asRecord(asRecord(entry)["meta"]) : null;
196
+ const nCtx = meta ? finiteNumber(meta["n_ctx_train"]) : undefined;
197
+ if (nCtx !== undefined && nCtx > 0) {
198
+ m.contextLength = Math.floor(nCtx);
199
+ m.capabilities = { ...m.capabilities, contextLength: Math.floor(nCtx) };
200
+ }
201
+ out.push(m);
202
+ }
203
+ return out;
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ // ---- Per-runtime discoverers (never throw) ----------------------------------
210
+ function fail(provider, baseURL, error) {
211
+ return { provider, baseURL, ok: false, models: [], error };
212
+ }
213
+ export async function discoverOllama(deps = {}) {
214
+ const baseURL = discoveryBaseURL("ollama", deps.baseURL);
215
+ if (!baseURL)
216
+ return fail("ollama", "", "no base URL");
217
+ try {
218
+ const tags = await fetchJson(`${baseURL}/api/tags`, deps);
219
+ const parsed = parseOllamaTagsPayload(tags);
220
+ if (parsed !== null)
221
+ return { provider: "ollama", baseURL, ok: true, models: parsed };
222
+ }
223
+ catch {
224
+ // fall through to the OpenAI-compatible listing
225
+ }
226
+ try {
227
+ const compat = await fetchJson(`${baseURL}/v1/models`, deps);
228
+ const parsed = parseOpenAIModelsPayload(compat);
229
+ if (parsed !== null)
230
+ return { provider: "ollama", baseURL, ok: true, models: parsed };
231
+ return fail("ollama", baseURL, "malformed response");
232
+ }
233
+ catch (e) {
234
+ return fail("ollama", baseURL, e instanceof Error ? e.message : "fetch failed");
235
+ }
236
+ }
237
+ async function discoverOpenAICompatible(provider, deps = {}) {
238
+ const baseURL = discoveryBaseURL(provider, deps.baseURL);
239
+ if (!baseURL)
240
+ return fail(provider, "", "no base URL");
241
+ try {
242
+ const data = await fetchJson(`${baseURL}/v1/models`, deps);
243
+ const parsed = parseOpenAIModelsPayload(data);
244
+ if (parsed === null)
245
+ return fail(provider, baseURL, "malformed response");
246
+ return { provider, baseURL, ok: true, models: parsed };
247
+ }
248
+ catch (e) {
249
+ return fail(provider, baseURL, e instanceof Error ? e.message : "fetch failed");
250
+ }
251
+ }
252
+ export async function discoverLMStudio(deps = {}) {
253
+ return discoverOpenAICompatible("lmstudio", deps);
254
+ }
255
+ export async function discoverLlamaCpp(deps = {}) {
256
+ return discoverOpenAICompatible("llamacpp", deps);
257
+ }
258
+ export async function discoverLocalProvider(provider, deps = {}) {
259
+ try {
260
+ if (provider === "ollama")
261
+ return await discoverOllama(deps);
262
+ if (provider === "lmstudio")
263
+ return await discoverLMStudio(deps);
264
+ return await discoverLlamaCpp(deps);
265
+ }
266
+ catch (e) {
267
+ return fail(provider, discoveryBaseURL(provider, deps.baseURL), e instanceof Error ? e.message : "fetch failed");
268
+ }
269
+ }
270
+ export function createLocalDiscovery(deps = {}) {
271
+ let snap = emptyLocalSnapshot();
272
+ const inFlight = new Map();
273
+ const runAll = async () => {
274
+ const [ollama, lmstudio, llamacpp] = await Promise.all([
275
+ discoverLocalProvider("ollama", deps),
276
+ discoverLocalProvider("lmstudio", deps),
277
+ discoverLocalProvider("llamacpp", deps),
278
+ ]);
279
+ snap = {
280
+ results: { ollama, lmstudio, llamacpp },
281
+ version: snap.version + 1,
282
+ };
283
+ return snap;
284
+ };
285
+ const runOne = async (provider) => {
286
+ const res = await discoverLocalProvider(provider, deps);
287
+ snap = {
288
+ results: { ...snap.results, [provider]: res },
289
+ version: snap.version + 1,
290
+ };
291
+ return snap;
292
+ };
293
+ return {
294
+ snapshot: () => snap,
295
+ refresh: (provider) => {
296
+ const key = provider ?? "all";
297
+ const existing = inFlight.get(key);
298
+ if (existing)
299
+ return existing;
300
+ const p = (provider ? runOne(provider) : runAll()).finally(() => {
301
+ if (inFlight.get(key) === p)
302
+ inFlight.delete(key);
303
+ });
304
+ inFlight.set(key, p);
305
+ return p;
306
+ },
307
+ };
308
+ }