pi-freeflow 1.1.3 → 1.1.5

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.
Files changed (3) hide show
  1. package/README.md +137 -23
  2. package/extensions/index.ts +164 -112
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -94,10 +94,18 @@ omp plugin link /path/to/pi-freeflow
94
94
 
95
95
  ---
96
96
 
97
- ## 🌐 Setting Up Your Own Egress Relays
97
+ ## 🌐 Setting Up Your Own Egress Relays (Multi-Cloud)
98
98
 
99
- ### 1. Cloudflare Worker Relay (Recommended)
100
- Deploy this script to Cloudflare Workers (100,000 free requests/day):
99
+ By default, FreeFlow works out of the box with direct upstream calls. To expand concurrency, bypass IP rate limits, and enable rolling failover, you can connect your own free cloud relays across **Cloudflare Workers**, **Vercel Edge**, **Deno Deploy**, or a **Linux VPS**.
100
+
101
+ ---
102
+
103
+ ### 1. Cloudflare Worker Relay (Recommended ⭐)
104
+ > **Free Tier**: 100,000 requests/day · Low latency · No 25-second execution timeout.
105
+
106
+ 1. Go to [dash.cloudflare.com](https://dash.cloudflare.com) $\to$ **Workers & Pages** $\to$ **Create application** $\to$ **Create Worker**.
107
+ 2. Name your worker (e.g. `my-freeflow-relay`) $\to$ click **Deploy**.
108
+ 3. Click **Edit code**, paste this JavaScript, and click **Save and Deploy**:
101
109
 
102
110
  ```javascript
103
111
  const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
@@ -108,8 +116,9 @@ export default {
108
116
  return new Response(null, {
109
117
  headers: {
110
118
  "Access-Control-Allow-Origin": "*",
111
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
119
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS, HEAD",
112
120
  "Access-Control-Allow-Headers": "*",
121
+ "Access-Control-Max-Age": "86400",
113
122
  },
114
123
  });
115
124
  }
@@ -117,8 +126,8 @@ export default {
117
126
  const target = request.headers.get("x-relay-target");
118
127
  const relayPath = request.headers.get("x-relay-path") || "/";
119
128
 
120
- if (!target || !ALLOWED_TARGETS.includes(target)) {
121
- return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
129
+ if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
130
+ return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
122
131
  }
123
132
 
124
133
  const targetUrl = target.replace(/\/$/, "") + relayPath;
@@ -126,42 +135,147 @@ export default {
126
135
  headers.delete("x-relay-target");
127
136
  headers.delete("x-relay-path");
128
137
  headers.delete("host");
138
+ headers.delete("cf-connecting-ip");
139
+ headers.delete("cf-ray");
140
+
141
+ try {
142
+ const response = await fetch(targetUrl, {
143
+ method: request.method,
144
+ headers,
145
+ body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined,
146
+ });
129
147
 
130
- const response = await fetch(targetUrl, {
131
- method: request.method,
132
- headers,
133
- body: request.method !== "GET" && request.method !== "HEAD" ? request.body : undefined,
134
- });
148
+ const outHeaders = new Headers(response.headers);
149
+ outHeaders.set("Access-Control-Allow-Origin", "*");
150
+ return new Response(response.body, { status: response.status, headers: outHeaders });
151
+ } catch (err) {
152
+ return new Response(JSON.stringify({ error: "Upstream failed", details: String(err) }), { status: 502, headers: { "content-type": "application/json" } });
153
+ }
154
+ },
155
+ };
156
+ ```
135
157
 
136
- return new Response(response.body, {
137
- status: response.status,
138
- headers: response.headers,
139
- });
140
- ### 2. Vercel Relay
141
- Deploy using the built-in deploy command:
158
+ 4. Copy your Worker URL and register it inside OMP:
159
+ ```text
160
+ /freeflow use https://my-freeflow-relay.your-subdomain.workers.dev
161
+ ```
162
+
163
+ ---
164
+
165
+ ### 2. Vercel Edge Relay
166
+ > **Free Tier**: 1,000,000 requests/month.
167
+
168
+ #### Method A: Automated In-Memory CLI Deploy
169
+ 1. Generate a Vercel token at [vercel.com/account/tokens](https://vercel.com/account/tokens).
170
+ 2. In OMP TUI, run:
142
171
  ```text
143
172
  /freeflow deploy
144
173
  ```
145
- *(Prompts for your Vercel API token in-memory and automatically creates a private Edge Function relay).*
174
+ *(Prompts for your token in-memory, provisions a private Edge Function, and adds it to your relay pool automatically).*
175
+
176
+ #### Method B: Manual Git Deploy
177
+ Deploy a Git repository with these 3 files:
178
+ * **`api/relay.js`**:
179
+ ```javascript
180
+ const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
181
+ export const config = { runtime: "edge" };
182
+ export default async function handler(req) {
183
+ const target = req.headers.get("x-relay-target");
184
+ const relayPath = req.headers.get("x-relay-path") || "/";
185
+ if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 });
186
+ const targetUrl = target.replace(/\/$/, "") + relayPath;
187
+ const headers = new Headers(req.headers);
188
+ headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
189
+ const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
190
+ return new Response(response.body, { status: response.status, headers: response.headers });
191
+ }
192
+ ```
193
+ * **`package.json`**: `{ "name": "freeflow-relay", "version": "1.0.0" }`
194
+ * **`vercel.json`**: `{ "rewrites": [{ "source": "/(.*)", "destination": "/api/relay" }] }`
195
+
196
+ ---
197
+
198
+ ### 3. Deno Deploy Relay
199
+ > **Free Tier**: 100,000 requests/day.
200
+
201
+ 1. Open [dash.deno.com](https://dash.deno.com) $\to$ **New Project** $\to$ **Playground**.
202
+ 2. Paste the following TypeScript code:
203
+
204
+ ```typescript
205
+ const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
206
+
207
+ Deno.serve(async (req) => {
208
+ if (req.method === "OPTIONS") {
209
+ return new Response(null, {
210
+ headers: {
211
+ "Access-Control-Allow-Origin": "*",
212
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
213
+ "Access-Control-Allow-Headers": "*",
214
+ },
215
+ });
216
+ }
217
+
218
+ const target = req.headers.get("x-relay-target");
219
+ const relayPath = req.headers.get("x-relay-path") || "/";
220
+ if (!target || !ALLOWED_TARGETS.includes(target.replace(/\/$/, ""))) {
221
+ return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403 });
222
+ }
223
+
224
+ const targetUrl = target.replace(/\/$/, "") + relayPath;
225
+ const headers = new Headers(req.headers);
226
+ headers.delete("x-relay-target");
227
+ headers.delete("x-relay-path");
228
+ headers.delete("host");
229
+
230
+ const response = await fetch(targetUrl, {
231
+ method: req.method,
232
+ headers,
233
+ body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
234
+ });
235
+ return new Response(response.body, { status: response.status, headers: response.headers });
236
+ });
237
+ ```
238
+ 3. Click **Deploy** and copy your Deno project URL (`https://project-name.deno.dev`).
239
+
240
+ ---
241
+
242
+ ## 🎮 Managing Relays in FreeFlow
243
+
244
+ Inside the OMP / Pi TUI:
245
+ ```text
246
+ /freeflow use <URL> # Set active relay and add to saved pool
247
+ /freeflow list # View all saved relays with status indicators (★ = active)
248
+ /freeflow status # Check current relay health and egress mode
249
+ /freeflow refresh # Trigger live on-demand model catalog sync from upstream APIs
250
+ /freeflow on # Force enable relay egress mode
251
+ /freeflow off # Switch to direct upstream mode
252
+ /freeflow remove <URL> # Remove an inactive relay from pool
253
+ /freeflow logs # Show last 25 lines of debug logs
254
+ ```
146
255
 
147
256
  ---
148
257
 
149
258
  ## 🛠️ Diagnostics & Troubleshooting
150
259
 
151
260
  ### Custom Port Configuration
152
- By default, `pi-freeflow` uses a **Single Shared Port** (`18080`). All concurrent sub-agents automatically share the same master proxy port without spawning redundant servers.
261
+ By default, `pi-freeflow` uses a **Single Shared Master Port** (`18080`). All concurrent sub-agents automatically reuse this master proxy without spawning redundant servers or extra sockets.
153
262
 
154
263
  To change the base port:
155
264
  ```env
156
265
  FREEFLOW_PORT=19000
157
266
  ```
158
267
 
159
- View live debug logs:
268
+ ### Subagent Credentials
269
+ If subagents report missing provider keys, verify `~/.omp/agent/.env`:
270
+ ```env
271
+ FREEFLOW_API_KEY=freeflow
272
+ ```
273
+
274
+ ### Live Logs
160
275
  ```bash
161
- cat ~/.pi/agent/pi-freeflow.log | tail -n 30
162
- # or inside OMP:
163
- /freeflow logs
276
+ cat ~/.pi/agent/pi-freeflow.log | tail -n 40
164
277
  ```
278
+
165
279
  ---
166
280
 
167
281
  ## 📄 License
@@ -746,10 +746,7 @@ function checkRateLimit(ip: string, upstream: Upstream): boolean {
746
746
  return true;
747
747
  }
748
748
 
749
- // ── Health Check (OpenCode/Kilo catalogs; no per-model inference) ──
750
- // Each upstream publishes a model list; fetch it ONCE (cached) and check
751
- // membership. A 1-token chat probe per model was too slow (large models need
752
- // 10s+ for the first token). Real usability is validated at chat time (300s).
749
+ // ── Health Check & Dynamic Catalog Auto-Update ────────────────────
753
750
  const CATALOG_CACHE_FILE = path.join(
754
751
  homedir(),
755
752
  ".pi",
@@ -758,10 +755,98 @@ const CATALOG_CACHE_FILE = path.join(
758
755
  );
759
756
  const CATALOG_CACHE_TTL_MS = 3600_000; // 1 hour
760
757
 
758
+ function formatCleanDisplayName(id: string, customName?: string): string {
759
+ if (customName && customName.trim()) return customName.trim();
760
+ const known = MODEL_MAP.get(id);
761
+ if (known && known.name) return known.name;
762
+
763
+ // Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
764
+ let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
765
+ // Strip variant suffixes
766
+ clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
767
+ clean = clean.replace(/-(free|contributor|preview)$/i, "");
768
+
769
+ // Capitalize words nicely
770
+ const parts = clean.split(/[-_]/).map((w) => {
771
+ const lower = w.toLowerCase();
772
+ if (lower === "gpt") return "GPT";
773
+ if (lower === "ai") return "AI";
774
+ if (lower === "lfm") return "LFM";
775
+ if (lower === "hy3") return "Hy3";
776
+ if (lower === "mimo") return "MiMo";
777
+ if (lower === "ocr") return "OCR";
778
+ return w.charAt(0).toUpperCase() + w.slice(1);
779
+ });
780
+
781
+ return parts.join(" ");
782
+ }
783
+
784
+ interface RawModelItem {
785
+ id: string;
786
+ context_length?: number;
787
+ max_output_tokens?: number;
788
+ [key: string]: unknown;
789
+ }
790
+
791
+ function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
792
+ const known = MODEL_MAP.get(raw.id);
793
+ if (known) return { ...known, source };
794
+
795
+ const idLower = raw.id.toLowerCase();
796
+ const hasVision =
797
+ idLower.includes("vision") ||
798
+ idLower.includes("vl") ||
799
+ idLower.includes("omni") ||
800
+ idLower.includes("note") ||
801
+ idLower.includes("image");
802
+ const hasReasoning =
803
+ idLower.includes("reasoning") ||
804
+ idLower.includes("r1") ||
805
+ idLower.includes("o1") ||
806
+ idLower.includes("think") ||
807
+ idLower.includes("alpha") ||
808
+ idLower.includes("spark");
809
+
810
+ let contextWindow =
811
+ typeof raw.context_length === "number" ? raw.context_length : 262_144;
812
+ if (
813
+ idLower.includes("1m") ||
814
+ idLower.includes("ultra") ||
815
+ idLower.includes("lightning") ||
816
+ idLower.includes("mimo-v2.5") ||
817
+ idLower.includes("muse-spark")
818
+ ) {
819
+ contextWindow = 1_048_576;
820
+ }
821
+
822
+ let maxTokens =
823
+ typeof raw.max_output_tokens === "number"
824
+ ? raw.max_output_tokens
825
+ : 65_536;
826
+ if (idLower.includes("ultra") || idLower.includes("lightning")) {
827
+ maxTokens = 131_072;
828
+ }
829
+
830
+ const isResponses = raw.id === "muse-spark-1.2-contributor-free";
831
+
832
+ return {
833
+ id: raw.id,
834
+ name: formatCleanDisplayName(raw.id),
835
+ source,
836
+ reasoning: hasReasoning,
837
+ contextWindow,
838
+ maxTokens,
839
+ api: isResponses ? "openai-responses" : undefined,
840
+ input: hasVision ? ["text", "image"] : ["text"],
841
+ thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
842
+ };
843
+ }
844
+
761
845
  interface CatalogCacheData {
762
846
  timestamp: number;
763
847
  opencode: string[];
764
848
  kilo: string[];
849
+ models?: RegisteredModel[];
765
850
  }
766
851
 
767
852
  function readCatalogCache(): CatalogCacheData | null {
@@ -776,99 +861,81 @@ function readCatalogCache(): CatalogCacheData | null {
776
861
  return null;
777
862
  }
778
863
 
779
- function writeCatalogCache(opencode: string[], kilo: string[]): void {
864
+ async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
865
+ if (!force) {
866
+ const disk = readCatalogCache();
867
+ if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
868
+ aliveCatalog = disk.models;
869
+ return aliveCatalog;
870
+ }
871
+ }
872
+
873
+ // 1. Fetch OpenCode Zen models
874
+ let opencodeList: RegisteredModel[] = [];
875
+ try {
876
+ const r = await fetch(`${API}/models`, {
877
+ headers: opencodeHeaders(),
878
+ signal: AbortSignal.timeout(10_000),
879
+ });
880
+ if (r.ok) {
881
+ const d = await r.json();
882
+ const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
883
+ const aliveIds = new Set(items.map((m) => m.id));
884
+ opencodeList = KNOWN_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
885
+ ...m,
886
+ source: "opencode" as const,
887
+ }));
888
+ }
889
+ } catch {}
890
+ if (!opencodeList.length) {
891
+ opencodeList = KNOWN_MODELS.map((m) => ({ ...m, source: "opencode" as const }));
892
+ }
893
+
894
+ // 2. Fetch KiloCode Gateway models
895
+ let kiloList: RegisteredModel[] = [];
896
+ try {
897
+ const r = await fetch(
898
+ KILO_CHAT_URL.replace("/chat/completions", "/models"),
899
+ {
900
+ headers: { Authorization: "Bearer kilo-free" },
901
+ signal: AbortSignal.timeout(10_000),
902
+ },
903
+ );
904
+ if (r.ok) {
905
+ const d = await r.json();
906
+ const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
907
+ const aliveIds = new Set(items.map((m) => m.id));
908
+ kiloList = KILO_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
909
+ ...m,
910
+ source: "kilo" as const,
911
+ }));
912
+ }
913
+ } catch {}
914
+ if (!kiloList.length) {
915
+ kiloList = KILO_MODELS.map((m) => ({ ...m, source: "kilo" as const }));
916
+ }
917
+
918
+ const all = [...opencodeList, ...kiloList];
919
+ aliveCatalog = all;
920
+
921
+ // Write rich models to cache atomically
780
922
  try {
781
923
  const dir = path.dirname(CATALOG_CACHE_FILE);
782
924
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
783
925
  const data: CatalogCacheData = {
784
926
  timestamp: Date.now(),
785
- opencode,
786
- kilo,
927
+ opencode: opencodeList.map((m) => m.id),
928
+ kilo: kiloList.map((m) => m.id),
929
+ models: all,
787
930
  };
788
931
  const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
789
- fs.writeFileSync(tmpPath, JSON.stringify(data), "utf8");
932
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
790
933
  fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
791
934
  } catch {}
792
- }
793
-
794
- let opencodeCatalogP: Promise<Set<string> | null> | null = null;
795
- function opencodeCatalog(): Promise<Set<string> | null> {
796
- if (!opencodeCatalogP)
797
- opencodeCatalogP = (async () => {
798
- const disk = readCatalogCache();
799
- if (disk && Array.isArray(disk.opencode) && disk.opencode.length > 0) {
800
- return new Set<string>(disk.opencode);
801
- }
802
- try {
803
- const r = await fetch(`${API}/models`, {
804
- headers: opencodeHeaders(),
805
- signal: AbortSignal.timeout(10_000),
806
- });
807
- if (!r.ok) {
808
- // Fallback to static known models if network fails
809
- return new Set<string>(KNOWN_MODELS.map((m) => m.id));
810
- }
811
- const d = await r.json();
812
- const arr = (d?.data ?? []).map((m: { id: string }) => m.id);
813
- const s = new Set<string>(arr);
814
- const kDisk = disk?.kilo ?? KILO_MODELS.map((m) => m.id);
815
- writeCatalogCache(arr, kDisk);
816
- return s;
817
- } catch {
818
- return new Set<string>(KNOWN_MODELS.map((m) => m.id));
819
- }
820
- })();
821
- return opencodeCatalogP;
822
- }
823
- let kiloCatalogP: Promise<Set<string> | null> | null = null;
824
- function kiloCatalog(): Promise<Set<string> | null> {
825
- if (!kiloCatalogP)
826
- kiloCatalogP = (async () => {
827
- const disk = readCatalogCache();
828
- if (disk && Array.isArray(disk.kilo) && disk.kilo.length > 0) {
829
- return new Set<string>(disk.kilo);
830
- }
831
- try {
832
- const r = await fetch(
833
- KILO_CHAT_URL.replace("/chat/completions", "/models"),
834
- {
835
- headers: { Authorization: "Bearer kilo-free" },
836
- signal: AbortSignal.timeout(10_000),
837
- },
838
- );
839
- if (!r.ok) {
840
- return new Set<string>(KILO_MODELS.map((m) => m.id));
841
- }
842
- const d = await r.json();
843
- const arr = (d?.data ?? []).map((m: { id: string }) => m.id);
844
- const s = new Set<string>(arr);
845
- const ocDisk = disk?.opencode ?? KNOWN_MODELS.map((m) => m.id);
846
- writeCatalogCache(ocDisk, arr);
847
- return s;
848
- } catch {
849
- return new Set<string>(KILO_MODELS.map((m) => m.id));
850
- }
851
- })();
852
- return kiloCatalogP;
853
- }
854
935
 
855
- async function checkModelAlive(id: string): Promise<boolean> {
856
- try {
857
- const cat = await opencodeCatalog();
858
- return cat ? cat.has(id) : false;
859
- } catch {
860
- return false;
861
- }
936
+ return all;
862
937
  }
863
938
 
864
- async function checkKiloAlive(id: string): Promise<boolean> {
865
- try {
866
- const cat = await kiloCatalog();
867
- return cat ? cat.has(id) : false;
868
- } catch {
869
- return false;
870
- }
871
- }
872
939
 
873
940
  // ── Helpers ────────────────────────────────────────────────────────
874
941
  function getClientIP(req: http.IncomingMessage): string {
@@ -1371,31 +1438,8 @@ export default async function (pi: ExtensionAPI) {
1371
1438
  return;
1372
1439
  }
1373
1440
  }
1374
- // Health check opencode models
1375
- log("info", `checking ${KNOWN_MODELS.length} opencode model(s)...`);
1376
- const opencodeChecks = await Promise.all(
1377
- KNOWN_MODELS.map(async (model) => {
1378
- const alive = await checkModelAlive(model.id);
1379
- if (alive) log("info", `✓ ${model.id} is alive`);
1380
- else log("warn", `✗ ${model.id} is dead — skipping`);
1381
- return { ...model, alive, source: "opencode" as const };
1382
- }),
1383
- );
1384
-
1385
- // Health check kilo models
1386
- log("info", `checking ${KILO_MODELS.length} kilo model(s)...`);
1387
- const kiloChecks = await Promise.all(
1388
- KILO_MODELS.map(async (model) => {
1389
- const alive = await checkKiloAlive(model.id);
1390
- if (alive) log("info", `✓ ${model.id} (kilo) is alive`);
1391
- else log("warn", `✗ ${model.id} (kilo) is dead — skipping`);
1392
- return { ...model, alive, source: "kilo" as const };
1393
- }),
1394
- );
1395
-
1396
- const aliveModels = [...opencodeChecks, ...kiloChecks].filter((m) => m.alive);
1441
+ const aliveModels = await refreshCatalog();
1397
1442
  aliveCatalog = aliveModels;
1398
-
1399
1443
  if (aliveModels.length === 0) {
1400
1444
  // Don't bail: still register /bansos below so the user can recover
1401
1445
  // (e.g. switch the relay off) instead of being stranded with no command.
@@ -1439,9 +1483,9 @@ export default async function (pi: ExtensionAPI) {
1439
1483
  // ── /bansos command: toggle relay egress live (on|off|status|url [URL]) ───
1440
1484
  const commandSpec = {
1441
1485
  description:
1442
- "Relay egress: on | off | status | logs | url [URL] | deploy | list | use <URL> | remove <URL>",
1486
+ "Relay egress: on | off | status | logs | url [URL] | deploy | list | use <URL> | remove <URL> | refresh",
1443
1487
  getArgumentCompletions: (prefix: string) =>
1444
- ["on", "off", "status", "url", "deploy", "list", "use", "remove"]
1488
+ ["on", "off", "status", "url", "deploy", "list", "use", "remove", "refresh", "models"]
1445
1489
  .filter((s) => s.startsWith(prefix))
1446
1490
  .map((s) => ({ value: s, label: s })),
1447
1491
  handler: async (args: string, ctx) => {
@@ -1582,7 +1626,7 @@ export default async function (pi: ExtensionAPI) {
1582
1626
  } else if (sub === "logs" || sub === "log") {
1583
1627
  try {
1584
1628
  if (!fs.existsSync(LOG_FILE)) {
1585
- ctx.ui.notify(`No logs recorded yet in ${LOG_FILE}`, "info");
1629
+ ctx.ui.notify("Log file is empty", "info");
1586
1630
  return;
1587
1631
  }
1588
1632
  const content = fs.readFileSync(LOG_FILE, "utf8");
@@ -1591,6 +1635,14 @@ export default async function (pi: ExtensionAPI) {
1591
1635
  } catch (e) {
1592
1636
  ctx.ui.notify(`Could not read log file: ${(e as Error).message}`, "error");
1593
1637
  }
1638
+ } else if (sub === "refresh" || sub === "reload" || sub === "models") {
1639
+ ctx.ui.notify("Refreshing model catalog from live upstreams…", "info");
1640
+ const updated = await refreshCatalog(true);
1641
+ persist();
1642
+ ctx.ui.notify(
1643
+ `✓ Refreshed ${updated.length} models with full-spec metadata!`,
1644
+ "info",
1645
+ );
1594
1646
  } else if (sub === "remove") {
1595
1647
  const url = (
1596
1648
  rest ||
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.1.3",
4
+ "version": "1.1.5",
5
5
  "description": "Personal multi-cloud rolling fallback relay for OpenCode Zen and KiloCode models in OMP/Pi",
6
6
  "keywords": [
7
7
  "pi-package",