pi-lemonade-link 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -375,6 +375,7 @@ The full shape (values shown are the built-in defaults):
375
375
  "classifyPath": "/v1/classify",
376
376
  "beaconPort": 13305,
377
377
  "chatOnly": true,
378
+ "cacheOnOutage": false,
378
379
  "defaultTranscriptionModel": "Whisper-Large-v3",
379
380
  "defaultImageModel": "",
380
381
  "defaultUpscaleModel": "RealESRGAN-x4plus",
@@ -403,6 +404,7 @@ The full shape (values shown are the built-in defaults):
403
404
  | `apiKey` | Shared fallback `Authorization: Bearer …` for instances without their own. A dummy value works for auth-less servers (pi requires non-empty auth to list models). |
404
405
  | `*Path` | API endpoint paths, in case a future lemonade changes them. |
405
406
  | `chatOnly` | Filter the provider down to chat-capable models. |
407
+ | `cacheOnOutage` | **Outage cache**: when a probe can't reach an instance, `/model` lists its last good catalog — persisted on every successful probe, provenance-marked `(cached <time>)`, warned at session start, and re-synced automatically (prompt submits, model selections, session starts, plus the status bar's own health poll) with a `+new −gone` diff notice once the box answers. Once an instance is known down (durable flag from the last failed probe), the next startup serves the cached listing **instantly — no probe wait** and reconciles in the background, so the down-box stall only ever bites once per outage, at the transition into it. `false` (default) keeps the fully-honest empty listing. Manage via `/lemonade-setup` → *Catalog cache*; force a re-probe with *Refresh connection*. |
406
408
  | `defaultTranscriptionModel` / `defaultImageModel` / `defaultUpscaleModel` / `defaultClassifierModel` | Fixed model ids for the tools. Empty = auto-pick from the live catalog (by label, or by `onnxruntime` recipe for classifiers). |
407
409
  | `outputDir` | Where generated files (images, audio, meshes) are saved. Empty = agent's working directory. |
408
410
  | `*TimeoutMs` | Per-operation timeouts. `pullTimeoutMs` only caps the *blocking* pull fallback — normal pulls use download jobs with live progress. |
@@ -432,6 +434,18 @@ WAN, VPN down, box asleep):
432
434
  (`Error: Model "lemonade/<id>" not found`), and resume working once the box
433
435
  is back and a Refresh has re-registered it. Non-reachable chat requests are
434
436
  the honest outcome; phantom listings are never shown.
437
+ - **`cacheOnOutage: true` softens the offline cliff**: instead of the empty
438
+ listing, `/model` shows the instance's last good catalog — provenance-marked
439
+ `(cached <time>)` in the model name, warned once per session start, and
440
+ automatically re-synced (with a `+new −gone` diff notice) on prompt submits,
441
+ model selections, session starts, and the status bar's own health poll.
442
+ Pinned sessions then resolve and soft-fail at send time instead of
443
+ hard-failing model resolution. `/lemonade-setup` → *Refresh connection*
444
+ forces an immediate re-probe; *Catalog cache* shows/clears the snapshots.
445
+ Once an instance is known down, the flag makes the next startup **instant**:
446
+ the cached listing is served with zero probe wait and reconciled by a
447
+ background probe — the `discoveryTimeoutMs` stall only happens at the
448
+ transition into an outage, never on startups after it.
435
449
  - **Tools fail with actionable text, not cryptic errors**: every tool detects
436
450
  the unreachable server and returns the same message — *where* it tried to
437
451
  connect and *what to do about it* (reconnect, or point the extension at a
package/index.ts CHANGED
@@ -108,6 +108,11 @@ interface LemonadeConfig {
108
108
  // info notice, deduplicated to once per (instance, new release) — the
109
109
  // TUI always shows the live truth. Separately toggleable; default on.
110
110
  upstreamVersionCheck: boolean;
111
+ // Outage cache: when a probe can't reach an instance, register its last
112
+ // good catalog (persisted on every successful probe) instead of nothing —
113
+ // provenance-marked "(cached <time>)" in /model, warned at session start,
114
+ // and re-synced with a diff notice once the box answers again.
115
+ cacheOnOutage: boolean;
111
116
  discoveryTimeoutMs: number;
112
117
  beaconTimeoutMs: number;
113
118
  loadTimeoutMs: number;
@@ -156,6 +161,7 @@ const DEFAULT_CONFIG: LemonadeConfig = {
156
161
  classifyPath: "/v1/classify",
157
162
  beaconPort: 13305,
158
163
  chatOnly: true,
164
+ cacheOnOutage: false,
159
165
  defaultTranscriptionModel: "Whisper-Large-v3",
160
166
  defaultImageModel: "",
161
167
  defaultUpscaleModel: "RealESRGAN-x4plus",
@@ -752,6 +758,105 @@ function saveUpstreamSnapshot(snap: UpstreamVersionSnapshot): void {
752
758
  }
753
759
  }
754
760
 
761
+ // ─── Outage catalog cache (cacheOnOutage) ────────────────────────────────
762
+
763
+ interface CatalogCacheEntry {
764
+ fetchedAt: number; // epoch ms of the successful probe this snapshot came from
765
+ catalog: LemonadeModel[];
766
+ health: LemonadeHealth | null;
767
+ /** Durable last-known-state: the most recent probe pass for this instance
768
+ * FAILED. Gates instant cache-serving (hybrid SWR) at the next activation —
769
+ * an instance known down serves its cached listing with zero probe wait,
770
+ * then reconciles in the background. Cleared on every successful probe.
771
+ * Only meaningful alongside a catalog: a snapshotless instance is never
772
+ * flagged (there is nothing to serve anyway). */
773
+ unreachableAtLastProbe?: boolean;
774
+ }
775
+
776
+ interface CatalogCacheFile {
777
+ instances: Record<string, CatalogCacheEntry>;
778
+ }
779
+
780
+ function catalogCachePath(): string {
781
+ return path.join(getAgentDir(), "cache", "pi-lemonade-link", "catalog-cache.json");
782
+ }
783
+
784
+ /** Defensively validated load — a corrupt/absent cache is an empty cache,
785
+ * never a load failure. */
786
+ function loadCatalogCache(): CatalogCacheFile {
787
+ try {
788
+ const raw = JSON.parse(fs.readFileSync(catalogCachePath(), "utf8")) as CatalogCacheFile;
789
+ if (!raw || typeof raw !== "object" || typeof raw.instances !== "object" || raw.instances === null) {
790
+ return { instances: {} };
791
+ }
792
+ const instances: Record<string, CatalogCacheEntry> = {};
793
+ for (const [name, e] of Object.entries(raw.instances)) {
794
+ if (
795
+ e && typeof e === "object" && typeof e.fetchedAt === "number" && Array.isArray(e.catalog) &&
796
+ e.catalog.every((m) => m && typeof (m as LemonadeModel).id === "string")
797
+ ) {
798
+ instances[name] = {
799
+ fetchedAt: e.fetchedAt,
800
+ catalog: e.catalog as LemonadeModel[],
801
+ health: (e.health ?? null) as LemonadeHealth | null,
802
+ ...(e.unreachableAtLastProbe === true ? { unreachableAtLastProbe: true } : {}),
803
+ };
804
+ }
805
+ }
806
+ return { instances };
807
+ } catch {
808
+ return { instances: {} };
809
+ }
810
+ }
811
+
812
+ function saveCatalogCacheEntry(name: string, entry: CatalogCacheEntry): void {
813
+ try {
814
+ const cache = loadCatalogCache();
815
+ cache.instances[name] = entry;
816
+ fs.mkdirSync(path.dirname(catalogCachePath()), { recursive: true });
817
+ fs.writeFileSync(catalogCachePath(), JSON.stringify(cache, null, 2) + "\n");
818
+ } catch {
819
+ /* cache dir unwritable → the outage fallback simply has nothing to serve */
820
+ }
821
+ }
822
+
823
+ /** Mark the durable last-known-state WITHOUT touching the snapshot: the
824
+ * most recent probe for this instance failed (true) / succeeded (false).
825
+ * Keeps the catalog/fetchedAt/health of the last successful probe — the
826
+ * flag and the snapshot age independently by design. */
827
+ function flagCatalogCacheEntry(name: string, unreachableAtLastProbe: boolean): void {
828
+ try {
829
+ const cache = loadCatalogCache();
830
+ const entry = cache.instances[name];
831
+ if (!entry) return; // no snapshot → nothing to serve, flag would be meaningless
832
+ entry.unreachableAtLastProbe = unreachableAtLastProbe;
833
+ fs.mkdirSync(path.dirname(catalogCachePath()), { recursive: true });
834
+ fs.writeFileSync(catalogCachePath(), JSON.stringify(cache, null, 2) + "\n");
835
+ } catch {
836
+ /* unwritable cache — the next activation just probes live instead */
837
+ }
838
+ }
839
+
840
+ function clearCatalogCache(): void {
841
+ try {
842
+ fs.rmSync(catalogCachePath(), { force: true });
843
+ } catch {
844
+ /* nothing to clear / unwritable — loadCatalogCache still returns {} */
845
+ }
846
+ }
847
+
848
+ /** Human stamp for a cached snapshot: "14:32" same-day, "Sep 12 14:32"
849
+ * older. The age IS the provenance — users discount old snapshots themselves. */
850
+ function formatCacheStamp(ts: number): string {
851
+ const d = new Date(ts);
852
+ const pad = (n: number) => String(n).padStart(2, "0");
853
+ const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
854
+ const now = new Date();
855
+ const sameDay =
856
+ d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
857
+ return sameDay ? hms : `${d.toLocaleString("en", { month: "short" })} ${d.getDate()}, ${hms}`;
858
+ }
859
+
755
860
  /** The latest published lemonade version, or undefined when unreachable
756
861
  * (unknown ≠ outdated — never cry wolf). */
757
862
  async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | undefined> {
@@ -1066,59 +1171,133 @@ function registerSpecDriftWarnings(pi: ExtensionAPI, configRef: { current: Lemon
1066
1171
 
1067
1172
  // ─── Provider registration ──────────────────────────────────────────────────
1068
1173
 
1069
- function isChatModel(m: LemonadeModel): boolean {
1070
- return (
1071
- (m.labels ?? []).includes("chat") ||
1072
- m.recipe === "llamacpp" ||
1073
- // Omni collections bundle a chat LLM with server-side tools (image gen,
1074
- // TTS) and are driven through chat completions like any chat model.
1075
- m.recipe === "collection.omni"
1076
- );
1174
+ /** One instance's registration-pass probe: catalog + health fetched in
1175
+ * parallel (null = unreachable). Probes are fetched once per pass — all
1176
+ * instances at once — and shared by every consumer of that pass (provider
1177
+ * registration AND the tool-registration classifier hint), so a full
1178
+ * activation costs one network round per instance even though two
1179
+ * consumers need the catalog: an unreachable box stalls startup once,
1180
+ * not once per consumer, and a fleet of instances stalls in parallel,
1181
+ * not serially. */
1182
+ export interface InstanceProbe {
1183
+ view: InstanceView;
1184
+ catalog: LemonadeModel[] | null;
1185
+ health: LemonadeHealth | null;
1186
+ /** Set when catalog/health come from the on-disk outage cache rather
1187
+ * than a live probe (see withCacheFallback): the epoch ms of the fetch
1188
+ * the cache entry was written at. */
1189
+ cachedAt?: number;
1077
1190
  }
1078
1191
 
1079
- function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade") {
1080
- const labels = m.labels ?? [];
1081
- const contextWindow = m.max_context_window ?? 128000;
1082
- return {
1083
- id: m.id,
1084
- name: loaded.has(m.id)
1085
- ? `${m.id} (${providerId}, loaded)`
1086
- : `${m.id} (${providerId}, on-demand)`,
1087
- reasoning: labels.includes("reasoning"),
1088
- input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
1089
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1090
- contextWindow,
1091
- maxTokens: Math.min(contextWindow, 32768),
1092
- compat: {
1093
- supportsDeveloperRole: false,
1094
- supportsReasoningEffort: false,
1095
- maxTokensField: "max_tokens" as const,
1096
- },
1097
- };
1192
+ async function probeInstance(view: InstanceView): Promise<InstanceProbe> {
1193
+ try {
1194
+ const [catalog, health] = await Promise.all([
1195
+ fetchCatalog(view.config),
1196
+ fetchHealth(view.config),
1197
+ ]);
1198
+ if (view.config.cacheOnOutage) {
1199
+ // Fresh truth — persist the snapshot the outage fallback will serve
1200
+ // if a future probe can't reach this box, and clear the down-flag.
1201
+ saveCatalogCacheEntry(view.name, { fetchedAt: Date.now(), catalog, health, unreachableAtLastProbe: false });
1202
+ }
1203
+ return { view, catalog, health };
1204
+ } catch {
1205
+ // Unreachable. What that means for /model depends on the outage cache
1206
+ // (withCacheFallback decides right after) — here we only report the
1207
+ // network truth: the box did not answer within discoveryTimeoutMs.
1208
+ // Durable last-known-state for the hybrid SWR activation path.
1209
+ if (view.config.cacheOnOutage) flagCatalogCacheEntry(view.name, true);
1210
+ console.error(`[pi-lemonade-link] instance "${view.name}" unreachable at ${view.config.baseUrl}`);
1211
+ return { view, catalog: null, health: null };
1212
+ }
1213
+ }
1214
+
1215
+ /** Probe every configured instance in one parallel pass. */
1216
+ function probeInstances(config: LemonadeConfig): Promise<InstanceProbe[]> {
1217
+ return Promise.all(listInstances(config).map(probeInstance));
1218
+ }
1219
+
1220
+ /** Outage fallback (cacheOnOutage): an unreachable instance serves its last
1221
+ * successfully cached catalog — provenance-marked "(cached <stamp>)" at
1222
+ * registration time — instead of listing nothing. Live truth always wins:
1223
+ * reachable instances pass through untouched, and a box with no cached
1224
+ * snapshot yet still lists nothing (no cache, nothing honest to serve).
1225
+ * Idempotent: probes that already carry a cached catalog pass through. */
1226
+ function withCacheFallback(config: LemonadeConfig, probes: InstanceProbe[]): InstanceProbe[] {
1227
+ if (!config.cacheOnOutage) return probes;
1228
+ const cache = loadCatalogCache();
1229
+ return probes.map((p) => {
1230
+ if (p.catalog) return p;
1231
+ const entry = cache.instances[p.view.name];
1232
+ if (!entry || !entry.catalog.length) return p;
1233
+ return { ...p, catalog: entry.catalog, health: entry.health, cachedAt: entry.fetchedAt };
1234
+ });
1098
1235
  }
1099
1236
 
1100
- async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): Promise<number> {
1237
+ /** Hybrid stale-while-revalidate probe pass (cacheOnOutage): instances whose
1238
+ * LAST probe failed (durable flag, see flagCatalogCacheEntry) serve their
1239
+ * cached catalog instantly — no probe wait, no discoveryTimeoutMs stall —
1240
+ * while the rest probe live in one parallel pass (failures still get the
1241
+ * withCacheFallback safety net, so the transition INTO an outage is the
1242
+ * only stall left). The caller reconciles instant-served instances with a
1243
+ * background probe pass. Without the flag (or without a snapshot)
1244
+ * everything probes live: probe-first is both the honest and the fast path
1245
+ * when the box was up last time. */
1246
+ async function instantOrLiveProbes(config: LemonadeConfig): Promise<InstanceProbe[]> {
1247
+ if (!config.cacheOnOutage) return await probeInstances(config);
1248
+ const cache = loadCatalogCache();
1249
+ const instant: InstanceProbe[] = [];
1250
+ const live: InstanceView[] = [];
1251
+ for (const view of listInstances(config)) {
1252
+ const entry = cache.instances[view.name];
1253
+ if (entry && entry.catalog.length && entry.unreachableAtLastProbe) {
1254
+ instant.push({ view, catalog: entry.catalog, health: entry.health, cachedAt: entry.fetchedAt });
1255
+ } else {
1256
+ live.push(view);
1257
+ }
1258
+ }
1259
+ if (!instant.length) return withCacheFallback(config, await probeInstances(config));
1260
+ return withCacheFallback(config, [...instant, ...(await Promise.all(live.map(probeInstance)))]);
1261
+ }
1262
+
1263
+ /** Instances whose current /model listing is served from the outage cache
1264
+ * (probe failed, cache served): name → when it was cached + the listed
1265
+ * ids. Single source of truth: every registration pass (registerInstanceProvider
1266
+ * records; registerLemonadeProvider resets) — same discipline as
1267
+ * serverVersionMismatches. Drives the session-start warning and the
1268
+ * background re-sync's recovery diff. */
1269
+ const cachedServing = new Map<string, { fetchedAt: number; ids: string[] }>();
1270
+
1271
+ /** Register one instance's provider from a probe that already ran — no
1272
+ * network here; the probe pass owns all registration-time I/O. */
1273
+ function registerInstanceProvider(pi: ExtensionAPI, probe: InstanceProbe): number {
1274
+ const view = probe.view;
1101
1275
  const config = view.config;
1102
1276
  const providerId = instanceProviderId(view);
1103
- let catalog: LemonadeModel[];
1104
- let loaded = new Set<string>();
1105
- try {
1106
- const [catalogRes, health] = await Promise.all([
1107
- fetchCatalog(config),
1108
- fetchHealth(config),
1109
- ]);
1110
- catalog = catalogRes;
1111
- loaded = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
1112
- recordServerVersion(config, view.name, health?.version);
1113
- } catch {
1114
- // Unreachable => contribute nothing to /model. No placeholders: an
1115
- // unreachable model is not listable. Sessions pinned to a lemonade model
1116
- // will hard-fail model resolution until the box is reachable again —
1117
- // the accepted tradeoff of "fully automated, fully honest" listings.
1118
- catalog = [];
1277
+ const catalog = probe.catalog ?? [];
1278
+ const loaded = new Set((probe.health?.all_models_loaded ?? []).map((m) => m.model_name));
1279
+ const cachedStamp = probe.cachedAt !== undefined ? formatCacheStamp(probe.cachedAt) : undefined;
1280
+ if (cachedStamp) {
1281
+ // Outage-cache serving: the listing is a provenance-marked snapshot, not
1282
+ // live truth — record it so the session-start warning and the background
1283
+ // re-sync know this instance is listed from cache.
1119
1284
  console.error(
1120
- `[pi-lemonade-link] instance "${view.name}" unreachable at ${config.baseUrl}; registering no models`
1285
+ `[pi-lemonade-link] instance "${view.name}" unreachable — serving cached catalog from ${cachedStamp}`
1121
1286
  );
1287
+ cachedServing.set(view.name, { fetchedAt: probe.cachedAt!, ids: catalog.map((m) => m.id) });
1288
+ recordServerVersion(config, view.name, probe.health?.version); // cached version — self-heals on re-sync
1289
+ } else if (probe.catalog) {
1290
+ cachedServing.delete(view.name);
1291
+ recordServerVersion(config, view.name, probe.health?.version);
1292
+ } else {
1293
+ // Unreachable with no cache to serve: contribute nothing to /model. No
1294
+ // placeholders: an unreachable model is not listable. Sessions pinned to
1295
+ // a lemonade model will hard-fail model resolution until the box is
1296
+ // reachable again — the honest tradeoff when there is no cached truth.
1297
+ console.error(
1298
+ `[pi-lemonade-link] instance "${view.name}" has no cached catalog — registering no models`
1299
+ );
1300
+ cachedServing.delete(view.name);
1122
1301
  }
1123
1302
 
1124
1303
  const advertised = config.chatOnly ? catalog.filter(isChatModel) : catalog;
@@ -1135,12 +1314,45 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
1135
1314
  apiKey: config.apiKey || "lemonade",
1136
1315
  authHeader: true,
1137
1316
  api: "openai-completions",
1138
- models: advertised.map((m) => toProviderModel(m, loaded, providerId)),
1317
+ models: advertised.map((m) => toProviderModel(m, loaded, providerId, cachedStamp)),
1139
1318
  });
1140
1319
 
1141
1320
  return advertised.length;
1142
1321
  }
1143
1322
 
1323
+ function isChatModel(m: LemonadeModel): boolean {
1324
+ return (
1325
+ (m.labels ?? []).includes("chat") ||
1326
+ m.recipe === "llamacpp" ||
1327
+ // Omni collections bundle a chat LLM with server-side tools (image gen,
1328
+ // TTS) and are driven through chat completions like any chat model.
1329
+ m.recipe === "collection.omni"
1330
+ );
1331
+ }
1332
+
1333
+ function toProviderModel(m: LemonadeModel, loaded: Set<string>, providerId = "lemonade", cachedStamp?: string) {
1334
+ const labels = m.labels ?? [];
1335
+ const contextWindow = m.max_context_window ?? 128000;
1336
+ return {
1337
+ id: m.id,
1338
+ name: cachedStamp
1339
+ ? `${m.id} (${providerId}, cached ${cachedStamp})`
1340
+ : loaded.has(m.id)
1341
+ ? `${m.id} (${providerId}, loaded)`
1342
+ : `${m.id} (${providerId}, on-demand)`,
1343
+ reasoning: labels.includes("reasoning"),
1344
+ input: labels.includes("vision") ? (["text", "image"] as ("text" | "image")[]) : (["text"] as ("text" | "image")[]),
1345
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1346
+ contextWindow,
1347
+ maxTokens: Math.min(contextWindow, 32768),
1348
+ compat: {
1349
+ supportsDeveloperRole: false,
1350
+ supportsReasoningEffort: false,
1351
+ maxTokensField: "max_tokens" as const,
1352
+ },
1353
+ };
1354
+ }
1355
+
1144
1356
  /** Provider ids used by past versions of this extension. unregisterProvider
1145
1357
  * only works within the running pi process, and ids changed over the
1146
1358
  * extension's evolution, so a long-lived session (or /reload after an
@@ -1150,22 +1362,114 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
1150
1362
  const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
1151
1363
 
1152
1364
  /** Register (or re-register) every configured instance. Each becomes its own
1153
- * pi provider under the uniform lemonade-<name> id. */
1154
- async function registerLemonadeProvider(pi: ExtensionAPI, config: LemonadeConfig): Promise<number> {
1365
+ * pi provider under the uniform lemonade-<name> id. Probes are shared per
1366
+ * pass: pass the caller's probeInstances() result to reuse the network
1367
+ * round (activation does exactly that — one pass feeds both provider and
1368
+ * tool registration); omitted, a fresh parallel pass runs. */
1369
+ async function registerLemonadeProvider(
1370
+ pi: ExtensionAPI,
1371
+ config: LemonadeConfig,
1372
+ probes?: InstanceProbe[]
1373
+ ): Promise<number> {
1155
1374
  serverVersionMismatches.length = 0; // (re)collected per registration pass
1156
1375
  serverVersionsSeen.clear();
1376
+ cachedServing.clear();
1157
1377
  for (const staleId of LEGACY_PROVIDER_IDS) {
1158
1378
  try {
1159
1379
  pi.unregisterProvider(staleId);
1160
1380
  } catch { /* never registered; fine */ }
1161
1381
  }
1162
1382
  let total = 0;
1163
- for (const view of listInstances(config)) {
1164
- total += await registerInstanceProvider(pi, view);
1383
+ for (const probe of probes ?? withCacheFallback(config, await probeInstances(config))) {
1384
+ total += registerInstanceProvider(pi, probe);
1165
1385
  }
1166
1386
  return total;
1167
1387
  }
1168
1388
 
1389
+ // ─── Outage re-sync (cacheOnOutage) ─────────────────────────────────────
1390
+
1391
+ let resyncBusy = false;
1392
+ let lastResyncProbeAt = 0;
1393
+
1394
+ /** Fire-and-forget outage re-sync: probe all instances and re-register.
1395
+ * Throttled to one probe pass per discoveryTimeoutMs window (turn_start
1396
+ * fires once per turn — agentic loops fire it rapidly) and a no-op while
1397
+ * every instance is live (cachedServing empty). Recovery is announced
1398
+ * only when the live catalog differs from the cached one — state-steady
1399
+ * means silent. force (the setup menu's Refresh connection) bypasses both
1400
+ * guards and always re-registers. Returns the registered model count
1401
+ * (−1 when a pass is already in flight). */
1402
+ async function resyncFromOutage(
1403
+ pi: ExtensionAPI,
1404
+ configRef: { current: LemonadeConfig },
1405
+ notify?: (message: string) => void,
1406
+ force = false
1407
+ ): Promise<number> {
1408
+ if (resyncBusy) return -1;
1409
+ if (!force) {
1410
+ if (cachedServing.size === 0) return 0;
1411
+ if (Date.now() - lastResyncProbeAt < configRef.current.discoveryTimeoutMs) return 0;
1412
+ }
1413
+ resyncBusy = true;
1414
+ lastResyncProbeAt = Date.now();
1415
+ try {
1416
+ const cachedBefore = new Map(cachedServing);
1417
+ const probes = await probeInstances(configRef.current);
1418
+ const registered = await registerLemonadeProvider(
1419
+ pi,
1420
+ configRef.current,
1421
+ withCacheFallback(configRef.current, probes)
1422
+ );
1423
+ // Recovery notice — only for instances that answered, and only when
1424
+ // their live catalog actually differs from the served snapshot.
1425
+ for (const [name, before] of cachedBefore) {
1426
+ const recovered = probes.find((p) => p.view.name === name && p.catalog && p.cachedAt === undefined);
1427
+ if (!recovered?.catalog) continue; // still unreachable (or still cache-served)
1428
+ const beforeIds = new Set(before.ids);
1429
+ const afterIds = new Set(recovered.catalog.map((m) => m.id));
1430
+ const added = recovered.catalog.filter((m) => !beforeIds.has(m.id)).map((m) => m.id);
1431
+ const removed = before.ids.filter((id) => !afterIds.has(id));
1432
+ if (!added.length && !removed.length) continue;
1433
+ notify?.(
1434
+ `pi-lemonade-link: instance "${name}" reachable again — re-synced: ` +
1435
+ `${added.length ? `+${added.join(", ")}` : ""}${added.length && removed.length ? "; " : ""}${removed.length ? `−${removed.join(", ")}` : ""}`
1436
+ );
1437
+ }
1438
+ return registered;
1439
+ } finally {
1440
+ resyncBusy = false;
1441
+ }
1442
+ }
1443
+
1444
+ /** Event wiring for the outage re-sync: prompt submit (turn_start), model
1445
+ * selection, and session start/resume each trigger a throttled,
1446
+ * cached-state-only probe pass (never blocking the turn). The status bar's
1447
+ * own health poll is the free continuous detector (see registerLemonadeBar's
1448
+ * tick). The session-start handler also warns, one line per outage-cached
1449
+ * instance, while the state persists — same shape as the tier-1 version
1450
+ * mismatch warning. */
1451
+ function registerOutageResync(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
1452
+ type ExtCtx = import("@earendil-works/pi-coding-agent").ExtensionContext;
1453
+ const trigger = (ctx: ExtCtx) => {
1454
+ if (cachedServing.size === 0) return; // every instance live — nothing to re-sync
1455
+ void resyncFromOutage(pi, configRef, ctx.hasUI ? (m) => ctx.ui.notify(m, "info") : undefined);
1456
+ };
1457
+ pi.on("turn_start", (_e, ctx) => trigger(ctx as ExtCtx));
1458
+ pi.on("model_select", (_e, ctx) => trigger(ctx as ExtCtx));
1459
+ pi.on("session_start", (_e, ctx) => {
1460
+ trigger(ctx as ExtCtx);
1461
+ if (!(ctx as ExtCtx).hasUI) return;
1462
+ for (const [instance, served] of cachedServing) {
1463
+ (ctx as ExtCtx).ui.notify(
1464
+ `pi-lemonade-link: instance "${instance}" is unreachable — /model lists its cached catalog from ` +
1465
+ `${formatCacheStamp(served.fetchedAt)}; requests to it will fail until the box answers. ` +
1466
+ `It re-syncs automatically on the next prompt, model selection, or session start. cacheOnOutage: false disables.`,
1467
+ "warning"
1468
+ );
1469
+ }
1470
+ });
1471
+ }
1472
+
1169
1473
  // ─── Multimodal agent tools ──────────────────────────────────────────────────
1170
1474
 
1171
1475
  /** Combine an operation timeout with the caller's cancel signal (if any),
@@ -1427,7 +1731,11 @@ function runFfmpeg(args: string[], timeoutMs: number): Promise<void> {
1427
1731
  });
1428
1732
  }
1429
1733
 
1430
- async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Promise<void> {
1734
+ async function registerAgentTools(
1735
+ pi: ExtensionAPI,
1736
+ gconfig: LemonadeConfig,
1737
+ probes?: InstanceProbe[]
1738
+ ): Promise<void> {
1431
1739
  // Harness-level self-awareness: these lines are merged into the Guidelines
1432
1740
  // section of every chat's system prompt, so even a virgin chat knows where
1433
1741
  // its lemonade machinery lives without having to hunt for it. (pi dedupes
@@ -1456,19 +1764,18 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
1456
1764
  {
1457
1765
  const ids = new Set<string>();
1458
1766
  const pulled = new Set<string>();
1459
- await Promise.all(
1460
- listInstances(gconfig).map(async (v) => {
1461
- try {
1462
- const catalog = await fetchCatalog(v.config);
1463
- for (const m of catalog.filter((m) => m.recipe === "onnxruntime")) {
1464
- ids.add(m.id);
1465
- if (m.downloaded !== false) pulled.add(m.id);
1466
- }
1467
- } catch {
1468
- // instance unreachable at registration time — hint stays generic for it
1469
- }
1470
- })
1471
- );
1767
+ // Share the activation-pass probes when the caller has them (provider
1768
+ // registration just fetched the same catalogs); otherwise one fresh
1769
+ // parallel pass — never a serial per-instance round.
1770
+ for (const probe of probes ?? withCacheFallback(gconfig, await probeInstances(gconfig))) {
1771
+ if (!probe.catalog) {
1772
+ continue; // instance unreachable at registration time — hint stays generic for it
1773
+ }
1774
+ for (const m of probe.catalog.filter((m) => m.recipe === "onnxruntime")) {
1775
+ ids.add(m.id);
1776
+ if (m.downloaded !== false) pulled.add(m.id);
1777
+ }
1778
+ }
1472
1779
  if (ids.size) {
1473
1780
  classifierHint =
1474
1781
  `Classifiers on this server: ${[...ids].join(", ")}. ` +
@@ -1886,6 +2193,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1886
2193
  const metrics: BarMetrics = { toksPerSec: null, cachePct: null, busy: null, cpuPct: null, gpuPct: null, npuPct: null, npuSupported: null, vramGb: null };
1887
2194
  let activeInstance: string | null = null;
1888
2195
  let timer: ReturnType<typeof setInterval> | undefined;
2196
+ let barCtx: ExtCtx | undefined; // ctx of the installed row — for re-sync notices
1889
2197
  let tuiRef: { requestRender(): void } | undefined;
1890
2198
  let msgStartAt: number | null = null;
1891
2199
 
@@ -1947,6 +2255,13 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1947
2255
  metrics.npuSupported = false; // box reported system-stats with no NPU
1948
2256
  }
1949
2257
  if (typeof sys?.vram_gb === "number") metrics.vramGb = sys.vram_gb;
2258
+ // Free liveness detector: this poll already proves the box answers —
2259
+ // while its /model listing is served from the outage cache, trigger
2260
+ // the throttled re-sync (throttle/in-flight guards live inside
2261
+ // resyncFromOutage; the notice goes through barCtx while installed).
2262
+ if (cachedServing.has(instance)) {
2263
+ void resyncFromOutage(pi, configRef, (m) => barCtx?.ui.notify(m, "info"));
2264
+ }
1950
2265
  }
1951
2266
  tuiRef?.requestRender();
1952
2267
  };
@@ -1954,6 +2269,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1954
2269
  const teardown = (ctx: ExtCtx) => {
1955
2270
  stopTimer();
1956
2271
  tuiRef = undefined;
2272
+ barCtx = undefined;
1957
2273
  activeInstance = null;
1958
2274
  resetLocal();
1959
2275
  try {
@@ -1967,6 +2283,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
1967
2283
  stopTimer();
1968
2284
  resetLocal();
1969
2285
  activeInstance = instance;
2286
+ barCtx = ctx;
1970
2287
  ctx.ui.setWidget(
1971
2288
  KEY,
1972
2289
  (tui, theme) => {
@@ -3001,11 +3318,88 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
3001
3318
  { value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
3002
3319
  { value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
3003
3320
  { value: "spec", label: "Spec & version check", description: "Docs drift + live server-version drift — status, re-check, re-sync, toggles" },
3321
+ { value: "refresh", label: "Refresh connection", description: "Force a re-probe of every instance now and re-register (bypasses the outage-resync throttle)" },
3322
+ { value: "cache", label: "Catalog cache", description: `Outage listings (cacheOnOutage: ${config.cacheOnOutage ? "on" : "off"}) — cached catalog snapshots: status, toggle, clear` },
3004
3323
  { value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
3005
3324
  { value: "exit", label: "Exit", description: "Close the setup menu" },
3006
3325
  ]);
3007
3326
  if (choice === null || choice === "exit") break;
3008
3327
 
3328
+ // ── Refresh connection (manual outage re-sync) ──────────────────
3329
+ if (choice === "refresh") {
3330
+ const [ok, msg] = await withStatus(ctx, "Probing instances…", async () => {
3331
+ const n = await resyncFromOutage(pi, configRef, undefined, true);
3332
+ if (n < 0) return "A re-sync is already in flight — try again in a moment.";
3333
+ return cachedServing.size
3334
+ ? `Refreshed — ${n} model(s) registered; ${cachedServing.size} instance(s) unreachable, serving cached catalogs.`
3335
+ : `Refreshed — ${n} model(s) registered.`;
3336
+ });
3337
+ ctx.ui.notify(msg, ok ? "info" : "error");
3338
+ continue;
3339
+ }
3340
+
3341
+ // ── Catalog cache (outage listings) ─────────────────────
3342
+ if (choice === "cache") {
3343
+ for (;;) {
3344
+ const config = configRef.current;
3345
+ const cache = loadCatalogCache();
3346
+ const entries = Object.entries(cache.instances).sort(([a], [b]) => a.localeCompare(b));
3347
+ const state = !config.cacheOnOutage
3348
+ ? "disabled (cacheOnOutage: false) — catalogs are neither cached nor served"
3349
+ : entries.length
3350
+ ? entries.map(([name, e]) => `${name}: ${e.catalog.length} model(s) cached ${formatCacheStamp(e.fetchedAt)}`).join(" · ")
3351
+ : "enabled — no snapshots yet (written on every successful probe)";
3352
+ const sub = await menu(ctx, "Catalog Cache — Outage Listings", [
3353
+ { value: "status", label: "Status", description: state },
3354
+ {
3355
+ value: "toggle",
3356
+ label: `Toggle outage cache: ${config.cacheOnOutage ? "on → off" : "off → on"}`,
3357
+ description: `cacheOnOutage (currently ${config.cacheOnOutage ? "on" : "off"}) — when a probe fails, list the instance's last good catalog, provenance-marked "(cached …)"`,
3358
+ },
3359
+ {
3360
+ value: "clear",
3361
+ label: "Clear cached catalogs",
3362
+ description: "Drop the on-disk snapshots — unreachable instances then list nothing",
3363
+ },
3364
+ { value: "back", label: "Back", description: "" },
3365
+ ]);
3366
+ if (sub === null || sub === "back") break;
3367
+
3368
+ if (sub === "toggle") {
3369
+ config.cacheOnOutage = !config.cacheOnOutage;
3370
+ saveConfig(config);
3371
+ ctx.ui.notify(
3372
+ `Outage cache ${config.cacheOnOutage ? "on" : "off"} — takes effect at the next registration (Refresh connection applies it now).`,
3373
+ "info"
3374
+ );
3375
+ continue;
3376
+ }
3377
+ if (sub === "clear") {
3378
+ clearCatalogCache();
3379
+ ctx.ui.notify(
3380
+ "Cached catalogs cleared — unreachable instances will list nothing from the next registration on (Refresh connection applies it now).",
3381
+ "info"
3382
+ );
3383
+ continue;
3384
+ }
3385
+ if (sub === "status") {
3386
+ await textView(ctx, "Catalog Cache — Status", [
3387
+ `cacheOnOutage: ${config.cacheOnOutage ? "on" : "off"}`,
3388
+ `Snapshot path: ${catalogCachePath()}`,
3389
+ "",
3390
+ entries.length ? "Cached catalogs on disk:" : "No cached catalogs on disk.",
3391
+ ...entries.map(([name, e]) => ` ${name} — ${e.catalog.length} model(s), fetched ${formatCacheStamp(e.fetchedAt)}${e.unreachableAtLastProbe ? " — LAST PROBE FAILED (instant-served at startup)" : ""}`),
3392
+ "",
3393
+ "Instances currently listed from cache in /model:",
3394
+ ...(cachedServing.size
3395
+ ? [...cachedServing].map(([name, s]) => ` ${name} — serving cache from ${formatCacheStamp(s.fetchedAt)}`)
3396
+ : [" (none — every instance is live or lists nothing)"]),
3397
+ ]);
3398
+ }
3399
+ }
3400
+ continue;
3401
+ }
3402
+
3009
3403
  // ── Spec & version check ───────────────────────────────────────
3010
3404
  if (choice === "spec") {
3011
3405
  for (;;) {
@@ -3787,11 +4181,33 @@ export default async function piLemonadeLink(pi: ExtensionAPI): Promise<void> {
3787
4181
  // merged from DEFAULT_CONFIG in memory; /lemonade-setup persists the full
3788
4182
  // normalized object when the user actually changes something.
3789
4183
 
3790
- await registerLemonadeProvider(pi, configRef.current);
3791
- await registerAgentTools(pi, configRef.current);
4184
+ // One shared parallel probe pass feeds both consumers: an unreachable or
4185
+ // slow box stalls startup once (not once per consumer, not once per
4186
+ // instance) — bounded by a single discoveryTimeoutMs window. With
4187
+ // cacheOnOutage, instances the probe can't reach are listed from their
4188
+ // last good cached catalog, provenance-marked — see withCacheFallback.
4189
+ //
4190
+ // Hybrid SWR: instances whose LAST probe failed (durable flag) are served
4191
+ // from cache instantly — zero probe wait — and reconciled by a background
4192
+ // probe pass; instances last known reachable still probe first, so a
4193
+ // healthy box never flashes stale data. Net: the down-box stall only ever
4194
+ // bites once per outage, at the transition into it.
4195
+ const probes = await instantOrLiveProbes(configRef.current);
4196
+ await registerLemonadeProvider(pi, configRef.current, probes);
4197
+ await registerAgentTools(pi, configRef.current, probes);
4198
+ lastResyncProbeAt = Date.now(); // the activation pass IS a probe pass — throttle the session_start re-sync trigger
4199
+ if (probes.some((p) => p.cachedAt !== undefined)) {
4200
+ // Background reconcile for instant-served instances: probes everything
4201
+ // again (cheap for instances that just answered) and re-registers with
4202
+ // live truth when the box is back. Diff notices are silent here —
4203
+ // activation has no ui ctx, and the session_start cached warning has
4204
+ // already told the user the state; the suffix flips are visible in /model.
4205
+ void resyncFromOutage(pi, configRef, undefined, true);
4206
+ }
3792
4207
  registerLemonadeBar(pi, configRef);
3793
4208
  registerSetupCommand(pi, configRef);
3794
4209
  registerSpecDriftWarnings(pi, configRef);
4210
+ registerOutageResync(pi, configRef);
3795
4211
 
3796
4212
  // Fresh install (or a hand-emptied config): nothing is registered yet —
3797
4213
  // point the user at the wizard instead of leaving them wondering why
@@ -51,6 +51,14 @@
51
51
  // as pi providers.
52
52
  "chatOnly": true,
53
53
 
54
+ // Outage cache: when a probe can't reach an instance, register its last
55
+ // good catalog (persisted on every successful probe) instead of nothing —
56
+ // provenance-marked "(cached <time>)" in /model, warned at session start,
57
+ // and re-synced with a diff notice once the box answers again. Once an
58
+ // instance is known down (durable flag), the next startup serves the cache
59
+ // instantly and reconciles in the background — see instantOrLiveProbes.
60
+ "cacheOnOutage": false,
61
+
54
62
  // --- Model defaults (empty string = auto-pick first in catalog / tool default) ---
55
63
  "defaultTranscriptionModel": "Whisper-Large-v3",
56
64
  "defaultImageModel": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lemonade-link",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Unified pi extension for a self-hosted Lemonade server: dynamic chat-model discovery, multimodal agent tools (transcription, image gen/edit/upscale, TTS, audio, 3D mesh), a live /lemonade-setup management TUI, and a lemonade-only below-editor status bar (tok/s, cache hits, busy/queue, VRAM).",
5
5
  "keywords": [
6
6
  "pi-package",