pi-lemonade-link 1.0.0 → 1.0.2

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
@@ -282,6 +282,7 @@ and the normal menu takes over once the first instance exists.
282
282
  | **Model management** | List catalog (including not-yet-downloaded registry entries), load / unload, pull (download) with live progress + esc-to-cancel, install from Hugging Face (search → pick variant → install as `user.*`), delete, change context size (unload → reload with `ctx_size`, saved), toggle chat-only filter, refresh provider |
283
283
  | **Transcription settings** | Default Whisper model, endpoint path, smoke-test a file |
284
284
  | **Live server logs** | WebSocket stream of the server's log: full snapshot backlog (up to 5000 entries) then live entries as they happen; Esc to close |
285
+ | **Spec & version check** | The three drift tiers, interactive: docs-vs-upstream status (TUI-only), live per-instance server versions beside the verified build and the latest upstream release, re-check now, one-keystroke docs re-sync (re-downloads upstream docs into the extension's docs/ folder — the code is not updated), `specCheck` / `serverVersionCheck` / `upstreamVersionCheck` toggles |
285
286
 
286
287
  Notes on the destructive and slow paths:
287
288
 
@@ -387,7 +388,12 @@ The full shape (values shown are the built-in defaults):
387
388
  "generationTimeoutMs": 600000,
388
389
  "statusPollMs": 2000,
389
390
  "statusBar": true,
390
- "barPollMs": 5000
391
+ "barPollMs": 5000,
392
+ "specCheck": true,
393
+ "specCheckCooldownMs": 86400000,
394
+ "specCheckTimeoutMs": 5000,
395
+ "serverVersionCheck": true,
396
+ "upstreamVersionCheck": true
391
397
  }
392
398
  ```
393
399
 
@@ -403,6 +409,9 @@ The full shape (values shown are the built-in defaults):
403
409
  | `statusPollMs` | How often the setup-menu status panel re-fetches while open (live refresh). `0` = static snapshot. |
404
410
  | `statusBar` | Show the below-editor lemonade status bar while a lemonade model is active (see *Lemonade status bar*). |
405
411
  | `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
+ | `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
+ | `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 — **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. |
406
415
 
407
416
  ## Offline & unreachable networks
408
417
 
@@ -533,6 +542,71 @@ git clone --depth 1 --filter=blob:none --sparse https://github.com/lemonade-sdk/
533
542
  && cp docs/api/*.md ~/.pi/agent/extensions/pi-lemonade-link/docs/
534
543
  ```
535
544
 
545
+ ### Keeping the specs honest
546
+
547
+ Drift is watched **in tiers, ordered by what actually matters at runtime**:
548
+
549
+ 1. **Extension ↔ server sync (startup warning).** Every registration and
550
+ refresh compares each instance's live `/v1/health` version against the
551
+ build this extension was verified against (`v11.7.0` — a constant in the
552
+ extension code, **not a user setting**: it is a property of the code,
553
+ updated with each extension release). A mismatch is the one signal that
554
+ means *endpoints may have changed for this pairing*, so it warns —
555
+ **directionally**: server *newer* → "check for an extension update";
556
+ server *older* → "update the server, or expect some endpoints to be
557
+ missing".
558
+ 2. **Server ↔ latest published release (info notice, one sentence, on by
559
+ default, separately toggleable).** The latest published lemonade release
560
+ is probed via GitHub (throttled, durable, silent on network failure); any
561
+ server older than it gets a single info line — *"instance 'x' runs
562
+ lemonade v11.7.0; the latest published release is v11.9.0 — a server
563
+ update is available."* Much lower tier: an older server isn't broken,
564
+ it's just behind. The notice is **deduplicated to once per (instance,
565
+ release)** — it fires when a new upstream release first appears, never
566
+ re-fires on every session for the same fact; `/lemonade-setup` →
567
+ Spec & version check always shows the current state.
568
+ `upstreamVersionCheck: false` disables.
569
+ 3. **Docs drift — TUI-only status, deliberately no startup warning.** The
570
+ `docs/` snapshot is the reference for the version the extension targets;
571
+ as long as extension and server agree on version, whether upstream HEAD
572
+ has moved on changes **nothing at runtime** — so it's shown only in
573
+ `/lemonade-setup` → Spec & version check (per-file status, re-check now,
574
+ one-keystroke re-sync that re-downloads the current upstream docs into
575
+ the extension's `docs/` folder). `specCheck: false` disables even the
576
+ background refresh.
577
+
578
+ All tiers share the family mechanics: fire-and-forget, never delays
579
+ startup, durable verdicts (at most one network pass per 24 h across
580
+ restarts), silent on network failure (unknown ≠ out of date). The Spec &
581
+ version check view manages every toggle interactively and shows live
582
+ per-instance versions beside the verified build and the latest upstream
583
+ release.
584
+
585
+ ## Extension version & provider API
586
+
587
+ This extension's version (`version` in `package.json`) and the lemonade
588
+ server build it was verified against (`11.7.0`) are recorded together in
589
+ the `provider` block of `package.json` and in the
590
+ `EXTENSION_CONFIRMED_SERVER_VERSION` constant in `index.ts` (which drives
591
+ the startup version-drift warning). The server status panel and the
592
+ Spec & version check view both show the extension version, the live server
593
+ version(s), and the verified build.
594
+
595
+ **On update, keep these in lockstep** (all record the same fact):
596
+
597
+ 1. `version` in `package.json` — the extension release
598
+ 2. the `provider` block in `package.json` (`apiVerifiedAgainst`,
599
+ `specSnapshot`) — what that release was verified against
600
+ 3. `EXTENSION_CONFIRMED_SERVER_VERSION` in `index.ts` — the peg the
601
+ version-drift warning compares your servers against
602
+ 4. re-sync `docs/` in the extension folder from upstream (the README
603
+ re-sync command, or `/lemonade-setup` → Spec & version check)
604
+ 5. the behavior-reference dates below
605
+
606
+ A release that skips any of these is lying somewhere — the spec-freshness
607
+ and server-version checks (see *Keeping the specs honest*) are what catch
608
+ #3 and #4 drifting silently.
609
+
536
610
  ## Lemonade behavior reference
537
611
 
538
612
  Empirically confirmed against lemonade 11.7.0 (cross-checked with the
package/index.ts CHANGED
@@ -46,6 +46,8 @@ import * as os from "node:os";
46
46
  import * as path from "node:path";
47
47
  import dgram from "node:dgram";
48
48
  import { spawn } from "node:child_process";
49
+ import { createHash } from "node:crypto";
50
+ import { fileURLToPath } from "node:url";
49
51
  import { Type } from "typebox";
50
52
  import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
51
53
  import { Container, Key, matchesKey, SelectList, Text, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
@@ -89,6 +91,23 @@ interface LemonadeConfig {
89
91
  statusPollMs: number; // status-panel live-refresh interval while viewing
90
92
  statusBar: boolean; // belowEditor bar shown while a lemonade model is active
91
93
  barPollMs: number; // bar server-poll cadence; 0 = local metrics only (tok/s, cache, instance)
94
+ // Spec-freshness: compare the docs/ snapshot against upstream
95
+ // lemonade-sdk/lemonade by git blob SHA. TUI-only status (Spec & version
96
+ // check view + re-sync actions) — no startup warning: as long as the
97
+ // extension and the server agree on version, docs matching upstream HEAD
98
+ // changes nothing at runtime.
99
+ specCheck: boolean;
100
+ specCheckCooldownMs: number; // durable verdicts re-checked at most this often
101
+ specCheckTimeoutMs: number;
102
+ // Live-server drift: /v1/health self-reports the server version; a version
103
+ // other than the build this extension was verified against (a constant in
104
+ // the code, not config) warns at startup, with direction.
105
+ serverVersionCheck: boolean;
106
+ // Low-tier notice: compare each server's live version against the latest
107
+ // published lemonade release (GitHub releases); server older → one-line
108
+ // info notice, deduplicated to once per (instance, new release) — the
109
+ // TUI always shows the live truth. Separately toggleable; default on.
110
+ upstreamVersionCheck: boolean;
92
111
  discoveryTimeoutMs: number;
93
112
  beaconTimeoutMs: number;
94
113
  loadTimeoutMs: number;
@@ -145,6 +164,11 @@ const DEFAULT_CONFIG: LemonadeConfig = {
145
164
  statusPollMs: 2000,
146
165
  statusBar: true,
147
166
  barPollMs: 5000,
167
+ specCheck: true,
168
+ specCheckCooldownMs: 24 * 60 * 60 * 1000,
169
+ specCheckTimeoutMs: 5000,
170
+ serverVersionCheck: true,
171
+ upstreamVersionCheck: true,
148
172
  discoveryTimeoutMs: 5000,
149
173
  beaconTimeoutMs: 3000,
150
174
  loadTimeoutMs: 300_000,
@@ -656,6 +680,390 @@ async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer
656
680
  return [...seen.values()];
657
681
  }
658
682
 
683
+ // ─── Spec freshness & server-version drift ─────────────────────────────────
684
+
685
+ /** The two drift signals this extension watches, so "ground truth snapshotted"
686
+ * stays honest:
687
+ *
688
+ * 1. Docs drift — the docs/ folder is a snapshot of lemonade-sdk/lemonade's
689
+ * docs/api (see the README re-sync command). Each file's git blob SHA is
690
+ * computed locally at runtime (nothing stored, so re-syncing docs/
691
+ * re-arms the comparison) and matched against the same file upstream via
692
+ * GitHub's contents API (a tiny JSON response; the markdown is never
693
+ * downloaded). Drift → TUI-only status (the Spec & version check
694
+ * view); deliberately NO startup warning — extension ↔ server version
695
+ * agreement is what matters at runtime.
696
+ *
697
+ * 2. Live-server drift — an on-prem box publishes no spec to diff, but it
698
+ * self-reports its version in /v1/health. A version this extension's
699
+ * behavior reference was never confirmed against is the honest signal
700
+ * that endpoints may have changed; each such instance is warned on
701
+ * session_start (mismatches are (re)collected on every registration and
702
+ * refresh, so fixing the server or the config clears the warning).
703
+ *
704
+ * Both checks are fire-and-forget, never delay startup, and are silent on
705
+ * network failure — a hygiene check must never cry wolf on offline
706
+ * startups: unknown ≠ out of date. */
707
+
708
+ const DOCS_FILES = ["README.md", "anthropic.md", "lemonade.md", "llamacpp.md", "mcp.md", "ollama.md", "openai.md"];
709
+ const DOCS_UPSTREAM_BASE = "https://github.com/lemonade-sdk/lemonade/blob/main/docs/api/";
710
+ const DOCS_GH_CONTENTS = (file: string) =>
711
+ `https://api.github.com/repos/lemonade-sdk/lemonade/contents/docs/api/${file}`;
712
+ const DOCS_RAW = (file: string) => `https://raw.githubusercontent.com/lemonade-sdk/lemonade/main/docs/api/${file}`;
713
+
714
+ /** Latest published (non-prerelease) lemonade release — the low-tier "a
715
+ * server update is available" notice compares each server's live version
716
+ * against this. Throttled by the same durable-verdict discipline. */
717
+ const GH_LATEST_RELEASE = "https://api.github.com/repos/lemonade-sdk/lemonade/releases/latest";
718
+
719
+ interface UpstreamVersionSnapshot {
720
+ checkedAt: number;
721
+ latest: string;
722
+ /** Tier-2 notice dedup: instance → the latest release version at the
723
+ * time that instance was last noticed. "A server update is available"
724
+ * fires once per (instance, release) — never a per-session nag about
725
+ * the same fact; the TUI always shows the live truth. */
726
+ notified?: Record<string, string>;
727
+ }
728
+
729
+ function upstreamSnapshotPath(): string {
730
+ return path.join(getAgentDir(), "cache", "pi-lemonade-link", "upstream-version.json");
731
+ }
732
+
733
+ function loadUpstreamSnapshot(): UpstreamVersionSnapshot | undefined {
734
+ try {
735
+ const raw = JSON.parse(fs.readFileSync(upstreamSnapshotPath(), "utf8")) as UpstreamVersionSnapshot;
736
+ const notifiedOk =
737
+ raw.notified === undefined ||
738
+ (typeof raw.notified === "object" && raw.notified !== null &&
739
+ Object.values(raw.notified).every((v) => typeof v === "string"));
740
+ return typeof raw.checkedAt === "number" && typeof raw.latest === "string" && notifiedOk ? raw : undefined;
741
+ } catch {
742
+ return undefined;
743
+ }
744
+ }
745
+
746
+ function saveUpstreamSnapshot(snap: UpstreamVersionSnapshot): void {
747
+ try {
748
+ fs.mkdirSync(path.dirname(upstreamSnapshotPath()), { recursive: true });
749
+ fs.writeFileSync(upstreamSnapshotPath(), JSON.stringify(snap, null, 2) + "\n");
750
+ } catch {
751
+ /* cache dir unwritable → re-probe next time */
752
+ }
753
+ }
754
+
755
+ /** The latest published lemonade version, or undefined when unreachable
756
+ * (unknown ≠ outdated — never cry wolf). */
757
+ async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | undefined> {
758
+ const fresh = loadUpstreamSnapshot();
759
+ if (!fresh || Date.now() - fresh.checkedAt >= Math.max(0, config.specCheckCooldownMs)) {
760
+ try {
761
+ const res = await fetch(GH_LATEST_RELEASE, {
762
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-lemonade-link" },
763
+ signal: AbortSignal.timeout(config.specCheckTimeoutMs),
764
+ });
765
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
766
+ const tag = ((await res.json()) as { tag_name?: string }).tag_name;
767
+ if (typeof tag !== "string" || !tag) throw new Error("release carried no tag_name");
768
+ saveUpstreamSnapshot({ checkedAt: Date.now(), latest: tag.replace(/^v/, ""), notified: fresh?.notified });
769
+ } catch (error) {
770
+ console.error(
771
+ `[pi-lemonade-link] upstream-version probe skipped: ${error instanceof Error ? error.message : error}`
772
+ );
773
+ // A failed probe must still engage the cooldown ("at most one network
774
+ // pass per cooldown") — otherwise offline days re-probe at every
775
+ // session start. Keep the last known latest; a failed probe proves
776
+ // nothing new (unknown ≠ outdated).
777
+ saveUpstreamSnapshot({ checkedAt: Date.now(), latest: fresh?.latest ?? "", notified: fresh?.notified });
778
+ }
779
+ }
780
+ return loadUpstreamSnapshot()?.latest;
781
+ }
782
+
783
+ /** Git blob SHA of a file's bytes: sha1("blob <size>\0" + content). Pure. */
784
+ function gitBlobSha(data: Buffer): string {
785
+ return createHash("sha1").update(`blob ${data.length}\0`).update(data).digest("hex");
786
+ }
787
+
788
+ /** Absolute path of a snapshotted doc, inside this extension's folder. */
789
+ function extensionDocsPath(file: string): string {
790
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "docs", file);
791
+ }
792
+
793
+ interface DocsSpecSnapshot {
794
+ checkedAt: number; // epoch ms of the last real check
795
+ changed: string[]; // docs/ files whose upstream blob SHA differs (sorted)
796
+ }
797
+
798
+ function docsSnapshotPath(): string {
799
+ return path.join(getAgentDir(), "cache", "pi-lemonade-link", "spec-check.json");
800
+ }
801
+
802
+ function loadDocsSnapshot(): DocsSpecSnapshot | undefined {
803
+ try {
804
+ const raw = JSON.parse(fs.readFileSync(docsSnapshotPath(), "utf8")) as DocsSpecSnapshot;
805
+ return typeof raw.checkedAt === "number" && Array.isArray(raw.changed) ? raw : undefined;
806
+ } catch {
807
+ return undefined;
808
+ }
809
+ }
810
+
811
+ function saveDocsSnapshot(snap: DocsSpecSnapshot): void {
812
+ try {
813
+ fs.mkdirSync(path.dirname(docsSnapshotPath()), { recursive: true });
814
+ fs.writeFileSync(docsSnapshotPath(), JSON.stringify(snap, null, 2) + "\n");
815
+ } catch {
816
+ /* cache dir unwritable — the check still runs, just re-checks next time */
817
+ }
818
+ }
819
+
820
+ /** The lemonade server version THIS BUILD of the extension was verified
821
+ * against (the README behavior reference). A constant in the code, not
822
+ * config: it is a property of the build — updated with each extension
823
+ * release after re-verification — never a user preference to move. The
824
+ * server's own version is probed live from /v1/health; this is the peg. */
825
+ const EXTENSION_CONFIRMED_SERVER_VERSION = "11.7.0";
826
+
827
+ /** This extension's own version, from package.json (kept beside the
828
+ * provider.apiVerifiedAgainst record — see the README versioning
829
+ * section; both bump together on release). */
830
+ const PKG_VERSION = (() => {
831
+ try {
832
+ return (
833
+ (JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8")) as {
834
+ version?: string;
835
+ }).version ?? "0.0.0"
836
+ );
837
+ } catch {
838
+ return "0.0.0";
839
+ }
840
+ })();
841
+
842
+ /** Compare dotted numeric versions: >0 if a is newer than b, <0 if older,
843
+ * null when either side isn't dotted-numeric (no direction, just "differs"). Pure. */
844
+ function versionCompare(a: string, b: string): number | null {
845
+ const pa = a.split(".").map(Number);
846
+ const pb = b.split(".").map(Number);
847
+ if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
848
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
849
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
850
+ if (d) return d;
851
+ }
852
+ return 0;
853
+ }
854
+
855
+ /** Whether the tier-2 "server update is available" notice is due for one
856
+ * instance: the server is behind the latest published release AND this
857
+ * exact release hasn't already been noticed for that instance — one
858
+ * notice per (instance, release), never a per-session nag about the
859
+ * same fact. Pure. */
860
+ function upstreamNoticeDue(serverVersion: string, latest: string, lastNotifiedLatest: string | undefined): boolean {
861
+ const cmp = versionCompare(serverVersion, latest);
862
+ return cmp !== null && cmp < 0 && lastNotifiedLatest !== latest;
863
+ }
864
+
865
+ /** Server-version drift collected during provider registration/refresh
866
+ * (registerInstanceProvider records; registerLemonadeProvider resets).
867
+ * direction: "ahead" = server newer than the extension was verified for
868
+ * (fix: extension update); "behind" = older (fix: update the server). */
869
+ interface ServerVersionMismatch {
870
+ instance: string;
871
+ server: string;
872
+ confirmed: string;
873
+ direction: "ahead" | "behind" | "unknown";
874
+ }
875
+
876
+ const serverVersionMismatches: ServerVersionMismatch[] = [];
877
+
878
+ /** Live server versions seen at the last registration/refresh (instance →
879
+ * version), reused by the low-tier upstream notice without extra probes. */
880
+ const serverVersionsSeen = new Map<string, string>();
881
+
882
+ function recordServerVersion(config: LemonadeConfig, instance: string, version: string | undefined): void {
883
+ const i = serverVersionMismatches.findIndex((m) => m.instance === instance);
884
+ const cmp = version ? versionCompare(version, EXTENSION_CONFIRMED_SERVER_VERSION) : null;
885
+ const mismatched = config.serverVersionCheck && !!version && version !== EXTENSION_CONFIRMED_SERVER_VERSION;
886
+ if (version) serverVersionsSeen.set(instance, version);
887
+ else serverVersionsSeen.delete(instance);
888
+ if (mismatched && version) {
889
+ const entry: ServerVersionMismatch = {
890
+ instance,
891
+ server: version,
892
+ confirmed: EXTENSION_CONFIRMED_SERVER_VERSION,
893
+ direction: cmp !== null ? (cmp > 0 ? "ahead" : "behind") : "unknown",
894
+ };
895
+ if (i >= 0) serverVersionMismatches[i] = entry;
896
+ else serverVersionMismatches.push(entry);
897
+ } else if (i >= 0) {
898
+ serverVersionMismatches.splice(i, 1); // back on the verified version — clear the warning
899
+ }
900
+ }
901
+
902
+ /** One live docs comparison: per-file local blob SHA vs upstream blob SHA. Network. */
903
+ async function performDocsLiveCheck(
904
+ timeoutMs: number
905
+ ): Promise<{ verdict: "current" | "changed"; changed: string[] } | { verdict: "unknown"; error: string }> {
906
+ const changed: string[] = [];
907
+ let reached = false;
908
+ let lastError = "GitHub unreachable";
909
+ await Promise.all(
910
+ DOCS_FILES.map(async (file) => {
911
+ try {
912
+ const localSha = gitBlobSha(fs.readFileSync(extensionDocsPath(file)));
913
+ const res = await fetch(DOCS_GH_CONTENTS(file), {
914
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-lemonade-link" },
915
+ signal: AbortSignal.timeout(timeoutMs),
916
+ });
917
+ if (!res.ok) {
918
+ lastError = `GitHub API HTTP ${res.status} (${file})`;
919
+ return;
920
+ }
921
+ reached = true;
922
+ const sha = ((await res.json()) as { sha?: string }).sha;
923
+ if (typeof sha === "string" && sha && sha !== localSha) changed.push(file);
924
+ } catch (error) {
925
+ lastError = error instanceof Error ? error.message : String(error);
926
+ /* one file unreachable → not drift; the reached-gate below stays */
927
+ }
928
+ })
929
+ );
930
+ if (!reached) return { verdict: "unknown", error: lastError };
931
+ return changed.length ? { verdict: "changed", changed } : { verdict: "current", changed: [] };
932
+ }
933
+
934
+ /** Re-sync the docs/ snapshot from upstream: download the current markdown
935
+ * into docs/ and record a "current" verdict (true by construction). This
936
+ * refreshes the GROUND TRUTH ONLY — the extension code itself is unchanged
937
+ * and may still need an update; the caller's messaging must say so. */
938
+ async function resyncLemonadeDocs(timeoutMs: number): Promise<{ ok: boolean; error?: string }> {
939
+ try {
940
+ await Promise.all(
941
+ DOCS_FILES.map(async (file) => {
942
+ const res = await fetch(DOCS_RAW(file), { signal: AbortSignal.timeout(timeoutMs) });
943
+ if (!res.ok) throw new Error(`HTTP ${res.status} (${file})`);
944
+ const body = Buffer.from(await res.arrayBuffer());
945
+ fs.mkdirSync(path.dirname(extensionDocsPath(file)), { recursive: true });
946
+ fs.writeFileSync(extensionDocsPath(file), body);
947
+ })
948
+ );
949
+ saveDocsSnapshot({ checkedAt: Date.now(), changed: [] });
950
+ return { ok: true };
951
+ } catch (error) {
952
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
953
+ }
954
+ }
955
+
956
+ /** One throttled docs-freshness pass: a fresh durable verdict serves without
957
+ * a network call; a stale/absent one triggers the bounded GitHub probe.
958
+ * Drift → the caller-supplied channel. The startup caller passes a no-op
959
+ * (docs drift is TUI-only status — see registerSpecDriftWarnings); the
960
+ * throttled check still runs so the TUI's Spec & version check view has a
961
+ * fresh verdict. */
962
+ async function runDocsSpecCheck(config: LemonadeConfig, notify: (message: string) => void): Promise<void> {
963
+ if (!config.specCheck) return;
964
+ let snapshot = loadDocsSnapshot();
965
+ if (!snapshot || Date.now() - snapshot.checkedAt >= Math.max(0, config.specCheckCooldownMs)) {
966
+ const result = await performDocsLiveCheck(config.specCheckTimeoutMs);
967
+ if (result.verdict === "unknown") {
968
+ // GitHub unreachable: unknown ≠ out of date — never cry wolf. But the
969
+ // failed pass still engages the cooldown ("at most one network pass
970
+ // per cooldown") — otherwise offline days re-probe at every session
971
+ // start — and the last known verdict stands until it expires.
972
+ console.error(`[pi-lemonade-link] spec check skipped: ${result.error}`);
973
+ saveDocsSnapshot({ checkedAt: Date.now(), changed: snapshot?.changed ?? [] });
974
+ return;
975
+ }
976
+ snapshot = result.verdict === "changed" ? { checkedAt: Date.now(), changed: result.changed } : { checkedAt: Date.now(), changed: [] };
977
+ saveDocsSnapshot(snapshot);
978
+ }
979
+ if (snapshot.changed.length) {
980
+ notify(
981
+ `pi-lemonade-link: ${snapshot.changed.length} Lemonade API doc${snapshot.changed.length > 1 ? "s" : ""} this extension keeps in its docs/ folder changed upstream ` +
982
+ `(${snapshot.changed.join(", ")}) — the extension may be out of date. ` +
983
+ `Fix: check for an extension update, or open /lemonade-setup → Spec & version check to re-download the current docs into the extension's ` +
984
+ `docs/ folder and compare what changed. Runs once a day; specCheck: false disables.`
985
+ );
986
+ }
987
+ }
988
+
989
+ /** Session-start drift signals, in tiers — ordered by what actually matters
990
+ * at runtime. Registered once from the extension entry.
991
+ *
992
+ * Tier 1 (warning): extension ↔ server sync — the signal that matters; a
993
+ * version mismatch means endpoints may have changed for this pairing.
994
+ * Directional (see ServerVersionMismatch).
995
+ *
996
+ * Tier 2 (info, one sentence, separately toggleable): server ↔ latest
997
+ * published upstream release — "a server update is available". Much lower
998
+ * tier: an older server is not broken, it's just behind.
999
+ *
1000
+ * Docs drift: TUI-only status — as long as extension and server agree on
1001
+ * version, the docs snapshot matching upstream HEAD changes nothing at
1002
+ * runtime. The throttled check still runs so the Spec & version check view
1003
+ * has a fresh verdict; it just never warns. */
1004
+ function registerSpecDriftWarnings(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
1005
+ pi.on("session_start", async (_event, ctx) => {
1006
+ if (!ctx.hasUI) return;
1007
+ const config = configRef.current;
1008
+
1009
+ // Tier 1 — extension ↔ server sync.
1010
+ for (const m of serverVersionMismatches) {
1011
+ const relation =
1012
+ m.direction === "ahead" ? "newer than" : m.direction === "behind" ? "older than" : "different from";
1013
+ const fix =
1014
+ m.direction === "ahead"
1015
+ ? "check for an extension update — until then, endpoints on that box may differ"
1016
+ : m.direction === "behind"
1017
+ ? `update the lemonade server to v${m.confirmed} or newer, or expect some endpoints to be missing on that box`
1018
+ : "update the server or the extension so both are on the verified pairing";
1019
+ ctx.ui.notify(
1020
+ `pi-lemonade-link: instance "${m.instance}" runs server v${m.server} (${relation} the v${m.confirmed} this extension was verified against) — ${fix}. ` +
1021
+ `Details: /lemonade-setup → Spec & version check. serverVersionCheck: false disables.`,
1022
+ "warning"
1023
+ );
1024
+ }
1025
+
1026
+ // Tier 2 — server vs the latest published upstream release.
1027
+ // Fire-and-forget by design: pi's runner awaits session_start handlers
1028
+ // (and every UI mode awaits that emit), so awaiting the probe here would
1029
+ // stall startup/reload by up to ~specCheckTimeoutMs whenever the durable
1030
+ // verdict is stale. The notice simply arrives when the probe lands.
1031
+ if (config.upstreamVersionCheck) {
1032
+ void latestUpstreamVersion(config)
1033
+ .then((latest) => {
1034
+ if (!latest) return;
1035
+ // Dedup: fire the notice once per (instance, release). The TUI
1036
+ // always shows the live truth; the session-start line never
1037
+ // repeats for the same release.
1038
+ const snap = loadUpstreamSnapshot();
1039
+ if (!snap) return;
1040
+ const notified: Record<string, string> = { ...(snap.notified ?? {}) };
1041
+ let dirty = false;
1042
+ for (const [instance, serverVersion] of serverVersionsSeen) {
1043
+ if (!upstreamNoticeDue(serverVersion, latest, notified[instance])) continue;
1044
+ ctx.ui.notify(
1045
+ `pi-lemonade-link: instance "${instance}" runs lemonade v${serverVersion}; the latest published release is v${latest} — a server update is available. ` +
1046
+ `(noted once per release; /lemonade-setup → Spec & version check always shows current status)`,
1047
+ "info"
1048
+ );
1049
+ notified[instance] = latest;
1050
+ dirty = true;
1051
+ }
1052
+ if (dirty) saveUpstreamSnapshot({ ...snap, notified });
1053
+ })
1054
+ .catch((error) =>
1055
+ console.error(`[pi-lemonade-link] upstream notice skipped: ${error instanceof Error ? error.message : error}`)
1056
+ );
1057
+ }
1058
+
1059
+ // Docs drift — TUI-only: keep the verdict fresh, never warn. Same
1060
+ // fire-and-forget discipline as Tier 2.
1061
+ if (config.specCheck) {
1062
+ void runDocsSpecCheck(config, () => {});
1063
+ }
1064
+ });
1065
+ }
1066
+
659
1067
  // ─── Provider registration ──────────────────────────────────────────────────
660
1068
 
661
1069
  function isChatModel(m: LemonadeModel): boolean {
@@ -701,6 +1109,7 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
701
1109
  ]);
702
1110
  catalog = catalogRes;
703
1111
  loaded = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
1112
+ recordServerVersion(config, view.name, health?.version);
704
1113
  } catch {
705
1114
  // Unreachable => contribute nothing to /model. No placeholders: an
706
1115
  // unreachable model is not listable. Sessions pinned to a lemonade model
@@ -743,6 +1152,8 @@ const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
743
1152
  /** Register (or re-register) every configured instance. Each becomes its own
744
1153
  * pi provider under the uniform lemonade-<name> id. */
745
1154
  async function registerLemonadeProvider(pi: ExtensionAPI, config: LemonadeConfig): Promise<number> {
1155
+ serverVersionMismatches.length = 0; // (re)collected per registration pass
1156
+ serverVersionsSeen.clear();
746
1157
  for (const staleId of LEGACY_PROVIDER_IDS) {
747
1158
  try {
748
1159
  pi.unregisterProvider(staleId);
@@ -2146,6 +2557,12 @@ function formatStatus(
2146
2557
  `Status: ${health.status ?? "?"} Lemonade v${health.version ?? "?"} Telemetry: ${telemetry}` +
2147
2558
  (updated ? ` · updated ${updated.toTimeString().slice(0, 8)}` : ""),
2148
2559
  `Endpoint: ${config.baseUrl} WebSocket port: ${health.websocket_port ?? "?"}`,
2560
+ `This extension: v${PKG_VERSION}, verified against lemonade v${EXTENSION_CONFIRMED_SERVER_VERSION} · docs snapshot: ${
2561
+ (() => {
2562
+ const s = loadDocsSnapshot();
2563
+ return !s ? "not checked yet" : s.changed.length ? `${s.changed.length} file(s) changed upstream` : "up to date";
2564
+ })()
2565
+ }`,
2149
2566
  ""
2150
2567
  );
2151
2568
 
@@ -2583,11 +3000,157 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
2583
3000
  { value: "models", label: "Model management", description: `List, load, unload, pull (progress+cancel), delete, change-ctx, HF install, filter, refresh — active instance: ${activeInstance}` },
2584
3001
  { value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
2585
3002
  { value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
3003
+ { value: "spec", label: "Spec & version check", description: "Docs drift + live server-version drift — status, re-check, re-sync, toggles" },
2586
3004
  { value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
2587
3005
  { value: "exit", label: "Exit", description: "Close the setup menu" },
2588
3006
  ]);
2589
3007
  if (choice === null || choice === "exit") break;
2590
3008
 
3009
+ // ── Spec & version check ───────────────────────────────────────
3010
+ if (choice === "spec") {
3011
+ for (;;) {
3012
+ const config = configRef.current;
3013
+ const docsSnap = loadDocsSnapshot();
3014
+ const docsState = !config.specCheck
3015
+ ? "disabled (specCheck: false)"
3016
+ : docsSnap
3017
+ ? docsSnap.changed.length
3018
+ ? `CHANGED — ${docsSnap.changed.join(", ")}`
3019
+ : "up to date"
3020
+ : "no verdict yet this cooldown window";
3021
+ const serversState = !config.serverVersionCheck
3022
+ ? "sync check disabled (serverVersionCheck: false)"
3023
+ : serverVersionMismatches.length
3024
+ ? `${serverVersionMismatches.length} instance(s) at an unverified server version`
3025
+ : `all instances on v${EXTENSION_CONFIRMED_SERVER_VERSION} (the build this extension was verified against)`;
3026
+ const upstreamSnap = loadUpstreamSnapshot();
3027
+ const upstreamState = !config.upstreamVersionCheck
3028
+ ? "notice disabled (upstreamVersionCheck: false)"
3029
+ : upstreamSnap
3030
+ ? [...serverVersionsSeen.values()].some((v) => {
3031
+ const c = upstreamSnap.latest ? versionCompare(v, upstreamSnap.latest) : null;
3032
+ return c !== null && c < 0;
3033
+ })
3034
+ ? "update available — at least one server is behind the latest published release (notice fires once per release)"
3035
+ : `all servers current with the latest published release (v${upstreamSnap.latest})`
3036
+ : "not probed yet";
3037
+ const sub = await menu(ctx, "Spec & Version Check", [
3038
+ { value: "status", label: "Status", description: `Docs: ${docsState} · Servers: ${serversState} · Upstream: ${upstreamState}` },
3039
+ { value: "recheck", label: "Re-check docs now", description: "Compare the docs/ snapshot with upstream lemonade-sdk/lemonade by git blob SHA" },
3040
+ { 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" },
3041
+ { value: "toggledocs", label: `Toggle docs check: ${config.specCheck ? "on → off" : "off → on"}`, description: `specCheck (currently ${config.specCheck ? "on" : "off"})` },
3042
+ { 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` },
3043
+ { 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` },
3044
+ { value: "back", label: "Back", description: "" },
3045
+ ]);
3046
+ if (sub === null || sub === "back") break;
3047
+
3048
+ if (sub === "status") {
3049
+ ctx.ui.setStatus("lemonade-setup", "Probing instances…");
3050
+ const instanceLines: string[] = [];
3051
+ for (const v of listInstances(config)) {
3052
+ const h = await fetchHealth(v.config);
3053
+ const drift =
3054
+ h?.version && h.version !== EXTENSION_CONFIRMED_SERVER_VERSION
3055
+ ? ` — VERSION DRIFT (extension verified against v${EXTENSION_CONFIRMED_SERVER_VERSION})`
3056
+ : "";
3057
+ instanceLines.push(
3058
+ ` ${v.name}${v.isDefault ? " (default)" : ""} — ${h?.version ? `server v${h.version}` : "unreachable"}${drift}`
3059
+ );
3060
+ }
3061
+ ctx.ui.setStatus("lemonade-setup", undefined);
3062
+ await textView(
3063
+ ctx,
3064
+ "Spec & Version Check — Status",
3065
+ [
3066
+ "Docs (vs upstream lemonade-sdk/lemonade docs/api, by git blob SHA):",
3067
+ ` ${docsState}`,
3068
+ "",
3069
+ "Live server versions (vs the build this extension was verified against):",
3070
+ ...instanceLines,
3071
+ ` verified build: v${EXTENSION_CONFIRMED_SERVER_VERSION} (recorded in the extension code — not a setting)`,
3072
+ "",
3073
+ `Latest published upstream release (probed via GitHub, ${config.upstreamVersionCheck ? "notice on" : "notice off"}):`,
3074
+ ` latest: v${upstreamSnap?.latest ?? "(unknown — not probed or unreachable)"}`,
3075
+ ...[...serverVersionsSeen].map(([name, v]) => {
3076
+ const latest = upstreamSnap?.latest;
3077
+ const cmp = latest ? versionCompare(v, latest) : null;
3078
+ const rel =
3079
+ cmp === null
3080
+ ? ""
3081
+ : cmp < 0
3082
+ ? " — behind (a server update is available)"
3083
+ : cmp > 0
3084
+ ? " — ahead of the latest stable release"
3085
+ : " — current";
3086
+ return ` ${name}: v${v}${rel}`;
3087
+ }),
3088
+ "",
3089
+ "Docs drift is TUI-only status on purpose: as long as the extension and the",
3090
+ "server agree on version, the docs snapshot matching upstream HEAD changes",
3091
+ "nothing at runtime. Re-sync re-downloads the current upstream docs into",
3092
+ "the extension's docs/ folder; it does NOT update the extension code. The",
3093
+ "startup warnings are reserved for extension ↔ server sync (warning) and",
3094
+ "server-vs-latest-release (one-line info notice, deduplicated: it fires",
3095
+ "once per new release per instance — this view always shows the current",
3096
+ "state, even when the notice has already fired).",
3097
+ ],
3098
+ true
3099
+ );
3100
+ } else if (sub === "recheck") {
3101
+ ctx.ui.setStatus("lemonade-setup", "Checking upstream docs…");
3102
+ const result = await performDocsLiveCheck(config.specCheckTimeoutMs);
3103
+ ctx.ui.setStatus("lemonade-setup", undefined);
3104
+ if (result.verdict === "unknown") {
3105
+ ctx.ui.notify(`Docs check failed: ${result.error}`, "error");
3106
+ continue;
3107
+ }
3108
+ saveDocsSnapshot({ checkedAt: Date.now(), changed: result.changed });
3109
+ ctx.ui.notify(
3110
+ result.verdict === "current"
3111
+ ? "Docs snapshot is up to date with upstream."
3112
+ : `Docs differ from upstream (${result.changed.join(", ")}) — the extension may be out of date. Update the extension if one is available, or re-sync the docs for review.`,
3113
+ result.verdict === "current" ? "info" : "warning"
3114
+ );
3115
+ } else if (sub === "resync") {
3116
+ const ok = await ctx.ui.confirm(
3117
+ "Re-sync docs/ from upstream?",
3118
+ "Downloads the current docs/api markdown into docs/. This refreshes the ground truth ONLY — the extension code is unchanged and may still need an update."
3119
+ );
3120
+ if (!ok) continue;
3121
+ ctx.ui.setStatus("lemonade-setup", "Downloading upstream docs…");
3122
+ const result = await resyncLemonadeDocs(config.specCheckTimeoutMs);
3123
+ ctx.ui.setStatus("lemonade-setup", undefined);
3124
+ ctx.ui.notify(
3125
+ result.ok
3126
+ ? "Docs re-synced from upstream. The extension code itself has NOT changed — re-verify it against the new docs or update the extension when available."
3127
+ : `Re-sync failed: ${result.error}`,
3128
+ result.ok ? "info" : "error"
3129
+ );
3130
+ } else if (sub === "toggledocs") {
3131
+ config.specCheck = !config.specCheck;
3132
+ saveConfig(config);
3133
+ ctx.ui.notify(`Docs spec check ${config.specCheck ? "enabled" : "disabled"}.`, "info");
3134
+ } else if (sub === "toggleupstream") {
3135
+ config.upstreamVersionCheck = !config.upstreamVersionCheck;
3136
+ saveConfig(config);
3137
+ ctx.ui.notify(
3138
+ `Upstream-release notice ${config.upstreamVersionCheck ? "enabled" : "disabled"}.`,
3139
+ "info"
3140
+ );
3141
+ } else if (sub === "toggleserver") {
3142
+ config.serverVersionCheck = !config.serverVersionCheck;
3143
+ saveConfig(config);
3144
+ ctx.ui.notify(`Server-version check ${config.serverVersionCheck ? "enabled" : "disabled"}.`, "info");
3145
+ const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
3146
+ registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
3147
+ );
3148
+ void msg;
3149
+ }
3150
+ }
3151
+ continue;
3152
+ }
3153
+
2591
3154
  // ── Switch instance ─────────────────────────────────────────────
2592
3155
  if (choice === "instance") {
2593
3156
  const picked = await menu(
@@ -3228,6 +3791,7 @@ export default async function piLemonadeLink(pi: ExtensionAPI): Promise<void> {
3228
3791
  await registerAgentTools(pi, configRef.current);
3229
3792
  registerLemonadeBar(pi, configRef);
3230
3793
  registerSetupCommand(pi, configRef);
3794
+ registerSpecDriftWarnings(pi, configRef);
3231
3795
 
3232
3796
  // Fresh install (or a hand-emptied config): nothing is registered yet —
3233
3797
  // point the user at the wizard instead of leaving them wondering why
@@ -81,5 +81,29 @@
81
81
  // How often (ms) the bar polls the active instance for busy/queue +
82
82
  // CPU/GPU/NPU/VRAM. 0 = local metrics only (instance name, tok/s,
83
83
  // cache-hit %).
84
- "barPollMs": 5000
84
+ "barPollMs": 5000,
85
+
86
+ // Docs-freshness check: compare the docs/ snapshot against upstream
87
+ // lemonade-sdk/lemonade by git blob SHA. TUI-only status (shown in
88
+ // /lemonade-setup → Spec & version check, with re-check/re-sync) — NO
89
+ // startup warning: extension ↔ server version agreement is what matters
90
+ // at runtime, and that has its own checks below.
91
+ "specCheck": true,
92
+ "specCheckCooldownMs": 86400000,
93
+ "specCheckTimeoutMs": 5000,
94
+
95
+ // Tier 1 (startup warning): /v1/health self-reports the server version.
96
+ // If an instance advertises a version other than the build this extension
97
+ // was verified against (a constant in the extension's code — see the
98
+ // README "Keeping the specs honest" section), a directional warning fires
99
+ // on startup: server newer → check for an extension update; server older →
100
+ // update the server.
101
+ "serverVersionCheck": true,
102
+
103
+ // Tier 2 (info notice, one sentence): servers older than the latest
104
+ // published lemonade release (probed via GitHub, throttled) get an
105
+ // "a server update is available" notice — deduplicated to once per
106
+ // (instance, release); the /lemonade-setup TUI always shows the current
107
+ // state. Separately toggleable.
108
+ "upstreamVersionCheck": true
85
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lemonade-link",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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",
@@ -20,5 +20,10 @@
20
20
  },
21
21
  "pi": {
22
22
  "extensions": ["./index.ts"]
23
+ },
24
+ "provider": {
25
+ "name": "lemonade",
26
+ "apiVerifiedAgainst": "11.7.0",
27
+ "specSnapshot": "docs/ from lemonade-sdk/lemonade docs/api, snapshotted 2026-09-12"
23
28
  }
24
29
  }