pi-lemonade-link 1.0.0 → 1.0.1

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. Default on; separately toggleable. |
406
415
 
407
416
  ## Offline & unreachable networks
408
417
 
@@ -533,6 +542,67 @@ 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. `upstreamVersionCheck: false` disables.
565
+ 3. **Docs drift — TUI-only status, deliberately no startup warning.** The
566
+ `docs/` snapshot is the reference for the version the extension targets;
567
+ as long as extension and server agree on version, whether upstream HEAD
568
+ has moved on changes **nothing at runtime** — so it's shown only in
569
+ `/lemonade-setup` → Spec & version check (per-file status, re-check now,
570
+ one-keystroke re-sync that re-downloads the current upstream docs into
571
+ the extension's `docs/` folder). `specCheck: false` disables even the
572
+ background refresh.
573
+
574
+ All tiers share the family mechanics: fire-and-forget, never delays
575
+ startup, durable verdicts (at most one network pass per 24 h across
576
+ restarts), silent on network failure (unknown ≠ out of date). The Spec &
577
+ version check view manages every toggle interactively and shows live
578
+ per-instance versions beside the verified build and the latest upstream
579
+ release.
580
+
581
+ ## Extension version & provider API
582
+
583
+ This extension's version (`version` in `package.json`) and the lemonade
584
+ server build it was verified against (`11.7.0`) are recorded together in
585
+ the `provider` block of `package.json` and in the
586
+ `EXTENSION_CONFIRMED_SERVER_VERSION` constant in `index.ts` (which drives
587
+ the startup version-drift warning). The server status panel and the
588
+ Spec & version check view both show the extension version, the live server
589
+ version(s), and the verified build.
590
+
591
+ **On update, keep these in lockstep** (all record the same fact):
592
+
593
+ 1. `version` in `package.json` — the extension release
594
+ 2. the `provider` block in `package.json` (`apiVerifiedAgainst`,
595
+ `specSnapshot`) — what that release was verified against
596
+ 3. `EXTENSION_CONFIRMED_SERVER_VERSION` in `index.ts` — the peg the
597
+ version-drift warning compares your servers against
598
+ 4. re-sync `docs/` in the extension folder from upstream (the README
599
+ re-sync command, or `/lemonade-setup` → Spec & version check)
600
+ 5. the behavior-reference dates below
601
+
602
+ A release that skips any of these is lying somewhere — the spec-freshness
603
+ and server-version checks (see *Keeping the specs honest*) are what catch
604
+ #3 and #4 drifting silently.
605
+
536
606
  ## Lemonade behavior reference
537
607
 
538
608
  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,22 @@ 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. Separately toggleable; default on.
109
+ upstreamVersionCheck: boolean;
92
110
  discoveryTimeoutMs: number;
93
111
  beaconTimeoutMs: number;
94
112
  loadTimeoutMs: number;
@@ -145,6 +163,11 @@ const DEFAULT_CONFIG: LemonadeConfig = {
145
163
  statusPollMs: 2000,
146
164
  statusBar: true,
147
165
  barPollMs: 5000,
166
+ specCheck: true,
167
+ specCheckCooldownMs: 24 * 60 * 60 * 1000,
168
+ specCheckTimeoutMs: 5000,
169
+ serverVersionCheck: true,
170
+ upstreamVersionCheck: true,
148
171
  discoveryTimeoutMs: 5000,
149
172
  beaconTimeoutMs: 3000,
150
173
  loadTimeoutMs: 300_000,
@@ -656,6 +679,362 @@ async function discoverViaHttp(config: LemonadeConfig): Promise<DiscoveredServer
656
679
  return [...seen.values()];
657
680
  }
658
681
 
682
+ // ─── Spec freshness & server-version drift ─────────────────────────────────
683
+
684
+ /** The two drift signals this extension watches, so "ground truth snapshotted"
685
+ * stays honest:
686
+ *
687
+ * 1. Docs drift — the docs/ folder is a snapshot of lemonade-sdk/lemonade's
688
+ * docs/api (see the README re-sync command). Each file's git blob SHA is
689
+ * computed locally at runtime (nothing stored, so re-syncing docs/
690
+ * re-arms the comparison) and matched against the same file upstream via
691
+ * GitHub's contents API (a tiny JSON response; the markdown is never
692
+ * downloaded). Drift → TUI-only status (the Spec & version check
693
+ * view); deliberately NO startup warning — extension ↔ server version
694
+ * agreement is what matters at runtime.
695
+ *
696
+ * 2. Live-server drift — an on-prem box publishes no spec to diff, but it
697
+ * self-reports its version in /v1/health. A version this extension's
698
+ * behavior reference was never confirmed against is the honest signal
699
+ * that endpoints may have changed; each such instance is warned on
700
+ * session_start (mismatches are (re)collected on every registration and
701
+ * refresh, so fixing the server or the config clears the warning).
702
+ *
703
+ * Both checks are fire-and-forget, never delay startup, and are silent on
704
+ * network failure — a hygiene check must never cry wolf on offline
705
+ * startups: unknown ≠ out of date. */
706
+
707
+ const DOCS_FILES = ["README.md", "anthropic.md", "lemonade.md", "llamacpp.md", "mcp.md", "ollama.md", "openai.md"];
708
+ const DOCS_UPSTREAM_BASE = "https://github.com/lemonade-sdk/lemonade/blob/main/docs/api/";
709
+ const DOCS_GH_CONTENTS = (file: string) =>
710
+ `https://api.github.com/repos/lemonade-sdk/lemonade/contents/docs/api/${file}`;
711
+ const DOCS_RAW = (file: string) => `https://raw.githubusercontent.com/lemonade-sdk/lemonade/main/docs/api/${file}`;
712
+
713
+ /** Latest published (non-prerelease) lemonade release — the low-tier "a
714
+ * server update is available" notice compares each server's live version
715
+ * against this. Throttled by the same durable-verdict discipline. */
716
+ const GH_LATEST_RELEASE = "https://api.github.com/repos/lemonade-sdk/lemonade/releases/latest";
717
+
718
+ interface UpstreamVersionSnapshot {
719
+ checkedAt: number;
720
+ latest: string;
721
+ }
722
+
723
+ function upstreamSnapshotPath(): string {
724
+ return path.join(getAgentDir(), "cache", "pi-lemonade-link", "upstream-version.json");
725
+ }
726
+
727
+ function loadUpstreamSnapshot(): UpstreamVersionSnapshot | undefined {
728
+ try {
729
+ const raw = JSON.parse(fs.readFileSync(upstreamSnapshotPath(), "utf8")) as UpstreamVersionSnapshot;
730
+ return typeof raw.checkedAt === "number" && typeof raw.latest === "string" ? raw : undefined;
731
+ } catch {
732
+ return undefined;
733
+ }
734
+ }
735
+
736
+ function saveUpstreamSnapshot(snap: UpstreamVersionSnapshot): void {
737
+ try {
738
+ fs.mkdirSync(path.dirname(upstreamSnapshotPath()), { recursive: true });
739
+ fs.writeFileSync(upstreamSnapshotPath(), JSON.stringify(snap, null, 2) + "\n");
740
+ } catch {
741
+ /* cache dir unwritable → re-probe next time */
742
+ }
743
+ }
744
+
745
+ /** The latest published lemonade version, or undefined when unreachable
746
+ * (unknown ≠ outdated — never cry wolf). */
747
+ async function latestUpstreamVersion(config: LemonadeConfig): Promise<string | undefined> {
748
+ const fresh = loadUpstreamSnapshot();
749
+ if (!fresh || Date.now() - fresh.checkedAt >= Math.max(0, config.specCheckCooldownMs)) {
750
+ try {
751
+ const res = await fetch(GH_LATEST_RELEASE, {
752
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-lemonade-link" },
753
+ signal: AbortSignal.timeout(config.specCheckTimeoutMs),
754
+ });
755
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
756
+ const tag = ((await res.json()) as { tag_name?: string }).tag_name;
757
+ if (typeof tag !== "string" || !tag) throw new Error("release carried no tag_name");
758
+ saveUpstreamSnapshot({ checkedAt: Date.now(), latest: tag.replace(/^v/, "") });
759
+ } catch (error) {
760
+ console.error(
761
+ `[pi-lemonade-link] upstream-version probe skipped: ${error instanceof Error ? error.message : error}`
762
+ );
763
+ // A failed probe must still engage the cooldown ("at most one network
764
+ // pass per cooldown") — otherwise offline days re-probe at every
765
+ // session start. Keep the last known latest; a failed probe proves
766
+ // nothing new (unknown ≠ outdated).
767
+ saveUpstreamSnapshot({ checkedAt: Date.now(), latest: fresh?.latest ?? "" });
768
+ }
769
+ }
770
+ return loadUpstreamSnapshot()?.latest;
771
+ }
772
+
773
+ /** Git blob SHA of a file's bytes: sha1("blob <size>\0" + content). Pure. */
774
+ function gitBlobSha(data: Buffer): string {
775
+ return createHash("sha1").update(`blob ${data.length}\0`).update(data).digest("hex");
776
+ }
777
+
778
+ /** Absolute path of a snapshotted doc, inside this extension's folder. */
779
+ function extensionDocsPath(file: string): string {
780
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "docs", file);
781
+ }
782
+
783
+ interface DocsSpecSnapshot {
784
+ checkedAt: number; // epoch ms of the last real check
785
+ changed: string[]; // docs/ files whose upstream blob SHA differs (sorted)
786
+ }
787
+
788
+ function docsSnapshotPath(): string {
789
+ return path.join(getAgentDir(), "cache", "pi-lemonade-link", "spec-check.json");
790
+ }
791
+
792
+ function loadDocsSnapshot(): DocsSpecSnapshot | undefined {
793
+ try {
794
+ const raw = JSON.parse(fs.readFileSync(docsSnapshotPath(), "utf8")) as DocsSpecSnapshot;
795
+ return typeof raw.checkedAt === "number" && Array.isArray(raw.changed) ? raw : undefined;
796
+ } catch {
797
+ return undefined;
798
+ }
799
+ }
800
+
801
+ function saveDocsSnapshot(snap: DocsSpecSnapshot): void {
802
+ try {
803
+ fs.mkdirSync(path.dirname(docsSnapshotPath()), { recursive: true });
804
+ fs.writeFileSync(docsSnapshotPath(), JSON.stringify(snap, null, 2) + "\n");
805
+ } catch {
806
+ /* cache dir unwritable — the check still runs, just re-checks next time */
807
+ }
808
+ }
809
+
810
+ /** The lemonade server version THIS BUILD of the extension was verified
811
+ * against (the README behavior reference). A constant in the code, not
812
+ * config: it is a property of the build — updated with each extension
813
+ * release after re-verification — never a user preference to move. The
814
+ * server's own version is probed live from /v1/health; this is the peg. */
815
+ const EXTENSION_CONFIRMED_SERVER_VERSION = "11.7.0";
816
+
817
+ /** This extension's own version, from package.json (kept beside the
818
+ * provider.apiVerifiedAgainst record — see the README versioning
819
+ * section; both bump together on release). */
820
+ const PKG_VERSION = (() => {
821
+ try {
822
+ return (
823
+ (JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8")) as {
824
+ version?: string;
825
+ }).version ?? "0.0.0"
826
+ );
827
+ } catch {
828
+ return "0.0.0";
829
+ }
830
+ })();
831
+
832
+ /** Compare dotted numeric versions: >0 if a is newer than b, <0 if older,
833
+ * null when either side isn't dotted-numeric (no direction, just "differs"). Pure. */
834
+ function versionCompare(a: string, b: string): number | null {
835
+ const pa = a.split(".").map(Number);
836
+ const pb = b.split(".").map(Number);
837
+ if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
838
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
839
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
840
+ if (d) return d;
841
+ }
842
+ return 0;
843
+ }
844
+
845
+ /** Server-version drift collected during provider registration/refresh
846
+ * (registerInstanceProvider records; registerLemonadeProvider resets).
847
+ * direction: "ahead" = server newer than the extension was verified for
848
+ * (fix: extension update); "behind" = older (fix: update the server). */
849
+ interface ServerVersionMismatch {
850
+ instance: string;
851
+ server: string;
852
+ confirmed: string;
853
+ direction: "ahead" | "behind" | "unknown";
854
+ }
855
+
856
+ const serverVersionMismatches: ServerVersionMismatch[] = [];
857
+
858
+ /** Live server versions seen at the last registration/refresh (instance →
859
+ * version), reused by the low-tier upstream notice without extra probes. */
860
+ const serverVersionsSeen = new Map<string, string>();
861
+
862
+ function recordServerVersion(config: LemonadeConfig, instance: string, version: string | undefined): void {
863
+ const i = serverVersionMismatches.findIndex((m) => m.instance === instance);
864
+ const cmp = version ? versionCompare(version, EXTENSION_CONFIRMED_SERVER_VERSION) : null;
865
+ const mismatched = config.serverVersionCheck && !!version && version !== EXTENSION_CONFIRMED_SERVER_VERSION;
866
+ if (version) serverVersionsSeen.set(instance, version);
867
+ else serverVersionsSeen.delete(instance);
868
+ if (mismatched && version) {
869
+ const entry: ServerVersionMismatch = {
870
+ instance,
871
+ server: version,
872
+ confirmed: EXTENSION_CONFIRMED_SERVER_VERSION,
873
+ direction: cmp !== null ? (cmp > 0 ? "ahead" : "behind") : "unknown",
874
+ };
875
+ if (i >= 0) serverVersionMismatches[i] = entry;
876
+ else serverVersionMismatches.push(entry);
877
+ } else if (i >= 0) {
878
+ serverVersionMismatches.splice(i, 1); // back on the verified version — clear the warning
879
+ }
880
+ }
881
+
882
+ /** One live docs comparison: per-file local blob SHA vs upstream blob SHA. Network. */
883
+ async function performDocsLiveCheck(
884
+ timeoutMs: number
885
+ ): Promise<{ verdict: "current" | "changed"; changed: string[] } | { verdict: "unknown"; error: string }> {
886
+ const changed: string[] = [];
887
+ let reached = false;
888
+ let lastError = "GitHub unreachable";
889
+ await Promise.all(
890
+ DOCS_FILES.map(async (file) => {
891
+ try {
892
+ const localSha = gitBlobSha(fs.readFileSync(extensionDocsPath(file)));
893
+ const res = await fetch(DOCS_GH_CONTENTS(file), {
894
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-lemonade-link" },
895
+ signal: AbortSignal.timeout(timeoutMs),
896
+ });
897
+ if (!res.ok) {
898
+ lastError = `GitHub API HTTP ${res.status} (${file})`;
899
+ return;
900
+ }
901
+ reached = true;
902
+ const sha = ((await res.json()) as { sha?: string }).sha;
903
+ if (typeof sha === "string" && sha && sha !== localSha) changed.push(file);
904
+ } catch (error) {
905
+ lastError = error instanceof Error ? error.message : String(error);
906
+ /* one file unreachable → not drift; the reached-gate below stays */
907
+ }
908
+ })
909
+ );
910
+ if (!reached) return { verdict: "unknown", error: lastError };
911
+ return changed.length ? { verdict: "changed", changed } : { verdict: "current", changed: [] };
912
+ }
913
+
914
+ /** Re-sync the docs/ snapshot from upstream: download the current markdown
915
+ * into docs/ and record a "current" verdict (true by construction). This
916
+ * refreshes the GROUND TRUTH ONLY — the extension code itself is unchanged
917
+ * and may still need an update; the caller's messaging must say so. */
918
+ async function resyncLemonadeDocs(timeoutMs: number): Promise<{ ok: boolean; error?: string }> {
919
+ try {
920
+ await Promise.all(
921
+ DOCS_FILES.map(async (file) => {
922
+ const res = await fetch(DOCS_RAW(file), { signal: AbortSignal.timeout(timeoutMs) });
923
+ if (!res.ok) throw new Error(`HTTP ${res.status} (${file})`);
924
+ const body = Buffer.from(await res.arrayBuffer());
925
+ fs.mkdirSync(path.dirname(extensionDocsPath(file)), { recursive: true });
926
+ fs.writeFileSync(extensionDocsPath(file), body);
927
+ })
928
+ );
929
+ saveDocsSnapshot({ checkedAt: Date.now(), changed: [] });
930
+ return { ok: true };
931
+ } catch (error) {
932
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
933
+ }
934
+ }
935
+
936
+ /** One throttled docs-freshness pass: a fresh durable verdict serves without
937
+ * a network call; a stale/absent one triggers the bounded GitHub probe.
938
+ * Drift → the caller-supplied channel. The startup caller passes a no-op
939
+ * (docs drift is TUI-only status — see registerSpecDriftWarnings); the
940
+ * throttled check still runs so the TUI's Spec & version check view has a
941
+ * fresh verdict. */
942
+ async function runDocsSpecCheck(config: LemonadeConfig, notify: (message: string) => void): Promise<void> {
943
+ if (!config.specCheck) return;
944
+ let snapshot = loadDocsSnapshot();
945
+ if (!snapshot || Date.now() - snapshot.checkedAt >= Math.max(0, config.specCheckCooldownMs)) {
946
+ const result = await performDocsLiveCheck(config.specCheckTimeoutMs);
947
+ if (result.verdict === "unknown") {
948
+ // GitHub unreachable: unknown ≠ out of date — never cry wolf. But the
949
+ // failed pass still engages the cooldown ("at most one network pass
950
+ // per cooldown") — otherwise offline days re-probe at every session
951
+ // start — and the last known verdict stands until it expires.
952
+ console.error(`[pi-lemonade-link] spec check skipped: ${result.error}`);
953
+ saveDocsSnapshot({ checkedAt: Date.now(), changed: snapshot?.changed ?? [] });
954
+ return;
955
+ }
956
+ snapshot = result.verdict === "changed" ? { checkedAt: Date.now(), changed: result.changed } : { checkedAt: Date.now(), changed: [] };
957
+ saveDocsSnapshot(snapshot);
958
+ }
959
+ if (snapshot.changed.length) {
960
+ notify(
961
+ `pi-lemonade-link: ${snapshot.changed.length} Lemonade API doc${snapshot.changed.length > 1 ? "s" : ""} this extension keeps in its docs/ folder changed upstream ` +
962
+ `(${snapshot.changed.join(", ")}) — the extension may be out of date. ` +
963
+ `Fix: check for an extension update, or open /lemonade-setup → Spec & version check to re-download the current docs into the extension's ` +
964
+ `docs/ folder and compare what changed. Runs once a day; specCheck: false disables.`
965
+ );
966
+ }
967
+ }
968
+
969
+ /** Session-start drift signals, in tiers — ordered by what actually matters
970
+ * at runtime. Registered once from the extension entry.
971
+ *
972
+ * Tier 1 (warning): extension ↔ server sync — the signal that matters; a
973
+ * version mismatch means endpoints may have changed for this pairing.
974
+ * Directional (see ServerVersionMismatch).
975
+ *
976
+ * Tier 2 (info, one sentence, separately toggleable): server ↔ latest
977
+ * published upstream release — "a server update is available". Much lower
978
+ * tier: an older server is not broken, it's just behind.
979
+ *
980
+ * Docs drift: TUI-only status — as long as extension and server agree on
981
+ * version, the docs snapshot matching upstream HEAD changes nothing at
982
+ * runtime. The throttled check still runs so the Spec & version check view
983
+ * has a fresh verdict; it just never warns. */
984
+ function registerSpecDriftWarnings(pi: ExtensionAPI, configRef: { current: LemonadeConfig }): void {
985
+ pi.on("session_start", async (_event, ctx) => {
986
+ if (!ctx.hasUI) return;
987
+ const config = configRef.current;
988
+
989
+ // Tier 1 — extension ↔ server sync.
990
+ for (const m of serverVersionMismatches) {
991
+ const relation =
992
+ m.direction === "ahead" ? "newer than" : m.direction === "behind" ? "older than" : "different from";
993
+ const fix =
994
+ m.direction === "ahead"
995
+ ? "check for an extension update — until then, endpoints on that box may differ"
996
+ : m.direction === "behind"
997
+ ? `update the lemonade server to v${m.confirmed} or newer, or expect some endpoints to be missing on that box`
998
+ : "update the server or the extension so both are on the verified pairing";
999
+ ctx.ui.notify(
1000
+ `pi-lemonade-link: instance "${m.instance}" runs server v${m.server} (${relation} the v${m.confirmed} this extension was verified against) — ${fix}. ` +
1001
+ `Details: /lemonade-setup → Spec & version check. serverVersionCheck: false disables.`,
1002
+ "warning"
1003
+ );
1004
+ }
1005
+
1006
+ // Tier 2 — server vs the latest published upstream release.
1007
+ // Fire-and-forget by design: pi's runner awaits session_start handlers
1008
+ // (and every UI mode awaits that emit), so awaiting the probe here would
1009
+ // stall startup/reload by up to ~specCheckTimeoutMs whenever the durable
1010
+ // verdict is stale. The notice simply arrives when the probe lands.
1011
+ if (config.upstreamVersionCheck) {
1012
+ void latestUpstreamVersion(config)
1013
+ .then((latest) => {
1014
+ if (!latest) return;
1015
+ for (const [instance, serverVersion] of serverVersionsSeen) {
1016
+ const cmp = versionCompare(serverVersion, latest);
1017
+ if (cmp !== null && cmp < 0) {
1018
+ ctx.ui.notify(
1019
+ `pi-lemonade-link: instance "${instance}" runs lemonade v${serverVersion}; the latest published release is v${latest} — a server update is available.`,
1020
+ "info"
1021
+ );
1022
+ }
1023
+ }
1024
+ })
1025
+ .catch((error) =>
1026
+ console.error(`[pi-lemonade-link] upstream notice skipped: ${error instanceof Error ? error.message : error}`)
1027
+ );
1028
+ }
1029
+
1030
+ // Docs drift — TUI-only: keep the verdict fresh, never warn. Same
1031
+ // fire-and-forget discipline as Tier 2.
1032
+ if (config.specCheck) {
1033
+ void runDocsSpecCheck(config, () => {});
1034
+ }
1035
+ });
1036
+ }
1037
+
659
1038
  // ─── Provider registration ──────────────────────────────────────────────────
660
1039
 
661
1040
  function isChatModel(m: LemonadeModel): boolean {
@@ -701,6 +1080,7 @@ async function registerInstanceProvider(pi: ExtensionAPI, view: InstanceView): P
701
1080
  ]);
702
1081
  catalog = catalogRes;
703
1082
  loaded = new Set((health?.all_models_loaded ?? []).map((m) => m.model_name));
1083
+ recordServerVersion(config, view.name, health?.version);
704
1084
  } catch {
705
1085
  // Unreachable => contribute nothing to /model. No placeholders: an
706
1086
  // unreachable model is not listable. Sessions pinned to a lemonade model
@@ -743,6 +1123,8 @@ const LEGACY_PROVIDER_IDS = ["lemonade", "lemonade-undefined"];
743
1123
  /** Register (or re-register) every configured instance. Each becomes its own
744
1124
  * pi provider under the uniform lemonade-<name> id. */
745
1125
  async function registerLemonadeProvider(pi: ExtensionAPI, config: LemonadeConfig): Promise<number> {
1126
+ serverVersionMismatches.length = 0; // (re)collected per registration pass
1127
+ serverVersionsSeen.clear();
746
1128
  for (const staleId of LEGACY_PROVIDER_IDS) {
747
1129
  try {
748
1130
  pi.unregisterProvider(staleId);
@@ -2146,6 +2528,12 @@ function formatStatus(
2146
2528
  `Status: ${health.status ?? "?"} Lemonade v${health.version ?? "?"} Telemetry: ${telemetry}` +
2147
2529
  (updated ? ` · updated ${updated.toTimeString().slice(0, 8)}` : ""),
2148
2530
  `Endpoint: ${config.baseUrl} WebSocket port: ${health.websocket_port ?? "?"}`,
2531
+ `This extension: v${PKG_VERSION}, verified against lemonade v${EXTENSION_CONFIRMED_SERVER_VERSION} · docs snapshot: ${
2532
+ (() => {
2533
+ const s = loadDocsSnapshot();
2534
+ return !s ? "not checked yet" : s.changed.length ? `${s.changed.length} file(s) changed upstream` : "up to date";
2535
+ })()
2536
+ }`,
2149
2537
  ""
2150
2538
  );
2151
2539
 
@@ -2583,11 +2971,155 @@ function registerSetupCommand(pi: ExtensionAPI, configRef: { current: LemonadeCo
2583
2971
  { value: "models", label: "Model management", description: `List, load, unload, pull (progress+cancel), delete, change-ctx, HF install, filter, refresh — active instance: ${activeInstance}` },
2584
2972
  { value: "transcription", label: "Transcription settings", description: "Whisper model, paths, smoke test" },
2585
2973
  { value: "logs", label: "Live server logs", description: "Stream the server's log over websocket" },
2974
+ { value: "spec", label: "Spec & version check", description: "Docs drift + live server-version drift — status, re-check, re-sync, toggles" },
2586
2975
  { value: "instance", label: "Switch instance", description: `Current: ${activeInstance} — pick which box status/models/logs act on` },
2587
2976
  { value: "exit", label: "Exit", description: "Close the setup menu" },
2588
2977
  ]);
2589
2978
  if (choice === null || choice === "exit") break;
2590
2979
 
2980
+ // ── Spec & version check ───────────────────────────────────────
2981
+ if (choice === "spec") {
2982
+ for (;;) {
2983
+ const config = configRef.current;
2984
+ const docsSnap = loadDocsSnapshot();
2985
+ const docsState = !config.specCheck
2986
+ ? "disabled (specCheck: false)"
2987
+ : docsSnap
2988
+ ? docsSnap.changed.length
2989
+ ? `CHANGED — ${docsSnap.changed.join(", ")}`
2990
+ : "up to date"
2991
+ : "no verdict yet this cooldown window";
2992
+ const serversState = !config.serverVersionCheck
2993
+ ? "sync check disabled (serverVersionCheck: false)"
2994
+ : serverVersionMismatches.length
2995
+ ? `${serverVersionMismatches.length} instance(s) at an unverified server version`
2996
+ : `all instances on v${EXTENSION_CONFIRMED_SERVER_VERSION} (the build this extension was verified against)`;
2997
+ const upstreamSnap = loadUpstreamSnapshot();
2998
+ const upstreamState = !config.upstreamVersionCheck
2999
+ ? "notice disabled (upstreamVersionCheck: false)"
3000
+ : upstreamSnap
3001
+ ? [...serverVersionsSeen.values()].some((v) => {
3002
+ const c = upstreamSnap.latest ? versionCompare(v, upstreamSnap.latest) : null;
3003
+ return c !== null && c < 0;
3004
+ })
3005
+ ? "update available — at least one server is behind the latest published release"
3006
+ : `all servers current with the latest published release (v${upstreamSnap.latest})`
3007
+ : "not probed yet";
3008
+ const sub = await menu(ctx, "Spec & Version Check", [
3009
+ { value: "status", label: "Status", description: `Docs: ${docsState} · Servers: ${serversState} · Upstream: ${upstreamState}` },
3010
+ { value: "recheck", label: "Re-check docs now", description: "Compare the docs/ snapshot with upstream lemonade-sdk/lemonade by git blob SHA" },
3011
+ { 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
+ { value: "toggledocs", label: `Toggle docs check: ${config.specCheck ? "on → off" : "off → on"}`, description: `specCheck (currently ${config.specCheck ? "on" : "off"})` },
3013
+ { 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` },
3015
+ { value: "back", label: "Back", description: "" },
3016
+ ]);
3017
+ if (sub === null || sub === "back") break;
3018
+
3019
+ if (sub === "status") {
3020
+ ctx.ui.setStatus("lemonade-setup", "Probing instances…");
3021
+ const instanceLines: string[] = [];
3022
+ for (const v of listInstances(config)) {
3023
+ const h = await fetchHealth(v.config);
3024
+ const drift =
3025
+ h?.version && h.version !== EXTENSION_CONFIRMED_SERVER_VERSION
3026
+ ? ` — VERSION DRIFT (extension verified against v${EXTENSION_CONFIRMED_SERVER_VERSION})`
3027
+ : "";
3028
+ instanceLines.push(
3029
+ ` ${v.name}${v.isDefault ? " (default)" : ""} — ${h?.version ? `server v${h.version}` : "unreachable"}${drift}`
3030
+ );
3031
+ }
3032
+ ctx.ui.setStatus("lemonade-setup", undefined);
3033
+ await textView(
3034
+ ctx,
3035
+ "Spec & Version Check — Status",
3036
+ [
3037
+ "Docs (vs upstream lemonade-sdk/lemonade docs/api, by git blob SHA):",
3038
+ ` ${docsState}`,
3039
+ "",
3040
+ "Live server versions (vs the build this extension was verified against):",
3041
+ ...instanceLines,
3042
+ ` verified build: v${EXTENSION_CONFIRMED_SERVER_VERSION} (recorded in the extension code — not a setting)`,
3043
+ "",
3044
+ `Latest published upstream release (probed via GitHub, ${config.upstreamVersionCheck ? "notice on" : "notice off"}):`,
3045
+ ` latest: v${upstreamSnap?.latest ?? "(unknown — not probed or unreachable)"}`,
3046
+ ...[...serverVersionsSeen].map(([name, v]) => {
3047
+ const latest = upstreamSnap?.latest;
3048
+ const cmp = latest ? versionCompare(v, latest) : null;
3049
+ const rel =
3050
+ cmp === null
3051
+ ? ""
3052
+ : cmp < 0
3053
+ ? " — behind (a server update is available)"
3054
+ : cmp > 0
3055
+ ? " — ahead of the latest stable release"
3056
+ : " — current";
3057
+ return ` ${name}: v${v}${rel}`;
3058
+ }),
3059
+ "",
3060
+ "Docs drift is TUI-only status on purpose: as long as the extension and the",
3061
+ "server agree on version, the docs snapshot matching upstream HEAD changes",
3062
+ "nothing at runtime. Re-sync re-downloads the current upstream docs into",
3063
+ "the extension's docs/ folder; it does NOT update the extension code. The",
3064
+ "startup warnings are reserved for extension ↔ server sync (warning) and",
3065
+ "server-vs-latest-release (one-line info notice).",
3066
+ ],
3067
+ true
3068
+ );
3069
+ } else if (sub === "recheck") {
3070
+ ctx.ui.setStatus("lemonade-setup", "Checking upstream docs…");
3071
+ const result = await performDocsLiveCheck(config.specCheckTimeoutMs);
3072
+ ctx.ui.setStatus("lemonade-setup", undefined);
3073
+ if (result.verdict === "unknown") {
3074
+ ctx.ui.notify(`Docs check failed: ${result.error}`, "error");
3075
+ continue;
3076
+ }
3077
+ saveDocsSnapshot({ checkedAt: Date.now(), changed: result.changed });
3078
+ ctx.ui.notify(
3079
+ result.verdict === "current"
3080
+ ? "Docs snapshot is up to date with upstream."
3081
+ : `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.`,
3082
+ result.verdict === "current" ? "info" : "warning"
3083
+ );
3084
+ } else if (sub === "resync") {
3085
+ const ok = await ctx.ui.confirm(
3086
+ "Re-sync docs/ from upstream?",
3087
+ "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."
3088
+ );
3089
+ if (!ok) continue;
3090
+ ctx.ui.setStatus("lemonade-setup", "Downloading upstream docs…");
3091
+ const result = await resyncLemonadeDocs(config.specCheckTimeoutMs);
3092
+ ctx.ui.setStatus("lemonade-setup", undefined);
3093
+ ctx.ui.notify(
3094
+ result.ok
3095
+ ? "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."
3096
+ : `Re-sync failed: ${result.error}`,
3097
+ result.ok ? "info" : "error"
3098
+ );
3099
+ } else if (sub === "toggledocs") {
3100
+ config.specCheck = !config.specCheck;
3101
+ saveConfig(config);
3102
+ ctx.ui.notify(`Docs spec check ${config.specCheck ? "enabled" : "disabled"}.`, "info");
3103
+ } else if (sub === "toggleupstream") {
3104
+ config.upstreamVersionCheck = !config.upstreamVersionCheck;
3105
+ saveConfig(config);
3106
+ ctx.ui.notify(
3107
+ `Upstream-release notice ${config.upstreamVersionCheck ? "enabled" : "disabled"}.`,
3108
+ "info"
3109
+ );
3110
+ } else if (sub === "toggleserver") {
3111
+ config.serverVersionCheck = !config.serverVersionCheck;
3112
+ saveConfig(config);
3113
+ ctx.ui.notify(`Server-version check ${config.serverVersionCheck ? "enabled" : "disabled"}.`, "info");
3114
+ const [, msg] = await withStatus(ctx, "Re-registering providers…", () =>
3115
+ registerLemonadeProvider(pi, config).then((n) => `${n} model(s) registered.`)
3116
+ );
3117
+ void msg;
3118
+ }
3119
+ }
3120
+ continue;
3121
+ }
3122
+
2591
3123
  // ── Switch instance ─────────────────────────────────────────────
2592
3124
  if (choice === "instance") {
2593
3125
  const picked = await menu(
@@ -3228,6 +3760,7 @@ export default async function piLemonadeLink(pi: ExtensionAPI): Promise<void> {
3228
3760
  await registerAgentTools(pi, configRef.current);
3229
3761
  registerLemonadeBar(pi, configRef);
3230
3762
  registerSetupCommand(pi, configRef);
3763
+ registerSpecDriftWarnings(pi, configRef);
3231
3764
 
3232
3765
  // Fresh install (or a hand-emptied config): nothing is registered yet —
3233
3766
  // point the user at the wizard instead of leaving them wondering why
@@ -81,5 +81,27 @@
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. Separately toggleable.
106
+ "upstreamVersionCheck": true
85
107
  }
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.1",
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
  }