pi-lemonade-link 1.0.1 → 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 +20 -2
- package/index.ts +527 -80
- package/lemonade.example.json +11 -1
- package/package.json +1 -1
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. |
|
|
@@ -411,7 +413,7 @@ The full shape (values shown are the built-in defaults):
|
|
|
411
413
|
| `barPollMs` | Server-poll cadence for the status bar's busy/queue + CPU/GPU/NPU/VRAM segments. `0` = local metrics only (instance, tok/s, cache-hit %). |
|
|
412
414
|
| `specCheck` / `specCheckCooldownMs` / `specCheckTimeoutMs` | Docs-freshness check: the `docs/` snapshot is compared against upstream `lemonade-sdk/lemonade` by git blob SHA — **TUI-only status, no startup warning** (extension ↔ server agreement is what matters; see *Keeping the specs honest*); the verdict is durable for `specCheckCooldownMs` (default 24 h, also the upstream-release probe's throttle). |
|
|
413
415
|
| `serverVersionCheck` | **Tier 1, startup warning**: `/v1/health` self-reports the server version; any instance reporting a version other than the build this extension was verified against (`v11.7.0` — a constant in the extension code, not a setting) warns **with direction**: server newer → check for an extension update; server older → update the server. |
|
|
414
|
-
| `upstreamVersionCheck` | **Tier 2, info notice**: servers older than the latest published lemonade release (probed via GitHub, throttled) get a one-line "a server update is available" notice. Default on; separately toggleable. |
|
|
416
|
+
| `upstreamVersionCheck` | **Tier 2, info notice**: servers older than the latest published lemonade release (probed via GitHub, throttled) get a one-line "a server update is available" notice — **deduplicated to once per (instance, release)**, so it never re-fires on every session for the same release; the TUI always shows current state. Default on; separately toggleable. |
|
|
415
417
|
|
|
416
418
|
## Offline & unreachable networks
|
|
417
419
|
|
|
@@ -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
|
|
@@ -561,7 +575,11 @@ Drift is watched **in tiers, ordered by what actually matters at runtime**:
|
|
|
561
575
|
server older than it gets a single info line — *"instance 'x' runs
|
|
562
576
|
lemonade v11.7.0; the latest published release is v11.9.0 — a server
|
|
563
577
|
update is available."* Much lower tier: an older server isn't broken,
|
|
564
|
-
it's just behind.
|
|
578
|
+
it's just behind. The notice is **deduplicated to once per (instance,
|
|
579
|
+
release)** — it fires when a new upstream release first appears, never
|
|
580
|
+
re-fires on every session for the same fact; `/lemonade-setup` →
|
|
581
|
+
Spec & version check always shows the current state.
|
|
582
|
+
`upstreamVersionCheck: false` disables.
|
|
565
583
|
3. **Docs drift — TUI-only status, deliberately no startup warning.** The
|
|
566
584
|
`docs/` snapshot is the reference for the version the extension targets;
|
|
567
585
|
as long as extension and server agree on version, whether upstream HEAD
|
package/index.ts
CHANGED
|
@@ -105,8 +105,14 @@ interface LemonadeConfig {
|
|
|
105
105
|
serverVersionCheck: boolean;
|
|
106
106
|
// Low-tier notice: compare each server's live version against the latest
|
|
107
107
|
// published lemonade release (GitHub releases); server older → one-line
|
|
108
|
-
// info notice
|
|
108
|
+
// info notice, deduplicated to once per (instance, new release) — the
|
|
109
|
+
// TUI always shows the live truth. Separately toggleable; default on.
|
|
109
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;
|
|
110
116
|
discoveryTimeoutMs: number;
|
|
111
117
|
beaconTimeoutMs: number;
|
|
112
118
|
loadTimeoutMs: number;
|
|
@@ -155,6 +161,7 @@ const DEFAULT_CONFIG: LemonadeConfig = {
|
|
|
155
161
|
classifyPath: "/v1/classify",
|
|
156
162
|
beaconPort: 13305,
|
|
157
163
|
chatOnly: true,
|
|
164
|
+
cacheOnOutage: false,
|
|
158
165
|
defaultTranscriptionModel: "Whisper-Large-v3",
|
|
159
166
|
defaultImageModel: "",
|
|
160
167
|
defaultUpscaleModel: "RealESRGAN-x4plus",
|
|
@@ -718,6 +725,11 @@ const GH_LATEST_RELEASE = "https://api.github.com/repos/lemonade-sdk/lemonade/re
|
|
|
718
725
|
interface UpstreamVersionSnapshot {
|
|
719
726
|
checkedAt: number;
|
|
720
727
|
latest: string;
|
|
728
|
+
/** Tier-2 notice dedup: instance → the latest release version at the
|
|
729
|
+
* time that instance was last noticed. "A server update is available"
|
|
730
|
+
* fires once per (instance, release) — never a per-session nag about
|
|
731
|
+
* the same fact; the TUI always shows the live truth. */
|
|
732
|
+
notified?: Record<string, string>;
|
|
721
733
|
}
|
|
722
734
|
|
|
723
735
|
function upstreamSnapshotPath(): string {
|
|
@@ -727,7 +739,11 @@ function upstreamSnapshotPath(): string {
|
|
|
727
739
|
function loadUpstreamSnapshot(): UpstreamVersionSnapshot | undefined {
|
|
728
740
|
try {
|
|
729
741
|
const raw = JSON.parse(fs.readFileSync(upstreamSnapshotPath(), "utf8")) as UpstreamVersionSnapshot;
|
|
730
|
-
|
|
742
|
+
const notifiedOk =
|
|
743
|
+
raw.notified === undefined ||
|
|
744
|
+
(typeof raw.notified === "object" && raw.notified !== null &&
|
|
745
|
+
Object.values(raw.notified).every((v) => typeof v === "string"));
|
|
746
|
+
return typeof raw.checkedAt === "number" && typeof raw.latest === "string" && notifiedOk ? raw : undefined;
|
|
731
747
|
} catch {
|
|
732
748
|
return undefined;
|
|
733
749
|
}
|
|
@@ -742,6 +758,105 @@ function saveUpstreamSnapshot(snap: UpstreamVersionSnapshot): void {
|
|
|
742
758
|
}
|
|
743
759
|
}
|
|
744
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
|
+
|
|
745
860
|
/** The latest published lemonade version, or undefined when unreachable
|
|
746
861
|
* (unknown ≠ outdated — never cry wolf). */
|
|
747
862
|
async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | undefined> {
|
|
@@ -755,7 +870,7 @@ async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | u
|
|
|
755
870
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
756
871
|
const tag = ((await res.json()) as { tag_name?: string }).tag_name;
|
|
757
872
|
if (typeof tag !== "string" || !tag) throw new Error("release carried no tag_name");
|
|
758
|
-
saveUpstreamSnapshot({ checkedAt: Date.now(), latest: tag.replace(/^v/, "") });
|
|
873
|
+
saveUpstreamSnapshot({ checkedAt: Date.now(), latest: tag.replace(/^v/, ""), notified: fresh?.notified });
|
|
759
874
|
} catch (error) {
|
|
760
875
|
console.error(
|
|
761
876
|
`[pi-lemonade-link] upstream-version probe skipped: ${error instanceof Error ? error.message : error}`
|
|
@@ -764,7 +879,7 @@ async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | u
|
|
|
764
879
|
// pass per cooldown") — otherwise offline days re-probe at every
|
|
765
880
|
// session start. Keep the last known latest; a failed probe proves
|
|
766
881
|
// nothing new (unknown ≠ outdated).
|
|
767
|
-
saveUpstreamSnapshot({ checkedAt: Date.now(), latest: fresh?.latest ?? "" });
|
|
882
|
+
saveUpstreamSnapshot({ checkedAt: Date.now(), latest: fresh?.latest ?? "", notified: fresh?.notified });
|
|
768
883
|
}
|
|
769
884
|
}
|
|
770
885
|
return loadUpstreamSnapshot()?.latest;
|
|
@@ -842,6 +957,16 @@ function versionCompare(a: string, b: string): number | null {
|
|
|
842
957
|
return 0;
|
|
843
958
|
}
|
|
844
959
|
|
|
960
|
+
/** Whether the tier-2 "server update is available" notice is due for one
|
|
961
|
+
* instance: the server is behind the latest published release AND this
|
|
962
|
+
* exact release hasn't already been noticed for that instance — one
|
|
963
|
+
* notice per (instance, release), never a per-session nag about the
|
|
964
|
+
* same fact. Pure. */
|
|
965
|
+
function upstreamNoticeDue(serverVersion: string, latest: string, lastNotifiedLatest: string | undefined): boolean {
|
|
966
|
+
const cmp = versionCompare(serverVersion, latest);
|
|
967
|
+
return cmp !== null && cmp < 0 && lastNotifiedLatest !== latest;
|
|
968
|
+
}
|
|
969
|
+
|
|
845
970
|
/** Server-version drift collected during provider registration/refresh
|
|
846
971
|
* (registerInstanceProvider records; registerLemonadeProvider resets).
|
|
847
972
|
* direction: "ahead" = server newer than the extension was verified for
|
|
@@ -1012,15 +1137,24 @@ function registerSpecDriftWarnings(pi: ExtensionAPI, configRef: { current: Lemon
|
|
|
1012
1137
|
void latestUpstreamVersion(config)
|
|
1013
1138
|
.then((latest) => {
|
|
1014
1139
|
if (!latest) return;
|
|
1140
|
+
// Dedup: fire the notice once per (instance, release). The TUI
|
|
1141
|
+
// always shows the live truth; the session-start line never
|
|
1142
|
+
// repeats for the same release.
|
|
1143
|
+
const snap = loadUpstreamSnapshot();
|
|
1144
|
+
if (!snap) return;
|
|
1145
|
+
const notified: Record<string, string> = { ...(snap.notified ?? {}) };
|
|
1146
|
+
let dirty = false;
|
|
1015
1147
|
for (const [instance, serverVersion] of serverVersionsSeen) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
`
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1148
|
+
if (!upstreamNoticeDue(serverVersion, latest, notified[instance])) continue;
|
|
1149
|
+
ctx.ui.notify(
|
|
1150
|
+
`pi-lemonade-link: instance "${instance}" runs lemonade v${serverVersion}; the latest published release is v${latest} — a server update is available. ` +
|
|
1151
|
+
`(noted once per release; /lemonade-setup → Spec & version check always shows current status)`,
|
|
1152
|
+
"info"
|
|
1153
|
+
);
|
|
1154
|
+
notified[instance] = latest;
|
|
1155
|
+
dirty = true;
|
|
1023
1156
|
}
|
|
1157
|
+
if (dirty) saveUpstreamSnapshot({ ...snap, notified });
|
|
1024
1158
|
})
|
|
1025
1159
|
.catch((error) =>
|
|
1026
1160
|
console.error(`[pi-lemonade-link] upstream notice skipped: ${error instanceof Error ? error.message : error}`)
|
|
@@ -1037,59 +1171,133 @@ function registerSpecDriftWarnings(pi: ExtensionAPI, configRef: { current: Lemon
|
|
|
1037
1171
|
|
|
1038
1172
|
// ─── Provider registration ──────────────────────────────────────────────────
|
|
1039
1173
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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;
|
|
1048
1190
|
}
|
|
1049
1191
|
|
|
1050
|
-
function
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
+
}
|
|
1069
1213
|
}
|
|
1070
1214
|
|
|
1071
|
-
|
|
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
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
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;
|
|
1072
1275
|
const config = view.config;
|
|
1073
1276
|
const providerId = instanceProviderId(view);
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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.
|
|
1284
|
+
console.error(
|
|
1285
|
+
`[pi-lemonade-link] instance "${view.name}" unreachable — serving cached catalog from ${cachedStamp}`
|
|
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.
|
|
1090
1297
|
console.error(
|
|
1091
|
-
`[pi-lemonade-link] instance "${view.name}"
|
|
1298
|
+
`[pi-lemonade-link] instance "${view.name}" has no cached catalog — registering no models`
|
|
1092
1299
|
);
|
|
1300
|
+
cachedServing.delete(view.name);
|
|
1093
1301
|
}
|
|
1094
1302
|
|
|
1095
1303
|
const advertised = config.chatOnly ? catalog.filter(isChatModel) : catalog;
|
|
@@ -1106,12 +1314,45 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
|
|
|
1106
1314
|
apiKey: config.apiKey || "lemonade",
|
|
1107
1315
|
authHeader: true,
|
|
1108
1316
|
api: "openai-completions",
|
|
1109
|
-
models: advertised.map((m) => toProviderModel(m, loaded, providerId)),
|
|
1317
|
+
models: advertised.map((m) => toProviderModel(m, loaded, providerId, cachedStamp)),
|
|
1110
1318
|
});
|
|
1111
1319
|
|
|
1112
1320
|
return advertised.length;
|
|
1113
1321
|
}
|
|
1114
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
|
+
|
|
1115
1356
|
/** Provider ids used by past versions of this extension. unregisterProvider
|
|
1116
1357
|
* only works within the running pi process, and ids changed over the
|
|
1117
1358
|
* extension's evolution, so a long-lived session (or /reload after an
|
|
@@ -1121,22 +1362,114 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
|
|
|
1121
1362
|
const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
|
|
1122
1363
|
|
|
1123
1364
|
/** Register (or re-register) every configured instance. Each becomes its own
|
|
1124
|
-
* pi provider under the uniform lemonade-<name> id.
|
|
1125
|
-
|
|
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> {
|
|
1126
1374
|
serverVersionMismatches.length = 0; // (re)collected per registration pass
|
|
1127
1375
|
serverVersionsSeen.clear();
|
|
1376
|
+
cachedServing.clear();
|
|
1128
1377
|
for (const staleId of LEGACY_PROVIDER_IDS) {
|
|
1129
1378
|
try {
|
|
1130
1379
|
pi.unregisterProvider(staleId);
|
|
1131
1380
|
} catch { /* never registered; fine */ }
|
|
1132
1381
|
}
|
|
1133
1382
|
let total = 0;
|
|
1134
|
-
for (const
|
|
1135
|
-
total +=
|
|
1383
|
+
for (const probe of probes ?? withCacheFallback(config, await probeInstances(config))) {
|
|
1384
|
+
total += registerInstanceProvider(pi, probe);
|
|
1136
1385
|
}
|
|
1137
1386
|
return total;
|
|
1138
1387
|
}
|
|
1139
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
|
+
|
|
1140
1473
|
// ─── Multimodal agent tools ──────────────────────────────────────────────────
|
|
1141
1474
|
|
|
1142
1475
|
/** Combine an operation timeout with the caller's cancel signal (if any),
|
|
@@ -1398,7 +1731,11 @@ function runFfmpeg(args: string[], timeoutMs: number): Promise<void> {
|
|
|
1398
1731
|
});
|
|
1399
1732
|
}
|
|
1400
1733
|
|
|
1401
|
-
async function registerAgentTools(
|
|
1734
|
+
async function registerAgentTools(
|
|
1735
|
+
pi: ExtensionAPI,
|
|
1736
|
+
gconfig: LemonadeConfig,
|
|
1737
|
+
probes?: InstanceProbe[]
|
|
1738
|
+
): Promise<void> {
|
|
1402
1739
|
// Harness-level self-awareness: these lines are merged into the Guidelines
|
|
1403
1740
|
// section of every chat's system prompt, so even a virgin chat knows where
|
|
1404
1741
|
// its lemonade machinery lives without having to hunt for it. (pi dedupes
|
|
@@ -1427,19 +1764,18 @@ async function registerAgentTools(pi: ExtensionAPI, gconfig: LemonadeConfig): Pr
|
|
|
1427
1764
|
{
|
|
1428
1765
|
const ids = new Set<string>();
|
|
1429
1766
|
const pulled = new Set<string>();
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
);
|
|
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
|
+
}
|
|
1443
1779
|
if (ids.size) {
|
|
1444
1780
|
classifierHint =
|
|
1445
1781
|
`Classifiers on this server: ${[...ids].join(", ")}. ` +
|
|
@@ -1857,6 +2193,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1857
2193
|
const metrics: BarMetrics = { toksPerSec: null, cachePct: null, busy: null, cpuPct: null, gpuPct: null, npuPct: null, npuSupported: null, vramGb: null };
|
|
1858
2194
|
let activeInstance: string | null = null;
|
|
1859
2195
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
2196
|
+
let barCtx: ExtCtx | undefined; // ctx of the installed row — for re-sync notices
|
|
1860
2197
|
let tuiRef: { requestRender(): void } | undefined;
|
|
1861
2198
|
let msgStartAt: number | null = null;
|
|
1862
2199
|
|
|
@@ -1918,6 +2255,13 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1918
2255
|
metrics.npuSupported = false; // box reported system-stats with no NPU
|
|
1919
2256
|
}
|
|
1920
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
|
+
}
|
|
1921
2265
|
}
|
|
1922
2266
|
tuiRef?.requestRender();
|
|
1923
2267
|
};
|
|
@@ -1925,6 +2269,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1925
2269
|
const teardown = (ctx: ExtCtx) => {
|
|
1926
2270
|
stopTimer();
|
|
1927
2271
|
tuiRef = undefined;
|
|
2272
|
+
barCtx = undefined;
|
|
1928
2273
|
activeInstance = null;
|
|
1929
2274
|
resetLocal();
|
|
1930
2275
|
try {
|
|
@@ -1938,6 +2283,7 @@ function registerLemonadeBar(pi: ExtensionAPI, configRef: { current: LemonadeCon
|
|
|
1938
2283
|
stopTimer();
|
|
1939
2284
|
resetLocal();
|
|
1940
2285
|
activeInstance = instance;
|
|
2286
|
+
barCtx = ctx;
|
|
1941
2287
|
ctx.ui.setWidget(
|
|
1942
2288
|
KEY,
|
|
1943
2289
|
(tui, theme) => {
|
|
@@ -2972,11 +3318,88 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
2972
3318
|
{ value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
|
|
2973
3319
|
{ value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
|
|
2974
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` },
|
|
2975
3323
|
{ value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
|
|
2976
3324
|
{ value: "exit", label: "Exit", description: "Close the setup menu" },
|
|
2977
3325
|
]);
|
|
2978
3326
|
if (choice === null || choice === "exit") break;
|
|
2979
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
|
+
|
|
2980
3403
|
// ── Spec & version check ───────────────────────────────────────
|
|
2981
3404
|
if (choice === "spec") {
|
|
2982
3405
|
for (;;) {
|
|
@@ -3002,7 +3425,7 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3002
3425
|
const c = upstreamSnap.latest ? versionCompare(v, upstreamSnap.latest) : null;
|
|
3003
3426
|
return c !== null && c < 0;
|
|
3004
3427
|
})
|
|
3005
|
-
? "update available — at least one server is behind the latest published release"
|
|
3428
|
+
? "update available — at least one server is behind the latest published release (notice fires once per release)"
|
|
3006
3429
|
: `all servers current with the latest published release (v${upstreamSnap.latest})`
|
|
3007
3430
|
: "not probed yet";
|
|
3008
3431
|
const sub = await menu(ctx, "Spec & Version Check", [
|
|
@@ -3011,7 +3434,7 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3011
3434
|
{ value: "resync", label: "Re-sync docs/ from upstream", description: "Download the current docs/api markdown into docs/ — ground truth only, the extension code is NOT updated" },
|
|
3012
3435
|
{ value: "toggledocs", label: `Toggle docs check: ${config.specCheck ? "on → off" : "off → on"}`, description: `specCheck (currently ${config.specCheck ? "on" : "off"})` },
|
|
3013
3436
|
{ value: "toggleserver", label: `Toggle server-version check: ${config.serverVersionCheck ? "on → off" : "off → on"}`, description: `serverVersionCheck (currently ${config.serverVersionCheck ? "on" : "off"}) — extension ↔ server sync, the startup warning` },
|
|
3014
|
-
{ value: "toggleupstream", label: `Toggle upstream-release notice: ${config.upstreamVersionCheck ? "on → off" : "off → on"}`, description: `upstreamVersionCheck (currently ${config.upstreamVersionCheck ? "on" : "off"}) — "a server update is available", info tier` },
|
|
3437
|
+
{ value: "toggleupstream", label: `Toggle upstream-release notice: ${config.upstreamVersionCheck ? "on → off" : "off → on"}`, description: `upstreamVersionCheck (currently ${config.upstreamVersionCheck ? "on" : "off"}) — "a server update is available", info tier, once per release` },
|
|
3015
3438
|
{ value: "back", label: "Back", description: "" },
|
|
3016
3439
|
]);
|
|
3017
3440
|
if (sub === null || sub === "back") break;
|
|
@@ -3062,7 +3485,9 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
|
|
|
3062
3485
|
"nothing at runtime. Re-sync re-downloads the current upstream docs into",
|
|
3063
3486
|
"the extension's docs/ folder; it does NOT update the extension code. The",
|
|
3064
3487
|
"startup warnings are reserved for extension ↔ server sync (warning) and",
|
|
3065
|
-
"server-vs-latest-release (one-line info notice
|
|
3488
|
+
"server-vs-latest-release (one-line info notice, deduplicated: it fires",
|
|
3489
|
+
"once per new release per instance — this view always shows the current",
|
|
3490
|
+
"state, even when the notice has already fired).",
|
|
3066
3491
|
],
|
|
3067
3492
|
true
|
|
3068
3493
|
);
|
|
@@ -3756,11 +4181,33 @@ export default async function piLemonadeLink(pi: ExtensionAPI): Promise<void> {
|
|
|
3756
4181
|
// merged from DEFAULT_CONFIG in memory; /lemonade-setup persists the full
|
|
3757
4182
|
// normalized object when the user actually changes something.
|
|
3758
4183
|
|
|
3759
|
-
|
|
3760
|
-
|
|
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
|
+
}
|
|
3761
4207
|
registerLemonadeBar(pi, configRef);
|
|
3762
4208
|
registerSetupCommand(pi, configRef);
|
|
3763
4209
|
registerSpecDriftWarnings(pi, configRef);
|
|
4210
|
+
registerOutageResync(pi, configRef);
|
|
3764
4211
|
|
|
3765
4212
|
// Fresh install (or a hand-emptied config): nothing is registered yet —
|
|
3766
4213
|
// point the user at the wizard instead of leaving them wondering why
|
package/lemonade.example.json
CHANGED
|
@@ -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": "",
|
|
@@ -102,6 +110,8 @@
|
|
|
102
110
|
|
|
103
111
|
// Tier 2 (info notice, one sentence): servers older than the latest
|
|
104
112
|
// published lemonade release (probed via GitHub, throttled) get an
|
|
105
|
-
// "a server update is available" notice
|
|
113
|
+
// "a server update is available" notice — deduplicated to once per
|
|
114
|
+
// (instance, release); the /lemonade-setup TUI always shows the current
|
|
115
|
+
// state. Separately toggleable.
|
|
106
116
|
"upstreamVersionCheck": true
|
|
107
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-lemonade-link",
|
|
3
|
-
"version": "1.0
|
|
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",
|