pi-freeflow 1.4.5 → 1.4.8

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.
@@ -4,5 +4,20 @@
4
4
  * Lightweight bridge re-exporting the modular codebase rooted in src/
5
5
  */
6
6
 
7
- export { default } from "../src/index.ts";
7
+ import { checkForUpdateInBackground } from "../src/update-checker.ts";
8
+ import originalDefault from "../src/index.ts";
9
+ import type { ExtensionAPI } from "../src/types.ts";
10
+
11
+ export default async function (pi: ExtensionAPI): Promise<void> {
12
+ try {
13
+ const r = checkForUpdateInBackground() as unknown as Promise<void> | void;
14
+ if (r && typeof (r as Promise<void>).catch === "function") {
15
+ (r as Promise<void>).catch(() => {});
16
+ }
17
+ } catch {
18
+ // swallow — never block activation
19
+ }
20
+ return originalDefault(pi);
21
+ }
22
+
8
23
  export * from "../src/index.ts";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.4.5",
4
+ "version": "1.4.8",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/commands.ts CHANGED
@@ -4,8 +4,18 @@
4
4
  * log viewing, debug level configuration, and live catalog refreshing.
5
5
  */
6
6
 
7
- import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
8
- import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
7
+ import { spawn } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
12
+ import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
13
+ import {
14
+ compareVersions,
15
+ fetchLatestVersion,
16
+ getCachedUpdate,
17
+ isLinkedInstall,
18
+ } from "./update-checker.ts";
9
19
  import {
10
20
  deployCloudflareWorker,
11
21
  deployDenoRelay,
@@ -66,13 +76,59 @@ export function updateStatusBar(ui?: ExtensionUIContext): void {
66
76
  }
67
77
  }
68
78
 
79
+ function getLocalVersion(): string {
80
+ try {
81
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
82
+ const pkgPath = path.join(thisDir, "..", "package.json");
83
+ const raw = readFileSync(pkgPath, "utf8");
84
+ const pkg = JSON.parse(raw) as { version?: string };
85
+ if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
86
+ return pkg.version.trim();
87
+ }
88
+ return "0.0.0";
89
+ } catch {
90
+ return "0.0.0";
91
+ }
92
+ }
93
+
94
+ function spawnWithProgress(
95
+ cmd: string,
96
+ args: string[],
97
+ ctx: ExtensionContext,
98
+ ): Promise<number> {
99
+ return new Promise((resolve) => {
100
+ try {
101
+ const child = spawn(cmd, args, {
102
+ shell: process.platform === "win32",
103
+ stdio: "pipe",
104
+ });
105
+ child.stdout?.on("data", (d: Buffer) => {
106
+ const s = String(d).trim();
107
+ if (s) ctx.ui.notify(s, "info");
108
+ });
109
+ child.stderr?.on("data", (d: Buffer) => {
110
+ const s = String(d).trim();
111
+ if (s) ctx.ui.notify(s, "info");
112
+ });
113
+ child.on("error", (err: Error) => {
114
+ ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
115
+ resolve(1);
116
+ });
117
+ child.on("close", (code: number | null) => resolve(code ?? 0));
118
+ } catch (e) {
119
+ ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
120
+ resolve(1);
121
+ }
122
+ });
123
+ }
124
+
69
125
  export function createCommandSpec(
70
126
  _pi: ExtensionAPI,
71
127
  onCatalogRefreshed?: (models: RegisteredModel[]) => void,
72
128
  ): Omit<RegisteredCommand, "name"> {
73
129
  return {
74
130
  description:
75
- "Relay egress: auto | on | off | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | deploy vercel | deploy cloudflare | deploy deno",
131
+ "Relay egress: auto | on | off | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
76
132
  getArgumentCompletions: (prefix: string) =>
77
133
  [
78
134
  "auto",
@@ -346,8 +402,73 @@ export function createCommandSpec(
346
402
  setActiveRelayState(relayState);
347
403
  persist();
348
404
  flash();
349
- } else if (sub === "status") {
350
- flash();
405
+ } else if (sub === "status") {
406
+ flash();
407
+ try {
408
+ const local = getLocalVersion();
409
+ let latest: string | null = null;
410
+ const cached = getCachedUpdate();
411
+ if (cached && typeof cached.latest === "string") {
412
+ latest = cached.latest;
413
+ }
414
+ if (latest && compareVersions(latest, local) > 0) {
415
+ ctx.ui.notify(
416
+ `Update available: ${local} -> ${latest} - run /freeflow update`,
417
+ "info",
418
+ );
419
+ }
420
+ } catch {
421
+ // swallow — status banner is best-effort
422
+ }
423
+ } else if (sub === "update") {
424
+ if (isLinkedInstall()) {
425
+ ctx.ui.notify(
426
+ "LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
427
+ "info",
428
+ );
429
+ } else {
430
+ try {
431
+ ctx.ui.notify("Checking for updates…", "info");
432
+ let latest: string | null = null;
433
+ try {
434
+ latest = await fetchLatestVersion();
435
+ } catch {
436
+ latest = null;
437
+ }
438
+ if (!latest) {
439
+ const cached = getCachedUpdate();
440
+ if (cached && typeof cached.latest === "string") {
441
+ latest = cached.latest;
442
+ }
443
+ }
444
+ if (!latest) {
445
+ ctx.ui.notify("Could not check latest version (offline?)", "warning");
446
+ } else {
447
+ const local = getLocalVersion();
448
+ const cmp = compareVersions(latest, local);
449
+ if (cmp <= 0) {
450
+ ctx.ui.notify(`Already on latest (v${local})`, "info");
451
+ } else {
452
+ ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
453
+ let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
454
+ if (code !== 0) {
455
+ ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
456
+ code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
457
+ }
458
+ if (code === 0) {
459
+ ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
460
+ } else {
461
+ ctx.ui.notify(
462
+ `Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
463
+ "warning",
464
+ );
465
+ }
466
+ }
467
+ }
468
+ } catch (e) {
469
+ ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
470
+ }
471
+ }
351
472
  } else if (sub === "list") {
352
473
  showList();
353
474
  } else if (sub === "add") {
package/src/config.ts CHANGED
@@ -53,8 +53,8 @@ export const VERCEL_API = "https://api.vercel.com";
53
53
 
54
54
  // ── Catalog & Logging constants ─────────────────────────────────────
55
55
  export const CATALOG_CACHE_TTL_MS = 86_400_000; // 24 hours — delegate to host fetchDynamicModels
56
- export const LOG_MAX_BYTES = 5 * 1024 * 1024; // 5MB
57
- export const LOG_MAX_FILES = 3;
56
+ export const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10MB per file
57
+ export const LOG_MAX_FILES = 10; // 10 archived + current ≈ 110MB max (≈100MB per your request, rotated, not single 100MB blob)
58
58
 
59
59
  // ── Rate Limit Maxima ───────────────────────────────────────────────
60
60
  export const RATE_LIMIT_MAX: Record<Upstream, number> = {
@@ -138,7 +138,21 @@ export function resolveDebugStatePath(): string {
138
138
  }
139
139
  }
140
140
 
141
+ export function resolveUpdateCachePath(): string {
142
+ try {
143
+ return path.join(homedir(), ".pi", "agent", "pi-freeflow-update.json");
144
+ } catch {
145
+ return path.join(
146
+ path.dirname(fileURLToPath(import.meta.url)),
147
+ "..",
148
+ ".update-cache.json",
149
+ );
150
+ }
151
+ }
152
+
141
153
  export const RELAY_STATE_FILE = resolveRelayStatePath();
142
154
  export const LOG_FILE = resolveLogFilePath();
143
155
  export const CATALOG_CACHE_FILE = resolveCatalogCachePath();
144
156
  export const DEBUG_STATE_FILE = resolveDebugStatePath();
157
+ export const UPDATE_CACHE_FILE = resolveUpdateCachePath();
158
+ export const UPDATE_CHECK_TTL_MS = 86_400_000;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Update checker for pi-freeflow — background npm registry poll with 24h cache.
3
+ *
4
+ * LINK-skip: when the extension entry is a symlink (developer `omp plugin link`),
5
+ * the checker is disabled entirely.
6
+ */
7
+
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import { UPDATE_CACHE_FILE, UPDATE_CHECK_TTL_MS } from "./config.ts";
13
+ import { logInfo } from "./logger.ts";
14
+
15
+ const REGISTRY_URL = "https://registry.npmjs.org/pi-freeflow/latest";
16
+
17
+ export interface UpdateCacheData {
18
+ latest: string;
19
+ checkedAt: number;
20
+ }
21
+
22
+ /**
23
+ * Detect a LINK install by checking whether the published extension entry is a
24
+ * symlink. `omp plugin link <path>` creates a symlink at extensions/index.ts
25
+ * pointing at the dev checkout — when that link exists we skip the update check.
26
+ */
27
+ export function isLinkedInstall(): boolean {
28
+ try {
29
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
30
+ const entry = path.join(thisDir, "..", "extensions", "index.ts");
31
+ return lstatSync(entry).isSymbolicLink();
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ export function getCachedUpdate(): UpdateCacheData | null {
38
+ try {
39
+ if (!existsSync(UPDATE_CACHE_FILE)) return null;
40
+ const raw = readFileSync(UPDATE_CACHE_FILE, "utf8");
41
+ const data = JSON.parse(raw) as UpdateCacheData;
42
+ if (!data || typeof data.latest !== "string" || typeof data.checkedAt !== "number") {
43
+ return null;
44
+ }
45
+ return data;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function setCachedUpdate(latest: string): void {
52
+ try {
53
+ const dir = path.dirname(UPDATE_CACHE_FILE);
54
+ mkdirSync(dir, { recursive: true });
55
+ const data: UpdateCacheData = { latest, checkedAt: Date.now() };
56
+ writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(data, null, 2), "utf8");
57
+ } catch {
58
+ // swallow — cache is best-effort
59
+ }
60
+ }
61
+
62
+ export async function fetchLatestVersion(): Promise<string | null> {
63
+ try {
64
+ const res = await fetch(REGISTRY_URL, {
65
+ signal: AbortSignal.timeout(3000),
66
+ headers: { Accept: "application/json" },
67
+ });
68
+ if (!res.ok) return null;
69
+ const json = (await res.json()) as { version?: string };
70
+ const v = json?.version;
71
+ if (typeof v === "string" && v.trim().length > 0) return v.trim();
72
+ return null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Simple semver compare: split on '.' and compare numeric segments.
80
+ * Returns >0 if a > b, <0 if a < b, 0 if equal.
81
+ */
82
+ export function compareVersions(a: string, b: string): number {
83
+ try {
84
+ const pa = a.split(".").map((s) => Number(s) || 0);
85
+ const pb = b.split(".").map((s) => Number(s) || 0);
86
+ const len = Math.max(pa.length, pb.length);
87
+ for (let i = 0; i < len; i++) {
88
+ const da = pa[i] ?? 0;
89
+ const db = pb[i] ?? 0;
90
+ if (da !== db) return da - db;
91
+ }
92
+ return 0;
93
+ } catch {
94
+ return 0;
95
+ }
96
+ }
97
+
98
+ function getLocalVersion(): string | null {
99
+ try {
100
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
101
+ const pkgPath = path.join(thisDir, "..", "package.json");
102
+ const raw = readFileSync(pkgPath, "utf8");
103
+ const pkg = JSON.parse(raw) as { version?: string };
104
+ if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
105
+ return pkg.version.trim();
106
+ }
107
+ return null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Non-blocking background update check. Safe to call during extension
115
+ * activation without awaiting — it never throws and never blocks the caller.
116
+ *
117
+ * Flow: LINK-skip → cache TTL (24h) skip → fetchLatestVersion in background
118
+ * → cache result → log if latest > local.
119
+ */
120
+ export function checkForUpdateInBackground(): void {
121
+ try {
122
+ if (isLinkedInstall()) return;
123
+
124
+ const cached = getCachedUpdate();
125
+ if (
126
+ cached &&
127
+ typeof cached.checkedAt === "number" &&
128
+ Date.now() - cached.checkedAt < UPDATE_CHECK_TTL_MS
129
+ ) {
130
+ return;
131
+ }
132
+
133
+ void fetchLatestVersion()
134
+ .then((latest) => {
135
+ if (!latest) return;
136
+ try {
137
+ setCachedUpdate(latest);
138
+ } catch {
139
+ // swallow
140
+ }
141
+ try {
142
+ const local = getLocalVersion();
143
+ if (local && compareVersions(latest, local) > 0) {
144
+ logInfo(`pi-freeflow update available: ${local} -> ${latest} (run /freeflow update)`);
145
+ }
146
+ } catch {
147
+ // swallow
148
+ }
149
+ })
150
+ .catch(() => {
151
+ // swallow — offline / transient network error
152
+ });
153
+ } catch {
154
+ // swallow — never throw from background check
155
+ }
156
+ }