local-lemonade 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/index.ts ADDED
@@ -0,0 +1,2374 @@
1
+ /**
2
+ * local-lemonade — unified pi extension for a self-hosted Lemonade server
3
+ *
4
+ * One extension, four capabilities:
5
+ *
6
+ * 1. Dynamic chat-model discovery — queries the server's model catalog and
7
+ * health endpoints at every pi startup, registers one
8
+ * lemonade-<instance-name> provider per configured instance
9
+ * with capability metadata (reasoning / vision / context window) and
10
+ * live loaded-state annotations. Chat-capable models only, by default.
11
+ * 2. Multimodal agent tools — transcribe_audio, generate_image, edit_image,
12
+ * vary_image, upscale_image, text_to_speech, generate_audio, and
13
+ * generate_3d_model, all backed by lemonade's OpenAI-compatible and
14
+ * Lemonade-specific endpoints. Unloaded models auto-load on demand;
15
+ * container audio formats are converted to wav via ffmpeg first.
16
+ * 3. /lemonade-setup — a navigable TUI menu for live status, endpoint
17
+ * configuration, server discovery, model management (load, unload,
18
+ * pull with live progress + cancel, delete with typed-phrase
19
+ * confirmation, change-ctx, Hugging Face search & install, filter,
20
+ * refresh).
21
+ * 4. Pulls use lemonade's server-owned download jobs (stream + subscribe:
22
+ * false + GET /v1/downloads) with a live progress view, Esc to cancel —
23
+ * no blocking 30-minute waits. Blocking pull is kept as a fallback.
24
+ *
25
+ * Nothing is hardcoded: every endpoint lives in ~/.pi/agent/lemonade.json,
26
+ * editable at runtime via /lemonade-setup (changes re-register the provider
27
+ * immediately; no restart or /reload needed). The config file is REQUIRED:
28
+ * if it is missing or unparseable at startup, the extension fails loudly
29
+ * and registers nothing — copy lemonade.example.json (beside this file,
30
+ * fully commented) to ~/.pi/agent/lemonade.json and edit it for your
31
+ * server. JSONC-style comments are allowed in the config file; note that
32
+ * /lemonade-setup rewrites strip them.
33
+ *
34
+ * Endpoints confirmed against lemonade 11.7.0 (see README for full list).
35
+ */
36
+
37
+ import * as fs from "node:fs";
38
+ import * as os from "node:os";
39
+ import * as path from "node:path";
40
+ import dgram from "node:dgram";
41
+ import { spawn } from "node:child_process";
42
+ import { Type } from "typebox";
43
+ import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
44
+ import { Container, Key, matchesKey, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
45
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
46
+
47
+ // ─── Configuration ──────────────────────────────────────────────────────────
48
+
49
+ interface LemonadeConfig {
50
+ /** Resolved per-instance on views produced by listInstances()/instanceView()
51
+ * (from servers[].baseUrl). Empty on the raw config — the only instance
52
+ * store is servers[], and servers[0] is the default instance. */
53
+ baseUrl: string;
54
+ apiKey: string; // shared fallback; a server entry without apiKey inherits it
55
+ chatPath: string; // OpenAI-compat chat completions base
56
+ modelsPath: string;
57
+ healthPath: string;
58
+ loadPath: string;
59
+ unloadPath: string;
60
+ pullPath: string;
61
+ deletePath: string;
62
+ downloadsPath: string;
63
+ downloadsControlPath: string;
64
+ registrySearchPath: string;
65
+ pullVariantsPath: string;
66
+ transcriptionPath: string;
67
+ imageGenerationPath: string;
68
+ imageEditPath: string;
69
+ imageVariationPath: string;
70
+ imageUpscalePath: string;
71
+ speechPath: string;
72
+ audioGenerationPath: string;
73
+ mesh3dPath: string;
74
+ classifyPath: string;
75
+ beaconPort: number;
76
+ chatOnly: boolean;
77
+ defaultTranscriptionModel: string;
78
+ defaultImageModel: string; // "" = auto-pick first image-labeled catalog model
79
+ defaultUpscaleModel: string;
80
+ defaultClassifierModel: string; // "" = auto-pick first classification model
81
+ outputDir: string; // "" = process cwd
82
+ discoveryTimeoutMs: number;
83
+ beaconTimeoutMs: number;
84
+ loadTimeoutMs: number;
85
+ pullTimeoutMs: number; // blocking-pull fallback cap only
86
+ transcriptionTimeoutMs: number;
87
+ generationTimeoutMs: number; // images, speech, audio gen, 3D
88
+ /** ALL lemonade instances — the only instance store. REQUIRED (at least
89
+ * one entry); the FIRST entry is the default instance (the one tools
90
+ * target when the `server` argument is omitted). There is no separate
91
+ * default: servers[0] IS the default, and reordering the array changes
92
+ * which instance that is. */
93
+ servers: ServerEntry[];
94
+ }
95
+
96
+ export interface ServerEntry {
97
+ name: string; // lowercase alnum + hyphens; "default" and duplicate names are reserved
98
+ baseUrl: string;
99
+ apiKey?: string; // falls back to the top-level (shared) apiKey when omitted
100
+ description?: string; // free-form context for the user's own reminder's sake
101
+ }
102
+
103
+ const CONFIG_PATH = path.join(getAgentDir(), "lemonade.json");
104
+
105
+ const DEFAULT_CONFIG: LemonadeConfig = {
106
+ baseUrl: "", // views resolve this from servers[]; empty on the raw config
107
+ apiKey: "lemonade",
108
+ chatPath: "/api/v1",
109
+ modelsPath: "/v1/models",
110
+ healthPath: "/api/v1/health",
111
+ loadPath: "/api/v1/load",
112
+ unloadPath: "/api/v1/unload",
113
+ pullPath: "/api/v1/pull",
114
+ deletePath: "/api/v1/delete",
115
+ downloadsPath: "/v1/downloads",
116
+ downloadsControlPath: "/v1/downloads/control",
117
+ registrySearchPath: "/v1/registry/search",
118
+ pullVariantsPath: "/v1/pull/variants",
119
+ transcriptionPath: "/v1/audio/transcriptions",
120
+ imageGenerationPath: "/v1/images/generations",
121
+ imageEditPath: "/v1/images/edits",
122
+ imageVariationPath: "/v1/images/variations",
123
+ imageUpscalePath: "/v1/images/upscale",
124
+ speechPath: "/v1/audio/speech",
125
+ audioGenerationPath: "/v1/audio/generations",
126
+ mesh3dPath: "/v1/3d/generations",
127
+ classifyPath: "/v1/classify",
128
+ beaconPort: 13305,
129
+ chatOnly: true,
130
+ defaultTranscriptionModel: "Whisper-Large-v3",
131
+ defaultImageModel: "",
132
+ defaultUpscaleModel: "RealESRGAN-x4plus",
133
+ defaultClassifierModel: "",
134
+ outputDir: "",
135
+ discoveryTimeoutMs: 5000,
136
+ beaconTimeoutMs: 3000,
137
+ loadTimeoutMs: 300_000,
138
+ pullTimeoutMs: 30 * 60 * 1000,
139
+ transcriptionTimeoutMs: 300_000,
140
+ generationTimeoutMs: 600_000,
141
+ servers: [],
142
+ };
143
+
144
+ /** Strip JSONC comments — // line comments and block comments — so the
145
+ * config file can be annotated by hand. String-aware: // inside quoted
146
+ * values (URLs like http://...) is preserved. */
147
+ function stripJsonComments(text: string): string {
148
+ let out = "";
149
+ let inString = false;
150
+ let i = 0;
151
+ while (i < text.length) {
152
+ const c = text[i];
153
+ if (inString) {
154
+ out += c;
155
+ if (c === "\\" && i + 1 < text.length) {
156
+ out += text[i + 1];
157
+ i += 2;
158
+ continue;
159
+ }
160
+ if (c === '"') inString = false;
161
+ i++;
162
+ continue;
163
+ }
164
+ if (c === '"') {
165
+ inString = true;
166
+ out += c;
167
+ i++;
168
+ continue;
169
+ }
170
+ if (c === "/" && text[i + 1] === "/") {
171
+ while (i < text.length && text[i] !== "\n") i++;
172
+ continue;
173
+ }
174
+ if (c === "/" && text[i + 1] === "*") {
175
+ i += 2;
176
+ while (i + 1 < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
177
+ i += 2;
178
+ continue;
179
+ }
180
+ out += c;
181
+ i++;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /** Load the required config file. Missing or unparseable config is a hard
187
+ * failure — never a silent fallback to defaults, which would point at a
188
+ * server that may not be yours. The loud failure lives in localLemonade()
189
+ * at the bottom of this file; this function just throws with a reason. */
190
+ function loadConfig(): LemonadeConfig {
191
+ if (!fs.existsSync(CONFIG_PATH)) {
192
+ throw new Error(
193
+ `${CONFIG_PATH} does not exist. Copy the fully-commented example from the extension folder ` +
194
+ `(lemonade.example.json) to your pi agent dir, edit "baseUrl" for your server, and restart pi.`
195
+ );
196
+ }
197
+ const raw = JSON.parse(stripJsonComments(fs.readFileSync(CONFIG_PATH, "utf8")));
198
+ if (typeof raw !== "object" || !raw || Array.isArray(raw)) {
199
+ throw new Error(`${CONFIG_PATH} must contain a JSON object.`);
200
+ }
201
+ // servers[] is the ONLY instance store — validate it hard, loudly, up front.
202
+ if (!Array.isArray(raw.servers) || raw.servers.length === 0) {
203
+ throw new Error(
204
+ `${CONFIG_PATH} must define a non-empty "servers" array — there is no default instance outside it; ` +
205
+ `the FIRST entry is the default. Minimal example: ` +
206
+ `{ "servers": [{ "name": "main", "baseUrl": "http://your-lemonade-server:13305" }] }`
207
+ );
208
+ }
209
+ const seen = new Set<string>();
210
+ for (const [i, s] of (raw.servers as Array<Record<string, unknown>>).entries()) {
211
+ if (!s || typeof s !== "object") throw new Error(`${CONFIG_PATH}: servers[${i}] must be an object.`);
212
+ if (typeof s.name !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(s.name) || s.name === "default") {
213
+ throw new Error(
214
+ `${CONFIG_PATH}: servers[${i}].name must be lowercase letters/numbers/hyphens ("default" is reserved).`
215
+ );
216
+ }
217
+ if (seen.has(s.name)) throw new Error(`${CONFIG_PATH}: duplicate instance name "${s.name}".`);
218
+ seen.add(s.name);
219
+ if (typeof s.baseUrl !== "string" || !/^https?:\/\//.test(s.baseUrl)) {
220
+ throw new Error(`${CONFIG_PATH}: servers[${i}].baseUrl must be an http(s) URL.`);
221
+ }
222
+ }
223
+ return { ...DEFAULT_CONFIG, ...raw };
224
+ }
225
+
226
+ function saveConfig(config: LemonadeConfig): void {
227
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
228
+ }
229
+
230
+ // ─── Multi-instance support ─────────────────────────────────────────────────
231
+
232
+ /** A resolved instance: its name plus a config *view* — same shared settings,
233
+ * but baseUrl/apiKey swapped for that instance. Because every existing helper
234
+ * (fetchCatalog, tools, TUI actions, discovery) takes a LemonadeConfig, an
235
+ * instance view flows through all of them unchanged. This is the one place
236
+ * instance semantics are defined; everything else inherits it. servers[0]
237
+ * is the default instance — position, not a flag, decides defaultness. */
238
+ export interface InstanceView {
239
+ name: string;
240
+ description: string;
241
+ isDefault: boolean;
242
+ config: LemonadeConfig;
243
+ }
244
+
245
+ function listInstances(config: LemonadeConfig): InstanceView[] {
246
+ return config.servers.map((s, i) => ({
247
+ name: s.name,
248
+ description: s.description ?? "",
249
+ isDefault: i === 0,
250
+ config: { ...config, baseUrl: s.baseUrl, apiKey: s.apiKey || config.apiKey },
251
+ }));
252
+ }
253
+
254
+ /** Resolve a config view for the named instance. Returns the default view
255
+ * (servers[0]) when name is omitted; returns an error string for unknown
256
+ * names, so callers surface actionable text instead of dialing a wrong box. */
257
+ function instanceView(config: LemonadeConfig, name?: string): LemonadeConfig | string {
258
+ // "default" stays a reserved alias for servers[0], so old muscle memory
259
+ // and scripts keep working.
260
+ const target = !name || name === "default" ? config.servers[0] : config.servers.find((s) => s.name === name);
261
+ if (!target) {
262
+ return `Unknown lemonade instance "${name}". Available instances: ${config.servers.map((s) => s.name).join(", ")}.`;
263
+ }
264
+ return { ...config, baseUrl: target.baseUrl, apiKey: target.apiKey || config.apiKey };
265
+ }
266
+
267
+ /** pi provider id for an instance — uniformly `lemonade-<name>` for EVERY
268
+ * instance, without prejudice. Model addresses follow: lemonade-main/
269
+ * <model>. Renaming an instance changes its id (and re-registers it); old
270
+ * references (pinned sessions, --model scripts) must follow the rename. */
271
+ function instanceProviderId(view: InstanceView): string {
272
+ return `lemonade-${view.name}`;
273
+ }
274
+
275
+ // ─── Lemonade API types & helpers ───────────────────────────────────────────
276
+
277
+ interface LemonadeModel {
278
+ id: string;
279
+ labels?: string[];
280
+ recipe?: string;
281
+ size?: number;
282
+ downloaded?: boolean;
283
+ max_context_window?: number;
284
+ }
285
+
286
+ interface LoadedModelInfo {
287
+ model_name: string;
288
+ type: string;
289
+ status: string;
290
+ device?: string;
291
+ pinned?: boolean;
292
+ max_context_window?: number;
293
+ }
294
+
295
+ interface LemonadeHealth {
296
+ status?: string;
297
+ version?: string;
298
+ model_loaded?: string | null;
299
+ all_models_loaded?: LoadedModelInfo[];
300
+ websocket_port?: number;
301
+ max_models?: Record<string, number>;
302
+ }
303
+
304
+ interface SystemStats {
305
+ cpu_percent?: number | null;
306
+ memory_gb?: number | null;
307
+ gpu_percent?: number | null;
308
+ vram_gb?: number | null;
309
+ npu_percent?: number | null;
310
+ }
311
+
312
+ interface PerfStats {
313
+ time_to_first_token?: number;
314
+ tokens_per_second?: number;
315
+ input_tokens?: number;
316
+ output_tokens?: number;
317
+ request_count_total?: number;
318
+ }
319
+
320
+ interface DiscoveredServer {
321
+ hostname: string;
322
+ baseUrl: string;
323
+ }
324
+
325
+ /** Server-owned download job snapshot (GET /v1/downloads). */
326
+ interface DownloadJob {
327
+ id: string;
328
+ model_name?: string;
329
+ status: string; // downloading | paused | cancelled | completed | error
330
+ running?: boolean;
331
+ file?: string;
332
+ file_index?: number;
333
+ total_files?: number;
334
+ bytes_downloaded?: number;
335
+ bytes_total?: number;
336
+ percent?: number;
337
+ cumulative_bytes_downloaded?: number;
338
+ total_download_size?: number;
339
+ complete?: boolean;
340
+ error?: string;
341
+ }
342
+
343
+ interface RegistryResult {
344
+ repository_id: string;
345
+ display_name?: string;
346
+ description?: string;
347
+ downloads?: number;
348
+ likes?: number;
349
+ tags?: string[];
350
+ }
351
+
352
+ interface PullVariants {
353
+ checkpoint: string;
354
+ recipe?: string;
355
+ suggested_name?: string;
356
+ suggested_labels?: string[];
357
+ mmproj_files?: string[];
358
+ variants: Array<{ name: string; primary_file: string; files: string[]; sharded: boolean; size_bytes: number }>;
359
+ }
360
+
361
+ function url(config: LemonadeConfig, p: string): string {
362
+ return `${config.baseUrl.replace(/\/+$/, "")}${p}`;
363
+ }
364
+
365
+ function authHeaders(config: LemonadeConfig): Record<string, string> {
366
+ return config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {};
367
+ }
368
+
369
+ async function postJson(fullUrl: string, config: LemonadeConfig, body: unknown, timeoutMs: number): Promise<Response> {
370
+ const res = await fetch(fullUrl, {
371
+ method: "POST",
372
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
373
+ body: JSON.stringify(body),
374
+ signal: AbortSignal.timeout(timeoutMs),
375
+ });
376
+ if (!res.ok) {
377
+ throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
378
+ }
379
+ return res;
380
+ }
381
+
382
+ async function fetchCatalog(config: LemonadeConfig, showAll = false): Promise<LemonadeModel[]> {
383
+ const res = await fetch(url(config, `${config.modelsPath}${showAll ? "?show_all=true" : ""}`), {
384
+ headers: authHeaders(config),
385
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs),
386
+ });
387
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
388
+ const payload = (await res.json()) as { data?: LemonadeModel[] };
389
+ return payload.data ?? [];
390
+ }
391
+
392
+ async function fetchJsonOrNull<T>(config: LemonadeConfig, p: string): Promise<T | null> {
393
+ try {
394
+ const res = await fetch(url(config, p), {
395
+ headers: authHeaders(config),
396
+ signal: AbortSignal.timeout(config.discoveryTimeoutMs),
397
+ });
398
+ return res.ok ? ((await res.json()) as T) : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ async function fetchHealth(config: LemonadeConfig): Promise<LemonadeHealth | null> {
405
+ return fetchJsonOrNull<LemonadeHealth>(config, config.healthPath);
406
+ }
407
+
408
+ async function loadModel(config: LemonadeConfig, id: string, ctxSize?: number): Promise<string> {
409
+ const body: Record<string, unknown> = { model_name: id };
410
+ if (ctxSize) {
411
+ body.ctx_size = ctxSize;
412
+ body.save_options = true;
413
+ }
414
+ await postJson(url(config, config.loadPath), config, body, config.loadTimeoutMs);
415
+ return ctxSize ? `Loaded ${id} with ctx_size ${ctxSize}.` : `Loaded ${id}.`;
416
+ }
417
+
418
+ async function unloadModel(config: LemonadeConfig, id: string): Promise<void> {
419
+ await postJson(url(config, config.unloadPath), config, { model_name: id }, config.discoveryTimeoutMs);
420
+ }
421
+
422
+ /** Start a server-owned download job; returns the initial snapshot. */
423
+ async function startPullJob(config: LemonadeConfig, id: string, extra?: Record<string, unknown>): Promise<DownloadJob> {
424
+ const res = await postJson(url(config, config.pullPath), config, {
425
+ model_name: id,
426
+ stream: true,
427
+ subscribe: false,
428
+ ...(extra ?? {}),
429
+ }, config.discoveryTimeoutMs);
430
+ return (await res.json()) as DownloadJob;
431
+ }
432
+
433
+ async function listDownloads(config: LemonadeConfig): Promise<DownloadJob[] | null> {
434
+ return fetchJsonOrNull<DownloadJob[]>(config, config.downloadsPath);
435
+ }
436
+
437
+ async function controlDownload(config: LemonadeConfig, jobId: string, action: "pause" | "cancel" | "remove"): Promise<void> {
438
+ await postJson(url(config, config.downloadsControlPath), config, { id: jobId, action }, config.discoveryTimeoutMs);
439
+ }
440
+
441
+ /** Blocking fallback for servers without download-job support. */
442
+ async function pullModelBlocking(config: LemonadeConfig, id: string): Promise<string> {
443
+ await postJson(url(config, config.pullPath), config, { model_name: id }, config.pullTimeoutMs);
444
+ return `Pulled ${id} (downloaded to disk).`;
445
+ }
446
+
447
+ async function deleteModel(config: LemonadeConfig, id: string): Promise<string> {
448
+ await postJson(url(config, config.deletePath), config, { model_name: id }, config.pullTimeoutMs);
449
+ return `Deleted ${id} from disk.`;
450
+ }
451
+
452
+ /** Change ctx for a loaded model: unload → reload with new ctx_size + save_options. */
453
+ async function changeModelContext(config: LemonadeConfig, id: string, ctxSize: number): Promise<string> {
454
+ await unloadModel(config, id);
455
+ return loadModel(config, id, ctxSize);
456
+ }
457
+
458
+ /** Parse "32k", "1m", "262144" → number of tokens. */
459
+ function parseCtxSize(input: string): number | null {
460
+ const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)(k|m)?$/);
461
+ if (!m) return null;
462
+ const n = parseFloat(m[1]);
463
+ return m[2] === "k" ? Math.round(n * 1024) : m[2] === "m" ? Math.round(n * 1024 * 1024) : Math.round(n);
464
+ }
465
+
466
+ function formatBytes(bytes?: number): string {
467
+ if (!bytes || bytes <= 0) return "—";
468
+ const units = ["B", "KB", "MB", "GB", "TB"];
469
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
470
+ return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
471
+ }
472
+
473
+ // ─── Server discovery (UDP beacon + HTTP fallback) ──────────────────────────
474
+
475
+ function normalizeBaseUrl(raw: string): string {
476
+ return raw.trim().replace(/\/+$/, "").replace(/\/api\/v1$/, "");
477
+ }
478
+
479
+ function discoverViaBeacon(config: LemonadeConfig, timeoutMs: number): Promise<DiscoveredServer[]> {
480
+ return new Promise((resolve) => {
481
+ const found = new Map<string, DiscoveredServer>();
482
+ let sock: ReturnType<typeof dgram.createSocket> | null = null;
483
+ let timer: ReturnType<typeof setTimeout> | null = null;
484
+
485
+ const finish = () => {
486
+ if (timer) clearTimeout(timer);
487
+ if (sock) {
488
+ try { sock.close(); } catch { /* ignore */ }
489
+ sock = null;
490
+ }
491
+ resolve([...found.values()]);
492
+ };
493
+
494
+ try {
495
+ sock = dgram.createSocket({ type: "udp4", reuseAddr: true, reusePort: true } as never);
496
+ sock.on("error", finish);
497
+ sock.on("message", (msg: Buffer, rinfo: { address: string }) => {
498
+ try {
499
+ const beacon = JSON.parse(msg.toString());
500
+ if (beacon?.service !== "lemonade") return;
501
+ const base = normalizeBaseUrl(String(beacon.url ?? ""));
502
+ if (!base) return;
503
+ if (!found.has(base)) {
504
+ found.set(base, { hostname: String(beacon.hostname ?? rinfo.address), baseUrl: base });
505
+ }
506
+ } catch { /* not our beacon */ }
507
+ });
508
+ sock.bind(config.beaconPort);
509
+ } catch {
510
+ finish();
511
+ return;
512
+ }
513
+ timer = setTimeout(finish, timeoutMs);
514
+ });
515
+ }
516
+
517
+ async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer[]> {
518
+ const candidateHosts = new Set(["localhost"]);
519
+ // Probe every configured instance's host, not just one: the raw config has
520
+ // no single baseUrl anymore — servers[] is the only instance store.
521
+ for (const s of config.servers) {
522
+ try {
523
+ candidateHosts.add(new URL(s.baseUrl).hostname);
524
+ } catch { /* skip malformed */ }
525
+ }
526
+ const ports = [13305, 8000, 1234, 9000, 8080];
527
+
528
+ const probes = [...candidateHosts].flatMap((host) =>
529
+ ports.map(async (port) => {
530
+ try {
531
+ const res = await fetch(`http://${host}:${port}/api/v1/health`, {
532
+ signal: AbortSignal.timeout(1500),
533
+ });
534
+ if (!res.ok) return null;
535
+ const body = (await res.json()) as { status?: string; version?: string };
536
+ if (typeof body.status !== "string") return null;
537
+ return { hostname: host, baseUrl: `http://${host}:${port}` } as DiscoveredServer;
538
+ } catch {
539
+ return null;
540
+ }
541
+ })
542
+ );
543
+ const results = await Promise.all(probes);
544
+ const seen = new Map<string, DiscoveredServer>();
545
+ for (const r of results) if (r && !seen.has(r.baseUrl)) seen.set(r.baseUrl, r);
546
+ return [...seen.values()];
547
+ }
548
+
549
+ // ─── Provider registration ──────────────────────────────────────────────────
550
+
551
+ function isChatModel(m: LemonadeModel): boolean {
552
+ return (
553
+ (m.labels ?? []).includes("chat") ||
554
+ m.recipe === "llamacpp" ||
555
+ // Omni collections bundle a chat LLM with server-side tools (image gen,
556
+ // TTS) and are driven through chat completions like any chat model.
557
+ m.recipe === "collection.omni"
558
+ );
559
+ }
560
+
561
+ function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade") {
562
+ const labels = m.labels ?? [];
563
+ const contextWindow = m.max_context_window ?? 128000;
564
+ return {
565
+ id: m.id,
566
+ name: loaded.has(m.id)
567
+ ? `${m.id} (${providerId}, loaded)`
568
+ : `${m.id} (${providerId}, on-demand)`,
569
+ reasoning: labels.includes("reasoning"),
570
+ input: labels.includes("vision") ? (["text", "image"] as const) : (["text"] as const),
571
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
572
+ contextWindow,
573
+ maxTokens: Math.min(contextWindow, 32768),
574
+ compat: {
575
+ supportsDeveloperRole: false,
576
+ supportsReasoningEffort: false,
577
+ maxTokensField: "max_tokens" as const,
578
+ },
579
+ };
580
+ }
581
+
582
+ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): Promise<number> {
583
+ const config = view.config;
584
+ const providerId = instanceProviderId(view);
585
+ let catalog: LemonadeModel[];
586
+ let loaded = new Set<string>();
587
+ try {
588
+ const [catalogRes, health] = await Promise.all([
589
+ fetchCatalog(config),
590
+ fetchHealth(config),
591
+ ]);
592
+ catalog = catalogRes;
593
+ loaded = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
594
+ } catch {
595
+ // Unreachable => contribute nothing to /model. No placeholders: an
596
+ // unreachable model is not listable. Sessions pinned to a lemonade model
597
+ // will hard-fail model resolution until the box is reachable again —
598
+ // the accepted tradeoff of "fully automated, fully honest" listings.
599
+ catalog = [];
600
+ console.error(
601
+ `[local-lemonade] instance "${view.name}" unreachable at ${config.baseUrl}; registering no models`
602
+ );
603
+ }
604
+
605
+ const advertised = config.chatOnly ? catalog.filter(isChatModel) : catalog;
606
+
607
+ try {
608
+ pi.unregisterProvider(providerId);
609
+ } catch {
610
+ // not previously registered
611
+ }
612
+
613
+ pi.registerProvider(providerId, {
614
+ name: `Lemonade ${view.name} (self-hosted)`,
615
+ baseUrl: url(config, config.chatPath),
616
+ apiKey: config.apiKey || "lemonade",
617
+ authHeader: true,
618
+ api: "openai-completions",
619
+ models: advertised.map((m) => toProviderModel(m, loaded, providerId)),
620
+ });
621
+
622
+ return advertised.length;
623
+ }
624
+
625
+ /** Provider ids used by past versions of this extension. unregisterProvider
626
+ * only works within the running pi process, and ids changed over the
627
+ * extension's evolution, so a long-lived session (or /reload after an
628
+ * upgrade) can accumulate ghost providers registered by old code. Each
629
+ * registration pass sweeps them; anything not in this list and not a
630
+ * current instance id persists until the pi process restarts. */
631
+ const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
632
+
633
+ /** Register (or re-register) every configured instance. Each becomes its own
634
+ * pi provider under the uniform lemonade-<name> id. */
635
+ async function registerLemonadeProvider(pi: ExtensionAPI, config: LemonadeConfig): Promise<number> {
636
+ for (const staleId of LEGACY_PROVIDER_IDS) {
637
+ try {
638
+ pi.unregisterProvider(staleId);
639
+ } catch { /* never registered; fine */ }
640
+ }
641
+ let total = 0;
642
+ for (const view of listInstances(config)) {
643
+ total += await registerInstanceProvider(pi, view);
644
+ }
645
+ return total;
646
+ }
647
+
648
+ // ─── Multimodal agent tools ──────────────────────────────────────────────────
649
+
650
+ /** Formats lemonade's whisper backend can't ingest; converted via ffmpeg. */
651
+ const CONTAINER_FORMATS = new Set([
652
+ ".mp4", ".m4a", ".mov", ".webm", ".mkv", ".avi", ".aac", ".ogg", ".opus", ".flac",
653
+ ]);
654
+
655
+ function runFfmpeg(args: string[], timeoutMs: number): Promise<void> {
656
+ return new Promise((resolve, reject) => {
657
+ const child = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...args]);
658
+ let stderr = "";
659
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
660
+ child.stderr.on("data", (d) => (stderr += d));
661
+ child.on("error", (e) => { clearTimeout(timer); reject(e); });
662
+ child.on("exit", (code) => {
663
+ clearTimeout(timer);
664
+ code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 300)}`));
665
+ });
666
+ });
667
+ }
668
+
669
+ function outputDir(config: LemonadeConfig): string {
670
+ return config.outputDir ? path.resolve(config.outputDir) : process.cwd();
671
+ }
672
+
673
+ function outputFilePath(config: LemonadeConfig, kind: string, ext: string): string {
674
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
675
+ return path.join(outputDir(config), `lemonade-${kind}-${stamp}.${ext}`);
676
+ }
677
+
678
+ /** Find a sensible default model for a tool, by label, from the live catalog. */
679
+ /**
680
+ * Returns: the picked model id, undefined if no catalog model matches, or
681
+ * null if the server is unreachable — callers must surface that distinctly,
682
+ * because "nothing matches" and "no route to the server" need different advice.
683
+ */
684
+ async function pickModelByLabels(
685
+ config: LemonadeConfig,
686
+ labelCandidates: string[]
687
+ ): Promise<string | null | undefined> {
688
+ try {
689
+ const catalog = await fetchCatalog(config);
690
+ const matches = catalog.filter((m) =>
691
+ (m.labels ?? []).some((l) => labelCandidates.includes(l))
692
+ );
693
+ const downloaded = matches.find((m) => m.downloaded !== false) ?? matches[0];
694
+ return downloaded?.id;
695
+ } catch {
696
+ return null; // unreachable
697
+ }
698
+ }
699
+
700
+ /** Find the first catalog model using a given recipe (e.g. onnxruntime). */
701
+ async function pickModelByRecipe(
702
+ config: LemonadeConfig,
703
+ recipe: string
704
+ ): Promise<string | null | undefined> {
705
+ try {
706
+ const catalog = await fetchCatalog(config);
707
+ const matches = catalog.filter((m) => m.recipe === recipe);
708
+ const downloaded = matches.find((m) => m.downloaded !== false) ?? matches[0];
709
+ return downloaded?.id;
710
+ } catch {
711
+ return null; // unreachable
712
+ }
713
+ }
714
+
715
+ function toolError(message: string): { content: Array<{ type: "text"; text: string }>; details: {} } {
716
+ return { content: [{ type: "text", text: `Error: ${message}` }], details: {} };
717
+ }
718
+
719
+ function unreachableMessage(config: LemonadeConfig): string {
720
+ return (
721
+ `Lemonade server at ${config.baseUrl} is unreachable from this network — ` +
722
+ "there is probably no route back to the lemonade LAN. Reconnect to a network " +
723
+ "with access, or point the extension at a reachable instance: /lemonade-setup → " +
724
+ "Server settings → Edit base URL (or Discover servers)."
725
+ );
726
+ }
727
+
728
+ /** fetch() for tool paths: converts raw network rejections (the cryptic
729
+ * "fetch failed") into a descriptive, actionable error. Abort/cancel passes
730
+ * through untouched. */
731
+ async function toolFetch(config: LemonadeConfig, p: string, init: RequestInit): Promise<Response> {
732
+ try {
733
+ return await fetch(url(config, p), init);
734
+ } catch (error) {
735
+ if (error instanceof Error && error.name === "AbortError") throw error;
736
+ throw new Error(unreachableMessage(config));
737
+ }
738
+ }
739
+
740
+ /** POST JSON, save binary response body to a file. */
741
+ async function postJsonSaveBinary(
742
+ config: LemonadeConfig,
743
+ p: string,
744
+ body: Record<string, unknown>,
745
+ kind: string,
746
+ ext: string
747
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
748
+ const res = await toolFetch(config, p, {
749
+ method: "POST",
750
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
751
+ body: JSON.stringify(body),
752
+ signal: AbortSignal.timeout(config.generationTimeoutMs),
753
+ });
754
+ if (!res.ok) {
755
+ return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
756
+ }
757
+ const buf = Buffer.from(await res.arrayBuffer());
758
+ const file = outputFilePath(config, kind, ext);
759
+ await fs.promises.writeFile(file, buf);
760
+ return {
761
+ content: [{ type: "text", text: `Saved ${file} (${formatBytes(buf.length)}).` }],
762
+ details: { file },
763
+ };
764
+ }
765
+
766
+ /** POST multipart, save JSON response's b64_json / raw binary to a file. */
767
+ async function postFormSaveImage(
768
+ config: LemonadeConfig,
769
+ p: string,
770
+ form: FormData,
771
+ kind: string
772
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
773
+ const res = await toolFetch(config, p, {
774
+ method: "POST",
775
+ headers: authHeaders(config),
776
+ body: form,
777
+ signal: AbortSignal.timeout(config.generationTimeoutMs),
778
+ });
779
+ if (!res.ok) {
780
+ return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
781
+ }
782
+ const contentType = res.headers.get("content-type") ?? "";
783
+ const file = outputFilePath(config, kind, "png");
784
+ if (contentType.includes("application/json")) {
785
+ const payload = (await res.json()) as { data?: Array<{ b64_json?: string; url?: string }> };
786
+ const b64 = payload.data?.[0]?.b64_json;
787
+ if (!b64) return toolError("Server response contained no image data.");
788
+ await fs.promises.writeFile(file, Buffer.from(b64, "base64"));
789
+ } else {
790
+ await fs.promises.writeFile(file, Buffer.from(await res.arrayBuffer()));
791
+ }
792
+ return {
793
+ content: [{ type: "text", text: `Saved ${file}.` }],
794
+ details: { file },
795
+ };
796
+ }
797
+
798
+ function readRequiredFile(resolved: string): Promise<Buffer> {
799
+ return fs.promises.readFile(resolved);
800
+ }
801
+
802
+ async function prepareAudioForWhisper(config: LemonadeConfig, resolved: string): Promise<string> {
803
+ if (!CONTAINER_FORMATS.has(path.extname(resolved).toLowerCase())) return resolved;
804
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-whisper-"));
805
+ const converted = path.join(tmpDir, "converted.wav");
806
+ await runFfmpeg(
807
+ ["-i", resolved, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", converted],
808
+ config.transcriptionTimeoutMs
809
+ );
810
+ return converted;
811
+ }
812
+
813
+ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Promise<void> {
814
+ // Harness-level self-awareness: these lines are merged into the Guidelines
815
+ // section of every chat's system prompt, so even a virgin chat knows where
816
+ // its lemonade machinery lives without having to hunt for it. (pi dedupes
817
+ // guideline strings, so attaching once is enough.)
818
+ const AWARENESS = [
819
+ "Self-hosted Lemonade server: base URL, endpoint paths, and defaults live in ~/.pi/agent/lemonade.json; full documentation (tools, endpoints, behavior reference, official API spec) is at ~/.pi/agent/extensions/local-lemonade/README.md and docs/ beside it.",
820
+ "For questions about the lemonade server (loaded models, catalog, health, host resources), query its HTTP API directly with bash + curl using the config; for interactive management, tell the user to run the /lemonade-setup command.",
821
+ `Lemonade instances available to tools: ${listInstances(gconfig)
822
+ .map((v) => `${v.name}${v.isDefault ? " (default)" : ""} (${v.config.baseUrl})`)
823
+ .join("; ")}. Tools accept an optional server argument naming an instance; omit it for the default.`,
824
+ ];
825
+
826
+ const SERVER_PARAM_DESC =
827
+ "Instance name for multi-server setups (omit for the default instance; see guidelines for the available names).";
828
+
829
+ // Enumerate the classifiers actually present on the server, so the tool's
830
+ // advertisement reflects reality each session. The label universe of each
831
+ // classifier is baked into the model itself and is revealed at runtime by
832
+ // the response's labels — the description can only hint via model ids.
833
+ let classifierHint = "";
834
+ try {
835
+ const catalog = await fetchCatalog(gconfig);
836
+ const classifiers = catalog.filter((m) => m.recipe === "onnxruntime");
837
+ if (classifiers.length) {
838
+ const pulled = classifiers.filter((m) => m.downloaded !== false).map((m) => m.id);
839
+ classifierHint =
840
+ `Classifiers on this server: ${classifiers.map((m) => m.id).join(", ")}. ` +
841
+ (pulled.length ? `Pulled and ready: ${pulled.join(", ")}.` : "None pulled yet — pull via /lemonade-setup.");
842
+ }
843
+ } catch {
844
+ // server unreachable at registration time — description stays generic
845
+ }
846
+
847
+ // ── transcribe_audio ────────────────────────────────────────────────────
848
+ pi.registerTool({
849
+ name: "transcribe_audio",
850
+ label: "Transcribe Audio",
851
+ description:
852
+ "Transcribe an audio or video file (wav, mp3, mp4, m4a, webm, flac, ...) to text " +
853
+ "using a Whisper model on the self-hosted lemonade server. Video/container " +
854
+ "formats are converted to wav with ffmpeg automatically. Returns the transcript.",
855
+ promptSnippet: "[lemonade] Transcribe audio or video files (wav, mp3, mp4, m4a, webm, ...) to text with Whisper",
856
+ promptGuidelines: AWARENESS,
857
+ parameters: Type.Object({
858
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
859
+ file_path: Type.String({ description: "Path to the audio/video file to transcribe." }),
860
+ language: Type.Optional(
861
+ Type.String({ description: "Optional ISO-639-1 language hint (e.g. 'en')." })
862
+ ),
863
+ }),
864
+ async execute(_toolCallId, params, signal) {
865
+ const instCfg = instanceView(gconfig, params.server);
866
+ if (typeof instCfg === "string") return toolError(instCfg);
867
+ const config = instCfg; // instance view — every reference below targets the selected box
868
+ const resolved = path.resolve(params.file_path);
869
+ let data: Buffer;
870
+ let fileToSend: string;
871
+ try {
872
+ fileToSend = await prepareAudioForWhisper(config, resolved);
873
+ data = await readRequiredFile(fileToSend);
874
+ } catch (error) {
875
+ return toolError(`Could not prepare '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
876
+ }
877
+ const form = new FormData();
878
+ form.append("file", new File([data], path.basename(fileToSend)));
879
+ form.append("model", config.defaultTranscriptionModel);
880
+ if (params.language) form.append("language", params.language);
881
+ const response = await toolFetch(config, config.transcriptionPath, {
882
+ method: "POST",
883
+ headers: authHeaders(config),
884
+ body: form,
885
+ signal,
886
+ });
887
+ if (!response.ok) {
888
+ return toolError(`HTTP ${response.status}: ${(await response.text()).slice(0, 400)}`);
889
+ }
890
+ const payload = (await response.json()) as { text?: string };
891
+ return {
892
+ content: [{ type: "text", text: payload.text ?? "" }],
893
+ details: { file: resolved, model: config.defaultTranscriptionModel },
894
+ };
895
+ },
896
+ });
897
+
898
+ // ── generate_image ──────────────────────────────────────────────────────
899
+ pi.registerTool({
900
+ name: "generate_image",
901
+ label: "Generate Image",
902
+ description:
903
+ "Generate an image from a text prompt using an image model on the lemonade server " +
904
+ "(e.g. Flux, Z-Image, SD-Turbo). Saves the PNG to disk and returns its path. " +
905
+ "If no model is specified, the first image model in the catalog is used. " +
906
+ "Image generation can take a while on first use (model auto-loads).",
907
+ promptSnippet: "[lemonade] Generate images from text prompts with the local Flux/SD models (saves PNG to disk)",
908
+ parameters: Type.Object({
909
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
910
+ prompt: Type.String({ description: "Text description of the image to generate." }),
911
+ model: Type.Optional(Type.String({ description: "Image model id. Omit to auto-pick." })),
912
+ size: Type.Optional(Type.String({ description: "Output size as WIDTHxHEIGHT, e.g. 512x512." })),
913
+ steps: Type.Optional(Type.Number({ description: "Inference steps. Turbo models work well with 4." })),
914
+ cfg_scale: Type.Optional(Type.Number({ description: "Classifier-free guidance scale. Turbo ~1.0, standard ~7.5." })),
915
+ seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
916
+ }),
917
+ async execute(_toolCallId, params, signal) {
918
+ const instCfg = instanceView(gconfig, params.server);
919
+ if (typeof instCfg === "string") return toolError(instCfg);
920
+ const config = instCfg; // instance view — every reference below targets the selected box
921
+ const auto = await pickModelByLabels(config, ["image"]);
922
+ if (auto === null) return toolError(unreachableMessage(config));
923
+ const model = params.model || config.defaultImageModel || auto;
924
+ if (!model) return toolError("No image model found on the lemonade server. Pull one first (e.g. Z-Image-Turbo).");
925
+ const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "b64_json" };
926
+ if (params.size) body.size = params.size;
927
+ if (params.steps) body.steps = params.steps;
928
+ if (params.cfg_scale) body.cfg_scale = params.cfg_scale;
929
+ if (params.seed !== undefined) body.seed = params.seed;
930
+ const res = await toolFetch(config, config.imageGenerationPath, {
931
+ method: "POST",
932
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
933
+ body: JSON.stringify(body),
934
+ signal,
935
+ });
936
+ if (!res.ok) return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
937
+ const payload = (await res.json()) as { data?: Array<{ b64_json?: string }> };
938
+ const b64 = payload.data?.[0]?.b64_json;
939
+ if (!b64) return toolError("Server response contained no image data.");
940
+ const file = outputFilePath(config, "image", "png");
941
+ await fs.promises.writeFile(file, Buffer.from(b64, "base64"));
942
+ return {
943
+ content: [{ type: "text", text: `Saved ${file} (model: ${model}).` }],
944
+ details: { file, model },
945
+ };
946
+ },
947
+ });
948
+
949
+ // ── edit_image ──────────────────────────────────────────────────────────
950
+ pi.registerTool({
951
+ name: "edit_image",
952
+ label: "Edit Image",
953
+ description:
954
+ "Edit an image with a text prompt (e.g. 'add a red barn, photorealistic') using an " +
955
+ "edit-capable image model on the lemonade server. Saves the edited PNG and returns its path.",
956
+ promptSnippet: "[lemonade] Edit local images with text prompts (add/remove/change things), saves result PNG",
957
+ parameters: Type.Object({
958
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
959
+ image_path: Type.String({ description: "Path to the source image (PNG)." }),
960
+ prompt: Type.String({ description: "Description of the desired edit." }),
961
+ model: Type.Optional(Type.String({ description: "Edit-capable model id (e.g. Flux-2-Klein-9B-GGUF). Omit to auto-pick." })),
962
+ mask_path: Type.Optional(Type.String({ description: "Optional mask PNG: white = edit, black = preserve." })),
963
+ size: Type.Optional(Type.String({ description: "Output size WIDTHxHEIGHT, e.g. 512x512." })),
964
+ }),
965
+ async execute(_toolCallId, params, signal) {
966
+ const instCfg = instanceView(gconfig, params.server);
967
+ if (typeof instCfg === "string") return toolError(instCfg);
968
+ const config = instCfg; // instance view — every reference below targets the selected box
969
+ const resolved = path.resolve(params.image_path);
970
+ let buf: Buffer;
971
+ try {
972
+ buf = await readRequiredFile(resolved);
973
+ } catch (error) {
974
+ return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
975
+ }
976
+ const auto = await pickModelByLabels(config, ["edit", "image"]);
977
+ if (auto === null) return toolError(unreachableMessage(config));
978
+ const model = params.model || config.defaultImageModel || auto;
979
+ if (!model) return toolError("No edit-capable image model found on the lemonade server.");
980
+ const form = new FormData();
981
+ form.append("model", model);
982
+ form.append("prompt", params.prompt);
983
+ form.append("response_format", "b64_json");
984
+ if (params.size) form.append("size", params.size);
985
+ form.append("image", new File([buf], path.basename(resolved), { type: "image/png" }));
986
+ if (params.mask_path) {
987
+ try {
988
+ const mask = await readRequiredFile(path.resolve(params.mask_path));
989
+ form.append("mask", new File([mask], path.basename(params.mask_path!), { type: "image/png" }));
990
+ } catch { /* mask is optional */ }
991
+ }
992
+ const result = await postFormSaveImage(config, config.imageEditPath, form, "image-edit");
993
+ return result;
994
+ },
995
+ });
996
+
997
+ // ── vary_image ──────────────────────────────────────────────────────────
998
+ pi.registerTool({
999
+ name: "vary_image",
1000
+ label: "Vary Image",
1001
+ description:
1002
+ "Generate a variation of an image using the lemonade server's image models. " +
1003
+ "Saves the variation PNG and returns its path.",
1004
+ promptSnippet: "[lemonade] Generate variations of local images",
1005
+ parameters: Type.Object({
1006
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1007
+ image_path: Type.String({ description: "Path to the source image (PNG)." }),
1008
+ model: Type.Optional(Type.String({ description: "Image model id. Omit to auto-pick." })),
1009
+ size: Type.Optional(Type.String({ description: "Output size WIDTHxHEIGHT." })),
1010
+ }),
1011
+ async execute(_toolCallId, params, signal) {
1012
+ const instCfg = instanceView(gconfig, params.server);
1013
+ if (typeof instCfg === "string") return toolError(instCfg);
1014
+ const config = instCfg; // instance view — every reference below targets the selected box
1015
+ const resolved = path.resolve(params.image_path);
1016
+ let buf: Buffer;
1017
+ try {
1018
+ buf = await readRequiredFile(resolved);
1019
+ } catch (error) {
1020
+ return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
1021
+ }
1022
+ const auto = await pickModelByLabels(config, ["edit", "image"]);
1023
+ if (auto === null) return toolError(unreachableMessage(config));
1024
+ const model = params.model || config.defaultImageModel || auto;
1025
+ if (!model) return toolError("No image model found on the lemonade server.");
1026
+ const form = new FormData();
1027
+ form.append("model", model);
1028
+ form.append("response_format", "b64_json");
1029
+ if (params.size) form.append("size", params.size);
1030
+ form.append("image", new File([buf], path.basename(resolved), { type: "image/png" }));
1031
+ return postFormSaveImage(config, config.imageVariationPath, form, "image-variation");
1032
+ },
1033
+ });
1034
+
1035
+ // ── upscale_image ───────────────────────────────────────────────────────
1036
+ pi.registerTool({
1037
+ name: "upscale_image",
1038
+ label: "Upscale Image",
1039
+ description:
1040
+ "Upscale an image 4x with Real-ESRGAN on the lemonade server " +
1041
+ "(models: RealESRGAN-x4plus, RealESRGAN-x4plus-anime). Saves the upscaled PNG and returns its path.",
1042
+ promptSnippet: "[lemonade] Upscale images 4x with Real-ESRGAN",
1043
+ parameters: Type.Object({
1044
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1045
+ image_path: Type.String({ description: "Path to the image to upscale (PNG)." }),
1046
+ model: Type.Optional(Type.String({ description: `Upscale model id. Default: ${DEFAULT_CONFIG.defaultUpscaleModel}.` })),
1047
+ }),
1048
+ async execute(_toolCallId, params, signal) {
1049
+ const instCfg = instanceView(gconfig, params.server);
1050
+ if (typeof instCfg === "string") return toolError(instCfg);
1051
+ const config = instCfg; // instance view — every reference below targets the selected box
1052
+ const resolved = path.resolve(params.image_path);
1053
+ let buf: Buffer;
1054
+ try {
1055
+ buf = await readRequiredFile(resolved);
1056
+ } catch (error) {
1057
+ return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
1058
+ }
1059
+ const model = params.model ?? config.defaultUpscaleModel;
1060
+ const res = await toolFetch(config, config.imageUpscalePath, {
1061
+ method: "POST",
1062
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
1063
+ body: JSON.stringify({ image: buf.toString("base64"), model }),
1064
+ signal,
1065
+ });
1066
+ if (!res.ok) return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
1067
+ const payload = (await res.json()) as { data?: Array<{ b64_json?: string }> };
1068
+ const b64 = payload.data?.[0]?.b64_json;
1069
+ if (!b64) return toolError("Server response contained no image data.");
1070
+ const file = outputFilePath(config, "image-upscaled", "png");
1071
+ await fs.promises.writeFile(file, Buffer.from(b64, "base64"));
1072
+ return { content: [{ type: "text", text: `Saved ${file} (model: ${model}).` }], details: { file, model } };
1073
+ },
1074
+ });
1075
+
1076
+ // ── text_to_speech ──────────────────────────────────────────────────────
1077
+ pi.registerTool({
1078
+ name: "text_to_speech",
1079
+ label: "Text to Speech",
1080
+ description:
1081
+ "Convert text to spoken audio using a TTS model on the lemonade server " +
1082
+ "(e.g. OpenMOSS-TTS). Saves the audio file and returns its path.",
1083
+ promptSnippet: "[lemonade] Convert text to spoken audio (TTS), saves mp3/wav to disk",
1084
+ parameters: Type.Object({
1085
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1086
+ text: Type.String({ description: "The text to speak." }),
1087
+ model: Type.Optional(Type.String({ description: "TTS model id. Omit to auto-pick from catalog." })),
1088
+ voice: Type.Optional(Type.String({ description: "Voice name (e.g. 'alloy', 'af_sky')." })),
1089
+ speed: Type.Optional(Type.Number({ description: "Speaking speed (default 1.0)." })),
1090
+ response_format: Type.Optional(Type.String({ description: "Output format: mp3 (default), wav, opus, pcm." })),
1091
+ }),
1092
+ async execute(_toolCallId, params, signal) {
1093
+ const instCfg = instanceView(gconfig, params.server);
1094
+ if (typeof instCfg === "string") return toolError(instCfg);
1095
+ const config = instCfg; // instance view — every reference below targets the selected box
1096
+ const auto = await pickModelByLabels(config, ["tts"]);
1097
+ if (auto === null) return toolError(unreachableMessage(config));
1098
+ const model = params.model || auto;
1099
+ if (!model) return toolError("No TTS model found on the lemonade server. Pull one first (e.g. OpenMOSS-TTS).");
1100
+ const fmt = params.response_format ?? "mp3";
1101
+ const body: Record<string, unknown> = { model, input: params.text, response_format: fmt };
1102
+ if (params.voice) body.voice = params.voice;
1103
+ if (params.speed) body.speed = params.speed;
1104
+ const res = await toolFetch(config, config.speechPath, {
1105
+ method: "POST",
1106
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
1107
+ body: JSON.stringify(body),
1108
+ signal,
1109
+ });
1110
+ if (!res.ok) return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
1111
+ const buf = Buffer.from(await res.arrayBuffer());
1112
+ const file = outputFilePath(config, "speech", fmt === "pcm" ? "pcm" : fmt);
1113
+ await fs.promises.writeFile(file, buf);
1114
+ return { content: [{ type: "text", text: `Saved ${file} (${formatBytes(buf.length)}, model: ${model}).` }], details: { file, model } };
1115
+ },
1116
+ });
1117
+
1118
+ // ── generate_audio (music / sound effects) ─────────────────────────────
1119
+ pi.registerTool({
1120
+ name: "generate_audio",
1121
+ label: "Generate Audio",
1122
+ description:
1123
+ "Generate music or sound effects from a text prompt using audio-generation models on " +
1124
+ "the lemonade server (e.g. ACE-Step-Music, ThinkSound-SFX). For music with vocals, " +
1125
+ "pass lyrics with section tags like [verse] and [chorus]. Saves a wav file and returns its path.",
1126
+ promptSnippet: "[lemonade] Generate music or sound effects from text prompts (ACE-Step / ThinkSound), saves wav",
1127
+ parameters: Type.Object({
1128
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1129
+ prompt: Type.String({ description: "Style description: genre, mood, tempo, instruments, voice character." }),
1130
+ model: Type.Optional(Type.String({ description: "Audio model id (e.g. ThinkSound-SFX, ACE-Step-Music). Omit to auto-pick." })),
1131
+ duration: Type.Optional(Type.Number({ description: "Clip length in seconds." })),
1132
+ lyrics: Type.Optional(Type.String({ description: "Lyrics to sing (music models only). Omit for instrumental." })),
1133
+ vocal_language: Type.Optional(Type.String({ description: "BCP-47 language of lyrics, e.g. 'en' (music models only)." })),
1134
+ seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
1135
+ }),
1136
+ async execute(_toolCallId, params, signal) {
1137
+ const instCfg = instanceView(gconfig, params.server);
1138
+ if (typeof instCfg === "string") return toolError(instCfg);
1139
+ const config = instCfg; // instance view — every reference below targets the selected box
1140
+ const auto = await pickModelByLabels(config, ["music", "sfx", "audio", "tts-audio"]);
1141
+ if (auto === null) return toolError(unreachableMessage(config));
1142
+ const model = params.model || auto;
1143
+ if (!model) return toolError("No audio-generation model found on the lemonade server. Pull one first (e.g. ThinkSound-SFX).");
1144
+ const body: Record<string, unknown> = { model, prompt: params.prompt, response_format: "wav" };
1145
+ if (params.duration) body.duration = params.duration;
1146
+ if (params.lyrics) body.lyrics = params.lyrics;
1147
+ if (params.vocal_language) body.vocal_language = params.vocal_language;
1148
+ if (params.seed !== undefined) body.seed = params.seed;
1149
+ const res = await toolFetch(config, config.audioGenerationPath, {
1150
+ method: "POST",
1151
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
1152
+ body: JSON.stringify(body),
1153
+ signal,
1154
+ });
1155
+ if (!res.ok) return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
1156
+ const buf = Buffer.from(await res.arrayBuffer());
1157
+ const file = outputFilePath(config, "audio-gen", "wav");
1158
+ await fs.promises.writeFile(file, buf);
1159
+ return { content: [{ type: "text", text: `Saved ${file} (${formatBytes(buf.length)}, model: ${model}).` }], details: { file, model } };
1160
+ },
1161
+ });
1162
+
1163
+ // ── generate_3d_model ──────────────────────────────────────────────────
1164
+ pi.registerTool({
1165
+ name: "generate_3d_model",
1166
+ label: "Generate 3D Model",
1167
+ description:
1168
+ "Generate a textured 3D mesh (.glb) from an image using the TRELLIS-3D model on the " +
1169
+ "lemonade server. Saves the .glb file and returns its path. Can take minutes.",
1170
+ promptSnippet: "[lemonade] Generate textured 3D meshes (.glb) from images with TRELLIS",
1171
+ parameters: Type.Object({
1172
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1173
+ image_path: Type.String({ description: "Path to the input image (PNG/JPEG/BMP/GIF)." }),
1174
+ model: Type.Optional(Type.String({ description: "3D model id, e.g. TRELLIS-3D. Omit to auto-pick." })),
1175
+ resolution: Type.Optional(Type.Number({ description: "Cascade resolution: 512 (default), 1024, or 1536." })),
1176
+ seed: Type.Optional(Type.Number({ description: "Random seed for reproducibility." })),
1177
+ }),
1178
+ async execute(_toolCallId, params, signal) {
1179
+ const instCfg = instanceView(gconfig, params.server);
1180
+ if (typeof instCfg === "string") return toolError(instCfg);
1181
+ const config = instCfg; // instance view — every reference below targets the selected box
1182
+ const resolved = path.resolve(params.image_path);
1183
+ let buf: Buffer;
1184
+ try {
1185
+ buf = await readRequiredFile(resolved);
1186
+ } catch (error) {
1187
+ return toolError(`Could not read '${resolved}': ${error instanceof Error ? error.message : String(error)}`);
1188
+ }
1189
+ const auto = await pickModelByLabels(config, ["3d"]);
1190
+ if (auto === null) return toolError(unreachableMessage(config));
1191
+ const model = params.model || auto;
1192
+ if (!model) return toolError("No 3D-generation model found on the lemonade server. Pull one first (e.g. TRELLIS-3D).");
1193
+ const body: Record<string, unknown> = { model, image: `data:image/png;base64,${buf.toString("base64")}` };
1194
+ if (params.resolution) body.resolution = params.resolution;
1195
+ if (params.seed !== undefined) body.seed = params.seed;
1196
+ return postJsonSaveBinary(config, config.mesh3dPath, body, "mesh3d", "glb");
1197
+ },
1198
+ });
1199
+
1200
+ // ── classify_text ────────────────────────────────────────────────────
1201
+ pi.registerTool({
1202
+ name: "classify_text",
1203
+ label: "Classify Text",
1204
+ description:
1205
+ "Classify input TEXT with an encoder-classifier model on the lemonade server " +
1206
+ "(recipe: onnxruntime). Each classifier answers exactly one question, decided by " +
1207
+ "its baked-in label set, and the response's labels reveal that universe — if they " +
1208
+ "don't fit the question, retry with a different model. Text-only by architecture: " +
1209
+ "image questions go to vision-capable chat models (read the image), audio must be " +
1210
+ "transcribed first (transcribe_audio). " +
1211
+ (classifierHint ||
1212
+ "Common classifier types: phishing/PII/prompt-injection detection. Pull via /lemonade-setup.") +
1213
+ " Returns all labels with confidence scores, ranked highest to lowest.",
1214
+ promptSnippet:
1215
+ "[lemonade] Classify text through safety/phishing/PII-style classifier models — ranked label confidences",
1216
+ parameters: Type.Object({
1217
+ server: Type.Optional(Type.String({ description: SERVER_PARAM_DESC })),
1218
+ input: Type.String({ description: "The text to classify." }),
1219
+ model: Type.Optional(
1220
+ Type.String({ description: "Classifier model id (recipe onnxruntime). Omit to auto-pick." })
1221
+ ),
1222
+ top_k: Type.Optional(
1223
+ Type.Number({ description: "Only return the highest-scoring k labels." })
1224
+ ),
1225
+ }),
1226
+ async execute(_toolCallId, params, signal) {
1227
+ const instCfg = instanceView(gconfig, params.server);
1228
+ if (typeof instCfg === "string") return toolError(instCfg);
1229
+ const config = instCfg; // instance view — every reference below targets the selected box
1230
+ const auto = await pickModelByRecipe(config, "onnxruntime");
1231
+ if (auto === null) return toolError(unreachableMessage(config));
1232
+ const model = params.model || config.defaultClassifierModel || auto;
1233
+ if (!model) {
1234
+ return toolError(
1235
+ "No classifier model (onnxruntime recipe) found on the lemonade server. " +
1236
+ "Pull one first, e.g. Bert-Phishing-ONNX or Phishing-Email-Detection-ONNX."
1237
+ );
1238
+ }
1239
+ const body: Record<string, unknown> = { model, input: params.input };
1240
+ if (params.top_k) body.top_k = params.top_k;
1241
+ const res = await toolFetch(config, config.classifyPath, {
1242
+ method: "POST",
1243
+ headers: { ...authHeaders(config), "Content-Type": "application/json" },
1244
+ body: JSON.stringify(body),
1245
+ signal,
1246
+ });
1247
+ if (!res.ok) {
1248
+ return toolError(`HTTP ${res.status}: ${(await res.text()).slice(0, 400)}`);
1249
+ }
1250
+ const payload = (await res.json()) as Record<string, unknown> & {
1251
+ labels?: Record<string, number>;
1252
+ model?: string;
1253
+ };
1254
+ const ranked = Object.entries(payload.labels ?? {})
1255
+ .sort((a, b) => b[1] - a[1])
1256
+ .map(([label, score]) => `${(score * 100).toFixed(3).padStart(7)}% ${label}`);
1257
+ // Some classifiers (token-classification, e.g. PII span detection) return
1258
+ // extra structure beyond the flat label map — pass it through verbatim.
1259
+ const extras = Object.keys(payload).filter((k) => !["labels", "model", "object"].includes(k));
1260
+ const extraText = extras.length
1261
+ ? "\n" + JSON.stringify(Object.fromEntries(extras.map((k) => [k, payload[k]])), null, 2)
1262
+ : "";
1263
+ const text =
1264
+ `Classification by ${payload.model ?? model}:\n` +
1265
+ (ranked.length ? ranked.join("\n") : "(no labels returned)") +
1266
+ extraText;
1267
+ return { content: [{ type: "text", text }], details: { model } };
1268
+ },
1269
+ });
1270
+ }
1271
+
1272
+ // ─── /lemonade-setup TUI ────────────────────────────────────────────────────
1273
+
1274
+ type UiCtx = { ui: import("@earendil-works/pi-coding-agent").ExtensionContext["ui"] };
1275
+
1276
+ /** Show a SelectList menu framed with DynamicBorder. Returns chosen value or null on Esc. */
1277
+ function menu<T extends string>(ctx: UiCtx, title: string, items: SelectItem<T>[]): Promise<T | null> {
1278
+ return ctx.ui.custom<T | null>((tui, theme, _kb, done) => {
1279
+ const container = new Container();
1280
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1281
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
1282
+ const list = new SelectList(items, Math.min(items.length, 12), {
1283
+ selectedPrefix: (t) => theme.fg("accent", t),
1284
+ selectedText: (t) => theme.fg("accent", t),
1285
+ description: (t) => theme.fg("muted", t),
1286
+ scrollInfo: (t) => theme.fg("dim", t),
1287
+ noMatch: (t) => theme.fg("warning", t),
1288
+ });
1289
+ list.onSelect = (item) => done(item.value);
1290
+ list.onCancel = () => done(null);
1291
+ container.addChild(list);
1292
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc back"), 1, 0));
1293
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1294
+ return {
1295
+ render: (w) => container.render(w),
1296
+ invalidate: () => container.invalidate(),
1297
+ handleInput: (data) => { list.handleInput(data); tui.requestRender(); },
1298
+ };
1299
+ });
1300
+ }
1301
+
1302
+ /** Full-screen framed text view (status output); any key returns. */
1303
+ async function textView(ctx: UiCtx, title: string, lines: string[]): Promise<void> {
1304
+ await ctx.ui.custom<null>((tui, theme, _kb, done) => {
1305
+ const container = new Container();
1306
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1307
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
1308
+ for (const line of lines) container.addChild(new Text(line, 0, 0));
1309
+ container.addChild(new Text(theme.fg("dim", "press any key to return"), 1, 0));
1310
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1311
+ return {
1312
+ render: (w) => container.render(w),
1313
+ invalidate: () => container.invalidate(),
1314
+ handleInput: () => { done(null); tui.requestRender(); },
1315
+ };
1316
+ });
1317
+ }
1318
+
1319
+ /**
1320
+ * Live download-progress view over a server-owned download job.
1321
+ * Polls GET /v1/downloads once per second; Esc sends a cancel via
1322
+ * /v1/downloads/control. Resolves when the job completes, errors,
1323
+ * disappears (treated as completed), or the user cancels.
1324
+ */
1325
+ async function trackDownload(
1326
+ ctx: UiCtx,
1327
+ config: LemonadeConfig,
1328
+ jobId: string,
1329
+ label: string
1330
+ ): Promise<"complete" | "cancelled" | "error"> {
1331
+ const outcome = await ctx.ui.custom<"complete" | "cancelled" | "error">((tui, theme, _kb, done) => {
1332
+ const container = new Container();
1333
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1334
+ container.addChild(new Text(theme.fg("accent", theme.bold(`Downloading ${label}`)), 1, 0));
1335
+ const statusLine = new Text("starting…", 0, 0);
1336
+ container.addChild(statusLine);
1337
+ container.addChild(new Text(theme.fg("dim", "esc cancel • progress updates every second"), 1, 0));
1338
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1339
+
1340
+ let finished = false;
1341
+ let seen = false;
1342
+ const finish = (v: "complete" | "cancelled" | "error") => {
1343
+ if (finished) return;
1344
+ finished = true;
1345
+ done(v);
1346
+ };
1347
+
1348
+ void (async () => {
1349
+ while (!finished) {
1350
+ const jobs = await listDownloads(config);
1351
+ const job = (jobs ?? []).find((j) => j.id === jobId);
1352
+ if (job) {
1353
+ seen = true;
1354
+ const pct = typeof job.percent === "number" ? `${job.percent.toFixed(1)}%` : "?";
1355
+ const bytes = `${formatBytes(job.cumulative_bytes_downloaded ?? job.bytes_downloaded)} / ${formatBytes(job.total_download_size ?? job.bytes_total)}`;
1356
+ statusLine.setText(
1357
+ `${job.status} — ${pct} (${bytes})${job.file ? ` — ${job.file}` : ""}` +
1358
+ `${job.file_index && job.total_files ? ` [file ${job.file_index}/${job.total_files}]` : ""}` +
1359
+ (job.error ? `\nerror: ${job.error.slice(0, 120)}` : "")
1360
+ );
1361
+ tui.requestRender();
1362
+ if (job.complete || job.status === "completed") return finish("complete");
1363
+ if (job.status === "error") return finish("error");
1364
+ if (job.status === "cancelled") return finish("cancelled");
1365
+ } else if (seen) {
1366
+ // Completed jobs are removed from the list after a short delay.
1367
+ return finish("complete");
1368
+ } else if (jobs !== null) {
1369
+ // Job not registered yet (server hasn't picked it up) — keep polling.
1370
+ } else {
1371
+ statusLine.setText("cannot reach server — press esc to cancel");
1372
+ tui.requestRender();
1373
+ }
1374
+ await new Promise((r) => setTimeout(r, 1000));
1375
+ }
1376
+ })();
1377
+
1378
+ return {
1379
+ render: (w) => container.render(w),
1380
+ invalidate: () => container.invalidate(),
1381
+ handleInput: (data) => {
1382
+ if (matchesKey(data, Key.escape)) {
1383
+ statusLine.setText("cancelling…");
1384
+ tui.requestRender();
1385
+ void controlDownload(config, jobId, "cancel").catch(() => {});
1386
+ finish("cancelled");
1387
+ }
1388
+ },
1389
+ };
1390
+ });
1391
+ return outcome;
1392
+ }
1393
+
1394
+ /** Live server log viewer: subscribes to ws://host:{wsPort}/logs/stream, shows
1395
+ * the snapshot backlog then live entries; Esc closes. */
1396
+ async function liveLogsView(ctx: UiCtx, config: LemonadeConfig): Promise<void> {
1397
+ ctx.ui.setStatus("lemonade-setup", "Finding websocket port…");
1398
+ const health = await fetchHealth(config);
1399
+ ctx.ui.setStatus("lemonade-setup", undefined);
1400
+ const wsPort = health?.websocket_port;
1401
+ if (!wsPort) {
1402
+ ctx.ui.notify("Server unreachable, or no websocket port advertised — cannot stream logs.", "error");
1403
+ return;
1404
+ }
1405
+ let host = "localhost";
1406
+ try {
1407
+ host = new URL(config.baseUrl).hostname;
1408
+ } catch { /* keep localhost */ }
1409
+ const wsUrl = `ws://${host}:${wsPort}/logs/stream`;
1410
+
1411
+ await ctx.ui.custom<null>((tui, theme, _kb, done) => {
1412
+ const container = new Container();
1413
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1414
+ container.addChild(new Text(theme.fg("accent", theme.bold(`Live Server Logs`)), 1, 0));
1415
+ container.addChild(new Text(theme.fg("dim", wsUrl), 0, 0));
1416
+ const logText = new Text("connecting…", 0, 0);
1417
+ container.addChild(logText);
1418
+ container.addChild(new Text(theme.fg("dim", "esc to close • newest 40 lines shown"), 1, 0));
1419
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1420
+
1421
+ const lines: string[] = [];
1422
+ let closed = false;
1423
+ const push = (line: string) => {
1424
+ if (closed) return;
1425
+ lines.push(line.slice(0, 200));
1426
+ if (lines.length > 500) lines.splice(0, lines.length - 500);
1427
+ logText.setText(lines.slice(-40).join("\n"));
1428
+ tui.requestRender();
1429
+ };
1430
+
1431
+ let ws: { close: () => void } | null = null;
1432
+ try {
1433
+ const WS = (globalThis as { WebSocket?: new (url: string) => WebSocket }).WebSocket;
1434
+ if (!WS) throw new Error("no WebSocket global in this runtime");
1435
+ const sock = new WS(wsUrl) as WebSocket & {
1436
+ onopen: (() => void) | null;
1437
+ onmessage: ((ev: { data: unknown }) => void) | null;
1438
+ onclose: (() => void) | null;
1439
+ onerror: (() => void) | null;
1440
+ send: (data: string) => void;
1441
+ };
1442
+ ws = { close: () => { try { sock.close(); } catch { /* ignore */ } } };
1443
+ sock.onopen = () => {
1444
+ sock.send(JSON.stringify({ type: "logs.subscribe", after_seq: null }));
1445
+ push("— subscribed —");
1446
+ };
1447
+ sock.onmessage = (ev) => {
1448
+ try {
1449
+ const msg = JSON.parse(String(ev.data)) as {
1450
+ type?: string;
1451
+ entries?: Array<{ timestamp?: string; line?: string }>;
1452
+ entry?: { timestamp?: string; line?: string };
1453
+ };
1454
+ if (msg.type === "logs.snapshot") {
1455
+ for (const e of msg.entries ?? []) push(`${e.timestamp ?? ""} ${e.line ?? ""}`);
1456
+ if (!(msg.entries ?? []).length) push("(no backlog entries)");
1457
+ } else if (msg.type === "logs.entry") {
1458
+ const e = msg.entry ?? {};
1459
+ push(`${e.timestamp ?? ""} ${e.line ?? ""}`);
1460
+ }
1461
+ } catch { /* non-JSON frame */ }
1462
+ };
1463
+ sock.onclose = () => push("— connection closed —");
1464
+ sock.onerror = () => push("— connection error —");
1465
+ } catch (error) {
1466
+ push(`connect failed: ${error instanceof Error ? error.message : String(error)}`);
1467
+ }
1468
+
1469
+ return {
1470
+ render: (w) => container.render(w),
1471
+ invalidate: () => container.invalidate(),
1472
+ handleInput: (data) => {
1473
+ if (matchesKey(data, Key.escape)) {
1474
+ closed = true;
1475
+ ws?.close();
1476
+ done(null);
1477
+ tui.requestRender();
1478
+ }
1479
+ },
1480
+ };
1481
+ });
1482
+ }
1483
+
1484
+ function formatHealth(config: LemonadeConfig, health: LemonadeHealth | null): string[] {
1485
+ if (!health) {
1486
+ return [
1487
+ `Unreachable: ${url(config, config.healthPath)}`,
1488
+ "",
1489
+ "Check that the lemonade server is running, then verify the baseUrl",
1490
+ "under Server settings (or run Discover servers).",
1491
+ ];
1492
+ }
1493
+ const lines = [
1494
+ `Status: ${health.status ?? "?"} Version: ${health.version ?? "?"}`,
1495
+ `Endpoint: ${config.baseUrl} WebSocket port: ${health.websocket_port ?? "?"}`,
1496
+ "",
1497
+ `Loaded models (${health.all_models_loaded?.length ?? 0}):`,
1498
+ ];
1499
+ for (const m of health.all_models_loaded ?? []) {
1500
+ lines.push(
1501
+ ` • ${m.model_name} — ${m.type}, ${m.status}, ${m.device ?? "?"}${m.pinned ? ", pinned" : ""}` +
1502
+ `, ctx ${m.max_context_window ?? "?"}`
1503
+ );
1504
+ }
1505
+ if (!health.all_models_loaded?.length) lines.push(" (none)");
1506
+ const slots = Object.entries(health.max_models ?? {})
1507
+ .map(([k, v]) => `${k}:${v}`)
1508
+ .join(" ");
1509
+ if (slots) lines.push("", `Slots: ${slots}`);
1510
+ return lines;
1511
+ }
1512
+
1513
+ function formatStatus(
1514
+ config: LemonadeConfig,
1515
+ health: LemonadeHealth | null,
1516
+ sys: SystemStats | null,
1517
+ perf: PerfStats | null
1518
+ ): string[] {
1519
+ const lines = formatHealth(config, health);
1520
+ if (sys) {
1521
+ const pct = (v?: number | null) => (typeof v === "number" ? `${v.toFixed(0)}%` : "?");
1522
+ const gb = (v?: number | null) => (typeof v === "number" ? `${v.toFixed(1)} GB` : "?");
1523
+ lines.push(
1524
+ "",
1525
+ `Host: CPU ${pct(sys.cpu_percent)} RAM ${gb(sys.memory_gb)} GPU ${pct(sys.gpu_percent)} VRAM ${gb(sys.vram_gb)}` +
1526
+ (sys.npu_percent !== undefined && sys.npu_percent !== null ? ` NPU ${pct(sys.npu_percent)}` : "")
1527
+ );
1528
+ }
1529
+ if (perf && typeof perf.tokens_per_second === "number") {
1530
+ lines.push(
1531
+ "",
1532
+ `Last request: ${perf.tokens_per_second.toFixed(1)} tok/s, TTFT ${
1533
+ typeof perf.time_to_first_token === "number" ? perf.time_to_first_token.toFixed(2) : "?"
1534
+ }s` +
1535
+ ` (in ${perf.input_tokens ?? "?"} / out ${perf.output_tokens ?? "?"} tokens)` +
1536
+ (typeof perf.request_count_total === "number" ? ` lifetime requests: ${perf.request_count_total}` : "")
1537
+ );
1538
+ }
1539
+ return lines;
1540
+ }
1541
+
1542
+ /** Run an async op with a status indicator; returns [ok, result]. */
1543
+ async function withStatus<T>(
1544
+ ctx: UiCtx,
1545
+ label: string,
1546
+ op: () => Promise<T>
1547
+ ): Promise<[boolean, T | string]> {
1548
+ ctx.ui.setStatus("lemonade-setup", label);
1549
+ try {
1550
+ return [true, await op()];
1551
+ } catch (error) {
1552
+ return [false, error instanceof Error ? error.message : String(error)];
1553
+ } finally {
1554
+ ctx.ui.setStatus("lemonade-setup", undefined);
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * Pull a model with live progress: server-owned download job + progress view.
1560
+ * Falls back to a blocking pull on servers without job support.
1561
+ */
1562
+ async function pullWithProgress(
1563
+ ctx: UiCtx,
1564
+ pi: ExtensionAPI,
1565
+ config: LemonadeConfig,
1566
+ id: string,
1567
+ extra?: Record<string, unknown>
1568
+ ): Promise<[boolean, string]> {
1569
+ let job: DownloadJob | null = null;
1570
+ try {
1571
+ job = await startPullJob(config, id, extra);
1572
+ } catch {
1573
+ // Fallback: blocking pull (older server without download jobs)
1574
+ return withStatus(ctx, `Pulling ${id} (blocking)…`, () => pullModelBlocking(config, id));
1575
+ }
1576
+ const outcome = await trackDownload(ctx, config, job.id, id);
1577
+ if (outcome === "complete") {
1578
+ await registerLemonadeProvider(pi, config); // model now in catalog
1579
+ return [true, `Pulled ${id}.`];
1580
+ }
1581
+ if (outcome === "cancelled") {
1582
+ return [false, `Pull of ${id} cancelled.`];
1583
+ }
1584
+ return [false, `Pull of ${id} failed.`];
1585
+ }
1586
+
1587
+ /** Search Hugging Face via the lemonade server and install a model as user.* */
1588
+ async function installFromHuggingFace(ctx: UiCtx, pi: ExtensionAPI, config: LemonadeConfig): Promise<void> {
1589
+ const query = (await ctx.ui.input("Search Hugging Face (GGUF repos):", ""))?.trim();
1590
+ if (!query || query.length < 3) {
1591
+ if (query) ctx.ui.notify("Search query must be at least 3 characters.", "warning");
1592
+ return;
1593
+ }
1594
+
1595
+ ctx.ui.setStatus("lemonade-setup", `Searching Hugging Face for "${query}"…`);
1596
+ const res = await fetch(
1597
+ url(config, `${config.registrySearchPath}?query=${encodeURIComponent(query)}&format=gguf&limit=12`),
1598
+ { headers: authHeaders(config), signal: AbortSignal.timeout(config.discoveryTimeoutMs * 3) }
1599
+ );
1600
+ ctx.ui.setStatus("lemonade-setup", undefined);
1601
+ if (!res.ok) {
1602
+ ctx.ui.notify(`Registry search failed (HTTP ${res.status}).`, "error");
1603
+ return;
1604
+ }
1605
+ const payload = (await res.json()) as { results?: RegistryResult[] };
1606
+ const results = payload.results ?? [];
1607
+ if (!results.length) {
1608
+ ctx.ui.notify("No matching repositories found.", "warning");
1609
+ return;
1610
+ }
1611
+
1612
+ const repo = await menu(
1613
+ ctx,
1614
+ "Search Results",
1615
+ results.map((r) => ({
1616
+ value: r.repository_id,
1617
+ label: r.repository_id,
1618
+ description: [
1619
+ r.downloads !== undefined ? `${(r.downloads / 1000).toFixed(0)}k downloads` : undefined,
1620
+ r.likes !== undefined ? `${r.likes} likes` : undefined,
1621
+ (r.tags ?? []).slice(0, 3).join(", "),
1622
+ ].filter(Boolean).join(" • "),
1623
+ })) as SelectItem<string>[]
1624
+ );
1625
+ if (!repo) return;
1626
+
1627
+ ctx.ui.setStatus("lemonade-setup", `Inspecting ${repo}…`);
1628
+ const varRes = await fetch(
1629
+ url(config, `${config.pullVariantsPath}?checkpoint=${encodeURIComponent(repo)}`),
1630
+ { headers: authHeaders(config), signal: AbortSignal.timeout(config.discoveryTimeoutMs * 4) }
1631
+ );
1632
+ ctx.ui.setStatus("lemonade-setup", undefined);
1633
+ if (!varRes.ok) {
1634
+ ctx.ui.notify(`Could not inspect variants (HTTP ${varRes.status}).`, "error");
1635
+ return;
1636
+ }
1637
+ const variants = (await varRes.json()) as PullVariants;
1638
+ if (!variants.variants?.length) {
1639
+ ctx.ui.notify("No installable variants found for this repository.", "warning");
1640
+ return;
1641
+ }
1642
+
1643
+ const variant = await menu(
1644
+ ctx,
1645
+ `Variants of ${repo}`,
1646
+ variants.variants.map((v) => ({
1647
+ value: v.name,
1648
+ label: v.name,
1649
+ description: `${formatBytes(v.size_bytes)}${v.sharded ? " • sharded" : ""}`,
1650
+ })) as SelectItem<string>[]
1651
+ );
1652
+ if (!variant) return;
1653
+ const chosen = variants.variants.find((v) => v.name === variant)!;
1654
+
1655
+ const modelName = `user.${variants.suggested_name ?? repo.split("/")[1]}`;
1656
+ const vision = (variants.suggested_labels ?? []).includes("vision");
1657
+ const ok = await ctx.ui.confirm(
1658
+ `Install as ${modelName}?`,
1659
+ `${repo}:${chosen.primary_file} (${formatBytes(chosen.size_bytes)})` +
1660
+ (vision ? " • vision model (mmproj included)" : "")
1661
+ );
1662
+ if (!ok) return;
1663
+
1664
+ const extra: Record<string, unknown> = {
1665
+ checkpoint: `${repo}:${chosen.primary_file}`,
1666
+ recipe: variants.recipe ?? "llamacpp",
1667
+ };
1668
+ if (vision && variants.mmproj_files?.[0]) {
1669
+ extra.mmproj = variants.mmproj_files[0];
1670
+ extra.vision = true;
1671
+ }
1672
+
1673
+ const [succeeded, msg] = await pullWithProgress(ctx, pi, config, modelName, extra);
1674
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
1675
+ }
1676
+
1677
+
1678
+ /** Interactive flow to register a new lemonade instance. Returns its name on
1679
+ * success, undefined otherwise. Validates the name, verifies reachability,
1680
+ * persists to config.servers, and registers the new provider immediately. */
1681
+ async function addInstanceFlow(
1682
+ ctx: UiCtx,
1683
+ pi: ExtensionAPI,
1684
+ config: LemonadeConfig,
1685
+ presetUrl?: string
1686
+ ): Promise<string | undefined> {
1687
+ const nameInput = (await ctx.ui.input("Instance name (lowercase letters, numbers, hyphens):", ""))?.trim();
1688
+ if (!nameInput) return undefined;
1689
+ if (
1690
+ nameInput === "default" ||
1691
+ config.servers.some((s) => s.name === nameInput) ||
1692
+ !/^[a-z0-9][a-z0-9-]*$/.test(nameInput)
1693
+ ) {
1694
+ ctx.ui.notify(
1695
+ 'Invalid name — use lowercase letters/numbers/hyphens; "default" and existing instance names are reserved.',
1696
+ "warning"
1697
+ );
1698
+ return undefined;
1699
+ }
1700
+ const urlInput = presetUrl ?? (await ctx.ui.input("Lemonade base URL:", "http://"))?.trim();
1701
+ const base = normalizeBaseUrl(urlInput ?? "");
1702
+ if (!/^https?:\/\/.+/.test(base)) {
1703
+ ctx.ui.notify("Invalid URL.", "warning");
1704
+ return undefined;
1705
+ }
1706
+ const description = (await ctx.ui.input("Description (optional, your own reminder):", ""))?.trim() ?? "";
1707
+ const view: InstanceView = {
1708
+ name: nameInput,
1709
+ description,
1710
+ isDefault: false,
1711
+ config: { ...config, baseUrl: base },
1712
+ };
1713
+ ctx.ui.setStatus("lemonade-setup", `Verifying ${base}…`);
1714
+ const health = await fetchHealth(view.config);
1715
+ ctx.ui.setStatus("lemonade-setup", undefined);
1716
+ if (!health) {
1717
+ const ok = await ctx.ui.confirm(
1718
+ "Server unreachable — add anyway?",
1719
+ `${base} did not respond to a health check. The provider will register with an empty model list until it is reachable.`
1720
+ );
1721
+ if (!ok) return undefined;
1722
+ }
1723
+ config.servers.push({ name: nameInput, baseUrl: base, description: description || undefined });
1724
+ saveConfig(config);
1725
+ const [, msg] = await withStatus(ctx, "Registering providers…", () =>
1726
+ registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered across all instances.`)
1727
+ );
1728
+ void msg;
1729
+ return nameInput;
1730
+ }
1731
+
1732
+ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
1733
+ pi.registerCommand("lemonade-setup", {
1734
+ description: "Lemonade server: status, discovery, endpoints, models, tools, transcription",
1735
+ handler: async (_args, ctx) => {
1736
+ let activeInstance = configRef.current.servers[0].name; // servers[0] IS the default
1737
+ for (;;) {
1738
+ const config = configRef.current;
1739
+ // Config view of the active instance — status/models/logs act on it.
1740
+ const viewOrErr = instanceView(config, activeInstance);
1741
+ if (typeof viewOrErr === "string") {
1742
+ activeInstance = configRef.current.servers[0].name; // instance was removed/renamed mid-session
1743
+ continue;
1744
+ }
1745
+ const iconfig = viewOrErr;
1746
+ const choice = await menu(ctx, "🍋 Lemonade Setup", [
1747
+ { value: "status", label: "Server status", description: `Live health + host + perf from ${iconfig.baseUrl}` },
1748
+ { value: "server", label: "Server settings", description: "Discover, base URL, API key, instances, connection test" },
1749
+ { value: "models", label: "Model management", description: `List, load, unload, pull (progress+cancel), delete, change-ctx, HF install, filter, refresh — active instance: ${activeInstance}` },
1750
+ { value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
1751
+ { value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
1752
+ { value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
1753
+ { value: "exit", label: "Exit", description: "Close the setup menu" },
1754
+ ] as SelectItem<string>[]);
1755
+ if (choice === null || choice === "exit") break;
1756
+
1757
+ // ── Switch instance ─────────────────────────────────────────────
1758
+ if (choice === "instance") {
1759
+ const picked = await menu(
1760
+ ctx,
1761
+ "Active Instance",
1762
+ listInstances(config).map((v) => ({
1763
+ value: v.name,
1764
+ label: `${v.name}${v.isDefault ? " (default)" : ""}`,
1765
+ description: [v.config.baseUrl, v.description].filter(Boolean).join(" — "),
1766
+ })) as SelectItem<string>[]
1767
+ );
1768
+ if (picked) activeInstance = picked;
1769
+ continue;
1770
+ }
1771
+
1772
+ // ── Live logs ────────────────────────────────────────────────
1773
+ if (choice === "logs") {
1774
+ await liveLogsView(ctx, iconfig);
1775
+ continue;
1776
+ }
1777
+
1778
+ // ── Status ────────────────────────────────────────────────────────
1779
+ if (choice === "status") {
1780
+ ctx.ui.setStatus("lemonade-setup", "Contacting server…");
1781
+ const [h, sys, perf] = await Promise.all([
1782
+ fetchHealth(iconfig),
1783
+ fetchJsonOrNull<SystemStats>(iconfig, "/v1/system-stats"),
1784
+ fetchJsonOrNull<PerfStats>(iconfig, "/v1/stats"),
1785
+ ]);
1786
+ ctx.ui.setStatus("lemonade-setup", undefined);
1787
+ await textView(
1788
+ ctx,
1789
+ `Server Status — ${activeInstance}`,
1790
+ formatStatus(iconfig, h, sys, perf)
1791
+ );
1792
+ continue;
1793
+ }
1794
+
1795
+ // ── Server settings ──────────────────────────────────────────────
1796
+ if (choice === "server") {
1797
+ for (;;) {
1798
+ const sub = await menu(ctx, "Server Settings", [
1799
+ { value: "discover", label: "Discover servers", description: "UDP beacon scan + HTTP fallback" },
1800
+ { value: "instances", label: "Manage instances", description: `${config.servers.length} instance(s) — servers[0] is the default` },
1801
+ { value: "baseurl", label: "Edit default instance base URL", description: `Current: ${config.servers[0].baseUrl}` },
1802
+ { value: "apikey", label: "Edit shared API key", description: `Fallback for instances without their own — ${config.apiKey ? "••••• (set)" : "(none)"}` },
1803
+ { value: "test", label: "Test connection", description: "Ping the health endpoint" },
1804
+ { value: "back", label: "Back", description: "" },
1805
+ ] as SelectItem<string>[]);
1806
+ if (sub === null || sub === "back") break;
1807
+
1808
+ if (sub === "discover") {
1809
+ ctx.ui.setStatus("lemonade-setup", `Scanning UDP beacons (${config.beaconTimeoutMs / 1000}s)…`);
1810
+ let servers = await discoverViaBeacon(config, config.beaconTimeoutMs);
1811
+ ctx.ui.setStatus("lemonade-setup", undefined);
1812
+ if (!servers.length) {
1813
+ ctx.ui.setStatus("lemonade-setup", "No beacons — probing known hosts/ports…");
1814
+ servers = await discoverViaHttp(config);
1815
+ ctx.ui.setStatus("lemonade-setup", undefined);
1816
+ }
1817
+ if (!servers.length) {
1818
+ ctx.ui.notify(
1819
+ "No lemonade servers found. Note: UDP LAN broadcasts don't reach into WSL2 — " +
1820
+ "set the URL manually, or run discovery on the Windows side.",
1821
+ "warning"
1822
+ );
1823
+ continue;
1824
+ }
1825
+ const picked = await menu(
1826
+ ctx,
1827
+ "Discovered Servers",
1828
+ servers.map((s) => ({
1829
+ value: s.baseUrl,
1830
+ label: `${s.hostname} — ${s.baseUrl}`,
1831
+ })) as SelectItem<string>[]
1832
+ );
1833
+ if (picked) {
1834
+ if (picked === config.servers[0].baseUrl) {
1835
+ ctx.ui.notify("That is already the default instance.", "info");
1836
+ continue;
1837
+ }
1838
+ const use = await menu(
1839
+ ctx,
1840
+ `Use ${picked}`,
1841
+ [
1842
+ { value: "default", label: "Set as default instance", description: "Replaces the current default" },
1843
+ { value: "instance", label: "Add as named instance", description: "Keeps the default; registers as lemonade-<name>" },
1844
+ { value: "back", label: "Cancel", description: "" },
1845
+ ] as SelectItem<string>[]
1846
+ );
1847
+ if (use === "default") {
1848
+ config.servers[0].baseUrl = picked;
1849
+ saveConfig(config);
1850
+ const [ok, msg] = await withStatus(ctx, "Re-registering providers…", () =>
1851
+ registerLemonadeProvider(pi, config).then((n) => `Connected — ${n} model(s) registered.`)
1852
+ );
1853
+ ctx.ui.notify(msg, ok ? "info" : "error");
1854
+ } else if (use === "instance") {
1855
+ const added = await addInstanceFlow(ctx, pi, config, picked);
1856
+ if (added) ctx.ui.notify(`Instance "${added}" added — provider lemonade-${added} registered.`, "info");
1857
+ }
1858
+ }
1859
+ } else if (sub === "instances") {
1860
+ for (;;) {
1861
+ const op = await menu(ctx, "Manage Instances", [
1862
+ { value: "list", label: "List instances", description: "Names, URLs, descriptions, live reachability" },
1863
+ { value: "edit", label: "Edit instance", description: "Rename, re-describe, re-point, or re-key an instance" },
1864
+ { value: "add", label: "Add instance", description: "Register another lemonade box as lemonade-<name>" },
1865
+ ...(config.servers.length > 1
1866
+ ? [
1867
+ { value: "makedefault", label: "Make default", description: "Move an instance to the front — servers[0] is the default" } as SelectItem<string>,
1868
+ { value: "remove", label: "Remove instance", description: "Unregister an instance (the last one cannot be removed)" } as SelectItem<string>,
1869
+ ]
1870
+ : []),
1871
+ { value: "back", label: "Back", description: "" },
1872
+ ] as SelectItem<string>[]);
1873
+ if (op === null || op === "back") break;
1874
+
1875
+ if (op === "list") {
1876
+ const lines: string[] = [];
1877
+ for (const v of listInstances(config)) {
1878
+ const h = await fetchHealth(v.config);
1879
+ lines.push(
1880
+ `${v.name}${v.isDefault ? " (default)" : ""} — ${v.config.baseUrl} — ` +
1881
+ (h ? `ok (v${h.version ?? "?"}, ${h.all_models_loaded?.length ?? 0} loaded)` : "unreachable")
1882
+ );
1883
+ if (v.description) lines.push(` ${v.description}`);
1884
+ }
1885
+ await textView(ctx, "Instances", lines);
1886
+ } else if (op === "edit") {
1887
+ const views = listInstances(config);
1888
+ const target = await menu(
1889
+ ctx,
1890
+ "Edit Instance",
1891
+ views.map((v) => ({
1892
+ value: v.name,
1893
+ label: `${v.name}${v.isDefault ? " (default)" : ""}`,
1894
+ description: [v.config.baseUrl, v.description].filter(Boolean).join(" — "),
1895
+ })) as SelectItem<string>[]
1896
+ );
1897
+ if (!target) continue;
1898
+ const view = views.find((v) => v.name === target)!;
1899
+ const entry = config.servers.find((x) => x.name === target)!;
1900
+
1901
+ for (;;) {
1902
+ const field = await menu(ctx, `Edit ${target}`, [
1903
+ { value: "description", label: "Edit description", description: view.description ? `Current: ${view.description.slice(0, 60)}` : "(none set)" },
1904
+ { value: "name", label: "Edit name", description: `Current: ${view.name}` },
1905
+ { value: "url", label: "Edit base URL", description: `Current: ${entry.baseUrl}` },
1906
+ { value: "apikey", label: "Edit API key", description: entry.apiKey ? "Current: ••••• (own key)" : "Current: (inherits the shared key)" },
1907
+ { value: "back", label: "Back", description: "" },
1908
+ ] as SelectItem<string>[]);
1909
+ if (field === null || field === "back") break;
1910
+
1911
+ if (field === "description") {
1912
+ const input = await ctx.ui.input(
1913
+ "Description (your own reminder's sake, shown in this menu):",
1914
+ view.description
1915
+ );
1916
+ if (input !== undefined) {
1917
+ entry.description = input.trim() || undefined;
1918
+ saveConfig(config);
1919
+ ctx.ui.notify("Description saved.", "info");
1920
+ }
1921
+ } else if (field === "name") {
1922
+ const input = (await ctx.ui.input("New name (lowercase letters, numbers, hyphens):", view.name))?.trim();
1923
+ if (!input || input === view.name) continue;
1924
+ const others = [
1925
+ "default",
1926
+ ...config.servers.filter((x) => x.name !== target).map((x) => x.name),
1927
+ ];
1928
+ if (others.includes(input) || !/^[a-z0-9][a-z0-9-]*$/.test(input)) {
1929
+ ctx.ui.notify("Invalid or duplicate name.", "warning");
1930
+ continue;
1931
+ }
1932
+ // Uniform ids: renaming changes the provider id too —
1933
+ // unregister the old one before re-registering.
1934
+ try {
1935
+ pi.unregisterProvider(`lemonade-${target}`);
1936
+ } catch { /* not registered */ }
1937
+ entry.name = input;
1938
+ if (activeInstance === target) activeInstance = input;
1939
+ saveConfig(config);
1940
+ const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
1941
+ registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
1942
+ );
1943
+ void msg;
1944
+ ctx.ui.notify(`Renamed to "${input}".`, "info");
1945
+ break; // menu keys changed; leave the edit loop
1946
+ } else if (field === "url") {
1947
+ const input = (await ctx.ui.input("New base URL:", entry.baseUrl))?.trim();
1948
+ const base = normalizeBaseUrl(input ?? "");
1949
+ if (!/^https?:\/\//.test(base)) {
1950
+ ctx.ui.notify("Invalid URL.", "warning");
1951
+ continue;
1952
+ }
1953
+ entry.baseUrl = base;
1954
+ saveConfig(config);
1955
+ const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
1956
+ registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
1957
+ );
1958
+ void msg;
1959
+ ctx.ui.notify("URL saved.", "info");
1960
+ } else if (field === "apikey") {
1961
+ const input = (await ctx.ui.input("Instance API key (empty = inherit the shared key):", entry.apiKey ?? ""))?.trim();
1962
+ if (input !== undefined) {
1963
+ entry.apiKey = input || undefined;
1964
+ saveConfig(config);
1965
+ const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
1966
+ registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
1967
+ );
1968
+ void msg;
1969
+ ctx.ui.notify(input ? "API key saved." : "API key cleared — instance inherits the shared key.", "info");
1970
+ }
1971
+ }
1972
+ }
1973
+ } else if (op === "add") {
1974
+ const added = await addInstanceFlow(ctx, pi, config);
1975
+ if (added) ctx.ui.notify(`Instance "${added}" added — provider lemonade-${added} registered.`, "info");
1976
+ } else if (op === "remove") {
1977
+ const victim = await menu(
1978
+ ctx,
1979
+ "Remove Instance",
1980
+ config.servers.map((s) => ({
1981
+ value: s.name,
1982
+ label: `${s.name}${s.name === config.servers[0].name ? " (default)" : ""}`,
1983
+ description: s.baseUrl,
1984
+ })) as SelectItem<string>[]
1985
+ );
1986
+ if (!victim) continue;
1987
+ const wasDefault = victim === config.servers[0].name;
1988
+ const ok = await ctx.ui.confirm(
1989
+ `Remove instance ${victim}?`,
1990
+ `Unregisters the lemonade-${victim} provider. Files on that box are untouched.` +
1991
+ (wasDefault ? " It is the DEFAULT — the next instance in the list becomes the default." : "")
1992
+ );
1993
+ if (!ok) continue;
1994
+ config.servers = config.servers.filter((x) => x.name !== victim);
1995
+ saveConfig(config);
1996
+ if (activeInstance === victim) activeInstance = config.servers[0].name;
1997
+ try {
1998
+ pi.unregisterProvider(`lemonade-${victim}`);
1999
+ } catch { /* not registered */ }
2000
+ const [rok, rmsg] = await withStatus(ctx, "Re-registering providers…", () =>
2001
+ registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
2002
+ );
2003
+ ctx.ui.notify(rmsg, rok ? "info" : "error");
2004
+ } else if (op === "makedefault") {
2005
+ const pick = await menu(
2006
+ ctx,
2007
+ "Make Default",
2008
+ config.servers.slice(1).map((s) => ({
2009
+ value: s.name,
2010
+ label: s.name,
2011
+ description: [s.baseUrl, s.description].filter(Boolean).join(" — "),
2012
+ })) as SelectItem<string>[]
2013
+ );
2014
+ if (!pick) continue;
2015
+ const idx = config.servers.findIndex((x) => x.name === pick);
2016
+ const [entry] = config.servers.splice(idx, 1);
2017
+ config.servers.unshift(entry);
2018
+ saveConfig(config);
2019
+ // Provider ids are lemonade-<name>, unchanged by reordering —
2020
+ // no re-registration needed; only defaultness moved.
2021
+ ctx.ui.notify(`"${pick}" is now the default instance (servers[0]).`, "info");
2022
+ }
2023
+ }
2024
+ } else if (sub === "baseurl") {
2025
+ const input = await ctx.ui.input("Default instance base URL:", config.servers[0].baseUrl);
2026
+ if (input !== undefined && input.trim()) {
2027
+ config.servers[0].baseUrl = normalizeBaseUrl(input);
2028
+ saveConfig(config);
2029
+ const [ok, msg] = await withStatus(ctx, "Re-registering provider…", () =>
2030
+ registerLemonadeProvider(pi, config).then((n) => `Saved — ${n} model(s) registered.`)
2031
+ );
2032
+ ctx.ui.notify(msg, ok ? "info" : "error");
2033
+ }
2034
+ } else if (sub === "apikey") {
2035
+ const input = await ctx.ui.input("Shared API key (instances without their own inherit it; empty for none):", config.apiKey);
2036
+ if (input !== undefined) {
2037
+ config.apiKey = input.trim();
2038
+ saveConfig(config);
2039
+ ctx.ui.notify("API key saved.", "info");
2040
+ }
2041
+ } else if (sub === "test") {
2042
+ const h = await fetchHealth(iconfig);
2043
+ ctx.ui.notify(
2044
+ h
2045
+ ? `OK — ${h.status ?? "?"} v${h.version ?? "?"}, ${h.all_models_loaded?.length ?? 0} model(s) loaded`
2046
+ : `Failed to reach ${iconfig.baseUrl}`,
2047
+ h ? "info" : "error"
2048
+ );
2049
+ }
2050
+ }
2051
+ continue;
2052
+ }
2053
+
2054
+ // ── Model management ──────────────────────────────────────────────
2055
+ if (choice === "models") {
2056
+ for (;;) {
2057
+ const sub = await menu(ctx, "Model Management", [
2058
+ { value: "list", label: "List catalog", description: "All advertised models + live state" },
2059
+ { value: "load", label: "Load a model", description: "Load into memory (may take minutes)" },
2060
+ { value: "unload", label: "Unload a model", description: "Free its slot" },
2061
+ { value: "pull", label: "Pull (download) a model", description: "Live progress, esc to cancel" },
2062
+ { value: "installhf", label: "Install from Hugging Face", description: "Search HF, pick a variant, install as user.*" },
2063
+ { value: "delete", label: "Delete a model", description: "Typed-phrase confirmation required" },
2064
+ { value: "changectx", label: "Change context size", description: "Unload → reload with new ctx (saved)" },
2065
+ {
2066
+ value: "chatonly",
2067
+ label: "Toggle chat-only filter",
2068
+ description: `Currently: ${config.chatOnly ? "chat models only" : "all models"}`,
2069
+ },
2070
+ { value: "refresh", label: "Refresh provider", description: "Re-discover and re-register in /model" },
2071
+ { value: "back", label: "Back", description: "" },
2072
+ ] as SelectItem<string>[]);
2073
+ if (sub === null || sub === "back") break;
2074
+
2075
+ if (sub === "installhf") {
2076
+ await installFromHuggingFace(ctx, pi, iconfig);
2077
+ continue;
2078
+ }
2079
+
2080
+ if (sub === "chatonly") {
2081
+ config.chatOnly = !config.chatOnly;
2082
+ saveConfig(config);
2083
+ const [ok, msg] = await withStatus(ctx, "Re-registering…", () =>
2084
+ registerLemonadeProvider(pi, config).then((n) => `Filter ${config.chatOnly ? "on" : "off"} — ${n} model(s) registered.`)
2085
+ );
2086
+ ctx.ui.notify(msg, ok ? "info" : "error");
2087
+ continue;
2088
+ }
2089
+ if (sub === "refresh") {
2090
+ const [ok, msg] = await withStatus(ctx, "Refreshing…", () =>
2091
+ registerLemonadeProvider(pi, config).then((n) => `Provider refreshed — ${n} model(s) registered.`)
2092
+ );
2093
+ ctx.ui.notify(msg, ok ? "info" : "error");
2094
+ continue;
2095
+ }
2096
+
2097
+ // Everything else needs the live catalog. show_all=true includes
2098
+ // not-yet-downloaded registry entries (pull candidates).
2099
+ let catalog: LemonadeModel[];
2100
+ let loadedIds = new Set<string>();
2101
+ let loadedCtx = new Map<string, number>();
2102
+ try {
2103
+ const health = await fetchHealth(iconfig);
2104
+ loadedIds = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
2105
+ for (const m of health?.all_models_loaded ?? []) {
2106
+ if (m.max_context_window) loadedCtx.set(m.model_name, m.max_context_window);
2107
+ }
2108
+ catalog = await fetchCatalog(iconfig, true);
2109
+ } catch (error) {
2110
+ ctx.ui.notify(`Could not reach server: ${error instanceof Error ? error.message : error}`, "error");
2111
+ continue;
2112
+ }
2113
+
2114
+ if (sub === "list") {
2115
+ const lines: string[] = [`Catalog: ${catalog.length} model(s), ${loadedIds.size} loaded`, ""];
2116
+ for (const m of catalog) {
2117
+ const state = loadedIds.has(m.id) ? "● loaded" : "○ on-demand";
2118
+ lines.push(
2119
+ `${state} ${m.id}` +
2120
+ (m.labels?.length ? ` [${m.labels.join(", ")}]` : "") +
2121
+ (m.downloaded === false ? " (not downloaded)" : "") +
2122
+ (m.max_context_window ? ` ctx ${m.max_context_window}` : "") +
2123
+ (m.size ? ` ${m.size} GB` : "")
2124
+ );
2125
+ }
2126
+ await textView(ctx, "Model Catalog", lines);
2127
+ continue;
2128
+ }
2129
+
2130
+ if (sub === "changectx") {
2131
+ if (!loadedIds.size) {
2132
+ ctx.ui.notify("Nothing is loaded — load a model first.", "warning");
2133
+ continue;
2134
+ }
2135
+ const picked = await menu(ctx, "Change Context Size (pick model)", [...loadedIds].map((id) => ({
2136
+ value: id,
2137
+ label: id,
2138
+ })) as SelectItem<string>[]);
2139
+ if (!picked) continue;
2140
+ const currentCtx =
2141
+ loadedCtx.get(picked) ??
2142
+ catalog.find((m) => m.id === picked)?.max_context_window ??
2143
+ 32768;
2144
+ const input = await ctx.ui.input(
2145
+ `New context size (current: ${currentCtx}; e.g. 32k, 1m, or raw tokens):`,
2146
+ String(currentCtx)
2147
+ );
2148
+ const ctxSize = input ? parseCtxSize(input) : null;
2149
+ if (!ctxSize) {
2150
+ ctx.ui.notify("Invalid size — use e.g. 32k, 1m, or 262144.", "warning");
2151
+ continue;
2152
+ }
2153
+ const ok = await ctx.ui.confirm(
2154
+ "Unload and reload?",
2155
+ `${picked} will be unloaded and reloaded with ctx ${ctxSize} (saved for future loads).`
2156
+ );
2157
+ if (!ok) continue;
2158
+ const [succeeded, msg] = await withStatus(ctx, `Reloading ${picked} with ctx ${ctxSize}…`, () =>
2159
+ changeModelContext(iconfig, picked, ctxSize)
2160
+ );
2161
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
2162
+ if (succeeded) await registerLemonadeProvider(pi, config);
2163
+ continue;
2164
+ }
2165
+
2166
+ if (sub === "pull" || sub === "delete") {
2167
+ const predicate = sub === "delete"
2168
+ ? (m: LemonadeModel) => m.downloaded !== false
2169
+ : (m: LemonadeModel) => m.downloaded === false;
2170
+ let candidates = catalog.filter(predicate);
2171
+ if (sub === "delete" && candidates.some((m) => !m.downloaded)) {
2172
+ candidates = candidates.filter((m) => m.downloaded === true || m.downloaded === undefined);
2173
+ }
2174
+
2175
+ let picked: string | null = null;
2176
+ if (candidates.length) {
2177
+ picked = await menu(
2178
+ ctx,
2179
+ sub === "pull" ? "Pull (download)" : "Delete from disk",
2180
+ candidates.map((m) => ({
2181
+ value: m.id,
2182
+ label: m.id,
2183
+ description: [
2184
+ loadedIds.has(m.id) ? "loaded" : undefined,
2185
+ m.size ? `${m.size} GB` : undefined,
2186
+ m.recipe,
2187
+ ].filter(Boolean).join(" • "),
2188
+ })) as SelectItem<string>[]
2189
+ );
2190
+ } else {
2191
+ const input = await ctx.ui.input(
2192
+ sub === "pull"
2193
+ ? "No undownloaded models in catalog. Enter a model id to pull:"
2194
+ : "Enter a model id to delete:",
2195
+ ""
2196
+ );
2197
+ picked = input?.trim() || null;
2198
+ }
2199
+ if (!picked) continue;
2200
+
2201
+ if (sub === "pull") {
2202
+ const size = catalog.find((m) => m.id === picked)?.size;
2203
+ const ok = await ctx.ui.confirm(
2204
+ `Pull ${picked}?`,
2205
+ size ? `Downloads ~${size} GB to the server's disk.` : "Downloads the model to the server's disk."
2206
+ );
2207
+ if (!ok) continue;
2208
+ const [succeeded, msg] = await pullWithProgress(ctx, pi, iconfig, picked!);
2209
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
2210
+ continue;
2211
+ }
2212
+
2213
+ // delete: confirm + typed phrase, 3 attempts
2214
+ const ok = await ctx.ui.confirm(
2215
+ `Delete ${picked}?`,
2216
+ "This permanently removes the model files from the server's disk. Load state and cache are also lost."
2217
+ );
2218
+ if (!ok) continue;
2219
+ const phrase = `i want to delete ${picked.toLowerCase()}`;
2220
+ let confirmed = false;
2221
+ for (let attempt = 1; attempt <= 3 && !confirmed; attempt++) {
2222
+ const chancesLeft = 4 - attempt;
2223
+ const typed = await ctx.ui.input(
2224
+ `Type "${phrase}" (${chancesLeft} chance${chancesLeft === 1 ? "" : "s"} left):`,
2225
+ ""
2226
+ );
2227
+ if (typed === undefined) break; // Esc = abort immediately
2228
+ if (typed.trim().toLowerCase().replace(/\s+/g, " ") === phrase) {
2229
+ confirmed = true;
2230
+ } else if (attempt < 3) {
2231
+ ctx.ui.notify("Phrase did not match.", "warning");
2232
+ }
2233
+ }
2234
+ if (!confirmed) {
2235
+ ctx.ui.notify(`Deletion of ${picked} aborted.`, "warning");
2236
+ continue;
2237
+ }
2238
+ const [succeeded, msg] = await withStatus(ctx, `Deleting ${picked}…`, () =>
2239
+ deleteModel(iconfig, picked!)
2240
+ );
2241
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
2242
+ if (succeeded) await registerLemonadeProvider(pi, config);
2243
+ continue;
2244
+ }
2245
+
2246
+ // load / unload
2247
+ {
2248
+ const candidates =
2249
+ sub === "unload"
2250
+ ? [...loadedIds]
2251
+ : catalog.map((m) => m.id).filter((id) => !loadedIds.has(id));
2252
+ if (!candidates.length) {
2253
+ ctx.ui.notify(sub === "unload" ? "Nothing is loaded." : "Everything is already loaded.", "warning");
2254
+ continue;
2255
+ }
2256
+ const picked = await menu(
2257
+ ctx,
2258
+ sub === "load" ? "Load model" : "Unload model",
2259
+ candidates.map((id) => ({
2260
+ value: id,
2261
+ label: id,
2262
+ description: loadedIds.has(id) ? "loaded" : "on-demand",
2263
+ })) as SelectItem<string>[]
2264
+ );
2265
+ if (!picked) continue;
2266
+ const [succeeded, msg] = await withStatus(ctx, `${sub === "load" ? "Loading" : "Unloading"} ${picked}…`, () =>
2267
+ sub === "load" ? loadModel(iconfig, picked) : unloadModel(iconfig, picked).then(() => `Unloaded ${picked}.`)
2268
+ );
2269
+ ctx.ui.notify(msg, succeeded ? "info" : "error");
2270
+ if (succeeded) await registerLemonadeProvider(pi, config);
2271
+ }
2272
+ }
2273
+ continue;
2274
+ }
2275
+
2276
+ // ── Transcription settings ────────────────────────────────────────
2277
+ if (choice === "transcription") {
2278
+ for (;;) {
2279
+ const sub = await menu(ctx, "Transcription Settings", [
2280
+ {
2281
+ value: "model",
2282
+ label: "Default transcription model",
2283
+ description: `Current: ${config.defaultTranscriptionModel}`,
2284
+ },
2285
+ {
2286
+ value: "transpath",
2287
+ label: "Transcription endpoint path",
2288
+ description: `Current: ${config.transcriptionPath}`,
2289
+ },
2290
+ { value: "test", label: "Smoke test", description: "Transcribe a file of your choosing" },
2291
+ { value: "back", label: "Back", description: "" },
2292
+ ] as SelectItem<string>[]);
2293
+ if (sub === null || sub === "back") break;
2294
+
2295
+ if (sub === "model" || sub === "transpath") {
2296
+ const input = await ctx.ui.input(
2297
+ sub === "model" ? "Transcription model id:" : "Transcription endpoint path:",
2298
+ sub === "model" ? config.defaultTranscriptionModel : config.transcriptionPath
2299
+ );
2300
+ if (input !== undefined && input.trim()) {
2301
+ if (sub === "model") config.defaultTranscriptionModel = input.trim();
2302
+ else config.transcriptionPath = input.trim();
2303
+ saveConfig(config);
2304
+ ctx.ui.notify("Saved.", "info");
2305
+ }
2306
+ } else if (sub === "test") {
2307
+ const file = await ctx.ui.input("Path to an audio/video file:", "");
2308
+ if (!file?.trim()) continue;
2309
+ const resolved = path.resolve(file.trim());
2310
+ const tool = pi.getAllTools().find((t) => t.name === "transcribe_audio");
2311
+ if (!tool) {
2312
+ ctx.ui.notify("transcribe_audio tool not registered.", "error");
2313
+ continue;
2314
+ }
2315
+ const [succeeded, result] = await withStatus(ctx, `Transcribing ${path.basename(resolved)}…`, () =>
2316
+ tool!.execute(`setup-${Date.now()}`, { file_path: resolved }, new AbortController().signal, () => {}, ctx as never) as Promise<{ content?: Array<{ type?: string; text?: string }> }>
2317
+ );
2318
+ if (typeof result === "string") {
2319
+ ctx.ui.notify(result, "error");
2320
+ } else {
2321
+ const text =
2322
+ result?.content?.find((c) => (c as { type?: string }).type === "text")?.text ?? "(empty)";
2323
+ await textView(ctx, "Transcription Result", [`File: ${resolved}`, "", text]);
2324
+ }
2325
+ }
2326
+ }
2327
+ }
2328
+ }
2329
+ },
2330
+ });
2331
+ }
2332
+
2333
+ // ─── Extension entry ────────────────────────────────────────────────────────
2334
+
2335
+ export default async function localLemonade(pi: ExtensionAPI): Promise<void> {
2336
+ // The config file is REQUIRED — nothing is hardcoded, not even a default
2337
+ // server, because a config-less run would silently target a server that
2338
+ // isn't yours. On failure we do two things, belt and braces: register a
2339
+ // session_start handler that shows a red error banner in the chat window,
2340
+ // and throw so pi logs the extension load failure. (The throw is in case
2341
+ // pi drops handlers registered by an extension that fails during load;
2342
+ // if it keeps them, the user sees the reason twice — loudly, both times.)
2343
+ let config: LemonadeConfig;
2344
+ try {
2345
+ config = loadConfig();
2346
+ } catch (err) {
2347
+ const reason = err instanceof Error ? err.message : String(err);
2348
+ const failure = [
2349
+ "local-lemonade did NOT load — no lemonade providers, tools, or /lemonade-setup are registered.",
2350
+ "",
2351
+ `Reason: ${reason}`,
2352
+ "",
2353
+ `Everything (base URL, endpoints, defaults) is config-driven from ${CONFIG_PATH} — nothing is hardcoded.`,
2354
+ "To set it up, copy the fully-commented example into your pi agent dir and edit it for your server:",
2355
+ "",
2356
+ " cp ~/.pi/agent/extensions/local-lemonade/lemonade.example.json ~/.pi/agent/lemonade.json",
2357
+ "",
2358
+ 'A minimal config is enough to start: { "servers": [{ "name": "main", "baseUrl": "http://your-lemonade-server:13305" }] }',
2359
+ ].join("\n");
2360
+ pi.on("session_start", async (_event, ctx) => {
2361
+ if (ctx.hasUI) ctx.ui.notify(failure, "error");
2362
+ });
2363
+ throw new Error(failure);
2364
+ }
2365
+ const configRef = { current: config };
2366
+ // No startup save: the config file is user-authored and may carry JSONC
2367
+ // comments, and rewriting it here would strip them. Missing keys are
2368
+ // merged from DEFAULT_CONFIG in memory; /lemonade-setup persists the full
2369
+ // normalized object when the user actually changes something.
2370
+
2371
+ await registerLemonadeProvider(pi, configRef.current);
2372
+ await registerAgentTools(pi, configRef.current);
2373
+ registerSetupCommand(pi, configRef);
2374
+ }