From 51d44e8fc32058373b8de25244fded920723d621 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Sun, 21 Jun 2026 12:07:09 -0400 Subject: [PATCH 1/7] Splash Screen Migration and Reindex Model intergrated in splash. Allows update if main process crashes. --- electron/main.ts | 71 ++++- electron/splash/boot-sequence.ts | 234 ++++++++++++++++ electron/splash/bootsplash.ts | 440 +++++++++++++++++++++++++++++++ electron/splash/cairn-icon.png | Bin 0 -> 10826 bytes src/app/page.tsx | 8 - 5 files changed, 738 insertions(+), 15 deletions(-) create mode 100644 electron/splash/boot-sequence.ts create mode 100644 electron/splash/bootsplash.ts create mode 100644 electron/splash/cairn-icon.png diff --git a/electron/main.ts b/electron/main.ts index 32ee27f5..1abf199e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -32,6 +32,8 @@ import { killTrackedBashProcesses } from "./lib/coding-tools/bash"; import { startMcpNotificationPoller } from "./lib/mcp-poller"; import { stopServerSync } from "./lib/llama-server"; import { dispose as disposeEmbeddingsWorker } from "./embeddings/client"; +import { BootSplash } from "./splash/bootsplash"; +import { runBootSequence } from "./splash/boot-sequence"; const isDev = !app.isPackaged; @@ -115,6 +117,7 @@ function createWindow(): BrowserWindow { height: 900, minWidth: 900, minHeight: 600, + show: false, titleBarStyle: isWin ? "hidden" : "hiddenInset", ...(isWin && { titleBarOverlay: { @@ -173,10 +176,6 @@ app.whenReady().then(async () => { const initialDbPath = getDbPathForWorkspace(initialWorkspacePath); const initialDb = initDb(initialDbPath); - // Recover any notes written to disk but missing from SQLite (e.g. due to - // a fire-and-forget IPC race or unexpected shutdown). - syncNotesFromDisk(initialDb, initialWorkspacePath); - // ── Mutable context — swapped in reinitialise() without relaunching ── // getWin is a lazy getter so it works even though win is assigned after ctx. let _win: import("electron").BrowserWindow | null = null; @@ -186,13 +185,68 @@ app.whenReady().then(async () => { getWin: () => _win, }; + // Register IPC handlers before boot sequence (boot calls reindexNotes, + // runAllPendingMigrations, etc. which need handler-level model resolution). registerIpcHandlers(ctx); registerAgentHandlers(ctx.db); registerPiAgentHandler(ctx); + // ── Splash + boot sequence ──────────────────────────────────────────── + // Create the splash window immediately so the user sees something while + // update check, migrations, reindex, and notes sync run. The main window + // is only created after the boot sequence completes (or errors). + const splash = new BootSplash(); + splash.create(); + + // Resolve workspace ID from DB (may not exist on first launch / onboarding). + const wsRow = initialDb.prepare( + "SELECT id FROM workspaces ORDER BY created_at LIMIT 1", + ).get() as { id?: string } | undefined; + const workspaceId = wsRow?.id ?? ""; + + let bootErrors: string[] = []; + try { + const result = await runBootSequence(splash, { + db: initialDb, + workspacePath: initialWorkspacePath, + workspaceId, + isDev, + }); + bootErrors = result.errors; + } catch (err) { + bootErrors.push(err instanceof Error ? err.message : String(err)); + } + + // ── Create main window ───────────────────────────────────────────────── const win = createWindow(); _win = win; + // Close splash only after the main window has finished loading its first + // page and a minimum display time has elapsed. The main window is created + // hidden (show: false) so only the splash is visible during boot. Once the + // renderer has painted, we show the main window and close the splash. + const MIN_SPLASH_MS = 3000; + const splashOpenedAt = Date.now(); + const closeSplash = () => { + const elapsed = Date.now() - splashOpenedAt; + const delay = Math.max(0, MIN_SPLASH_MS - elapsed); + setTimeout(() => { + // Notify splash to fill to 100% before closing, so the progress bar + // completes instead of vanishing mid-cycle. + splash.progress({ step: "done", label: "Ready", pct: 100 }); + setTimeout(() => { + win.show(); + splash.close(); + }, 200); + }, delay); + }; + win.webContents.once("did-finish-load", () => { + closeSplash(); + }); + setTimeout(() => { + closeSplash(); + }, 6000); + // ── File watcher for external .md edits ────────────────────────────── let debounceTimer: ReturnType | null = null; @@ -236,26 +290,29 @@ app.whenReady().then(async () => { } // ── Auto-updater (prod only) ────────────────────────────────────────── + // The initial update check runs in the boot sequence (above). Here we only + // register the event handlers for the in-app notification UI and set up the + // 4-hour poller for updates released during the session. if (!isDev) { autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on("update-available", (info) => { - win.webContents.send("updater:update-available", { + if (!win.isDestroyed()) win.webContents.send("updater:update-available", { version: info.version, releaseNotes: info.releaseNotes ?? null, }); }); autoUpdater.on("update-downloaded", () => { - win.webContents.send("updater:update-downloaded"); + if (!win.isDestroyed()) win.webContents.send("updater:update-downloaded"); }); autoUpdater.on("error", (err) => { console.error("[updater]", err.message); }); - autoUpdater.checkForUpdates().catch(() => {}); + // Poll for updates every 4 hours (initial check was done in boot sequence). setInterval(() => autoUpdater.checkForUpdates().catch(() => {}), 4 * 60 * 60 * 1000); } diff --git a/electron/splash/boot-sequence.ts b/electron/splash/boot-sequence.ts new file mode 100644 index 00000000..b9d9e96f --- /dev/null +++ b/electron/splash/boot-sequence.ts @@ -0,0 +1,234 @@ +/** + * Cairn — Boot sequence orchestrator + * + * Runs the pre-app startup steps sequentially, reporting progress to the + * splash window. Each step is wrapped in try/catch so a failure in one step + * doesn't block the others — the app still boots even if reindex fails. + * + * Steps: + * 1. Update check (prod only) — autoUpdater.checkForUpdates() + * 2. Workspace migrations — checkMigrations + runAllPendingMigrations + * 3. Embeddings reindex — if model changed, reindexNotes + * 4. Notes sync — syncNotesFromDisk + * + * After all steps complete (or error), the caller destroys the splash + * and creates the main window. + */ +import { autoUpdater } from "electron-updater"; +import type Database from "better-sqlite3"; + +import { BootSplash } from "./bootsplash"; +import { checkMigrations, runAllPendingMigrations } from "../migrations"; +import { reindexNotes } from "../embeddings/service"; +import { getEmbeddingsSettingsCached } from "../lib/config-cache"; +import { resolveEmbeddingModelId, getDefaultModelId } from "../embeddings/client"; +import * as manifest from "../embeddings/manifest"; +import { syncNotesFromDisk } from "../notes-files"; +import { computeSemanticRelationships } from "../db/graph-queries"; +import { saveCachedConfig } from "../lib/config-cache"; + +export interface BootContext { + db: Database.Database; + workspacePath: string; + workspaceId: string; + isDev: boolean; +} + +export interface BootResult { + updateInstalled: boolean; + migrationsRan: number; + reindexed: boolean; + notesSynced: number; + errors: string[]; +} + +export async function runBootSequence( + splash: BootSplash, + ctx: BootContext, +): Promise { + const errors: string[] = []; + let updateInstalled = false; + let migrationsRan = 0; + let reindexed = false; + let notesSynced = 0; + + // ── Step 1: Update check (prod only) ────────────────────────────────── + if (!ctx.isDev) { + try { + splash.progress({ step: "update", label: "Checking for updates…", pct: 10 }); + const result = await autoUpdater.checkForUpdates(); + if (result && result.updateInfo) { + const version = result.updateInfo.version; + splash.progress({ + step: "update", + label: `Downloading update v${version}…`, + detail: "Auto-installing on download complete", + pct: 30, + }); + + // Wait for the download to complete. electron-updater emits + // 'update-downloaded' when ready. If autoDownload is true (default), + // the download starts automatically after checkForUpdates resolves. + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("update download timed out after 120s")); + }, 120_000); + + autoUpdater.once("update-downloaded", () => { + clearTimeout(timeout); + splash.progress({ + step: "update", + label: `Installing v${version}…`, + detail: "The app will restart automatically", + pct: 100, + }); + updateInstalled = true; + // quitAndInstall restarts the app — the boot sequence will + // run again on next launch (finding no update). + autoUpdater.quitAndInstall(); + // The app quits here, so resolve is technically unreachable. + resolve(); + }); + + autoUpdater.once("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + } + } catch (err) { + // Network error, update server down, etc. — don't block the boot. + const msg = err instanceof Error ? err.message : String(err); + console.warn("[boot] Update check failed:", msg); + errors.push(`Update check: ${msg}`); + } + splash.stepDone("Update check"); + } + + // ── Step 2: Workspace migrations ────────────────────────────────────── + try { + splash.progress({ step: "migrations", label: "Checking migrations…", pct: 0 }); + const pending = checkMigrations(ctx.workspacePath).filter((m) => m.needed); + + if (pending.length > 0) { + splash.progress({ + step: "migrations", + label: `Running ${pending.length} migration${pending.length > 1 ? "s" : ""}…`, + pct: 0, + }); + + migrationsRan = await runAllPendingMigrations( + ctx.workspacePath, + (migrationId, pct, msg) => { + splash.progress({ + step: "migrations", + label: `Migrating: ${migrationId}`, + detail: msg, + pct, + }); + }, + ); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[boot] Migration failed:", msg); + errors.push(`Migration: ${msg}`); + } + splash.stepDone("Migrations"); + + // ── Step 3: Embeddings reindex ──────────────────────────────────────── + try { + splash.progress({ step: "reindex", label: "Checking embeddings…", pct: 0 }); + const settings = getEmbeddingsSettingsCached(); + + if (settings.enabled) { + // Resolve model id with self-heal (same logic as the IPC handler). + const model = resolveEmbeddingModelId( + settings.modelId, + getDefaultModelId(), + ); + if (settings.modelId !== model) { + console.log( + `[boot] self-healing stale modelId: ${settings.modelId ?? "(none)"} → ${model}`, + ); + saveCachedConfig("embeddings", { modelId: model }); + manifest.writeDefaultModelId(model); + } + + // Check for any embedding rows with a mismatched model. + const row = ctx.db.prepare( + "SELECT 1 FROM note_embeddings WHERE model != ? LIMIT 1", + ).get(model) as { 1?: number } | undefined; + + if (row) { + splash.progress({ + step: "reindex", + label: "Re-indexing notes with new model…", + detail: "This may take a moment", + pct: 0, + }); + + const result = await reindexNotes( + ctx.db, + ctx.workspaceId, + undefined, + model, + undefined, + (done, total) => { + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + splash.progress({ + step: "reindex", + label: "Re-indexing notes…", + detail: `${done} / ${total} notes`, + pct, + }); + }, + ); + reindexed = result.indexed > 0; + + if (result.total > 0) { + try { + computeSemanticRelationships(ctx.db, ctx.workspaceId, undefined); + } catch (e) { + console.warn("[boot] semantic recompute failed:", e instanceof Error ? e.message : e); + } + } + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[boot] Embeddings reindex failed:", msg); + errors.push(`Reindex: ${msg}`); + } + splash.stepDone("Embeddings"); + + // ── Step 4: Notes sync ───────────────────────────────────────────────── + try { + splash.progress({ + step: "notes-sync", + label: "Syncing notes…", + pct: 50, + }); + + // syncNotesFromDisk is synchronous but potentially slow for large + // workspaces. We run it in a microtask to let the splash paint. + await new Promise((resolve) => { + setImmediate(() => { + syncNotesFromDisk(ctx.db, ctx.workspacePath); + resolve(); + }); + }); + + notesSynced = 1; // syncNotesFromDisk doesn't return a count + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[boot] Notes sync failed:", msg); + errors.push(`Notes sync: ${msg}`); + } + splash.stepDone("Notes sync"); + + // ── Done ────────────────────────────────────────────────────────────── + splash.progress({ step: "done", label: "Ready", pct: 100 }); + + return { updateInstalled, migrationsRan, reindexed, notesSynced, errors }; +} diff --git a/electron/splash/bootsplash.ts b/electron/splash/bootsplash.ts new file mode 100644 index 00000000..c75c6665 --- /dev/null +++ b/electron/splash/bootsplash.ts @@ -0,0 +1,440 @@ +/** + * Cairn — Boot splash screen + * + * Shows a branded splash window immediately on app launch, before the main + * window is created. The splash displays progress for: + * + * 1. Update check (prod only — skipped in dev) + * 2. DB migration (workspace-level migrations) + * 3. Embeddings reindex (if model changed) + * 4. Notes sync (recover notes on disk missing from SQLite) + * + * When all steps complete, the caller destroys the splash and creates the + * main window. This replaces the old approach of showing ReindexModal and + * MigrationModal as overlays after the main window loaded. + * + * The HTML is embedded as a string literal so esbuild bundles it into + * main.js — no external file to copy. The Cairn icon is loaded at runtime + * from the Next.js static export (`out/icon.png`, RGBA with transparency) + * and embedded as a base64 data URL. + */ +import { app, BrowserWindow } from "electron"; +import * as fs from "fs"; +import * as path from "path"; + +export type SplashStep = + | "update" + | "migrations" + | "reindex" + | "notes-sync" + | "done"; + +export interface SplashProgress { + step: SplashStep; + label: string; + detail?: string; + pct: number; +} + +const SPLASH_WIDTH = 400; +const SPLASH_HEIGHT = 480; + +/** + * Load the Cairn icon from the Next.js static export. It has transparency + * (RGBA) so it renders cleanly on both dark and light splash backgrounds. + * Tries the `out/` directory (served by `app://` in prod, source in dev) + * with a fallback to `build-resources/` from the source tree. + */ +function getIconDataUrl(): string | null { + const outDir = path.join(__dirname, "..", "out"); + const candidates = [ + path.join(outDir, "icon.png"), + path.join(outDir, "icon_tray.png"), + path.join(__dirname, "..", "build-resources", "icon.png"), + path.join(__dirname, "icon.png"), + ]; + + for (const p of candidates) { + try { + if (fs.existsSync(p)) { + const buf = fs.readFileSync(p); + return "data:image/png;base64," + buf.toString("base64"); + } + } catch { + // try next + } + } + return null; +} + +function getAppVersion(): string { + // `NEXT_PUBLIC_APP_VERSION` (used in Settings) is a Next.js build-time + // injection — only available in the renderer, not in the main process. + // `app.getVersion()` works in prod but returns the Electron binary's + // version (e.g. 41.8.0) in dev. Reading package.json via `app.getAppPath()` + // works in both — it returns the app root where package.json lives, + // sourced from the same file next.config.ts reads at build time. + try { + const pkgPath = path.join(app.getAppPath(), "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + return pkg.version; + } catch { + try { + return app.getVersion(); + } catch { + return ""; + } + } +} + +function buildSplashHtml(iconDataUrl: string | null): string { + const version = getAppVersion(); + const logoHtml = iconDataUrl + ? `Cairn` + : `
C
`; + + return ` + + + + +Cairn + + + + +
Cairn
+
Local-first notes & projects
+
+
Starting…
+
+
+
+
+
+
+
+
+
+ v${version} + + cairn.app +
+ + + +`; +} + +function getThemeColors(): Record { + try { + const themeFile = path.join(app.getPath("userData"), "theme.json"); + if (fs.existsSync(themeFile)) { + const t = JSON.parse(fs.readFileSync(themeFile, "utf8")).theme; + if (t === "light") { + return { + text: "#1a1a1a", + "text-dim": "#888", + accent: "#6366f1", + "progress-bg": "#e0e0e0", + success: "#22c55e", + error: "#ef4444", + background: "#f5f4f1", + }; + } + } + } catch { + // ignore — use dark defaults + } + return { + text: "#e8e8e8", + "text-dim": "#666", + accent: "#8b7bd8", + "progress-bg": "#222", + success: "#22c55e", + error: "#ef4444", + background: "#0d0d0d", + }; +} + +export class BootSplash { + private win: BrowserWindow | null = null; + + create(): void { + const colors = getThemeColors(); + this.win = new BrowserWindow({ + width: SPLASH_WIDTH, + height: SPLASH_HEIGHT, + frame: false, + resizable: false, + minimizable: false, + maximizable: false, + center: true, + show: true, + backgroundColor: colors.background, + webPreferences: { + contextIsolation: false, + nodeIntegration: true, + sandbox: false, + }, + }); + + this.win.loadURL( + "data:text/html;charset=utf-8," + + encodeURIComponent(buildSplashHtml(getIconDataUrl())), + ); + + this.win.webContents.on("did-finish-load", () => { + this.send("splash:theme", colors); + }); + } + + progress(data: SplashProgress): void { + this.send("splash:progress", data); + } + + stepDone(label: string): void { + this.send("splash:step-done", { label }); + } + + error(message: string): void { + this.send("splash:error", { message }); + } + + close(): void { + if (this.win && !this.win.isDestroyed()) { + this.win.close(); + } + this.win = null; + } + + private send(channel: string, data: unknown): void { + if (this.win && !this.win.isDestroyed()) { + this.win.webContents.send(channel, data); + } + } +} diff --git a/electron/splash/cairn-icon.png b/electron/splash/cairn-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..610789d71169cafae9805ea6240fbcd897ed83f0 GIT binary patch literal 10826 zcmb_?Wl$VUx8^Vm?gR!641)%DcMI;pLvSZ}a2VX32@pbHf`#A#0)*i1?j9hxyD#s( z-+uSj*4;l_+g07?^f}M5?yCOLb)q%YUgKbrV*&sG97P3Lt!E1RXQBh2R~6&S&(8#8 zBc&<@08}Mn-J792?;#cnTB-nmFT*n~3;?+O2fqyfcya;&yQTnua0UQC;_~^Irs%Um z)=FQ|T2&Rm{LG^RkRt5>pl1&0d6GX*0021`34r`ek^Yh80{^Xru4VmGsNHn(E;c5?X#01)*SerBDlJj@{8PLA*0g}ud~|A7#G=Km3M zKq3DD@o*4>>Z@u%WSrfsApGo{?3_?>Ob7%b>Sk#ztR*Y|FZ1V}7}VCo!$p{b!^_Ky z-HV6a+0BN7OGrqFgOi(so15(!!RGGs-ownB?Y%qge>(Z!eq^oOE!^x}JnWp`L;mq= zX723iAqIv16X@ULKl8Nmw)?M0@7@1Z)^mXz|7bY4*f}}=?fcnO^dG6PjI*P&o3@LY zg_Ss$=zjqJyXe27{3lw&&fCgSU)Ija>b?7OM&ewY+`Rv?`af0wqp89F-Soey{$(o4 z@lV13?EwDsbpJ#8e7xeAq8$IeDsjxV1Ce3?fU#6jR!YYk=^(()k#;Vd+Q9Op*;$iF zQIk+^$-dTbcW1{!w3^D#vB!b+!`fR?eb)VlA4nkpsb*H-Mh6h-asaAuO5;ihnLPoj zeV7&*j;qK#QMoz~DE3sIl#3>mi@6GTchjV&6h=noq#d)}X$0hW7c^znwBCwT&f4iTtsdzf}sgqaxrTiUcd|TUDas z{*qV9jp zy&>jKmF9(#sKoeysbG;eXxEmXSED*7_ySq=-l?YRo`!R>m}S2xj~+O8iPXPR}Fpw%U6qy$`Y-sSWZy0etCUVOP$xh<2U{%MW%o4RXl5BIQ&;Un+R=-ZG zSd}d6*wJTQ33z*EEr|PAjdShd$IPaWD!0gr!_KPE9e;vc>x>Im69PvgD-gwhgWOjMn(CsGC6wumpkJ?3XA)#b8E z3pAkpO=s?VEsB&+RVgD;kK-tHNzaYYWqh0n5)v1-()T4IQ4D(sm2cA2b=JAQXW;EP z8i3-5;6|e`yb99q7Ber7=hfye78W9OgW=^fvWSr%aXRdiWChyQ-4&1TTgIRw;{AMCD(rfv z2Xs_RI2;lQmqKZ@slUAQv7O97sz?(=ytP`7xu0JW#lqqCyS9EkcP`b2<%Vccv4?Sg zCQL*W0n&qh5)D_Y4~<&Wwpu29O3OOvjN$(DeCKqQQjW+5ANxRhEsNB+R(pKkfGa%3 zH=BB*bQEsOO_ zC+$@~RwQvhxFL+7W4t#8EnXDM%UrvT?J!T%21Ss(km8aOPSikP7Btkw8U1Y~jRG+Fz(`Qwgo0^JD1+!Sk z@=(*(0~#!Oz7(|9EfjP?QlOvTdpst}<+C`s-m-UbSn<-nIu;wG;W_q_WrHqlBJbdS@Dih4-+loLaCN?D-786^hiDPb2?A7 z9&J?ajgfyuxEeI8}O`^7yMw-w5J+)J`rkIMj+ZhQ$lAb zH&7bIk=&Lj@T1tcOr?~PJ=9|kQI-la?S%knti+98!Ta%D;-!Yyquq)Ps{k6;su5_N z)pNIZL0fF;yL>Q(s_$tI%(cG_*KR(n=Y9{;sL)bQ3eV0uNM`YYV&qLny?=*?dM_k+ zU|44Iif{J~m5ZL8?6=Iqlm?Z2jS@~exFqJo$GGS4l^k`L&=Pn?RBe!4HMYP7Wgbqu))a=9PHBgak9CQ!OXsx2EQ$Cq^-H?g8|LP&V$N5&VJT8Bw1bjqWG2NrcWqZQ6hnnN zF?4f|oP8F_oqu;fuzv2+2~lBSkith32+#STY`DETtq25=;|LoiGt!58>zL9JC4oqJ zDQ`OdQVm_7w^F>LR_j(;u1)o(i!3E|9VlLPi$7N2`#LvDW{FHy-Nza)-+m>2fqL)C z8yYw&$LgDjt%^ke1$H?J6S7;Zj)H$*Mww zWLQ=!kXnZ^YN3m&FTx;#1cN^CyyM+&oEOJiv8$<-?7wGnYZ}7YgHsNo1xm{cMb=$# zv@5W)Nf04cQD>L}g(+bcc$|Lra~!v-`d$JzJQn=*^G9RV&-8(h^rdOUG0}OO7UGdNu|ZF4 z8~D>E>)CsL=3Hl&K_)4Ebl2eEH(y%5SWrnJ$KC)txbsL}a|E1m7p9MRi{Gg50Z@w! z%bK4ao|?`q7v_%c$M-5I;=8FIk0ZyfGg>VTJEEtM>@*|r^pHE|h0z{Dp^^z)Jtbc_ zx;P*pz!h6Ou@m%k^#mb^UHKK@u$ITzVEO2$CJdddMj)XYW$Z*&rX_PD{9<`dA?^yh!(RI}JSqMIqOjQ!=B{l1!gC zSfhVkz_f42tCAD@8a6)pJwDT?CsN|=-Q&BZaf->IUx4Tja+Ns_jzla33bzf#XDj#J z67G1o(rx9&4X2lQ%O-}#=ViAMHj$c$bz$Mq8J@BTZfWbyiA_t+nY%?54%w;N-t8?z*{S$m!@TO2k%c$gPX4O$*2e+rK7$Gnz(&z_$cFc_!sU7l|7 zmC~m9miLo^A%bTL&}&$rZzczY4)}jjpaUZ5*TUS|O@p3t9ae_~R>JtaU5-q6 ze`aj?(8s-We+B)5;VF=oVE7B*ZKU>z5;9>4a;0)D$_6m&I zCo4?{?z3cyz4Caex9orR_;4rSyo^oh)Mlbgnw2j^2g1qFrI{a{ zXF8@+`MSD}gHvNHnOgkjW{uoWe^c&y=vUcH;K_L1->t1URAfmGJ?3ki0ck?nYt`l? z4ht5zV!|}^rY7;SCFd0qDK0uJKZV?~$)w*BD?G%QT@PBCtb_#QLf`yhSbTkJm_lBVd7g4Oi%+cyJOE5b;r79!vQ`Z_kBkNT( z@r#J$X)z<5wt?brDzjs2H=RK@&SF z>{-Y-tYD>r6j|0^A2$2D8XDkJQ+qQNegZRRn=cTOERN?GKDvS0KxTq{ zmqN7ZmNpK)@RJBmV~0WlLRD;aqqX=JiEC+afM{{AsjZmhyv`}zqv~&FXmi&7J?*^zNHb2M%Rp0O}zYy ze+{O~=drU5?Q=ZbsRcIbFu!3=KQ8Mzp!(2=caN46=zBKS5m`Jbo+tz{O9b!|_T)}& z4`<(2rJJ6hdw7Mv zl~X`KP>=*%=?|lQZMRunVtl+2tF^aJy@v-7950XDuZkG$>+Y7P$$5PQ@@?lW$niFY z63XHV?(c}}BIRt}ACnQZP6d4pwq=LQebx)+vDEkw2p-3$QbS1=MyDF=x||+HJXOxv zA5#Lpm+e3BkU@j=C@wI?1jf1Rh9ImGb!XVB`^9bU3ir8Go+ zcugniPdr_surc#vJt^HgSrm)n)X=0kGok8VqXu;5q$agG4JUOo61Q1+N^{W3mT6m+ zxOj_5c7hF$94$$~Tv?43Sh{^JIKDfihbBz@ve_gSWxy>fT$(^%J5B+CK58KoP7ak2 z9OXz>YBE|#q>{iuJzsPQ=fO{2F_SdTzS4o+yEf8aiiby>@HCN>q(=Q3bu6 zgD3CV4@?IFlV0+XOFTFqnH7|&Ia6k4gg5&MOb3YP=r9P7o}>4;0;@3bcZ!}k_5GnT z5pae!EncvQ;4y7R)q)dl)~A@UPm4KtLdznJgfY_ zLWwfphZ$=~PkUF5<}Qh?G)e?|x!R4{9`1N0reoeT)xIp9B^5tvQ`{h&Z>U`Yt$q18 zght_~>=*iFOt0mfuR$wSvFm8%)+N9qz{0&lOw|8icWNw|fPh7saJ2n1ZhZmvBF(N) zTdFhO$0vaA=#g87ihQy{+;+1*Qv@x+Dit~Hsi~L^Ut?#ed2SF zi(DFf^wwsX?`9@nPe6kN&8QRK=v(L4d42g?3!~hrqeIN#C~6i6`2iLOtVHUUl$4TW zgWxQ8+sQ*p!q8zQ19ojSm62=^F7h%hzGS1*y7B@B?1IS>GpchFlin>z$J8FqoJ8T0 z*nk>Ii=aK~K1DeD+4$Y9Q|)!>u@8TdsF<=hz2y>e`?N!NjS;wrWefQmipP-4I~x`G zT1VZQYlp08D>)}=9&HV&8#I$Nv^hg1Zm6%1{t`{Us(xh6Em0-Y@8Ft{M7NBV9*iyr zC7W)Doi+dIjk>0>fC0fD%?iVx`sV4`AT6Z?q>sZH$hgtXnwXs+p~BdAET^P$Y`HsJ zS!s5^yC1y4s;;i&-1r~@Vx%kw8AGEtUijomVUuub<_*6r{LvqvUWVHY<*|2!W*WbJ z`_{l9MlR1hdUBJB;#9@Z_fC>R7S!dS3kkdG?$K)aJY-SJu_XBRYy8ihf&-8#`l7z6 z5n(jwqja$jbGAw3SJGWhreG=Qp=oh<^>@O2TMH&v2k!%q+c*xFn?jk}pMO=#W+F_B zL*(>{0z!bBYiSW+)*38yVRBlpO06nJ0RmHuMMc5izkcj5Ey|`8Y1ws;49VQo2)_u? zkW5sCCb!08g|({)XGQh6R3N1VhuP4nY;s@favXR&NFaf@eyr`932_S26)^F9n`AE2 zjZL9s#^>xO{if^u`*@e4W1T10%*)|m_&_9)1T5O|rQya$FGylgq!eccmz)+finkG7 zZx}<|h0TrU5UPD283_yY@kmQC8$9z&BAh_q9ZkV&a~(F}?e*on%tlYRJ8 zJ{;6$!iv)4r8M`~3}~`uQMP+i2v58jvZr@>!Kq{B{9S~7-l7KOX_{P@aXEX`86xB<)%U`;N|zrQsnN=oYNHcS$y1N4S{a zy{o1KtTa*M6c@fP8QS{=8Nte}%K%?XB1q!T$^OKh$B7c>^BTYwy#|hy?3%(p2k*awmyCOLPhSD2kMN` zSZ=eY-srvw$Z@tApoo{lPnf@pjCft>>-3<&k?WM^?Pr_?QU;I0)3TzCgwv$EOj!v`mDI4EulFB(BH0!Y7JuA4DKA* zm~Kpx9H|4ozJvC)%-~oKHnw%*^pGTK=8%`UBRiu!xd>h3sEd6KimTx`0QdI^R}F`u za6TrU$?wW|?_1S{QRPuZ*M$%+PD>>^{l;o{gXbXQjx{q#rpmg2wQ@y4&&LeWmb#`u z|81E99BUL;Tp(!yCW{7xv5bYV7(6v)+1AmgSXe4af#_f)ExG z=IS%8=zqhj;(*^J%MZ?h>MvWXS;U)8NuHLifeOnbrU%sboH3x0#FHHjjrd@5!ASZ0 z#$}nDx{8$pa;nftj&jF-s}!X4_4b=S|K^Q_s}5&92b_3$iKpGYE_V{1^FVt1SVdBl z_w4w}_>s_%F#V1yJZ!;AOj`$stL}@e4C~f&4$taJ!Bs4&7D#H;*6G}4xRF>B?np21 zALv)yNy;`MTI{ay)XK6U?ehbAz@|4gYB)(J)tru&bEPT0I+ic#TV_{YTW9ZXn_Azq z%UfyjoX7JHY6`ZWNLPaQ+o%11FNYsTVQ2S)pnPAy883hT5KJDrIolc-;}5!Y(s)!+ z&fcF_a}i7I{>FwTZw;1ut)06w?GzCMx5@DiWb{ICjq7+pr`lAE~D}_3)7E1yy zjrmo<4y{sp-}Mitrj2ap^>Mm-uReMOK1u`hCd4X#>JCNQ zEsvsXw*M@~DY5$dmM z(m~aPaFG+D(-@T#rROYmHL0Dr_&=;MyP;Gs4a$wHEBXeD9E=0dm!5w-1FtVf%n*FO z$deKTSOKTiUh;+Pvy{mCA7OFWFfDpW_<%I~=E{ne!?{vCl@gCIU(QJ36@Pk!d?67t zP9@T+1I5CEh(Ilb%ZtVEQaca(jdBzKugXWHRp~EQG0lve=TBNVrmD zi5X7SNpf1B82Sx4Fk}>c2k3zxK)|RXhQS>tBhfxvU@VLn-<7NN-KBdx4Dw|pRyDc( zOFX#WBDt8uKLrNnb|vQ>7abW#j$R0(5h>xU%1bd2$1dJn7cs|wFwGED$qccSheqdD z1bLr~Ih97>SANxd_Y@(G_%ZjkO@_Ml>)&vE6kH9f?)VRt(jtC$%mqMq45e^Zg4n_o zI+Ga4Z#(`Sd+Jhr`ee(hjMp`x_x%2DI_!VVf;KSuYF-!Bt)ly5kVj@l#+YW0A5P

YJk02|f(`QTub7oa*&9kXBUuEN1F({;V8$9$6>ON`lqr-8pGN5(EC$BrTMx zgxyE^$+$CRfP{OF5_DtgjCUCv-aVFCQM4v%Y2>_bW`IU1!%G+@O;3d_?W<$wfnF2x81NLa@@M;|9Un0Hql%aJZEuXC zelQ%Zk{~`p<$IDaDh>n7F$zuzmKP`eDkUY^xr*(1VW!?P>9GpRWnIEUW8%?0;bU+Z z@1&k~h14ZF@&;TGZ!|6evJT0hDxwpM->rhh66-k-L$Hp3$WZ|W8lgEb>5Mxa?hmVH z5x4>LOfjHAS_nSn3KL__Bf_Hp-*Z@9ve2cmaQ8!opeQD@@zL223up zhO&otcBgcN4Yd{ToOczw^7_2J15Vm5Z&QYlz}ADhk!_Y_q+iYtN~v#t15#;}-}gK4JpGknde7 zSBcG)5sW={M;j~=T$u-i#gyp%o6Mb;RmWU}JKl6rXqhxVoQ9}y@BCnekkk{lPm;f2 za|DBN!LjP;;|Fy zV!#C*Q(GdFP8Qsw2Lof^0Y%c*4^m8dotOGmSBG-bh}mi zMQe?5fvh5<+ut?Xci49#C7vaXjryV1J0(U5*}Rp(12`>@w++YKm_p#czgEt3CF*kp zzTq!7P(n7Icg}yR+su0l63rTim|?8}DGcC%G}Bkt`fxlHCMHYn)Ujc@q!LUp9{5VSPpIbqkk1Tm>k1`>|SI!Af+>O~dhsfKg1Q`8xK3Sfc|_M*F@Dl9BWbxC6E-=EV9@>adoBw{wUh_O(;t;X<2}9lL2%3ItZ|0J30DwXr4Az z$<%3jdfGJzP>xxMIa-&*IMeH-?$!HR`oTmrcmiu^U%2wf~m@O`x7Sg)%Nm5UM=45Twpy8Red2cu7yr*S%1 zjo&|Uz0-;QmX{(*dxK*3$TCzD4!~{>8obyJMJtxoVb*I8GRJ1y7!$e3g8_*BH*7Pw zWJ^^E)ktLr_jJD-HHtj8Cg+X(rJ^LIqC9^|WuD2?uFo1LS*BydZE@ktp2`Y7^&Z5> z#%ETuk4AC_qH}1vN8BLWm`7XMw(_ldm@|8OdIt2=Mb46BvhnM)euYn3JYjx2M@AEC z`!XU~#oZ?u10Eg=bVcA% zlG0|9`nshWdoC^J#TlX2Nyn5F`UL2HWnvaoc#cikG#CJANyIv+8#7&UFpn}YwLuj0$ci&{7N%FwTB?4?z8R)>HtT{}MmEdDi zQoLNh42(r)f+ET&2BjUTzYlNNHd&1@ z2_s{&G?K)b5}0Kf1VKzANg~PDD53kFTD3i&qTN&1>&Z|lP2~kiF=@kk>d;xZ2>8YM z&H1*rY4jMhZy@J0ht!qGdKqzVZp zTeX5k;iwKjk4-$qwejU}6v$s_9*pYyxlEEan0Ek8tN2zS%`u@(xI z0UIJZiUchpbJdsYIVz#(C5EeSQck4VV-r}Lrg!vgBHoc{ej@H9{OO}re*kjf1-v5t zX#S)A%*A2nZz}8wz51M~86wx7kA_Wf++}(a$xHgn4AAZgMZ6A3Na0;Ik{_Z{~_ zIG@5RSdrd*V=7Yx{A7lk4ifmBSkSJ<)JpwEz8@+1!zm6EjWEqOfxT^9t8eD)STa)% zM_J9>JpN;}6YB)MwG09MP_KsrKQUV1moW&D4)>d<)5lZ_tmd+8rLvH1ISWp^8r1iX zu>c&PlH6o?F4Vl3veV_dBXk0-2tOPalT-s9QR*A>Q;YtjDS_z;?6(BQ%Vbj0N#mhb z%D@;anB4!m+o=$zouXV*Y>l=~!rjZ=*{cjyu!kpDe+0DV{PZsbxnu(=yt{mhUF-k6 z`(0Jp*bvXSk1#BoCex^r>Py@i`ct>#h z*P@>w$#uxmJqS%3MP~= zPPfnRb*_^M(@o3q=V^Iboj7^UND9k`&J!-V&54+!xLp}@P8`tCZR7Fl^z`WP3PmyGz4W*$*Tj;B(Lu0L57jc*8-lf%d z?j-{0Gt?Cf?TiX9SCN@;m@EnVwqTq1j4tkepF8U;o4SyjzdnA{D~7xqCW;keWggqb ze&Mcdk_RkB_yDoQ%OfGR-NoEAeN)DZl&_3+mbv%q0Y{01N7cYYoyQCIj zy3BvZUVXn{Hm6(cqa#!%(1U)W!$4YeXpkKjK=cZ21JBFJ()Hb^r5n=^LICo^qB+?m<^9k)*{EioE$+pt8nHXR2*-%dgu`lL` zAtp3OZ7Q~BULvLO-B%#X{(g*Q?-8=;B*Ab*A!K~+kciSiXG!p|Zt|?0;kLC-Qa78g zlaYF;XuKQ|y6l4F@}aX^6CV#!xsNE|DdO2SL`=pgD&kBDgD$fBip0_WuH0Xc!~out zDhzVAEq|IuL~^d*?4!o6h)$=UW^q7}M%KMQ!Nr31V{6q}qTZD%KL+jDH-bDeM%e#< gHmLni?QTza6D&0Kw>Yn8{%N~Zlv9%}mo^RgUy7>UF#rGn literal 0 HcmV?d00001 diff --git a/src/app/page.tsx b/src/app/page.tsx index d5073e64..28780844 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,8 +19,6 @@ import { SearchPanel } from "@/components/search/search-panel"; import { SettingsView } from "@/components/settings/settings-view"; import { AgentView } from "@/components/agent/AgentView"; import { Onboarding } from "@/components/onboarding"; -import { MigrationModal } from "@/components/layout/MigrationModal"; -import { ReindexModal } from "@/components/layout/ReindexModal"; import { RightPanel } from "@/components/layout/RightPanel"; import { UpdateBanner, ErrorToasts } from "@/components/layout/app-chrome"; @@ -332,12 +330,6 @@ export default function Home() { {searchOpen && } - {/* Migration modal — blocks app when workspace migrations are pending */} - - - {/* Reindex modal — prompts when embedding model changed */} - - {/* IPC error toasts — bottom-right, auto-dismiss after 5s */} From 420348fb25d34188b6bbaa898ba7a0873c2e0ed9 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Sun, 21 Jun 2026 21:22:51 -0400 Subject: [PATCH 2/7] feat: unified runtime architecture + SHA256 verification + splash screen (v2.1.7) - Splash screen with boot sequence: update check, migrations, embeddings reindex, notes sync - Auto-updater with live download progress (prevents broken-build launch) - Unified runtime: single process for LLM + embeddings behind one HTTP API - Adapter interface for future runtimes (MLX etc.) - SHA256 verification on all model downloads + manifest migration - runtime:* IPC namespace + preload API surface - Runtime auto-start on boot when embeddings enabled - Migrate all renderer components to runtime.* channels - Packaged app: ship onnxruntime-common + sharp stub (fixes prod crash) - Bundle-guard: RUNTIME_SHIPPED classification - Security: execFile instead of exec, modelId validation, streaming chat proxy - Baselines captured (packaged app): idle +158MB, both-active -25MB --- .gitignore | 1 + changelogs/v2.1.7.md | 35 + electron-builder.yml | 10 +- electron/bundle-guard.test.ts | 33 +- electron/embeddings/service.ts | 6 +- electron/ipc/handlers.ts | 2 + electron/ipc/runtime-handlers.ts | 128 ++++ electron/main.ts | 3 + electron/mcp-server.ts | 11 + electron/mcp/tools/graph.ts | 59 +- electron/preload.ts | 106 +++ electron/runtime/adapters/embeddings.ts | 399 ++++++++++ electron/runtime/adapters/llama.ts | 683 ++++++++++++++++++ electron/runtime/adapters/types.ts | 139 ++++ electron/runtime/client.ts | 528 ++++++++++++++ electron/runtime/model-manager.test.ts | 227 ++++++ electron/runtime/model-manager.ts | 210 ++++++ electron/runtime/port-discovery.ts | 184 +++++ electron/runtime/server.ts | 412 +++++++++++ electron/splash/boot-sequence.ts | 116 ++- electron/splash/bootsplash.ts | 16 +- package.json | 4 +- scripts/build.js | 8 +- scripts/capture-runtime-baseline.sh | 141 ++++ src/components/graph/SemanticMapCanvas.tsx | 2 +- src/components/layout/ReindexModal.tsx | 6 +- src/components/onboarding/StepEmbeddings.tsx | 19 +- src/components/onboarding/index.tsx | 5 +- src/components/search/search-panel.tsx | 10 +- src/components/settings/AISettings.tsx | 79 +- .../settings/EmbeddingsSettings.tsx | 53 +- 31 files changed, 3480 insertions(+), 155 deletions(-) create mode 100644 changelogs/v2.1.7.md create mode 100644 electron/ipc/runtime-handlers.ts create mode 100644 electron/runtime/adapters/embeddings.ts create mode 100644 electron/runtime/adapters/llama.ts create mode 100644 electron/runtime/adapters/types.ts create mode 100644 electron/runtime/client.ts create mode 100644 electron/runtime/model-manager.test.ts create mode 100644 electron/runtime/model-manager.ts create mode 100644 electron/runtime/port-discovery.ts create mode 100644 electron/runtime/server.ts create mode 100755 scripts/capture-runtime-baseline.sh diff --git a/.gitignore b/.gitignore index 2d3452e8..af353c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ cairn.db-wal # test mock vaults /tests/fixtures/obsidian-vault/ +scripts/runtime-baselines/ diff --git a/changelogs/v2.1.7.md b/changelogs/v2.1.7.md new file mode 100644 index 00000000..530e5039 --- /dev/null +++ b/changelogs/v2.1.7.md @@ -0,0 +1,35 @@ +## What's new + +### Features + +- **Splash screen with boot sequence**: New `BootSplash` window shown immediately on launch while the main window is created hidden (`show: false`). A multi-step boot sequence runs before the main interface appears: (1) auto-updater check, (2) workspace migrations, (3) embeddings reindex, (4) notes sync, (5) semantic relationship computation. Each step reports live progress (label + detail + percent) to the splash UI. The main window only shows after the renderer has painted and a minimum 3-second display time has elapsed. Migration and reindex modals previously shown inside the app are now handled during boot — removed from `page.tsx`. + +- **Auto-updater with live download progress**: The boot sequence checks for updates *before* the main window opens, so a known-broken build can heal itself via auto-update without ever loading the crashing renderer code. When an update is found, the 172 MB download shows live progress on the splash: `84.3 / 172.9 MB (12.4 MB/s)` with a moving progress bar (15–95% range). After download completes, `quitAndInstall()` restarts the app automatically. Timeout is 300s (5 min) for slow connections; on timeout or network failure, the boot continues with the existing version rather than leaving the user stuck. + +- **Unified runtime architecture**: Replaced the separate embeddings-server and llama-server child processes with a single `runtime-server` process spawned by Electron on boot. The runtime-server hosts both adapters behind one HTTP API on a single port: embeddings run in-process via `@huggingface/transformers` + `onnxruntime-node`, while the LLM adapter spawns and proxies to `llama-server` as a grandchild. A new adapter interface (`RuntimeAdapter` / `ModelManagingAdapter` in `electron/runtime/adapters/types.ts`) makes future runtimes (e.g. MLX on Apple Silicon) a drop-in addition with zero Electron or IPC changes. The MCP server auto-discovers the runtime via `runtime-port.json` (with pid liveness check to reject stale ports) and can self-spawn the runtime in dev mode when Cairn isn't running. + +- **Runtime auto-start on boot**: `boot-sequence.ts` calls `runtime.ensureStarted()` when embeddings are enabled, so the unified runtime is ready for search and semantic features immediately on launch — no lazy-start latency on the first search. + +- **SHA256 model verification**: Every model download (LLM GGUFs and embedding ONNX) streams to a `.tmp` file, verifies against the HuggingFace LFS SHA256 checksum, then atomically renames on success. A `manifest.json` per models directory tracks `status`, `downloadProgress`, `verifiedAt`, and `error` per entry. `migrateManifest()` runs on every runtime startup, marking stale entries and backfilling `verifiedAt` for pre-existing files. `verifyOnDisk()` is called from `listModels` / `isModelInstalled` so a corrupted file is detected before inference, not during. + +### Changes + +- **`runtime:*` IPC channel namespace**: New unified IPC surface in `electron/ipc/runtime-handlers.ts` with full preload API (`window.electron.runtime.{onProgress, embeddings.*, llm.*}`): model install/remove/setDefault, status probes, progress streaming, LLM binary install/clearInactive/setDefault. Legacy `embeddings:*` / `llama:*` channels retained for DB-touching operations (reindex, search, projections, recompute). + +- **`defaultEmbed` routes through the unified runtime**: `electron/embeddings/service.ts` now calls `runtime.embed()` instead of the legacy embeddings-server client, so every embedding inference in the app goes through the single runtime port automatically. + +- **MCP `search_notes_semantic` uses the runtime**: The MCP tool now embeds queries via `embedViaRuntime()` in `electron/runtime/port-discovery.ts`, with graceful self-spawn fallback in dev mode. SIGTERM/SIGINT handlers in `mcp-server.ts` call `disposeSpawnedRuntime()` for clean shutdown. + +- **UI migrated to `runtime:*` channels**: `AISettings.tsx`, `EmbeddingsSettings.tsx`, `StepEmbeddings.tsx`, `onboarding/index.tsx`, `ReindexModal.tsx`, `search-panel.tsx`, and `SemanticMapCanvas.tsx` all use the new `window.electron.runtime.*` API. Enabling embeddings in settings now calls `runtime.embeddings.ensureStarted()` instead of just saving settings. + +- **Packaged app ships runtime dependencies**: `electron-builder.yml` now ships `@huggingface/transformers`, `onnxruntime-common`, `sharp` (stub), and `umap-js` inside the app asar, with `onnxruntime-node` unpacked from asar (via `asarUnpack`) so its native `.node` binding can load. The runtime-server bundle (`dist-electron/runtime-server.bundle.js`) runs as an `ELECTRON_RUN_AS_NODE=1` child of the Electron binary, so no separate Node.js runtime is needed. Updated `electron/bundle-guard.test.ts` with a `RUNTIME_SHIPPED` classification that verifies these packages are present in `electron-builder.yml` but never leak into `main.js` requires. `scripts/build.js` now builds `runtime-server.bundle.js` as part of the standard compile step. + +## Baseline (packaged macOS app, M5 Pro, 64 GB RAM) + +| State | Pre-Refactor (v2.1.6) | Post-Refactor (v2.1.7) | Delta | +|-------|----------------------|------------------------|-------| +| Idle | 719 MB | 877 MB | +158 MB | +| Embeddings active | 921 MB | 967 MB | +46 MB | +| LLM + embeddings active | 8,917 MB | 8,892 MB | −25 MB | + +Idle overhead is the always-on runtime-server process (103 MB). Both-active is 25 MB *less* than pre-refactor because the unified process is more memory-efficient than separate embeddings + LLM processes. See `scripts/runtime-baselines/` for raw captures. diff --git a/electron-builder.yml b/electron-builder.yml index bdf59c44..5b7ede5b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -26,8 +26,15 @@ files: - node_modules/file-uri-to-path/**/* # node-pty — native PTY addon, must be present at runtime - node_modules/node-pty/**/* + # Runtime server externals — transformers.js + onnxruntime + sharp stub (in-process embeddings) + - node_modules/@huggingface/transformers/**/* + - node_modules/onnxruntime-node/**/* + - node_modules/onnxruntime-common/**/* + - node_modules/sharp/**/* + - node_modules/umap-js/**/* + - electron/sharp-stub/**/* # Exclude everything else in node_modules - - "!node_modules/!(better-sqlite3|bindings|file-uri-to-path|electron-updater|node-pty)/**" + - "!node_modules/!(better-sqlite3|bindings|file-uri-to-path|electron-updater|node-pty|@huggingface|onnxruntime-node|onnxruntime-common|sharp|umap-js)/**" # Unpack from asar — native .node files and anything that needs real filesystem paths asarUnpack: @@ -41,6 +48,7 @@ asarUnpack: - node_modules/file-uri-to-path/**/* - node_modules/electron-updater/**/* - node_modules/node-pty/**/* + - node_modules/onnxruntime-node/**/* # Rebuild native modules against Electron's Node version npmRebuild: true diff --git a/electron/bundle-guard.test.ts b/electron/bundle-guard.test.ts index 96bd4566..07c5b3a5 100644 --- a/electron/bundle-guard.test.ts +++ b/electron/bundle-guard.test.ts @@ -31,6 +31,7 @@ const ROOT = path.resolve(__dirname, ".."); const BUNDLES = [ "dist-electron/main.js", "dist-electron/embeddings-server.bundle.js", + "dist-electron/runtime-server.bundle.js", ] as const; /* ──────────────────────────────────────────────────────────────────────── * @@ -57,13 +58,13 @@ const OPTIONAL_TRANSITIVE = new Set([ ]); /** - * Used only by the embeddings-server worker subprocess, never by the Electron - * main bundle. The worker (`embeddings-server.bundle.js`) has its own runtime - * resolution path (injected via `TRANSFORMERS_CACHE` / `pkg.config.js`), so - * these packages MUST NOT be listed in electron-builder.yml either — otherwise - * we'd bloat the main app with packages no main-bundle code can resolve. + * Used by the unified runtime server subprocess, which runs as an + * ELECTRON_RUN_AS_NODE child of Electron. These packages MUST be shipped + * via electron-builder.yml because the runtime-server resolves them from + * the app's node_modules at runtime. They must NOT appear in the Electron + * main bundle requires (main.js) — only in runtime-server.bundle.js. */ -const SUBPROCESS_ONLY = new Set([ +const RUNTIME_SHIPPED = new Set([ "@huggingface/transformers", "onnxruntime-node", ]); @@ -78,13 +79,13 @@ const SHIPPED_NATIVE = new Set([ ]); /** - * The full allowlist. Union of the four groups above. Any `require()` in any + * The full allowlist. Union of the groups above. Any `require()` in any * bundle that isn't in this set (or a Node built-in) fails the test. */ const ALLOWED_EXTERNALS = new Set([ ...RUNTIME_PROVIDED, ...OPTIONAL_TRANSITIVE, - ...SUBPROCESS_ONLY, + ...RUNTIME_SHIPPED, ...SHIPPED_NATIVE, ]); @@ -212,22 +213,22 @@ describe("allowlist drift checks", () => { ).toHaveLength(0); }); - it("every SUBPROCESS_ONLY external is NOT shipped via electron-builder.yml", () => { - const leaks = [...SUBPROCESS_ONLY].filter((x) => shipped.has(x)); + it("every RUNTIME_SHIPPED external ships via electron-builder.yml", () => { + const missing = [...RUNTIME_SHIPPED].filter((x) => !shipped.has(x)); expect( - leaks, - "These are classified SUBPROCESS_ONLY (used solely by the embeddings worker, which resolves them via its own node_modules/pkg runtime) but electron-builder.yml \`files\`/\`asarUnpack\` is shipping them too. Shipping them in the main Electron app is either wasted space or a sign of misclassification — they shouldn't be on main-bundle disk.\n Leaks:\n - " + leaks.join("\n - "), + missing, + "These are classified RUNTIME_SHIPPED (used by the runtime-server subprocess which runs as ELECTRON_RUN_AS_NODE) but electron-builder.yml `files`/`asarUnpack` doesn't include them. Add a `node_modules//**/*` entry.\n Missing:\n - " + missing.join("\n - "), ).toHaveLength(0); }); - it("SUBPROCESS_ONLY externals never appear in the Electron main bundle requires", () => { + it("RUNTIME_SHIPPED externals never appear in the Electron main bundle requires", () => { const mainPath = path.join(ROOT, "dist-electron/main.js"); if (!fs.existsSync(mainPath)) return; const mainReqs = new Set(extractRequires(mainPath)); - const leaks = [...SUBPROCESS_ONLY].filter((x) => mainReqs.has(x)); + const leaks = [...RUNTIME_SHIPPED].filter((x) => mainReqs.has(x)); expect( leaks, - "dist-electron/main.js \`require()\`s a package marked SUBPROCESS_ONLY. These packages are not shipped with the Electron app (they only live in the embeddings worker) — the main bundle must not import them. If a main-bundle module genuinely needs them, reclassify as SHIPPED_NATIVE and verify they ship in electron-builder.yml.\n Leaks:\n - " + leaks.join("\n - "), + "dist-electron/main.js `require()`s a package marked RUNTIME_SHIPPED. These packages are only for the runtime-server subprocess — the main bundle must not import them directly. If a main-bundle module genuinely needs them, reclassify as SHIPPED_NATIVE and verify they ship in electron-builder.yml.\n Leaks:\n - " + leaks.join("\n - "), ).toHaveLength(0); }); @@ -235,7 +236,7 @@ describe("allowlist drift checks", () => { const groups = [ ["RUNTIME_PROVIDED", RUNTIME_PROVIDED], ["OPTIONAL_TRANSITIVE", OPTIONAL_TRANSITIVE], - ["SUBPROCESS_ONLY", SUBPROCESS_ONLY], + ["RUNTIME_SHIPPED", RUNTIME_SHIPPED], ["SHIPPED_NATIVE", SHIPPED_NATIVE], ] as const; const seen = new Map(); diff --git a/electron/embeddings/service.ts b/electron/embeddings/service.ts index 1fe9ef14..1d863c0d 100644 --- a/electron/embeddings/service.ts +++ b/electron/embeddings/service.ts @@ -11,7 +11,6 @@ import { deleteNoteEmbeddingSections, } from "../db/queries"; import type { NoteEmbeddingRecord } from "../db/queries"; -import { embed as clientEmbed } from "./client"; import { projectTo2d, normaliseProjection } from "./projection"; import { toFloat32 } from "./cosine"; import { splitIntoSections } from "./sections"; @@ -20,7 +19,10 @@ import type { EmbedTask } from "./types"; export type EmbedFn = (texts: string[], task: EmbedTask, model?: string) => Promise; -const defaultEmbed: EmbedFn = (texts, task, model) => clientEmbed(texts, task, model); +const defaultEmbed: EmbedFn = async (texts, task, model) => { + const { embed: runtimeEmbed } = await import("../runtime/client"); + return runtimeEmbed(texts, task, model); +}; const BATCH_SIZE = 16; const CHUNK_CHAR_LIMIT = 4000; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index f5d807d9..a5aca6e4 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -36,6 +36,7 @@ import { registerAiHandlers } from "./ai-handlers"; import { registerLlamaHandlers } from "./llama-handlers"; import { registerGraphHandlers } from "./graph-handlers"; import { registerEmbeddingsHandlers } from "./embeddings-handlers"; +import { registerRuntimeHandlers } from "./runtime-handlers"; import { registerChatHandler } from "./chat"; import { registerPdfExportHandler } from "./pdf-export"; import { registerUrlMetadataHandler } from "./url-metadata"; @@ -60,6 +61,7 @@ export function registerIpcHandlers(ctx: DbContext): void { registerLlamaHandlers(ctx); registerGraphHandlers(ctx); registerEmbeddingsHandlers(ctx); + registerRuntimeHandlers(ctx); } /** diff --git a/electron/ipc/runtime-handlers.ts b/electron/ipc/runtime-handlers.ts new file mode 100644 index 00000000..90c61739 --- /dev/null +++ b/electron/ipc/runtime-handlers.ts @@ -0,0 +1,128 @@ +import { registerIpcHandle } from "./registry"; +import { handle, type DbContext } from "./result-helpers"; +import * as runtime from "../runtime/client"; +import { BrowserWindow } from "electron"; + +let progressForwarderSetUp = false; + +function ensureProgressForwarder(getWin: () => BrowserWindow | null): void { + if (progressForwarderSetUp) return; + progressForwarderSetUp = true; + runtime.onProgress((ev) => { + const win = getWin(); + if (!win || win.isDestroyed()) return; + if (ev.kind === "progress") { + win.webContents.send("runtime:download-progress", { + modelId: ev.modelId, + status: ev.status, + file: ev.file, + progress: ev.progress, + loaded: ev.loaded, + total: ev.total, + }); + } else if (ev.kind === "ready") { + win.webContents.send("runtime:download-progress", { + modelId: ev.modelId, + status: "ready", + progress: 100, + }); + } else if (ev.kind === "binary-progress") { + win.webContents.send("runtime:binary-progress", { + progress: ev.progress, + speed: ev.speed, + status: ev.status, + error: ev.error, + }); + } + }); +} + +export function registerRuntimeHandlers(ctx: DbContext): void { + ensureProgressForwarder(ctx.getWin); + // ── Runtime health & lifecycle ───────────────────────────── + registerIpcHandle("runtime:status", () => handle(async () => { + return runtime.getRuntimeStatus(); + })); + + registerIpcHandle("runtime:stop", () => handle(async () => { + await runtime.stopRuntime({ force: true }); + return { ok: true }; + })); + + // ── Embedding model management (via unified runtime) ──────── + registerIpcHandle("runtime:embeddings:status", () => handle(() => { + return runtime.getEmbeddingsStatus(); + })); + + registerIpcHandle("runtime:embeddings:ensureStarted", () => handle(async () => { + await runtime.ensureStarted(); + return { ok: true }; + })); + + registerIpcHandle("runtime:embeddings:models", () => handle(async () => { + return { models: await runtime.listEmbeddingModels() }; + })); + + registerIpcHandle("runtime:embeddings:install", (_e, { modelId }: { modelId: string }) => handle(async () => { + await runtime.installEmbeddingModel(modelId); + return { ok: true }; + })); + + registerIpcHandle("runtime:embeddings:remove", (_e, { modelId }: { modelId: string }) => handle(async () => { + await runtime.removeEmbeddingModel(modelId); + return { ok: true }; + })); + + registerIpcHandle("runtime:embeddings:setDefault", (_e, { modelId }: { modelId: string }) => handle(async () => { + await runtime.setDefaultEmbeddingModel(modelId); + return { ok: true }; + })); + + // ── LLM model management (via unified runtime) ───────────── + registerIpcHandle("runtime:llm:models", () => handle(async () => { + return { models: await runtime.listLLMModels() }; + })); + + registerIpcHandle("runtime:llm:install", (_e, { modelId, useMirror }: { modelId: string; useMirror?: boolean }) => handle(async () => { + await runtime.installLLMModel(modelId, useMirror); + return { ok: true }; + })); + + registerIpcHandle("runtime:llm:remove", (_e, { modelId }: { modelId: string }) => handle(async () => { + await runtime.removeLLMModel(modelId); + return { ok: true }; + })); + + registerIpcHandle("runtime:llm:start", (_e, { modelId, contextLimit }: { modelId: string; contextLimit?: number }) => handle(async () => { + const port = await runtime.startLLMServer(modelId, contextLimit); + return { port }; + })); + + registerIpcHandle("runtime:llm:stop", () => handle(async () => { + await runtime.stopLLMServer(); + return { ok: true }; + })); + + registerIpcHandle("runtime:llm:status", () => handle(async () => { + return runtime.getLLMStatus(); + })); + + registerIpcHandle("runtime:llm:checkUpdate", () => handle(async () => { + return runtime.checkLLMBinaryUpdate(); + })); + + registerIpcHandle("runtime:llm:binary:install", () => handle(async () => { + await runtime.installLLMBinary(); + return { ok: true }; + })); + + registerIpcHandle("runtime:llm:clearInactive", () => handle(async () => { + await runtime.clearInactiveLLMModels(); + return { ok: true }; + })); + + registerIpcHandle("runtime:llm:server:setDefault", (_e, { modelId }: { modelId: string }) => handle(async () => { + await runtime.setDefaultLLMModel(modelId); + return { ok: true }; + })); +} diff --git a/electron/main.ts b/electron/main.ts index 1abf199e..e12ada58 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -32,6 +32,7 @@ import { killTrackedBashProcesses } from "./lib/coding-tools/bash"; import { startMcpNotificationPoller } from "./lib/mcp-poller"; import { stopServerSync } from "./lib/llama-server"; import { dispose as disposeEmbeddingsWorker } from "./embeddings/client"; +import * as runtime from "./runtime/client"; import { BootSplash } from "./splash/bootsplash"; import { runBootSequence } from "./splash/boot-sequence"; @@ -370,6 +371,8 @@ app.on("before-quit", () => { stopServerSync(); // Terminate the embeddings worker child process (HTTP server) so it doesn't linger void disposeEmbeddingsWorker(); + // Terminate the unified runtime process (embeddings + LLM proxy) + runtime.stopRuntimeSync(); }); app.on("window-all-closed", () => { diff --git a/electron/mcp-server.ts b/electron/mcp-server.ts index 34abcd42..605b1794 100644 --- a/electron/mcp-server.ts +++ b/electron/mcp-server.ts @@ -152,4 +152,15 @@ if (require.main === module) { process.stderr.write(`[cairn:mcp] Fatal: ${err}\n`); process.exit(1); }); + + // Clean up any spawned runtime process on shutdown + const cleanup = () => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { disposeSpawnedRuntime } = require("./runtime/port-discovery"); + disposeSpawnedRuntime(); + } catch { /* ignore */ } + }; + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); + process.on("SIGINT", () => { cleanup(); process.exit(0); }); } diff --git a/electron/mcp/tools/graph.ts b/electron/mcp/tools/graph.ts index 8f070231..0af33ed2 100644 --- a/electron/mcp/tools/graph.ts +++ b/electron/mcp/tools/graph.ts @@ -258,31 +258,54 @@ export async function search_notes_semantic(db: Database.Database, args: Record< if (!query || typeof query !== "string" || !query.trim()) { return { error: "query is required" }; } - // Dynamic import — service.ts transitively imports manifest.ts which calls - // app.getPath("userData") at module load. Deferring avoids requiring the - // Electron `app` singleton to be ready at import time (e.g. in MCP tests). + // The standalone MCP server has no Electron dependency, so it can't import + // `embeddings/client.ts` (which calls `app.getPath("userData")` at module + // top-level). Instead we call the unified runtime's HTTP API directly via + // `embedViaRuntime`. If Cairn isn't running and the runtime can't be + // spawned, we fall back to a graceful error message. + const { embedViaRuntime } = await import("../../runtime/port-discovery"); const { searchAdjacent } = await import("../../embeddings/service"); const { getEmbeddingsSettingsCached } = await import("../../lib/config-cache"); - const { getDefaultModelId } = await import("../../embeddings/client"); const { EMBED_MODEL_ID } = await import("../../embeddings/types"); + const settings = getEmbeddingsSettingsCached(); if (!settings.enabled) { return { error: "Embeddings are not enabled. Enable them in Settings → Embeddings first." }; } - const model = settings.modelId ?? getDefaultModelId() ?? EMBED_MODEL_ID; + const model = settings.modelId ?? EMBED_MODEL_ID; const kVal = typeof k === "number" && k > 0 ? Math.min(Math.floor(k), 100) : 5; - const results = await searchAdjacent( - db, - workspaceId as string, - query as string, - kVal, - [], - model, - ); - insertNotification(db, "search_notes_semantic", "Semantic search", `query="${String(query).slice(0, 60)}" → ${results.length} hits`); - return { - query, - model, - results, + + // Build an embed function that calls the runtime HTTP API. + // Returns null when the runtime is unreachable — searchAdjacent would + // receive null vectors and we catch that before calling it. + const embedFn = async (texts: string[], task: import("../../embeddings/types").EmbedTask): Promise => { + const vectors = await embedViaRuntime(texts, task, model); + if (vectors === null) { + throw new Error( + "Embeddings runtime is not available. Start the Cairn app to enable semantic search.", + ); + } + return vectors; }; + + try { + const results = await searchAdjacent( + db, + workspaceId as string, + query as string, + kVal, + [], + model, + embedFn, + ); + insertNotification(db, "search_notes_semantic", "Semantic search", `query="${String(query).slice(0, 60)}" → ${results.length} hits`); + return { + query, + model, + results, + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { error: msg }; + } } diff --git a/electron/preload.ts b/electron/preload.ts index 64b4ddd1..8d7ad6b8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -571,6 +571,112 @@ const api = { { config }, ), }, + // ── Unified Runtime (embeddings + LLM) ─────────── + runtime: { + status: () => invoke<{ + embeddings: { healthy: boolean; model: string | null; loaded: boolean }; + llm: { healthy: boolean; model: string | null; loaded: boolean; port: number | null }; + }>("runtime:status"), + stop: () => invoke<{ ok: boolean }>("runtime:stop"), + onProgress: (cb: (e: { + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("runtime:download-progress", handler); + return () => { + ipcRenderer.off("runtime:download-progress", handler); + }; + }, + embeddings: { + status: () => invoke<{ + running: boolean; + port: number | null; + activeModelId: string | null; + defaultModelId: string | null; + installed: boolean; + error: string | null; + reindexInProgress: boolean; + recomputeInProgress: boolean; + lastReindexDone: number; + lastReindexTotal: number; + lastRecomputeDone: number; + lastRecomputeTotal: number; + }>("runtime:embeddings:status"), + ensureStarted: () => invoke<{ ok: boolean }>("runtime:embeddings:ensureStarted"), + models: () => invoke<{ models: Array> }>("runtime:embeddings:models"), + install: (modelId: string) => invoke<{ ok: boolean }>("runtime:embeddings:install", { modelId }), + remove: (modelId: string) => invoke<{ ok: boolean }>("runtime:embeddings:remove", { modelId }), + setDefault: (modelId: string) => invoke<{ ok: boolean }>("runtime:embeddings:setDefault", { modelId }), + onProgress: (cb: (e: { + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + error?: string; + }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("runtime:download-progress", handler); + return () => { + ipcRenderer.off("runtime:download-progress", handler); + }; + }, + }, + llm: { + models: () => invoke<{ models: Array> }>("runtime:llm:models"), + install: (modelId: string, useMirror?: boolean) => invoke<{ ok: boolean }>("runtime:llm:install", { modelId, useMirror }), + remove: (modelId: string) => invoke<{ ok: boolean }>("runtime:llm:remove", { modelId }), + clearInactive: () => invoke<{ ok: boolean }>("runtime:llm:clearInactive"), + onProgress: (cb: (e: { + modelId: string; + progress: number; + speed?: string; + loaded: number; + total: number; + status: string; + error?: string; + }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("runtime:download-progress", handler); + return () => { + ipcRenderer.off("runtime:download-progress", handler); + }; + }, + binary: { + install: () => invoke<{ ok: boolean }>("runtime:llm:binary:install"), + checkForUpdates: () => invoke<{ updateAvailable: boolean; currentVersion: string | null; latestVersion: string | null }>("runtime:llm:checkUpdate"), + onProgress: (cb: (e: { progress: number; speed?: string; status: string; error?: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("runtime:binary-progress", handler); + return () => { + ipcRenderer.off("runtime:binary-progress", handler); + }; + }, + }, + server: { + start: (modelId: string, contextLimit?: number) => invoke<{ port: number }>("runtime:llm:start", { modelId, contextLimit }), + stop: () => invoke<{ ok: boolean }>("runtime:llm:stop"), + status: () => invoke<{ + running: boolean; + port: number | null; + activeModelId: string | null; + defaultModelId: string | null; + binaryInstalled: boolean; + }>("runtime:llm:status"), + setDefault: (modelId: string) => invoke<{ ok: boolean }>("runtime:llm:server:setDefault", { modelId }), + }, + }, + }, // ── Mobile Access ──────────────────────────────── mobile: { status: () => invoke<{ diff --git a/electron/runtime/adapters/embeddings.ts b/electron/runtime/adapters/embeddings.ts new file mode 100644 index 00000000..6fa520cd --- /dev/null +++ b/electron/runtime/adapters/embeddings.ts @@ -0,0 +1,399 @@ +import * as fs from "fs"; +import * as path from "path"; + +import { AutoTokenizer, env, type PreTrainedTokenizer } from "@huggingface/transformers"; +import * as ort from "onnxruntime-node"; + +import type { + AdapterConfig, + AdapterHealth, + AdapterModelEntry, + AdapterStatus, + ModelManagingAdapter, +} from "./types"; +import type { EmbedTask } from "../../embeddings/types"; +import { EMBED_MODEL_ID, EMBED_DIM } from "../../embeddings/types"; +import { withTaskPrefix } from "../../embeddings/prefix"; +import { verifyModel, verifyOnDisk } from "../model-manager"; + +export interface EmbedProgress { + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; +} + +type ProgressCallback = (p: EmbedProgress) => void; + +interface LoadedPipeline { + tokenizer: PreTrainedTokenizer; + session: ort.InferenceSession; + modelId: string; +} + +export const SUPPORTED_EMBEDDING_MODELS: Record< + string, + Omit & { + meta: { dim: number; maxTokens: number; filename: string; sha256: string }; + } +> = { + [EMBED_MODEL_ID]: { + id: EMBED_MODEL_ID, + name: "BGE Small EN v1.5 (int8)", + repo: "Xenova/bge-small-en-v1.5", + sizeBytes: 34_014_426, + meta: { dim: EMBED_DIM, maxTokens: 512, filename: "onnx/model_quantized.onnx", sha256: "6c9c6101a956d62dfb5e7190c538226c0c5bb9cb27b651234b6df063ee7dbfe4" }, + }, +}; + +export function isSupportedEmbeddingModel(modelId: string): boolean { + return modelId in SUPPORTED_EMBEDDING_MODELS; +} + +env.allowLocalModels = false; +env.useBrowserCache = false; + +export class EmbeddingsAdapter implements ModelManagingAdapter { + readonly id = "onnx"; + readonly kind = "embedding" as const; + + private config: AdapterConfig; + private manifestPath: string; + private defaultModelPath: string; + private _pipeline: LoadedPipeline | null = null; + private _pipelinePromise: Promise | null = null; + private _pipelinePromiseModelId: string | null = null; + private _cacheDir: string; + + constructor(config: AdapterConfig) { + this.config = config; + this._cacheDir = config.modelsDir; + this.manifestPath = path.join(config.modelsDir, "manifest.json"); + this.defaultModelPath = path.join(config.modelsDir, "default-model.json"); + env.cacheDir = this._cacheDir; + } + + // ── Model management ────────────────────────────────────────── + + listModels(): AdapterModelEntry[] { + const manifest = this.readManifest(); + const result: AdapterModelEntry[] = []; + for (const [id, def] of Object.entries(SUPPORTED_EMBEDDING_MODELS)) { + const entry = manifest[id] ?? { status: "not_downloaded" as const, downloadProgress: 0 }; + // Check if files exist and verify checksum + let status = entry.status; + if (this.modelFilesPresent(id)) { + if (status !== "installed") { + // Files exist but manifest not marked — verify and update + const onnxPath = path.join(this._cacheDir, id, def.meta.filename); + status = verifyOnDisk(this.manifestPath, id, onnxPath, def.meta.sha256) ? "installed" : "not_downloaded"; + } + } else if (status === "installed") { + // Manifest says installed but files missing + status = "not_downloaded"; + this.updateManifestEntry(id, { status: "not_downloaded", downloadProgress: 0 }); + } + result.push({ + id, + name: def.name, + repo: def.repo, + sizeBytes: def.sizeBytes, + status, + downloadProgress: status === "installed" ? 100 : entry.downloadProgress, + downloadSpeed: entry.downloadSpeed, + error: entry.error, + meta: def.meta, + }); + } + return result; + } + + isModelInstalled(modelId: string): boolean { + const def = SUPPORTED_EMBEDDING_MODELS[modelId]; + if (!def) return false; + if (!this.modelFilesPresent(modelId)) return false; + // Verify checksum of the main ONNX file if defined + const onnxPath = path.join(this._cacheDir, modelId, def.meta.filename); + return verifyOnDisk(this.manifestPath, modelId, onnxPath, def.meta.sha256); + } + + /** Check if the model's required files are present on disk. */ + private modelFilesPresent(modelId: string): boolean { + const tokenizerPath = path.join(this._cacheDir, modelId, "tokenizer.json"); + const onnxDir = path.join(this._cacheDir, modelId, "onnx"); + return fs.existsSync(tokenizerPath) && fs.existsSync(onnxDir) && fs.readdirSync(onnxDir).some((f) => f.endsWith(".onnx")); + } + + async installModel(modelId: string): Promise { + if (!SUPPORTED_EMBEDDING_MODELS[modelId]) { + throw new Error(`Model ${modelId} is not supported.`); + } + // Warming up the pipeline triggers HF Hub download with progress callbacks. + this.updateManifestEntry(modelId, { status: "downloading", downloadProgress: 0, error: undefined }); + try { + await this.loadPipeline(modelId, (p) => { + this.config.onProgress?.({ + type: "load", + modelId, + status: p.status, + file: p.file, + progress: p.progress, + loaded: p.loaded, + total: p.total, + }); + }); + // Verify SHA256 of the main ONNX file after download + const def = SUPPORTED_EMBEDDING_MODELS[modelId]; + const onnxPath = path.join(this._cacheDir, modelId, def.meta.filename); + if (def.meta.sha256 && fs.existsSync(onnxPath)) { + if (!verifyModel(onnxPath, def.meta.sha256)) { + this.updateManifestEntry(modelId, { + status: "error", + error: `SHA256 checksum verification failed for ${modelId}. The downloaded ONNX file may be corrupted.`, + }); + throw new Error(`SHA256 checksum verification failed for ${modelId}.`); + } + } + this.updateManifestEntry(modelId, { status: "installed", downloadProgress: 100, downloadSpeed: undefined, verifiedAt: new Date().toISOString() }); + this.config.onProgress?.({ type: "ready", modelId }); + } catch (err) { + this.updateManifestEntry(modelId, { + status: "error", + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } + } + + removeModel(modelId: string): void { + if (!SUPPORTED_EMBEDDING_MODELS[modelId]) return; + const modelDir = path.join(this._cacheDir, modelId); + if (fs.existsSync(modelDir)) { + fs.rmSync(modelDir, { recursive: true, force: true }); + } + this.updateManifestEntry(modelId, { status: "not_downloaded", downloadProgress: 0, downloadSpeed: undefined, error: undefined }); + if (this.getDefaultModelId() === modelId) { + this.setDefaultModelId(EMBED_MODEL_ID); + } + if (this._pipeline?.modelId === modelId) { + this.resetPipeline(); + } + } + + getDefaultModelId(): string | null { + if (fs.existsSync(this.defaultModelPath)) { + try { + const data = JSON.parse(fs.readFileSync(this.defaultModelPath, "utf8")); + return data.defaultModelId || null; + } catch { + return null; + } + } + return null; + } + + setDefaultModelId(modelId: string): void { + try { + fs.mkdirSync(path.dirname(this.defaultModelPath), { recursive: true }); + fs.writeFileSync(this.defaultModelPath, JSON.stringify({ defaultModelId: modelId }), "utf8"); + } catch (e) { + console.error("[embeddings-adapter] Failed to save default model:", e); + } + } + + // ── Pipeline lifecycle ──────────────────────────────────────── + + async start(modelId: string): Promise<{ port: number }> { + // Embeddings adapter runs in-process — no port needed, but we return 0 for interface compliance. + // The runtime server handles HTTP; the adapter just loads the model. + if (this._pipeline?.modelId === modelId) return { port: 0 }; + await this.loadPipeline(modelId, (p) => { + this.config.onProgress?.({ + type: "load", + modelId, + status: p.status, + file: p.file, + progress: p.progress, + loaded: p.loaded, + total: p.total, + }); + }); + this.config.onProgress?.({ type: "ready", modelId }); + return { port: 0 }; + } + + async stop(): Promise { + this.resetPipeline(); + } + + async health(): Promise { + return { + healthy: this._pipeline !== null, + model: this._pipeline?.modelId ?? null, + loaded: this._pipeline !== null, + }; + } + + status(): AdapterStatus { + return { + kind: this.kind, + running: this._pipeline !== null, + model: this._pipeline?.modelId ?? null, + port: null, + error: null, + }; + } + + async dispose(): Promise { + this.resetPipeline(); + } + + // ── Embedding inference ─────────────────────────────────────── + + async embed( + texts: string[], + task: EmbedTask, + modelId?: string, + ): Promise { + if (texts.length === 0) return []; + const resolved = modelId ?? this._pipeline?.modelId ?? this.getDefaultModelId() ?? EMBED_MODEL_ID; + const pipe = await this.loadPipeline(resolved); + const prefixed = texts.map((t) => withTaskPrefix(task, t)); + + const encoded = await pipe.tokenizer(prefixed, { padding: true, truncation: true }); + const batchSize = encoded.input_ids.dims[0]; + const seqLen = encoded.input_ids.dims[1]; + const total = batchSize * seqLen; + + const inputIdsData = new BigInt64Array(total); + const attentionData = new BigInt64Array(total); + const tokenTypeData = new BigInt64Array(total); + for (let i = 0; i < total; i++) { + inputIdsData[i] = encoded.input_ids.data[i]; + attentionData[i] = encoded.attention_mask.data[i]; + if (encoded.token_type_ids) tokenTypeData[i] = encoded.token_type_ids.data[i]; + } + + const feeds: Record = { + input_ids: new ort.Tensor("int64", inputIdsData, [batchSize, seqLen]), + attention_mask: new ort.Tensor("int64", attentionData, [batchSize, seqLen]), + token_type_ids: new ort.Tensor("int64", tokenTypeData, [batchSize, seqLen]), + }; + + const output = await pipe.session.run(feeds); + const lastHidden = output.last_hidden_state; + const dims = lastHidden.dims; + const hidden = dims[2]; + const data = lastHidden.data as Float32Array; + + const vectors: number[][] = []; + for (let b = 0; b < dims[0]; b++) { + const sumVec = new Float32Array(hidden); + let count = 0; + for (let s = 0; s < dims[1]; s++) { + if (attentionData[b * seqLen + s] === 0n) continue; + const offset = (b * dims[1] + s) * hidden; + for (let h = 0; h < hidden; h++) sumVec[h] += data[offset + h]; + count++; + } + if (count > 0) for (let h = 0; h < hidden; h++) sumVec[h] /= count; + let norm = 0; + for (let h = 0; h < hidden; h++) norm += sumVec[h] * sumVec[h]; + norm = Math.sqrt(norm); + if (norm < 1e-9) norm = 1e-9; + for (let h = 0; h < hidden; h++) sumVec[h] /= norm; + vectors.push(Array.from(sumVec)); + } + + for (const v of vectors) { + if (v.length !== EMBED_DIM) { + throw new Error(`embed: expected dim ${EMBED_DIM}, got ${v.length}`); + } + } + return vectors; + } + + // ── Pipeline loading (ported from pipeline.ts) ──────────────── + + async loadPipeline( + modelId: string = EMBED_MODEL_ID, + onProgress?: ProgressCallback, + ): Promise { + if (this._pipeline && this._pipeline.modelId === modelId) return this._pipeline; + if (this._pipelinePromise && this._pipelinePromiseModelId === modelId) return this._pipelinePromise; + + this._pipelinePromiseModelId = modelId; + + this._pipelinePromise = (async () => { + onProgress?.({ status: "initiate" }); + + const tokenizer = await AutoTokenizer.from_pretrained(modelId, { + progress_callback: onProgress + ? (data: unknown) => { + if (data && typeof data === "object") { + const d = data as Record; + onProgress({ + status: typeof d.status === "string" ? d.status : "unknown", + file: typeof d.file === "string" ? d.file : undefined, + progress: typeof d.progress === "number" ? d.progress : undefined, + loaded: typeof d.loaded === "number" ? d.loaded : undefined, + total: typeof d.total === "number" ? d.total : undefined, + }); + } + } + : undefined, + }); + + const modelPath = `${this._cacheDir}/${modelId}/onnx/model_quantized.onnx`; + const session = await ort.InferenceSession.create(modelPath, { + executionProviders: ["cpu"], + graphOptimizationLevel: "all", + enableCpuMemArena: false, + enableMemPattern: false, + }); + + this._pipeline = { tokenizer, session, modelId }; + onProgress?.({ status: "ready" }); + return this._pipeline; + })().catch((err) => { + this._pipelinePromise = null; + this._pipelinePromiseModelId = null; + throw err; + }); + + return this._pipelinePromise; + } + + resetPipeline(): void { + this._pipeline = null; + this._pipelinePromise = null; + this._pipelinePromiseModelId = null; + } + + // ── Manifest helpers ───────────────────────────────────────── + + private readManifest(): Record { + if (!fs.existsSync(this.manifestPath)) return {}; + try { + return JSON.parse(fs.readFileSync(this.manifestPath, "utf8")); + } catch { + return {}; + } + } + + private writeManifest(manifest: Record): void { + try { + fs.mkdirSync(path.dirname(this.manifestPath), { recursive: true }); + fs.writeFileSync(this.manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + } catch (e) { + console.error("[embeddings-adapter] Failed to write manifest:", e); + } + } + + private updateManifestEntry(modelId: string, patch: Record): void { + const manifest = this.readManifest(); + (manifest as Record)[modelId] = { ...(manifest[modelId] ?? {}), ...patch }; + this.writeManifest(manifest); + } +} diff --git a/electron/runtime/adapters/llama.ts b/electron/runtime/adapters/llama.ts new file mode 100644 index 00000000..be158cb2 --- /dev/null +++ b/electron/runtime/adapters/llama.ts @@ -0,0 +1,683 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { spawn, execFileSync, execFile, ChildProcess } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import * as https from "https"; +import * as http from "http"; +import * as net from "net"; + +import type { + AdapterConfig, + AdapterHealth, + AdapterModelEntry, + AdapterStatus, + ModelManagingAdapter, +} from "./types"; +import { findFreePort } from "../../embeddings/port"; +import { verifyModel, verifyOnDisk } from "../model-manager"; + +export const SUPPORTED_LLM_MODELS: Record< + string, + Omit & { + meta: { filename: string; quant: string; downloadUrl: string; sha256: string }; + } +> = { + "gemma-4-e2b-it-q4": { + id: "gemma-4-e2b-it-q4", + name: "Gemma 4 E2B IT (Q4_K_M)", + repo: "unsloth/gemma-4-E2B-it-GGUF", + sizeBytes: 3_106_736_256, + meta: { filename: "gemma-4-E2B-it-Q4_K_M.gguf", quant: "Q4_K_M", downloadUrl: "https://huggingface.co/unsloth/gemma-4-E2B-it-GGUF/resolve/main/gemma-4-E2B-it-Q4_K_M.gguf", sha256: "9378bc471710229ef165709b62e34bfb62231420ddaf6d729e727305b5b8672d" }, + }, + "gemma-4-e2b-it-q8": { + id: "gemma-4-e2b-it-q8", + name: "Gemma 4 E2B IT (Q8_0)", + repo: "ggml-org/gemma-4-E2B-it-GGUF", + sizeBytes: 4_967_494_592, + meta: { filename: "gemma-4-E2B-it-Q8_0.gguf", quant: "Q8_0", downloadUrl: "https://huggingface.co/ggml-org/gemma-4-E2B-it-GGUF/resolve/main/gemma-4-E2B-it-Q8_0.gguf", sha256: "e049411c01fb7a81161768c52e38828970e55a64e22738957adcbe51d20f1c8e" }, + }, + "gemma-4-e4b-it-q4": { + id: "gemma-4-e4b-it-q4", + name: "Gemma 4 E4B IT (Q4_K_M)", + repo: "ggml-org/gemma-4-E4B-it-GGUF", + sizeBytes: 5_335_289_824, + meta: { filename: "gemma-4-E4B-it-Q4_K_M.gguf", quant: "Q4_K_M", downloadUrl: "https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q4_K_M.gguf", sha256: "90ce98129eb3e8cc57e62433d500c97c624b1e3af1fcc85dd3b55ad7e0313e9f" }, + }, + "gemma-4-e4b-it-q8": { + id: "gemma-4-e4b-it-q8", + name: "Gemma 4 E4B IT (Q8_0)", + repo: "ggml-org/gemma-4-E4B-it-GGUF", + sizeBytes: 8_031_240_160, + meta: { filename: "gemma-4-E4B-it-Q8_0.gguf", quant: "Q8_0", downloadUrl: "https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q8_0.gguf", sha256: "fb8f0c032de00b18c710824af3c7e5777c71e5fb60b13f13575f0a9e92ddecd0" }, + }, +}; + +export function isSupportedLLMModel(modelId: string): boolean { + return modelId in SUPPORTED_LLM_MODELS; +} + +export class LlamaAdapter implements ModelManagingAdapter { + readonly id = "llama"; + readonly kind = "llm" as const; + + private config: AdapterConfig; + private binDir: string; + private binPath: string; + private manifestPath: string; + private defaultModelPath: string; + + private serverProcess: ChildProcess | null = null; + private serverPort: number | null = null; + private activeModelId: string | null = null; + private bootError = ""; + private isBinaryDownloading = false; + private activeDownloads = new Map(); + + constructor(config: AdapterConfig) { + this.config = config; + this.binDir = config.binDir; + this.binPath = path.join( + this.binDir, + process.platform === "win32" ? "llama-server.exe" : "llama-server", + ); + this.manifestPath = path.join(config.modelsDir, "manifest.json"); + this.defaultModelPath = path.join(config.modelsDir, "default-model.json"); + } + + // ── Model management ────────────────────────────────────────── + + listModels(): AdapterModelEntry[] { + const manifest = this.readManifest(); + const result: AdapterModelEntry[] = []; + for (const [id, def] of Object.entries(SUPPORTED_LLM_MODELS)) { + const entry = manifest[id] ?? { status: "not_downloaded" as const, downloadProgress: 0 }; + const modelPath = this.modelPath(id); + const fileExists = fs.existsSync(modelPath); + // If file exists but manifest says not installed, verify checksum and update + let status = entry.status; + if (fileExists && status !== "installed") { + status = verifyOnDisk(this.manifestPath, id, modelPath, def.meta.sha256) ? "installed" : "not_downloaded"; + } else if (!fileExists && status === "installed") { + status = "not_downloaded"; + this.updateManifestEntry(id, { status: "not_downloaded", downloadProgress: 0 }); + } + result.push({ + id, + name: def.name, + repo: def.repo, + sizeBytes: def.sizeBytes, + status, + downloadProgress: status === "installed" ? 100 : entry.downloadProgress, + downloadSpeed: entry.downloadSpeed, + error: entry.error, + meta: def.meta, + }); + } + return result; + } + + isModelInstalled(modelId: string): boolean { + const def = SUPPORTED_LLM_MODELS[modelId]; + if (!def) return false; + const modelPath = this.modelPath(modelId); + if (!fs.existsSync(modelPath)) return false; + // Verify checksum if defined + return verifyOnDisk(this.manifestPath, modelId, modelPath, def.meta.sha256); + } + + async installModel(modelId: string, useMirror?: boolean): Promise { + const def = SUPPORTED_LLM_MODELS[modelId]; + if (!def) throw new Error(`Model ${modelId} is not supported.`); + + const manifest = this.readManifest(); + const entry = manifest[modelId]; + if (entry?.status === "downloading") throw new Error(`Model ${modelId} is already downloading.`); + + if (!entry || entry.status !== "installed") { + this.updateManifestEntry(modelId, { status: "downloading", downloadProgress: 0, error: undefined }); + } + + const destPath = this.modelPath(modelId); + const tempPath = `${destPath}.tmp`; + const downloadUrl = def.meta.downloadUrl; + + const sourceUrl = useMirror + ? downloadUrl.replace("https://huggingface.co", "https://hf-mirror.com") + : downloadUrl; + + await new Promise((resolve, reject) => { + let lastBytes = 0; + let lastTime = Date.now(); + let speed = "0 KB/s"; + let lastBroadcastTime = 0; + + const broadcastProgress = (bytesReceived: number, bytesTotal: number, force = false) => { + const now = Date.now(); + const duration = (now - lastTime) / 1000; + if (duration >= 1) { + const delta = bytesReceived - lastBytes; + lastBytes = bytesReceived; + lastTime = now; + speed = `${((delta / 1024 / 1024) / duration).toFixed(1)} MB/s`; + } + if (!force && now - lastBroadcastTime < 300) return; + lastBroadcastTime = now; + const progress = bytesTotal > 0 ? Math.min(100, Math.round((bytesReceived / bytesTotal) * 100)) : 0; + this.updateManifestEntry(modelId, { downloadProgress: progress, downloadSpeed: speed }); + this.config.onProgress?.({ + type: "download", + modelId, + progress, + speed, + status: "downloading", + }); + }; + + const download = (url: string) => { + const req = https.get(url, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + let redirectUrl = res.headers.location; + if (useMirror && /cdn-lfs[-a-zA-Z0-9]*\.huggingface\.co/.test(redirectUrl)) { + redirectUrl = redirectUrl.replace(/cdn-lfs[-a-zA-Z0-9]*\.huggingface\.co/g, "cdn-lfs.hf-mirror.com"); + } + download(redirectUrl); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Server responded with status code ${res.statusCode}`)); + return; + } + const totalBytes = parseInt(res.headers["content-length"] || "0", 10); + let receivedBytes = 0; + const fileStream = fs.createWriteStream(tempPath); + res.on("data", (chunk) => { + receivedBytes += chunk.length; + fileStream.write(chunk); + broadcastProgress(receivedBytes, totalBytes, false); + }); + res.on("end", () => { + fileStream.end(); + broadcastProgress(receivedBytes, totalBytes, true); + resolve(); + }); + res.on("error", (err) => { + fileStream.close(); + fs.unlink(tempPath, () => {}); + reject(err); + }); + }); + req.on("error", (err) => reject(err)); + this.activeDownloads.set(modelId, req); + }; + + download(sourceUrl); + }); + + this.activeDownloads.delete(modelId); + if (fs.existsSync(tempPath)) { + // Verify SHA256 checksum before promoting temp file to final path + if (def.meta.sha256 && !verifyModel(tempPath, def.meta.sha256)) { + fs.unlink(tempPath, () => { /* ignore */ }); + const errMsg = `SHA256 checksum verification failed for ${modelId}. Download may be corrupted.`; + this.updateManifestEntry(modelId, { status: "error", downloadProgress: 0, downloadSpeed: undefined, error: errMsg }); + this.config.onProgress?.({ type: "download", modelId, progress: 0, status: "error", error: errMsg }); + throw new Error(errMsg); + } + fs.renameSync(tempPath, destPath); + } + verifyOnDisk(this.manifestPath, modelId, destPath, def.meta.sha256); + this.config.onProgress?.({ type: "download", modelId, progress: 100, status: "installed" }); + } + + removeModel(modelId: string): void { + const def = SUPPORTED_LLM_MODELS[modelId]; + if (!def) return; + if (this.activeModelId === modelId && this.serverProcess) { + throw new Error("Model is currently running. Stop the server before deleting it."); + } + const activeReq = this.activeDownloads.get(modelId); + if (activeReq) { + activeReq.destroy(); + this.activeDownloads.delete(modelId); + } + const modelPath = this.modelPath(modelId); + if (fs.existsSync(modelPath)) fs.unlinkSync(modelPath); + const tempPath = `${modelPath}.tmp`; + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + this.updateManifestEntry(modelId, { status: "not_downloaded", downloadProgress: 0, downloadSpeed: undefined, error: undefined }); + } + + /** Remove all downloaded models except the one currently running. */ + clearInactiveModels(): void { + const manifest = this.readManifest(); + for (const [id, entry] of Object.entries(manifest)) { + if (entry.status === "installed" && id !== this.activeModelId) { + this.removeModel(id); + } + } + } + + getDefaultModelId(): string | null { + if (fs.existsSync(this.defaultModelPath)) { + try { + const data = JSON.parse(fs.readFileSync(this.defaultModelPath, "utf8")); + return data.defaultModelId || null; + } catch { + return null; + } + } + return null; + } + + setDefaultModelId(modelId: string): void { + try { + fs.mkdirSync(path.dirname(this.defaultModelPath), { recursive: true }); + fs.writeFileSync(this.defaultModelPath, JSON.stringify({ defaultModelId: modelId }), "utf8"); + } catch (e) { + console.error("[llama-adapter] Failed to save default model:", e); + } + } + + // ── Server lifecycle ────────────────────────────────────────── + + async start(modelId: string, opts?: { contextLimit?: number }): Promise<{ port: number }> { + const def = SUPPORTED_LLM_MODELS[modelId]; + if (!def || !this.isModelInstalled(modelId)) { + throw new Error(`Model ${modelId} is not downloaded.`); + } + if (!this.isBinaryInstalled()) { + throw new Error("llama-server binary is not installed."); + } + + if (this.serverProcess && this.activeModelId === modelId && this.serverPort) { + if (await this.checkHealth(this.serverPort)) return { port: this.serverPort }; + await this.stop(); + } else if (this.serverProcess) { + await this.stop(); + } + + const port = await findFreePort(); + const requestedContext = opts?.contextLimit ?? 16384; + const contextSize = requestedContext > 32768 ? 16384 : requestedContext; + + const processArgs = [ + "-m", this.modelPath(modelId), + "--port", port.toString(), + "--host", "127.0.0.1", + "-c", contextSize.toString(), + "-np", "1", + "--no-warmup", + ]; + + const binaryPath = fs.existsSync(this.binPath) ? this.binPath : "llama-server"; + const child = spawn(binaryPath, processArgs, { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + let bootError = ""; + child.stderr?.on("data", (data) => { bootError += data.toString(); }); + child.stdout?.on("data", (data) => { + console.log("[llama-adapter stdout]:", data.toString().trim()); + }); + + this.serverProcess = child; + this.serverPort = port; + this.activeModelId = modelId; + + let isHealthy = false; + for (let i = 0; i < 300; i++) { + if (child.killed || child.exitCode !== null) break; + await new Promise((r) => setTimeout(r, 200)); + isHealthy = await this.checkHealth(port); + if (isHealthy) break; + } + + if (!isHealthy) { + await this.stop(); + throw new Error(`llama-server failed to start. Stderr:\n${bootError.slice(-500)}`); + } + + this.setDefaultModelId(modelId); + return { port }; + } + + async stop(_opts?: { force?: boolean }): Promise { + if (!this.serverProcess) return; + const proc = this.serverProcess; + this.serverProcess = null; + this.serverPort = null; + this.activeModelId = null; + try { + proc.kill("SIGTERM"); + } catch { /* ignore */ } + await new Promise((resolve) => { + const timer = setTimeout(() => { + try { proc.kill("SIGKILL"); } catch { /* ignore */ } + resolve(); + }, 2000); + proc.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); + } + + async health(): Promise { + if (!this.serverProcess || !this.serverPort) { + return { healthy: false, model: null, loaded: false }; + } + const healthy = await this.checkHealth(this.serverPort); + return { healthy, model: this.activeModelId, loaded: healthy }; + } + + status(): AdapterStatus { + return { + kind: this.kind, + running: this.serverProcess !== null && !this.serverProcess.killed, + model: this.activeModelId, + port: this.serverPort, + error: this.bootError || null, + }; + } + + async dispose(): Promise { + await this.stop({ force: true }); + for (const [, req] of this.activeDownloads) { + try { req.destroy(); } catch { /* ignore */ } + } + this.activeDownloads.clear(); + } + + stopSync(): void { + if (this.serverProcess) { + try { this.serverProcess.kill("SIGKILL"); } catch { /* ignore */ } + this.serverProcess = null; + this.serverPort = null; + this.activeModelId = null; + } + } + + // ── Binary management ──────────────────────────────────────── + + isBinaryInstalled(): boolean { + if (fs.existsSync(this.binPath)) { + if (process.platform === "darwin") { + const dylib = path.join(this.binDir, "libllama-server-impl.dylib"); + if (!fs.existsSync(dylib)) return false; + } + return true; + } + try { + execFileSync(process.platform === "win32" ? "where" : "which", [process.platform === "win32" ? "llama-server" : "llama-server"], { stdio: "ignore" }); + return true; + } catch { + return false; + } + } + + getBinaryVersion(): string | null { + const engineMetaPath = path.join(this.binDir, "engine.json"); + if (fs.existsSync(engineMetaPath)) { + try { + const meta = JSON.parse(fs.readFileSync(engineMetaPath, "utf8")); + return meta.version || null; + } catch { return null; } + } + return null; + } + + async installBinary(onProgress?: (progress: number, speed: string, status: string, error?: string) => void): Promise { + if (this.isBinaryDownloading) throw new Error("Local Llama engine downloader is already active."); + if (!fs.existsSync(this.binDir)) fs.mkdirSync(this.binDir, { recursive: true }); + this.isBinaryDownloading = true; + + const sendProgress = (progress: number, speed: string, status: string, error?: string, force = false) => { + if (!force && status === "downloading") return; + onProgress?.(progress, speed, status, error); + }; + + sendProgress(0, "0 KB/s", "fetching_release"); + + try { + const releaseUrl = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"; + const releaseData = await this.fetchGitHubJson(releaseUrl); + const assets: any[] = releaseData.assets || []; + + let regex: RegExp; + let archiveExt: ".tar.gz" | ".zip"; + + if (process.platform === "darwin") { + archiveExt = ".tar.gz"; + regex = process.arch === "arm64" + ? /llama-.*-bin-macos-arm64(?!-kleidiai)\.tar\.gz$/ + : /llama-.*-bin-macos-x64\.tar\.gz$/; + } else if (process.platform === "win32") { + archiveExt = ".zip"; + regex = /llama-.*-bin-win-cpu-x64\.zip$/; + } else { + archiveExt = ".tar.gz"; + regex = /llama-.*-bin-ubuntu-x64\.tar\.gz$/; + } + + const asset = assets.find((a) => regex.test(a.name)); + if (!asset) { + throw new Error(`Could not find prebuilt llama-server for platform ${process.platform}-${process.arch}.`); + } + + const downloadUrl = asset.browser_download_url; + const tempArchive = path.join(this.binDir, `temp${archiveExt}`); + + sendProgress(5, "0 KB/s", "downloading"); + + await new Promise((resolve, reject) => { + let lastBytes = 0; + let lastTime = Date.now(); + let speed = "0 KB/s"; + const download = (url: string) => { + const options: https.RequestOptions = { headers: { "User-Agent": "Cairn-Client/1.0" } }; + const req = https.get(url, options, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + download(res.headers.location); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`Server responded with status code ${res.statusCode}`)); + return; + } + const totalBytes = parseInt(res.headers["content-length"] || "0", 10); + let receivedBytes = 0; + const fileStream = fs.createWriteStream(tempArchive); + res.on("data", (chunk) => { + receivedBytes += chunk.length; + fileStream.write(chunk); + const now = Date.now(); + const duration = (now - lastTime) / 1000; + if (duration >= 1) { + const delta = receivedBytes - lastBytes; + lastBytes = receivedBytes; + lastTime = now; + speed = `${((delta / 1024 / 1024) / duration).toFixed(1)} MB/s`; + } + const pct = Math.min(100, Math.round((receivedBytes / totalBytes) * 100)); + sendProgress(pct, speed, "downloading", undefined, false); + }); + res.on("end", () => { + fileStream.end(); + sendProgress(100, speed, "downloading", undefined, true); + resolve(); + }); + res.on("error", (err) => { + fileStream.close(); + fs.unlink(tempArchive, () => {}); + reject(err); + }); + }); + req.on("error", (err) => reject(err)); + }; + download(downloadUrl); + }); + + sendProgress(90, "0 KB/s", "extracting"); + + const extractDir = path.join(this.binDir, "extracted"); + if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + await new Promise((resolve, reject) => { + const tarArgs = process.platform === "win32" + ? ["-xf", tempArchive, "-C", extractDir] + : ["-xzf", tempArchive, "-C", extractDir]; + execFile("tar", tarArgs, (err) => { + if (err) { + if (process.platform === "win32") { + const psArgs = ["-Command", `Expand-Archive -Path '${tempArchive}' -DestinationPath '${extractDir}' -Force`]; + execFile("powershell", psArgs, (psErr) => { + if (psErr) reject(new Error(`Extraction failed: ${psErr.message}`)); + else resolve(); + }); + } else { + reject(new Error(`Extraction failed: ${err.message}`)); + } + } else { resolve(); } + }); + }); + + const binaryName = process.platform === "win32" ? "llama-server.exe" : "llama-server"; + let foundPath: string | null = null; + + const searchDir = (dir: string) => { + if (foundPath) return; + const items = fs.readdirSync(dir); + for (const item of items) { + const full = path.join(dir, item); + const stat = fs.statSync(full); + if (stat.isDirectory()) { searchDir(full); } + else if (item === binaryName) { foundPath = full; return; } + } + }; + searchDir(extractDir); + + if (!foundPath) throw new Error(`Could not find ${binaryName} inside downloaded archive.`); + + const binParentDir = path.dirname(foundPath); + const files = fs.readdirSync(binParentDir); + for (const file of files) { + const srcPath = path.join(binParentDir, file); + const destPath = path.join(this.binDir, file); + const stat = fs.statSync(srcPath); + if (stat.isFile()) { + if (fs.existsSync(destPath)) fs.unlinkSync(destPath); + fs.copyFileSync(srcPath, destPath); + if (process.platform !== "win32") { + fs.chmodSync(destPath, "755"); + if (process.platform === "darwin") { + try { execFileSync("xattr", ["-rd", "com.apple.quarantine", destPath], { stdio: "ignore" }); } catch { /* ignore */ } + } + } + } + } + + const engineMetaPath = path.join(this.binDir, "engine.json"); + fs.writeFileSync(engineMetaPath, JSON.stringify({ + version: releaseData.tag_name || "unknown", + installedAt: new Date().toISOString(), + }, null, 2), "utf8"); + + fs.unlinkSync(tempArchive); + fs.rmSync(extractDir, { recursive: true, force: true }); + + this.isBinaryDownloading = false; + sendProgress(100, "", "installed"); + } catch (err: any) { + this.isBinaryDownloading = false; + sendProgress(0, "", "error", err.message); + throw err; + } + } + + async checkBinaryUpdate(): Promise<{ updateAvailable: boolean; currentVersion: string | null; latestVersion: string | null }> { + const currentVersion = this.getBinaryVersion(); + try { + const releaseUrl = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"; + const releaseData = await this.fetchGitHubJson(releaseUrl); + const latestVersion = releaseData.tag_name || null; + if (!latestVersion) return { updateAvailable: false, currentVersion, latestVersion: null }; + const updateAvailable = !currentVersion || currentVersion !== latestVersion; + return { updateAvailable, currentVersion, latestVersion }; + } catch { + return { updateAvailable: false, currentVersion, latestVersion: null }; + } + } + + // ── Helpers ─────────────────────────────────────────────────── + + private modelPath(modelId: string): string { + const def = SUPPORTED_LLM_MODELS[modelId]; + return path.join(this.config.modelsDir, def?.meta.filename ?? modelId); + } + + private async checkHealth(port: number): Promise { + return new Promise((resolve) => { + const req = http.get(`http://127.0.0.1:${port}/health`, { timeout: 1000 } as any, (res) => { + resolve(res.statusCode === 200); + }); + req.on("error", () => { + const socket = new net.Socket(); + socket.setTimeout(500); + socket.connect(port, "127.0.0.1", () => { socket.destroy(); resolve(true); }); + socket.on("error", () => resolve(false)); + socket.on("timeout", () => { socket.destroy(); resolve(false); }); + }); + }); + } + + private async fetchGitHubJson(url: string): Promise { + return new Promise((resolve, reject) => { + const get = (currentUrl: string) => { + const options: https.RequestOptions = { + headers: { "User-Agent": "Cairn-Client/1.0", Accept: "application/json" }, + }; + https.get(currentUrl, options, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + get(res.headers.location); + return; + } + if (res.statusCode !== 200) { reject(new Error(`GitHub responded with status ${res.statusCode}`)); return; } + let body = ""; + res.on("data", (chunk) => { body += chunk; }); + res.on("end", () => { + try { resolve(JSON.parse(body)); } catch (e) { reject(e); } + }); + }).on("error", (err) => reject(err)); + }; + get(url); + }); + } + + // ── Manifest helpers ───────────────────────────────────────── + + private readManifest(): Record { + if (!fs.existsSync(this.manifestPath)) return {}; + try { + return JSON.parse(fs.readFileSync(this.manifestPath, "utf8")); + } catch { + return {}; + } + } + + private writeManifest(manifest: Record): void { + try { + fs.mkdirSync(path.dirname(this.manifestPath), { recursive: true }); + fs.writeFileSync(this.manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + } catch (e) { + console.error("[llama-adapter] Failed to write manifest:", e); + } + } + + private updateManifestEntry(modelId: string, patch: Record): void { + const manifest = this.readManifest(); + manifest[modelId] = { ...(manifest[modelId] ?? {}), ...patch }; + this.writeManifest(manifest); + } +} diff --git a/electron/runtime/adapters/types.ts b/electron/runtime/adapters/types.ts new file mode 100644 index 00000000..21ecdf55 --- /dev/null +++ b/electron/runtime/adapters/types.ts @@ -0,0 +1,139 @@ +/** + * Adapter interface — the contract every inference runtime implements. + * + * Each adapter (llama, onnx, future MLX) implements this interface. + * The unified runtime server registers adapters and routes requests to them. + * Adding a new runtime = implement this interface + register with the server. + * + * Lifecycle: + * 1. `start()` — spawn child process / load model into memory + * 2. `health()` — polled by the server's /health endpoint + * 3. `handleRequest()` — called for each HTTP request to /v1//... + * 4. `stop()` — graceful shutdown + */ + +export type AdapterKind = "llm" | "embedding"; + +export interface AdapterStatus { + kind: AdapterKind; + running: boolean; + model: string | null; + port: number | null; + error: string | null; +} + +export interface AdapterHealth { + healthy: boolean; + model: string | null; + loaded: boolean; + error?: string | null; +} + +export interface AdapterConfig { + /** Directory where model files are stored */ + modelsDir: string; + /** Directory where engine binaries are stored */ + binDir: string; + /** Default model id to use when none is specified */ + defaultModelId: string | null; + /** Optional callback for progress events (downloads, model loading) */ + onProgress?: (event: AdapterProgressEvent) => void; +} + +export type AdapterProgressEvent = + | { + type: "download"; + modelId: string; + progress: number; + speed?: string; + status: "downloading" | "installed" | "error"; + error?: string; + } + | { + type: "load"; + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + } + | { + type: "ready"; + modelId: string; + }; + +/** + * An adapter manages one inference runtime. + * + * The adapter is responsible for: + * - Starting/stopping the runtime process or loading/unloading the model + * - Health checking + * - Routing HTTP-style requests to the runtime's API + * - Model installation and removal + * + * The adapter does NOT manage ports, HTTP servers, or IPC — that's the + * runtime server's job. The adapter just exposes the operations. + */ +export interface RuntimeAdapter { + /** Unique identifier for this adapter (e.g. "llama", "onnx", "mlx") */ + readonly id: string; + /** What kind of inference this adapter provides */ + readonly kind: AdapterKind; + + /** + * Start the runtime with the given model. + * If already running with the same model, return immediately. + * If running with a different model, stop and restart. + */ + start(modelId: string, opts?: { contextLimit?: number }): Promise<{ port: number }>; + + /** Stop the runtime. Safe to call when not running. */ + stop(opts?: { force?: boolean }): Promise; + + /** Check if the runtime is healthy and responding. */ + health(): Promise; + + /** Current status snapshot (synchronous, does not poll). */ + status(): AdapterStatus; + + /** Stop and cleanup — called on app shutdown. */ + dispose(): Promise; +} + +/** + * Extended interface for adapters that manage model downloads + * (both llama and onnx adapters implement this). + */ +export interface ModelManagingAdapter extends RuntimeAdapter { + /** List all known models with their current status. */ + listModels(): AdapterModelEntry[]; + + /** Download and install a model. */ + installModel(modelId: string): Promise; + + /** Remove a model from disk. */ + removeModel(modelId: string): void; + + /** Get the default model id, if one is set. */ + getDefaultModelId(): string | null; + + /** Set the default model id. */ + setDefaultModelId(modelId: string): void; + + /** Check if model files are present on disk. */ + isModelInstalled(modelId: string): boolean; +} + +export interface AdapterModelEntry { + id: string; + name: string; + repo: string; + sizeBytes: number; + status: "not_downloaded" | "downloading" | "installed" | "error"; + downloadProgress: number; + downloadSpeed?: string; + error?: string; + /** Adapter-specific metadata (e.g. quant, filename, dim, maxTokens) */ + meta?: Record; +} diff --git a/electron/runtime/client.ts b/electron/runtime/client.ts new file mode 100644 index 00000000..5603e5d4 --- /dev/null +++ b/electron/runtime/client.ts @@ -0,0 +1,528 @@ +import { app } from "electron"; +import * as fs from "fs"; +import * as http from "http"; +import * as path from "path"; +import { spawn, type ChildProcess, execSync } from "child_process"; + +import { findFreePort } from "../embeddings/port"; +import { EMBED_MODEL_ID, EMBED_DIM } from "../embeddings/types"; +import type { EmbedTask, EmbeddingsStatus } from "../embeddings/types"; +import type { AdapterModelEntry } from "./adapters/types"; + +const USER_DATA = app.getPath("userData"); +const EMBEDDING_MODELS_DIR = path.join(USER_DATA, "embedding-models"); +const LLM_MODELS_DIR = path.join(USER_DATA, "llama-models"); +const LLM_BIN_DIR = path.join(USER_DATA, "llama-bin"); +const LOCAL_BIN_DIR = path.join(USER_DATA, "runtime-bin"); +const LOCAL_BIN_PATH = path.join( + LOCAL_BIN_DIR, + process.platform === "win32" ? "cairn-runtime.exe" : "cairn-runtime", +); + +function resolveDevServerScript(): string | null { + const candidates = [ + path.join(__dirname, "runtime-server.bundle.js"), + path.join(__dirname, "..", "dist-electron", "runtime-server.bundle.js"), + // In packaged apps the bundle is inside app.asar, but Electron can read it + // directly when spawned with ELECTRON_RUN_AS_NODE. + path.join(app.getAppPath(), "dist-electron", "runtime-server.bundle.js"), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return null; +} + +function resolveBinaryPath(): string | null { + if (fs.existsSync(LOCAL_BIN_PATH)) return LOCAL_BIN_PATH; + if (app.isPackaged) { + // First, check for a compiled native binary in unpacked resources + const unpacked = path.join( + process.resourcesPath, + "app.asar.unpacked", + "dist-runtime", + process.platform === "win32" ? "cairn-runtime.exe" : "cairn-runtime", + ); + if (fs.existsSync(unpacked)) return unpacked; + // Fall back to the JS bundle — works with ELECTRON_RUN_AS_NODE=1 + const script = resolveDevServerScript(); + if (script) return script; + } + if (!app.isPackaged) { + const script = resolveDevServerScript(); + if (script) return script; + } + return null; +} + +function resolveNodeRunner(): string | null { + if (!app.isPackaged) { + try { + const nodeBin = execSync( + process.platform === "win32" ? "where node" : "which node", + ) + .toString() + .trim() + .split(/\r?\n/)[0]; + if (nodeBin && fs.existsSync(nodeBin)) return nodeBin; + } catch { + // ignore — not on PATH + } + } + return null; +} + +function isScriptPath(p: string): boolean { + return p.endsWith(".js"); +} + +interface StdoutEvent { + kind: "listening" | "ready" | "progress" | "error" | "log" | "binary-progress"; + port?: number; + model?: string | null; + status?: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + speed?: string; + msg?: string; +} + +type ProgressListener = ( + ev: + | { + kind: "progress"; + modelId: string; + status: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + } + | { kind: "ready"; modelId: string } + | { kind: "binary-progress"; progress: number; speed: string; status: string; error?: string }, +) => void; + +let runtimeProcess: ChildProcess | null = null; +let runtimePort: number | null = null; +let isReady = false; +let bootError = ""; +const progressListeners = new Set(); + +let startPromise: Promise | null = null; +let inFlightRequests = 0; + +// Embeddings-specific tracking (for status API) +let reindexInProgress = false; +let recomputeInProgress = false; +let lastReindexDone = 0; +let lastReindexTotal = 0; +let lastRecomputeDone = 0; +let lastRecomputeTotal = 0; + +export function setReindexInProgress(v: boolean): void { + reindexInProgress = v; + if (v) { lastReindexDone = 0; lastReindexTotal = 0; } +} +export function setRecomputeInProgress(v: boolean): void { + recomputeInProgress = v; + if (v) { lastRecomputeDone = 0; lastRecomputeTotal = 0; } +} +export function setLastReindexProgress(done: number, total: number): void { + lastReindexDone = done; + lastReindexTotal = total; +} +export function setLastRecomputeProgress(done: number, total: number): void { + lastRecomputeDone = done; + lastRecomputeTotal = total; +} + +function emitProgress(ev: StdoutEvent): void { + if (ev.kind === "binary-progress") { + for (const l of progressListeners) { + l({ + kind: "binary-progress", + progress: ev.progress ?? 0, + speed: ev.speed ?? "0 KB/s", + status: ev.status ?? "unknown", + error: ev.msg, + }); + } + return; + } + if (ev.kind !== "progress" && ev.kind !== "ready") return; + const modelId = ev.model ?? EMBED_MODEL_ID; + for (const l of progressListeners) { + if (ev.kind === "ready") { + l({ kind: "ready", modelId }); + } else { + l({ + kind: "progress", + modelId, + status: ev.status ?? "unknown", + file: ev.file, + progress: ev.progress, + loaded: ev.loaded, + total: ev.total, + }); + } + } +} + +function parseStdoutLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) return; + let ev: StdoutEvent; + try { + ev = JSON.parse(trimmed) as StdoutEvent; + } catch { + console.log("[runtime stdout]:", trimmed); + return; + } + switch (ev.kind) { + case "listening": + if (typeof ev.port === "number") runtimePort = ev.port; + break; + case "ready": + isReady = true; + bootError = ""; + if (ev.model) emitProgress(ev); + break; + case "progress": + emitProgress(ev); + break; + case "binary-progress": + emitProgress(ev); + break; + case "error": + bootError = ev.msg ?? "unknown error"; + break; + case "log": + console.log("[runtime]:", ev.msg ?? ""); + break; + } +} + +async function spawnRuntime(): Promise { + const port = await findFreePort(); + const binaryPath = resolveBinaryPath(); + if (!binaryPath) { + throw new Error( + "Runtime binary not found. Run `npm run compile` or run in dev mode.", + ); + } + const isScript = isScriptPath(binaryPath); + const nodeRunner = isScript ? resolveNodeRunner() : null; + const args: string[] = []; + if (isScript) { + args.push("--max-old-space-size=512"); + args.push(binaryPath); + } + args.push( + `--port=${port}`, + `--embedding-models-dir=${EMBEDDING_MODELS_DIR}`, + `--llama-models-dir=${LLM_MODELS_DIR}`, + `--llama-bin-dir=${LLM_BIN_DIR}`, + `--data-dir=${USER_DATA}`, + ); + + const cmd = nodeRunner ?? (isScript ? process.execPath : binaryPath); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + TRANSFORMERS_CACHE: EMBEDDING_MODELS_DIR, + }; + if (isScript && !nodeRunner) childEnv.ELECTRON_RUN_AS_NODE = "1"; + + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + env: childEnv, + }); + + child.stdout?.setEncoding("utf8"); + let stdoutBuf = ""; + child.stdout?.on("data", (data: string) => { + stdoutBuf += data; + const lines = stdoutBuf.split("\n"); + stdoutBuf = lines.pop() ?? ""; + for (const line of lines) parseStdoutLine(line); + }); + + let stderrBuf = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (data: string) => { + stderrBuf += data; + console.error("[runtime stderr]:", data.trim()); + }); + + child.on("exit", (code, signal) => { + console.log( + `[runtime-client] process exited code=${code} signal=${signal} ` + + `inFlight=${inFlightRequests} ready=${isReady} bootError="${bootError}"`, + ); + if (!signal && code !== 0) { + console.log("[runtime-client] last 1KB stderr:\n" + stderrBuf.slice(-1024)); + } + runtimeProcess = null; + runtimePort = null; + isReady = false; + startPromise = null; + }); + + runtimeProcess = child; + return port; +} + +async function checkHealth(port: number, timeoutMs = 1000): Promise { + return new Promise((resolve) => { + const req = http.get(`http://127.0.0.1:${port}/health`, { timeout: timeoutMs }, (res) => { + resolve(res.statusCode === 200); + }); + req.on("error", () => resolve(false)); + req.on("timeout", () => { req.destroy(); resolve(false); }); + }); +} + +export async function ensureStarted(): Promise { + if (startPromise) return startPromise; + if (runtimeProcess && runtimePort) { + for (let i = 0; i < 5; i++) { + if (await checkHealth(runtimePort, 1500)) return runtimePort; + if (!runtimeProcess) break; + } + await stopRuntime({ force: true }); + } + startPromise = (async () => { + const port = await spawnRuntime(); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + if (runtimeProcess?.killed || runtimeProcess?.exitCode !== null) break; + await new Promise((r) => setTimeout(r, 200)); + if (await checkHealth(port)) { + isReady = true; + bootError = ""; + return port; + } + } + await stopRuntime(); + throw new Error( + `runtime failed to start within 120s.${bootError ? ` Last error: ${bootError}` : ""}`, + ); + })().catch((e) => { + startPromise = null; + throw e; + }); + return startPromise; +} + +export async function stopRuntime(opts?: { force?: boolean }): Promise { + if (!runtimeProcess) return; + if (!opts?.force && inFlightRequests > 0) return; + const proc = runtimeProcess; + runtimeProcess = null; + runtimePort = null; + isReady = false; + startPromise = null; + try { proc.kill("SIGTERM"); } catch { /* ignore */ } + await new Promise((resolve) => { + const timer = setTimeout(() => { + try { proc.kill("SIGKILL"); } catch { /* ignore */ } + resolve(); + }, 2000); + proc.once("exit", () => { clearTimeout(timer); resolve(); }); + }); +} + +export async function dispose(): Promise { + await stopRuntime({ force: true }); + progressListeners.clear(); +} + +export function onProgress(listener: ProgressListener): () => void { + progressListeners.add(listener); + return () => progressListeners.delete(listener); +} + +// ── HTTP helpers ────────────────────────────────────────────── + +async function runtimeFetch(path: string, opts?: { method?: string; body?: unknown; timeout?: number }): Promise { + const port = await ensureStarted(); + inFlightRequests++; + try { + const url = `http://127.0.0.1:${port}${path}`; + const method = opts?.method ?? "GET"; + const body = opts?.body ? JSON.stringify(opts.body) : undefined; + const res = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = http.request(url, { + method, + headers: body + ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } + : undefined, + timeout: opts?.timeout ?? 600_000, + }, (r) => { + const chunks: Buffer[] = []; + r.on("data", (c: Buffer) => chunks.push(c)); + r.on("end", () => { + resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }); + }); + }); + req.on("error", reject); + req.on("timeout", () => { req.destroy(new Error("runtime request timed out")); }); + if (body) req.write(body); + req.end(); + }); + if (res.status !== 200) { + throw new Error(`runtime ${path} http ${res.status}: ${res.body}`); + } + return JSON.parse(res.body) as T; + } finally { + inFlightRequests--; + } +} + +// ── Embeddings API ──────────────────────────────────────────── + +export async function embed( + texts: string[], + task: EmbedTask, + model = EMBED_MODEL_ID, +): Promise { + if (texts.length === 0) return []; + const res = await runtimeFetch<{ vectors: number[][]; dim: number; model: string }>( + "/v1/embed", + { method: "POST", body: { texts, task, model } }, + ); + if (res.dim !== EMBED_DIM) { + throw new Error(`embed: expected dim ${EMBED_DIM}, got ${res.dim}`); + } + return res.vectors; +} + +export async function listEmbeddingModels(): Promise { + const res = await runtimeFetch<{ models: AdapterModelEntry[] }>("/v1/embeddings/models"); + return res.models; +} + +export async function installEmbeddingModel(modelId: string): Promise { + await runtimeFetch("/v1/embeddings/models/install", { method: "POST", body: { modelId } }); +} + +export async function removeEmbeddingModel(modelId: string): Promise { + await runtimeFetch("/v1/embeddings/models/remove", { method: "POST", body: { modelId } }); +} + +export async function setDefaultEmbeddingModel(modelId: string): Promise { + await runtimeFetch("/v1/embeddings/models/setDefault", { method: "POST", body: { modelId } }); +} + +// ── LLM API ─────────────────────────────────────────────────── + +export async function listLLMModels(): Promise { + const res = await runtimeFetch<{ models: AdapterModelEntry[] }>("/v1/llm/models"); + return res.models; +} + +export async function installLLMModel(modelId: string, useMirror?: boolean): Promise { + await runtimeFetch("/v1/llm/models/install", { method: "POST", body: { modelId, useMirror } }); +} + +export async function removeLLMModel(modelId: string): Promise { + await runtimeFetch("/v1/llm/models/remove", { method: "POST", body: { modelId } }); +} + +export async function startLLMServer(modelId: string, contextLimit?: number): Promise { + const res = await runtimeFetch<{ port: number }>("/v1/llm/server/start", { + method: "POST", + body: { modelId, contextLimit }, + timeout: 120_000, + }); + return res.port; +} + +export async function stopLLMServer(): Promise { + await runtimeFetch("/v1/llm/server/stop", { method: "POST" }); +} + +export async function getLLMStatus(): Promise<{ + running: boolean; + port: number | null; + activeModelId: string | null; + defaultModelId: string | null; + binaryInstalled: boolean; +}> { + const res = await runtimeFetch<{ kind: string; running: boolean; port: number | null; model: string | null; error: string | null; defaultModelId: string | null; binaryInstalled: boolean }>( + "/v1/llm/server/status", + ); + return { + running: res.running, + port: res.port, + activeModelId: res.model, + defaultModelId: res.defaultModelId ?? null, + binaryInstalled: res.binaryInstalled ?? false, + }; +} + +export async function checkLLMBinaryUpdate(): Promise<{ + updateAvailable: boolean; + currentVersion: string | null; + latestVersion: string | null; +}> { + return runtimeFetch("/v1/llm/binary/check-update"); +} + +export async function installLLMBinary(): Promise { + await runtimeFetch("/v1/llm/binary/install", { method: "POST", timeout: 600_000 }); +} + +export async function clearInactiveLLMModels(): Promise { + await runtimeFetch("/v1/llm/models/clearInactive", { method: "POST" }); +} + +export async function setDefaultLLMModel(modelId: string): Promise { + await runtimeFetch("/v1/llm/server/setDefault", { method: "POST", body: { modelId } }); +} + +// ── Unified status ──────────────────────────────────────────── + +export async function getRuntimeStatus(): Promise<{ + embeddings: { healthy: boolean; model: string | null; loaded: boolean }; + llm: { healthy: boolean; model: string | null; loaded: boolean; port: number | null }; +}> { + return runtimeFetch("/health"); +} + +export function getEmbeddingsStatus(): EmbeddingsStatus { + const running = runtimeProcess !== null && !runtimeProcess.killed && runtimeProcess.exitCode === null; + return { + running, + port: running ? runtimePort : null, + activeModelId: null, + defaultModelId: EMBED_MODEL_ID, + installed: resolveBinaryPath() !== null, + error: bootError || null, + reindexInProgress, + recomputeInProgress, + lastReindexDone, + lastReindexTotal, + lastRecomputeDone, + lastRecomputeTotal, + }; +} + +export function isReadyForInference(): boolean { + return isReady && runtimeProcess !== null && runtimePort !== null; +} + +export function stopRuntimeSync(): void { + if (runtimeProcess) { + try { runtimeProcess.kill("SIGKILL"); } catch { /* ignore */ } + runtimeProcess = null; + runtimePort = null; + isReady = false; + startPromise = null; + } +} + +export const MODELS_DIRECTORY = EMBEDDING_MODELS_DIR; +export const BINARY_DIRECTORY = LOCAL_BIN_DIR; diff --git a/electron/runtime/model-manager.test.ts b/electron/runtime/model-manager.test.ts new file mode 100644 index 00000000..86834eea --- /dev/null +++ b/electron/runtime/model-manager.test.ts @@ -0,0 +1,227 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import * as crypto from "crypto"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + computeFileSha256, + verifyModel, + readManifest, + writeManifest, + updateManifestEntry, + verifyOnDisk, +} from "./model-manager"; +import { migrateManifest } from "./model-manager"; + +describe("model-manager", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-mm-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("computeFileSha256", () => { + it("computes SHA256 hash of a file", () => { + const filePath = path.join(tmpDir, "test.bin"); + const content = Buffer.from("hello world"); + fs.writeFileSync(filePath, content); + const expected = crypto.createHash("sha256").update(content).digest("hex"); + expect(computeFileSha256(filePath)).toBe(expected); + }); + + it("returns null for non-existent file", () => { + expect(computeFileSha256(path.join(tmpDir, "nope.bin"))).toBeNull(); + }); + + it("handles large files with chunked reads", () => { + const filePath = path.join(tmpDir, "large.bin"); + // Write 256KB file (larger than 64KB read buffer) + const content = Buffer.alloc(256 * 1024, 0x42); + fs.writeFileSync(filePath, content); + const expected = crypto.createHash("sha256").update(content).digest("hex"); + expect(computeFileSha256(filePath)).toBe(expected); + }); + }); + + describe("verifyModel", () => { + it("returns true when checksum matches", () => { + const filePath = path.join(tmpDir, "model.gguf"); + const content = Buffer.from("model data"); + fs.writeFileSync(filePath, content); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + expect(verifyModel(filePath, hash)).toBe(true); + }); + + it("returns false when checksum does not match", () => { + const filePath = path.join(tmpDir, "model.gguf"); + fs.writeFileSync(filePath, "different data"); + expect(verifyModel(filePath, "0".repeat(64))).toBe(false); + }); + + it("skips verification when expectedSha256 is empty", () => { + const filePath = path.join(tmpDir, "model.gguf"); + fs.writeFileSync(filePath, "anything"); + expect(verifyModel(filePath, "")).toBe(true); + }); + + it("is case-insensitive for hex checksum", () => { + const filePath = path.join(tmpDir, "model.gguf"); + const content = Buffer.from("test"); + fs.writeFileSync(filePath, content); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + const upperHash = hash.toUpperCase(); + expect(verifyModel(filePath, upperHash)).toBe(true); + }); + }); + + describe("manifest operations", () => { + it("readManifest returns empty object for non-existent file", () => { + expect(readManifest(path.join(tmpDir, "manifest.json"))).toEqual({}); + }); + + it("readManifest returns parsed object for existing file", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const data = { "model-1": { status: "installed", downloadProgress: 100 } }; + fs.writeFileSync(manifestPath, JSON.stringify(data)); + expect(readManifest(manifestPath)).toEqual(data); + }); + + it("readManifest returns empty object for invalid JSON", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + fs.writeFileSync(manifestPath, "{ invalid json }"); + expect(readManifest(manifestPath)).toEqual({}); + }); + + it("writeManifest creates parent directories", () => { + const manifestPath = path.join(tmpDir, "sub", "dir", "manifest.json"); + writeManifest(manifestPath, { "model-1": { status: "installed", downloadProgress: 100 } }); + expect(fs.existsSync(manifestPath)).toBe(true); + }); + + it("updateManifestEntry merges patch into existing entry", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + writeManifest(manifestPath, { "model-1": { status: "downloading", downloadProgress: 50 } }); + updateManifestEntry(manifestPath, "model-1", { status: "installed", downloadProgress: 100 }); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"]).toEqual({ status: "installed", downloadProgress: 100 }); + }); + + it("updateManifestEntry creates entry if it does not exist", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + updateManifestEntry(manifestPath, "model-new", { status: "installed", downloadProgress: 100 }); + const manifest = readManifest(manifestPath); + expect(manifest["model-new"]).toEqual({ status: "installed", downloadProgress: 100 }); + }); + }); + + describe("verifyOnDisk", () => { + it("returns true and marks installed when file exists and checksum matches", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const modelPath = path.join(tmpDir, "model.gguf"); + const content = Buffer.from("model content"); + fs.writeFileSync(modelPath, content); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + const result = verifyOnDisk(manifestPath, "model-1", modelPath, hash); + expect(result).toBe(true); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"].status).toBe("installed"); + expect(manifest["model-1"].downloadProgress).toBe(100); + expect(manifest["model-1"].verifiedAt).toBeDefined(); + }); + + it("returns false and marks not_downloaded when checksum does not match", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const modelPath = path.join(tmpDir, "model.gguf"); + fs.writeFileSync(modelPath, "wrong content"); + const result = verifyOnDisk(manifestPath, "model-1", modelPath, "0".repeat(64)); + expect(result).toBe(false); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"].status).toBe("not_downloaded"); + expect(manifest["model-1"].error).toContain("checksum"); + }); + + it("returns false when file does not exist", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const result = verifyOnDisk(manifestPath, "model-1", path.join(tmpDir, "nope.gguf"), "0".repeat(64)); + expect(result).toBe(false); + }); + + it("skips checksum when sha256 is empty, just checks existence", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const modelPath = path.join(tmpDir, "model.gguf"); + fs.writeFileSync(modelPath, "any content"); + const result = verifyOnDisk(manifestPath, "model-1", modelPath, ""); + expect(result).toBe(true); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"].status).toBe("installed"); + expect(manifest["model-1"].verifiedAt).toBeDefined(); + }); + }); + + describe("migrateManifest", () => { + it("adds verifiedAt to installed entries without it", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const modelPath = path.join(tmpDir, "model.gguf"); + const content = Buffer.from("model data"); + fs.writeFileSync(modelPath, content); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + writeManifest(manifestPath, { "model-1": { status: "installed", downloadProgress: 100 } }); + const updates = migrateManifest(manifestPath, [{ id: "model-1", filePath: modelPath, sha256: hash }]); + expect(updates).toBe(1); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"].verifiedAt).toBeDefined(); + }); + + it("marks not_downloaded when installed but file is missing", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + writeManifest(manifestPath, { "model-1": { status: "installed", downloadProgress: 100 } }); + const updates = migrateManifest(manifestPath, [{ id: "model-1", filePath: path.join(tmpDir, "nope.gguf"), sha256: "" }]); + expect(updates).toBe(1); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"].status).toBe("not_downloaded"); + }); + + it("marks not_downloaded when checksum fails", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const modelPath = path.join(tmpDir, "model.gguf"); + fs.writeFileSync(modelPath, "wrong content"); + writeManifest(manifestPath, { "model-1": { status: "installed", downloadProgress: 100 } }); + const updates = migrateManifest(manifestPath, [{ id: "model-1", filePath: modelPath, sha256: "0".repeat(64) }]); + expect(updates).toBe(1); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"].status).toBe("not_downloaded"); + }); + + it("skips entries with no manifest entry", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + // Empty manifest, no model-1 entry + writeManifest(manifestPath, {}); + const updates = migrateManifest(manifestPath, [{ id: "model-1", filePath: "/tmp/nope.gguf", sha256: "" }]); + expect(updates).toBe(0); + }); + + it("skips entries that already have verifiedAt", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + const modelPath = path.join(tmpDir, "model.gguf"); + const content = Buffer.from("model data"); + fs.writeFileSync(modelPath, content); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + writeManifest(manifestPath, { "model-1": { status: "installed", downloadProgress: 100, verifiedAt: "2026-01-01T00:00:00.000Z" } }); + const updates = migrateManifest(manifestPath, [{ id: "model-1", filePath: modelPath, sha256: hash }]); + expect(updates).toBe(0); + }); + + it("skips entries that are not installed (e.g. downloading)", () => { + const manifestPath = path.join(tmpDir, "manifest.json"); + writeManifest(manifestPath, { "model-1": { status: "downloading", downloadProgress: 50 } }); + const updates = migrateManifest(manifestPath, [{ id: "model-1", filePath: "/tmp/nope.gguf", sha256: "" }]); + expect(updates).toBe(0); + const manifest = readManifest(manifestPath); + expect(manifest["model-1"].status).toBe("downloading"); + }); + }); +}); diff --git a/electron/runtime/model-manager.ts b/electron/runtime/model-manager.ts new file mode 100644 index 00000000..97abe30f --- /dev/null +++ b/electron/runtime/model-manager.ts @@ -0,0 +1,210 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as crypto from "crypto"; + +/** + * Unified model manager — handles model file downloads, SHA256 verification, + * and manifest management for all adapter types. + * + * Each model definition includes a `sha256` checksum. Downloads are written + * to a `.tmp` file, verified against the checksum, then atomically renamed + * to the final path. If verification fails, the temp file is deleted and + * the install fails with a clear error. + * + * Manifest format (unified, per-adapter): + * { + * "": { + * "status": "installed" | "downloading" | "not_downloaded" | "error", + * "downloadProgress": 0-100, + * "downloadSpeed": "1.2 MB/s", + * "error": "optional error message", + * "verifiedAt": "2026-06-21T..." // ISO timestamp of last SHA256 check + * } + * } + */ + +export interface VerifiedModelDef { + id: string; + downloadUrl: string; + /** Destination path for the model file */ + destPath: string; + /** Expected file size in bytes (used for progress reporting) */ + sizeBytes: number; + /** SHA256 checksum of the downloaded file (hex, lowercase, 64 chars). Empty string = skip verification. */ + sha256: string; + /** Display name */ + name: string; +} + +export interface DownloadProgress { + modelId: string; + progress: number; + speed: string; + bytesReceived: number; + bytesTotal: number; +} + +export interface DownloadResult { + success: boolean; + error?: string; + verified: boolean; +} + +/** + * Compute SHA256 hash of a file. Returns hex lowercase or null on error. + */ +export function computeFileSha256(filePath: string): string | null { + try { + const fd = fs.openSync(filePath, "r"); + const hash = crypto.createHash("sha256"); + const buf = Buffer.alloc(64 * 1024); + let bytesRead: number; + do { + bytesRead = fs.readSync(fd, buf, 0, buf.length, null); + if (bytesRead > 0) hash.update(buf.subarray(0, bytesRead)); + } while (bytesRead > 0); + fs.closeSync(fd); + return hash.digest("hex"); + } catch { + return null; + } +} + +/** + * Verify a downloaded file against an expected SHA256 checksum. + * Returns true if the file matches, false otherwise. + * If `expectedSha256` is empty, verification is skipped (returns true). + */ +export function verifyModel(filePath: string, expectedSha256: string): boolean { + if (!expectedSha256) return true; + const actual = computeFileSha256(filePath); + if (!actual) return false; + return actual === expectedSha256.toLowerCase(); +} + +/** + * Manifest record for a single model. + */ +export interface ManifestEntry { + status: "not_downloaded" | "downloading" | "installed" | "error"; + downloadProgress: number; + downloadSpeed?: string; + error?: string; + verifiedAt?: string; +} + +/** + * Read a JSON manifest file, returning an empty object if it doesn't exist. + */ +export function readManifest(manifestPath: string): Record { + if (!fs.existsSync(manifestPath)) return {}; + try { + return JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; + } catch { + return {}; + } +} + +/** + * Write a manifest file, creating parent directories as needed. + */ +export function writeManifest(manifestPath: string, manifest: Record): void { + try { + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + } catch (e) { + console.error("[model-manager] Failed to write manifest:", e); + } +} + +/** + * Update a single entry in the manifest (merge patch). + */ +export function updateManifestEntry( + manifestPath: string, + modelId: string, + patch: Partial, +): void { + const manifest = readManifest(manifestPath); + manifest[modelId] = { ...(manifest[modelId] ?? { status: "not_downloaded", downloadProgress: 0 }), ...patch }; + writeManifest(manifestPath, manifest); +} + +/** + * Verify an existing model file on disk against its expected checksum. + * If the file fails verification, marks the manifest entry as "not_downloaded". + * If the file passes, marks as "installed" with verifiedAt timestamp. + * If no checksum is provided, just checks file existence. + */ +export function verifyOnDisk( + manifestPath: string, + modelId: string, + filePath: string, + expectedSha256: string, +): boolean { + if (!fs.existsSync(filePath)) return false; + if (!expectedSha256) { + updateManifestEntry(manifestPath, modelId, { status: "installed", downloadProgress: 100, verifiedAt: new Date().toISOString() }); + return true; + } + const ok = verifyModel(filePath, expectedSha256); + if (ok) { + updateManifestEntry(manifestPath, modelId, { status: "installed", downloadProgress: 100, verifiedAt: new Date().toISOString() }); + } else { + console.warn(`[model-manager] SHA256 mismatch for ${modelId}, marking not_downloaded`); + updateManifestEntry(manifestPath, modelId, { status: "not_downloaded", downloadProgress: 0, error: "checksum verification failed" }); + } + return ok; +} + +/** + * Migrate an old manifest into the unified format. The legacy manifest format + * is a subset of ManifestEntry (lacks `verifiedAt`). This function: + * 1. Reads the existing manifest at manifestPath (if any) + * 2. For each entry marked "installed", verifies the file on disk + * 3. Adds `verifiedAt` timestamp for verified entries + * 4. Removes stale entries (files missing) or marks them not_downloaded + * + * Returns the number of entries that were updated during migration. + */ +export function migrateManifest( + manifestPath: string, + models: Array<{ id: string; filePath: string; sha256: string }>, +): number { + const manifest = readManifest(manifestPath); + let updates = 0; + let changed = false; + + for (const { id, filePath, sha256 } of models) { + const entry = manifest[id]; + if (!entry) continue; + + if (entry.status === "installed") { + if (fs.existsSync(filePath)) { + if (sha256) { + const ok = verifyModel(filePath, sha256); + if (!ok && entry.verifiedAt === undefined) { + manifest[id] = { status: "not_downloaded", downloadProgress: 0, error: "checksum verification failed (migration)" }; + changed = true; + updates++; + } else if (ok && entry.verifiedAt === undefined) { + manifest[id] = { ...entry, verifiedAt: new Date().toISOString() }; + changed = true; + updates++; + } + } else if (entry.verifiedAt === undefined) { + manifest[id] = { ...entry, verifiedAt: new Date().toISOString() }; + changed = true; + updates++; + } + } else { + manifest[id] = { status: "not_downloaded", downloadProgress: 0 }; + changed = true; + updates++; + } + } + } + + if (changed) writeManifest(manifestPath, manifest); + return updates; +} diff --git a/electron/runtime/port-discovery.ts b/electron/runtime/port-discovery.ts new file mode 100644 index 00000000..04a38fd2 --- /dev/null +++ b/electron/runtime/port-discovery.ts @@ -0,0 +1,184 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { spawn, execSync, type ChildProcess } from "child_process"; + +import type { EmbedTask } from "../embeddings/types"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Finds the Cairn userData directory by scanning known locations. + * Mirrors the logic in `mcp/db.ts` `findDbPath()` — checks Cairn, cairn, Electron. + */ +function findUserDataDir(): string | null { + const home = os.homedir(); + const platform = process.platform; + let base: string; + if (platform === "win32") { + base = process.env.APPDATA ?? path.join(home, "AppData", "Roaming"); + } else if (platform === "darwin") { + base = path.join(home, "Library", "Application Support"); + } else { + base = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"); + } + for (const name of ["cairn", "Cairn", "Electron"]) { + const dir = path.join(base, name); + if (fs.existsSync(dir)) return dir; + } + return null; +} + +/** + * Reads the port file written by the unified runtime server. + * Returns null if the runtime isn't running or the port file doesn't exist. + */ +export function findRuntimePort(): number | null { + const userData = findUserDataDir(); + if (!userData) return null; + const portFile = path.join(userData, "runtime-port.json"); + if (!fs.existsSync(portFile)) return null; + try { + const data = JSON.parse(fs.readFileSync(portFile, "utf8")) as { port?: number; pid?: number }; + if (typeof data.port !== "number" || data.port <= 0) return null; + // If we have a pid, check the process is still alive + if (typeof data.pid === "number") { + try { + process.kill(data.pid, 0); + } catch { + return null; + } + } + return data.port; + } catch { + return null; + } +} + +/** + * Tries to locate and spawn the runtime server bundle when Cairn isn't running. + * Only works in dev mode (needs `node` on PATH + the bundle in dist-electron/). + * Returns the port if successful, null otherwise. + */ +let spawnedRuntime: ChildProcess | null = null; + +async function trySpawnRuntime(): Promise { + if (spawnedRuntime && !spawnedRuntime.killed) { + // Already spawned — check if port file appeared + return findRuntimePort(); + } + + const userData = findUserDataDir(); + if (!userData) return null; + + // Look for the runtime server bundle + const candidates = [ + path.join(__dirname, "..", "dist-electron", "runtime-server.bundle.js"), + path.join(__dirname, "runtime-server.bundle.js"), + path.resolve(__dirname, "..", "..", "dist-electron", "runtime-server.bundle.js"), + ]; + const bundle = candidates.find((c) => fs.existsSync(c)); + if (!bundle) return null; + + // Need `node` on PATH + let nodeBin: string | null = null; + try { + nodeBin = execSync( + process.platform === "win32" ? "where node" : "which node", + ) + .toString() + .trim() + .split(/\r?\n/)[0]; + } catch { /* node not on PATH */ } + if (!nodeBin || !fs.existsSync(nodeBin)) return null; + + const embeddingModelsDir = path.join(userData, "embedding-models"); + const llamaModelsDir = path.join(userData, "llama-models"); + const llamaBinDir = path.join(userData, "llama-bin"); + + spawnedRuntime = spawn( + nodeBin, + [bundle, `--data-dir=${userData}`, `--embedding-models-dir=${embeddingModelsDir}`, `--llama-models-dir=${llamaModelsDir}`, `--llama-bin-dir=${llamaBinDir}`], + { stdio: ["ignore", "pipe", "pipe"], detached: false, env: { ...process.env, TRANSFORMERS_CACHE: embeddingModelsDir, ELECTRON_RUN_AS_NODE: "1" } }, + ); + + spawnedRuntime.stdout?.on("data", (d: Buffer) => { + console.log("[spawned-runtime stdout]:", d.toString("utf8").trim()); + }); + spawnedRuntime.stderr?.on("data", (d: Buffer) => { + console.error("[spawned-runtime stderr]:", d.toString("utf8").trim()); + }); + + spawnedRuntime.on("exit", () => { + spawnedRuntime = null; + }); + + // Wait up to 10s for the port file to appear + const portFile = path.join(userData, "runtime-port.json"); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (fs.existsSync(portFile)) { + const port = findRuntimePort(); + if (port) return port; + } + await sleep(200); + } + return null; +} + +/** + * Returns the runtime port, spawning the server if needed. + * 1. Check port file (Cairn running) + * 2. Try to spawn the runtime bundle ourselves (dev mode) + */ +async function ensureRuntimePort(): Promise { + // Fast path — Cairn is running, port file exists + const existing = findRuntimePort(); + if (existing) return existing; + + // Slow path — try to spawn the runtime ourselves (dev only) + return trySpawnRuntime(); +} + +/** + * Embeds text via the unified runtime's HTTP API. + * Used by the standalone MCP server (no Electron dependency). + * + * Returns null if the runtime can't be reached (embeddings not available). + * The caller should surface a graceful error to the user. + */ +export async function embedViaRuntime( + texts: string[], + task: EmbedTask, + model?: string, +): Promise { + if (texts.length === 0) return []; + const port = await ensureRuntimePort(); + if (!port) return null; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30_000); + const res = await fetch(`http://127.0.0.1:${port}/v1/embed`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ texts, task, model }), + signal: controller.signal, + }); + clearTimeout(timer); + if (!res.ok) return null; + const data = (await res.json()) as { vectors: number[][]; dim: number }; + return data.vectors; + } catch { + return null; + } +} + +/** + * Clean up any spawned runtime process (called on MCP server shutdown). + */ +export function disposeSpawnedRuntime(): void { + if (spawnedRuntime) { + try { spawnedRuntime.kill("SIGTERM"); } catch { /* ignore */ } + spawnedRuntime = null; + } +} diff --git a/electron/runtime/server.ts b/electron/runtime/server.ts new file mode 100644 index 00000000..fa22fbfe --- /dev/null +++ b/electron/runtime/server.ts @@ -0,0 +1,412 @@ +import * as http from "http"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { Readable } from "stream"; + +import { EmbeddingsAdapter, SUPPORTED_EMBEDDING_MODELS } from "./adapters/embeddings"; +import { LlamaAdapter, SUPPORTED_LLM_MODELS } from "./adapters/llama"; +import { migrateManifest } from "./model-manager"; +import { EmbedRequest, EMBED_DIM } from "../embeddings/types"; +import type { EmbedTask } from "../embeddings/types"; + +interface StdoutEvent { + kind: "listening" | "ready" | "progress" | "error" | "log" | + "llm:list" | "llm:status" | "embed:list" | "embed:status" | + "binary-progress"; + port?: number; + model?: string | null; + status?: string; + file?: string; + progress?: number; + loaded?: number; + total?: number; + speed?: string; + msg?: string; + data?: unknown; +} + +function emit(ev: StdoutEvent): void { + process.stdout.write(JSON.stringify(ev) + "\n"); +} + +function parseArgs(argv: string[]): { + port?: number; + modelsDir: string; + embeddingModelsDir: string; + llamaModelsDir: string; + llamaBinDir: string; + dataDir: string; +} { + let port: number | undefined; + let modelsDir = path.join(os.homedir(), ".cache", "huggingface"); + let embeddingModelsDir = ""; + let llamaModelsDir = ""; + let llamaBinDir = ""; + let dataDir = ""; + for (const arg of argv.slice(2)) { + if (arg.startsWith("--port=")) { + const n = Number(arg.slice("--port=".length)); + if (Number.isFinite(n) && n > 0) port = n; + } else if (arg.startsWith("--models-dir=")) { + modelsDir = arg.slice("--models-dir=".length); + } else if (arg.startsWith("--embedding-models-dir=")) { + embeddingModelsDir = arg.slice("--embedding-models-dir=".length); + } else if (arg.startsWith("--llama-models-dir=")) { + llamaModelsDir = arg.slice("--llama-models-dir=".length); + } else if (arg.startsWith("--llama-bin-dir=")) { + llamaBinDir = arg.slice("--llama-bin-dir=".length); + } else if (arg.startsWith("--data-dir=")) { + dataDir = arg.slice("--data-dir=".length); + } + } + if (!embeddingModelsDir) embeddingModelsDir = path.join(modelsDir, "embedding-models"); + if (!llamaModelsDir) llamaModelsDir = path.join(modelsDir, "llama-models"); + if (!llamaBinDir) llamaBinDir = path.join(modelsDir, "llama-bin"); + return { port, modelsDir, embeddingModelsDir, llamaModelsDir, llamaBinDir, dataDir }; +} + +const MAX_BODY_BYTES = 10_000_000; + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + req.on("data", (c: Buffer) => { + total += c.length; + if (total > MAX_BODY_BYTES) { + reject(Object.assign(new Error("payload too large"), { statusCode: 413 })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +class HttpError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +async function run(): Promise { + const args = parseArgs(process.argv); + + const embeddingsAdapter = new EmbeddingsAdapter({ + modelsDir: args.embeddingModelsDir, + binDir: "", + defaultModelId: null, + onProgress: (ev) => { + if (ev.type === "load") { + emit({ + kind: "progress", + model: ev.modelId, + status: ev.status, + file: ev.file, + progress: ev.progress, + loaded: ev.loaded, + total: ev.total, + }); + } else if (ev.type === "ready") { + emit({ kind: "ready", model: ev.modelId }); + } + }, + }); + + const llamaAdapter = new LlamaAdapter({ + modelsDir: args.llamaModelsDir, + binDir: args.llamaBinDir, + defaultModelId: null, + onProgress: (ev) => { + if (ev.type === "download") { + emit({ + kind: "progress", + model: ev.modelId, + status: ev.status, + progress: ev.progress, + }); + } + }, + }); + + // Migrate legacy manifest entries — adds verifiedAt timestamps and verifies existing files. + migrateManifest( + path.join(args.embeddingModelsDir, "manifest.json"), + Object.values(SUPPORTED_EMBEDDING_MODELS).map((m) => ({ + id: m.id, + filePath: path.join(args.embeddingModelsDir, m.id, m.meta.filename), + sha256: m.meta.sha256, + })), + ); + migrateManifest( + path.join(args.llamaModelsDir, "manifest.json"), + Object.values(SUPPORTED_LLM_MODELS).map((m) => ({ + id: m.id, + filePath: path.join(args.llamaModelsDir, m.meta.filename), + sha256: m.meta.sha256, + })), + ); + + const server = http.createServer(async (req, res) => { + if (!req.url) { sendJson(res, 400, { error: "no url" }); return; } + + // ── Unified health ── + if (req.method === "GET" && req.url === "/health") { + const [embedHealth, llmHealth] = await Promise.all([ + embeddingsAdapter.health(), + llamaAdapter.health(), + ]); + sendJson(res, 200, { + status: "ok", + embeddings: { healthy: embedHealth.healthy, model: embedHealth.model, loaded: embedHealth.loaded }, + llm: { healthy: llmHealth.healthy, model: llmHealth.model, loaded: llmHealth.loaded, port: llamaAdapter.status().port }, + }); + return; + } + + // ── Embeddings endpoints (in-process) ── + if (req.method === "POST" && req.url === "/v1/embed") { + try { + const body = await readBody(req); + let parsed: unknown; + try { parsed = JSON.parse(body); } + catch { throw new HttpError(400, "invalid JSON body"); } + const reqParsed = EmbedRequest.safeParse(parsed); + if (!reqParsed.success) throw new HttpError(400, `invalid embed request: ${reqParsed.error.message}`); + const { texts, task, model } = reqParsed.data; + const vectors = await embeddingsAdapter.embed(texts, task as EmbedTask, model); + sendJson(res, 200, { vectors, dim: EMBED_DIM, model: model ?? embeddingsAdapter.getDefaultModelId() }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + emit({ kind: "error", msg }); + const errAny = e as { statusCode?: number }; + const status = e instanceof HttpError ? e.status : errAny.statusCode ?? 500; + sendJson(res, status, { error: msg }); + } + return; + } + + // ── Embeddings model management ── + if (req.method === "GET" && req.url === "/v1/embeddings/models") { + sendJson(res, 200, { models: embeddingsAdapter.listModels() }); + return; + } + if (req.method === "POST" && req.url === "/v1/embeddings/models/install") { + try { + const body = await readBody(req); + const { modelId } = JSON.parse(body) as { modelId: string }; + if (!modelId) { sendJson(res, 400, { error: "modelId is required" }); return; } + await embeddingsAdapter.installModel(modelId); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/embeddings/models/remove") { + try { + const body = await readBody(req); + const { modelId } = JSON.parse(body) as { modelId: string }; + if (!modelId) { sendJson(res, 400, { error: "modelId is required" }); return; } + embeddingsAdapter.removeModel(modelId); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/embeddings/models/setDefault") { + try { + const body = await readBody(req); + const { modelId } = JSON.parse(body) as { modelId: string }; + if (!modelId) { sendJson(res, 400, { error: "modelId is required" }); return; } + embeddingsAdapter.setDefaultModelId(modelId); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + + // ── LLM model management ── + if (req.method === "GET" && req.url === "/v1/llm/models") { + sendJson(res, 200, { models: llamaAdapter.listModels() }); + return; + } + if (req.method === "POST" && req.url === "/v1/llm/models/install") { + try { + const body = await readBody(req); + const { modelId, useMirror } = JSON.parse(body) as { modelId: string; useMirror?: boolean }; + await llamaAdapter.installModel(modelId, useMirror); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/llm/models/remove") { + try { + const body = await readBody(req); + const { modelId } = JSON.parse(body) as { modelId: string }; + llamaAdapter.removeModel(modelId); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/llm/server/start") { + try { + const body = await readBody(req); + const { modelId, contextLimit } = JSON.parse(body) as { modelId: string; contextLimit?: number }; + const { port } = await llamaAdapter.start(modelId, { contextLimit }); + sendJson(res, 200, { port }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/llm/server/stop") { + await llamaAdapter.stop(); + sendJson(res, 200, { success: true }); + return; + } + if (req.method === "GET" && req.url === "/v1/llm/server/status") { + sendJson(res, 200, { + ...llamaAdapter.status(), + defaultModelId: llamaAdapter.getDefaultModelId(), + binaryInstalled: llamaAdapter.isBinaryInstalled(), + }); + return; + } + if (req.method === "POST" && req.url === "/v1/llm/binary/check-update") { + try { + const result = await llamaAdapter.checkBinaryUpdate(); + sendJson(res, 200, result); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/llm/binary/install") { + try { + await llamaAdapter.installBinary((progress, speed, status, error) => { + emit({ kind: "binary-progress", progress, speed, status, msg: error }); + }); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/llm/models/clearInactive") { + try { + llamaAdapter.clearInactiveModels(); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + if (req.method === "POST" && req.url === "/v1/llm/server/setDefault") { + try { + const body = await readBody(req); + const { modelId } = JSON.parse(body) as { modelId: string }; + llamaAdapter.setDefaultModelId(modelId); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + + // ── LLM chat completions proxy ── + if (req.method === "POST" && req.url === "/v1/llm/chat/completions") { + try { + const body = await readBody(req); + const llmPort = llamaAdapter.status().port; + if (!llmPort) throw new HttpError(503, "LLM server is not running"); + // Proxy to llama-server's OpenAI-compatible endpoint + const proxyRes = await fetch(`http://127.0.0.1:${llmPort}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + // Pipe the response directly to support streaming (stream: true) + res.writeHead(proxyRes.status, { "Content-Type": "application/json" }); + if (proxyRes.body) { + Readable.fromWeb(proxyRes.body).pipe(res); + } else { + res.end(); + } + } catch (e) { + const status = e instanceof HttpError ? e.status : 500; + sendJson(res, status, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } + + sendJson(res, 404, { error: "not found" }); + }); + + const listenPort = args.port ?? 0; + server.listen(listenPort, "127.0.0.1", () => { + const addr = server.address(); + const actualPort = addr && typeof addr === "object" ? addr.port : listenPort; + emit({ kind: "listening", port: actualPort }); + // Write port file for MCP server discovery (no Electron dependency needed) + if (args.dataDir) { + const portFile = path.join(args.dataDir, "runtime-port.json"); + try { + fs.writeFileSync(portFile, JSON.stringify({ port: actualPort, pid: process.pid }), "utf8"); + } catch { /* ignore */ } + } + }); + + const shutdown = (sig: string) => { + emit({ kind: "log", msg: `received ${sig}, shutting down` }); + // Clean up port file + if (args.dataDir) { + const portFile = path.join(args.dataDir, "runtime-port.json"); + try { fs.unlinkSync(portFile); } catch { /* ignore */ } + } + Promise.allSettled([embeddingsAdapter.dispose(), llamaAdapter.dispose()]) + .finally(() => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1500).unref(); + }); + }; + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); +} + +if (require.main === module) { + process.on("uncaughtException", (err) => { + emit({ kind: "error", msg: `uncaughtException: ${err.stack ?? err.message ?? String(err)}` }); + try { console.error("[cairn-runtime] FATAL uncaughtException:", err); } catch { /* ignore */ } + setTimeout(() => process.exit(1), 100).unref(); + }); + process.on("unhandledRejection", (reason) => { + emit({ + kind: "error", + msg: `unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}`, + }); + try { console.error("[cairn-runtime] FATAL unhandledRejection:", reason); } catch { /* ignore */ } + setTimeout(() => process.exit(1), 100).unref(); + }); + run(); +} + +export { run }; diff --git a/electron/splash/boot-sequence.ts b/electron/splash/boot-sequence.ts index b9d9e96f..a0f3e15f 100644 --- a/electron/splash/boot-sequence.ts +++ b/electron/splash/boot-sequence.ts @@ -26,6 +26,7 @@ import * as manifest from "../embeddings/manifest"; import { syncNotesFromDisk } from "../notes-files"; import { computeSemanticRelationships } from "../db/graph-queries"; import { saveCachedConfig } from "../lib/config-cache"; +import * as runtime from "../runtime/client"; export interface BootContext { db: Database.Database; @@ -53,6 +54,11 @@ export async function runBootSequence( let notesSynced = 0; // ── Step 1: Update check (prod only) ────────────────────────────────── + // Block the boot until the update is downloaded and installed. This + // ensures the main window never opens with a known-broken build — the + // update heals the app before any potentially crashing renderer code + // runs. To avoid the splash appearing frozen during the ~172 MB + // download, we pipe live download-progress events to the splash UI. if (!ctx.isDev) { try { splash.progress({ step: "update", label: "Checking for updates…", pct: 10 }); @@ -62,42 +68,78 @@ export async function runBootSequence( splash.progress({ step: "update", label: `Downloading update v${version}…`, - detail: "Auto-installing on download complete", - pct: 30, + detail: "Starting download", + pct: 15, }); - // Wait for the download to complete. electron-updater emits - // 'update-downloaded' when ready. If autoDownload is true (default), - // the download starts automatically after checkForUpdates resolves. - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("update download timed out after 120s")); - }, 120_000); - - autoUpdater.once("update-downloaded", () => { - clearTimeout(timeout); - splash.progress({ - step: "update", - label: `Installing v${version}…`, - detail: "The app will restart automatically", - pct: 100, - }); - updateInstalled = true; - // quitAndInstall restarts the app — the boot sequence will - // run again on next launch (finding no update). - autoUpdater.quitAndInstall(); - // The app quits here, so resolve is technically unreachable. - resolve(); + // Pipe live download progress to the splash so the user sees a + // moving progress bar instead of a frozen "Downloading…" label. + const progressHandler = (p: { percent: number; transferred: number; total: number; bytesPerSecond: number }) => { + const pct = Math.round(p.percent); + const transferredMB = (p.transferred / 1024 / 1024).toFixed(1); + const totalMB = (p.total / 1024 / 1024).toFixed(1); + const speedMB = (p.bytesPerSecond / 1024 / 1024).toFixed(1); + splash.progress({ + step: "update", + label: `Downloading update v${version}…`, + detail: `${transferredMB} / ${totalMB} MB (${speedMB} MB/s)`, + pct: 15 + Math.floor(pct * 0.80), // map 0-100% download to 15-95% boot }); + }; + autoUpdater.on("download-progress", progressHandler); + + let cleanupDownloaded: (() => void) | undefined; + let cleanupError: (() => void) | undefined; + + try { + // Wait for the download to complete. electron-updater emits + // 'update-downloaded' when ready. autoDownload is true (set in + // main.ts), so the download starts automatically after + // checkForUpdates resolves. + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("update download timed out after 300s")); + }, 300_000); + + const onDownloaded = () => { + clearTimeout(timeout); + splash.progress({ + step: "update", + label: `Installing v${version}…`, + detail: "The app will restart automatically", + pct: 100, + }); + updateInstalled = true; + // quitAndInstall restarts the app — the boot sequence will + // run again on next launch (finding no update). + autoUpdater.quitAndInstall(); + // The app quits here, so resolve is technically unreachable. + resolve(); + }; + + const onError = (err: Error) => { + clearTimeout(timeout); + reject(err); + }; - autoUpdater.once("error", (err) => { - clearTimeout(timeout); - reject(err); + autoUpdater.once("update-downloaded", onDownloaded); + autoUpdater.once("error", onError); + // Store refs so the finally block can deregister them if the + // timeout fires before the download completes — otherwise + // quitAndInstall() could fire unexpectedly later. + cleanupDownloaded = () => autoUpdater.off("update-downloaded", onDownloaded); + cleanupError = () => autoUpdater.off("error", onError); }); - }); + } finally { + autoUpdater.off("download-progress", progressHandler); + cleanupDownloaded?.(); + cleanupError?.(); + } } } catch (err) { - // Network error, update server down, etc. — don't block the boot. + // Network error, update server down, download timeout, etc. — + // don't block the boot entirely. Better to try launching the app + // (which may work fine) than to leave the user stuck on splash. const msg = err instanceof Error ? err.message : String(err); console.warn("[boot] Update check failed:", msg); errors.push(`Update check: ${msg}`); @@ -142,6 +184,22 @@ export async function runBootSequence( const settings = getEmbeddingsSettingsCached(); if (settings.enabled) { + // Start the unified runtime process so embeddings are immediately + // available for search and semantic features. Without this, the runtime + // only starts lazily when an embed call happens — which means the first + // search after app launch fails until the user toggles embeddings off/on. + splash.progress({ + step: "reindex", + label: "Starting embeddings engine…", + pct: 0, + }); + try { + await runtime.ensureStarted(); + } catch (err) { + console.warn("[boot] Runtime startup failed:", err instanceof Error ? err.message : err); + errors.push(`Runtime startup: ${err instanceof Error ? err.message : String(err)}`); + } + // Resolve model id with self-heal (same logic as the IPC handler). const model = resolveEmbeddingModelId( settings.modelId, diff --git a/electron/splash/bootsplash.ts b/electron/splash/bootsplash.ts index c75c6665..e7d3a495 100644 --- a/electron/splash/bootsplash.ts +++ b/electron/splash/bootsplash.ts @@ -298,7 +298,21 @@ function buildSplashHtml(iconDataUrl: string | null): string { doneSteps.push(label); var row = document.createElement('div'); row.className = 'done-row'; - row.innerHTML = '' + label; + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'checkmark'); + svg.setAttribute('viewBox', '0 0 14 14'); + var path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M2 7l3.5 3.5L12 3'); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-width', '2'); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + svg.appendChild(path); + row.appendChild(svg); + var span = document.createElement('span'); + span.textContent = label; + row.appendChild(span); doneListEl.appendChild(row); } diff --git a/package.json b/package.json index 07453534..4d5bd267 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,12 @@ "test:watch": "vitest", "test:coverage": "npm run compile && vitest run --coverage", "dev": "node scripts/generate-licenses.js && concurrently -n next,esbuild,electron -c cyan,magenta,yellow \"next dev\" \"npm run compile:watch\" \"wait-on http://localhost:3000 dist-electron/main.js && node scripts/electron-dev.js\"", - "compile:watch": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --external:@huggingface/transformers --external:onnxruntime-node --outdir=dist-electron --format=cjs --watch", "build:mac": "node scripts/build.js --mac", "build:win": "node scripts/build.js --win", "build:linux": "node scripts/build.js --linux", "build:all": "node scripts/build.js --mac --win --linux", - "compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --external:@huggingface/transformers --external:onnxruntime-node --outdir=dist-electron --format=cjs && esbuild electron/mcp-server.ts --bundle --platform=node --target=node22 --external:better-sqlite3 --outfile=dist-mcp/mcp-server.bundle.js --format=cjs && esbuild electron/embeddings/server.ts --bundle --platform=node --target=node22 --external:@huggingface/transformers --external:onnxruntime-node --outfile=dist-electron/embeddings-server.bundle.js --format=cjs", + "compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --external:@huggingface/transformers --external:onnxruntime-node --outdir=dist-electron --format=cjs && esbuild electron/mcp-server.ts --bundle --platform=node --target=node22 --external:better-sqlite3 --outfile=dist-mcp/mcp-server.bundle.js --format=cjs && esbuild electron/embeddings/server.ts --bundle --platform=node --target=node22 --external:@huggingface/transformers --external:onnxruntime-node --outfile=dist-electron/embeddings-server.bundle.js --format=cjs && esbuild electron/runtime/server.ts --bundle --platform=node --target=node22 --external:@huggingface/transformers --external:onnxruntime-node --outfile=dist-electron/runtime-server.bundle.js --format=cjs", + "compile:watch": "concurrently -n main,embeddings,runtime \"esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --external:@huggingface/transformers --external:onnxruntime-node --outdir=dist-electron --format=cjs --watch\" \"esbuild electron/embeddings/server.ts --bundle --platform=node --target=node22 --external:@huggingface/transformers --external:onnxruntime-node --outfile=dist-electron/embeddings-server.bundle.js --format=cjs --watch\" \"esbuild electron/runtime/server.ts --bundle --platform=node --target=node22 --external:@huggingface/transformers --external:onnxruntime-node --outfile=dist-electron/runtime-server.bundle.js --format=cjs --watch\"", "build:embeddings-binary": "node scripts/build-embeddings-binary.js", "generate-tray-icon": "node scripts/generate-tray-icon.js", "postinstall": "electron-builder install-app-deps", diff --git a/scripts/build.js b/scripts/build.js index 7d5ac17f..858ce70c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -54,7 +54,13 @@ run("node scripts/generate-licenses.js"); run("cross-env ELECTRON_BUILD=true next build"); // 3. Bundle Electron main + preload with esbuild (inlines all deps except native/dynamic ones) -run("esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --outdir=dist-electron --format=cjs"); +run("esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node22 --external:electron --external:better-sqlite3 --external:node-pty --external:@huggingface/transformers --external:onnxruntime-node --outdir=dist-electron --format=cjs"); + +// 3b. Bundle the runtime server (unified embeddings + LLM — runs as ELECTRON_RUN_AS_NODE child) +run("esbuild electron/runtime/server.ts --bundle --platform=node --target=node22 --external:@huggingface/transformers --external:onnxruntime-node --outfile=dist-electron/runtime-server.bundle.js --format=cjs"); + +// 3c. Bundle the legacy embeddings server (backward compat, still referenced by old IPC channels) +run("esbuild electron/embeddings/server.ts --bundle --platform=node --target=node22 --external:@huggingface/transformers --external:onnxruntime-node --outfile=dist-electron/embeddings-server.bundle.js --format=cjs"); // 4. Bundle MCP server with esbuild (inlines all deps except better-sqlite3) run("esbuild electron/mcp-server.ts --bundle --platform=node --target=node22 --external:better-sqlite3 --outfile=dist-mcp/mcp-server.bundle.js --format=cjs"); diff --git a/scripts/capture-runtime-baseline.sh b/scripts/capture-runtime-baseline.sh new file mode 100755 index 00000000..3ad7a283 --- /dev/null +++ b/scripts/capture-runtime-baseline.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Captures memory, CPU, and disk metrics for Cairn runtime processes. +# Run while Cairn (and optional LLM/embeddings workloads) are active. +# Output: scripts/runtime-baselines/

Llama Engine: @@ -448,7 +443,7 @@ export function AISettings() { )} {/* Local Context Limit Row */} - {serverStatus.installed && ( + {serverStatus.binaryInstalled && (
Local Context Limit: @@ -469,7 +464,7 @@ export function AISettings() {
)} - {serverStatus.installed && binaryProgress && ( + {serverStatus.binaryInstalled && binaryProgress && (
@@ -493,7 +488,7 @@ export function AISettings() { )} {/* Automated 1-Click Engine Downloader Card */} - {!serverStatus.installed && ( + {!serverStatus.binaryInstalled && (
@@ -557,11 +552,7 @@ export function AISettings() { )} {/* Server Error Log if any */} - {serverStatus.error && ( -
- {serverStatus.error} -
- )} + {/* Note: runtime status doesn't surface error string; errors are shown via download progress */} {/* Model Quantization Manager */}
@@ -614,11 +605,11 @@ export function AISettings() {
{model.name} - {model.repo} · {model.quant} + {model.repo} · {model.meta?.quant}

- File: {model.filename} + File: {model.meta?.filename}

diff --git a/src/components/settings/EmbeddingsSettings.tsx b/src/components/settings/EmbeddingsSettings.tsx index 98ddae45..f585675e 100644 --- a/src/components/settings/EmbeddingsSettings.tsx +++ b/src/components/settings/EmbeddingsSettings.tsx @@ -79,12 +79,13 @@ export function EmbeddingsSettings() { const activeWorkspaceId = useCairnStore((s) => s.activeWorkspaceId); const refreshQuiet = useCallback(async () => { - if (!window.electron?.embeddings) return; + if (!window.electron?.runtime) return; try { - const [s, m] = await Promise.all([ - window.electron.embeddings.status(), - window.electron.embeddings.models.list(), + const [s, mr] = await Promise.all([ + window.electron.runtime.embeddings.status(), + window.electron.runtime.embeddings.models(), ]); + const m = mr.models as unknown as ModelEntry[]; setStatus(s); const active = s.reindexInProgress || s.recomputeInProgress; setReindexing(active); @@ -119,7 +120,7 @@ export function EmbeddingsSettings() { await refreshQuiet(); })(); - const off = window.electron?.embeddings?.models.onProgress((ev) => { + const off = window.electron?.runtime?.embeddings.onProgress((ev) => { if (!ev.modelId) { if (ev.status === "progress" && typeof ev.loaded === "number" && typeof ev.total === "number") { setReindexProgress({ done: ev.loaded, total: ev.total }); @@ -145,11 +146,11 @@ export function EmbeddingsSettings() { }, [refreshQuiet]); const handleInstall = async (modelId: string) => { - const e = window.electron?.embeddings; - if (!e) return; + const rt = window.electron?.runtime; + if (!rt) return; setProgressByModel((p) => ({ ...p, [modelId]: { modelId, status: "downloading", progress: 0 } })); try { - await e.models.install(modelId); + await rt.embeddings.install(modelId); } catch (err) { console.error("[embeddings] install failed:", err); setProgressByModel((prev) => { @@ -162,10 +163,10 @@ export function EmbeddingsSettings() { }; const handleRemove = async (modelId: string) => { - const e = window.electron?.embeddings; - if (!e) return; + const rt = window.electron?.runtime; + if (!rt) return; try { - await e.models.remove(modelId); + await rt.embeddings.remove(modelId); await refreshQuiet(); } catch (err) { console.error("[embeddings] remove failed:", err); @@ -173,13 +174,13 @@ export function EmbeddingsSettings() { }; const handleSetDefault = async (modelId: string) => { - const e = window.electron?.embeddings; - if (!e) return; + const e = window.electron; + if (!e?.embeddings || !e.runtime) return; try { - await e.models.setDefault(modelId); + await e.runtime.embeddings.setDefault(modelId); const next = { ...config, modelId }; setConfig(next); - await e.saveSettings(next); + await e.embeddings.saveSettings(next); await refreshQuiet(); } catch (err) { console.error("[embeddings] setDefault failed:", err); @@ -188,20 +189,30 @@ export function EmbeddingsSettings() { const handleToggleEnabled = async (enabled: boolean) => { const e = window.electron?.embeddings; - if (!e) return; + const rt = window.electron?.runtime; + if (!e || (enabled && !rt)) return; const next = { ...config, enabled }; setConfig(next); await e.saveSettings(next); - if (enabled && !status.running) { - setStartingWorker(false); + if (enabled) { + setStartingWorker(true); + try { + // Ensure the unified runtime process is spawned and healthy + await rt?.embeddings.ensureStarted(); + await refreshQuiet(); + } catch (err) { + console.error("[embeddings] failed to start runtime:", err); + } finally { + setStartingWorker(false); + } } }; const handleStop = async () => { - const e = window.electron?.embeddings; - if (!e) return; + const rt = window.electron?.runtime; + if (!rt) return; try { - await e.stop(); + await rt.stop(); await refreshQuiet(); } catch (err) { console.error("[embeddings] stop failed:", err); From 03873d048b6eedb7b281de75c64d0a34b382bb83 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Sun, 21 Jun 2026 21:27:01 -0400 Subject: [PATCH 3/7] fix: defer setState in effect to pass lint --max-warnings 0 --- src/components/settings/AISettings.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/settings/AISettings.tsx b/src/components/settings/AISettings.tsx index afcfc11d..cc33ab9b 100644 --- a/src/components/settings/AISettings.tsx +++ b/src/components/settings/AISettings.tsx @@ -98,8 +98,11 @@ export function AISettings() { useEffect(() => { if (typeof window !== "undefined" && window.electron && window.electron.runtime) { - refreshLlamaState(); - handleCheckForUpdates(); + // Defer initial state fetch to avoid setState-in-effect cascade + Promise.resolve().then(() => { + refreshLlamaState(); + handleCheckForUpdates(); + }); // Listen to download progress const unsub = window.electron.runtime.llm.onProgress((event) => { From 7a3188a243a17a6e9bfd4cf9d5ffdf0f66e3afab Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Sun, 21 Jun 2026 21:53:21 -0400 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20review=20round=202=20=E2=80=94=20fd?= =?UTF-8?q?=20leak,=20atomic=20manifest,=20LLM=20validation,=20SSE=20conte?= =?UTF-8?q?nt-type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model-manager.ts: try-finally for fd cleanup, atomic writeManifest via tmp+rename - server.ts: modelId validation on LLM install/remove, try-catch on stop, pass-through content-type for SSE streaming - capture-runtime-baseline.sh: curl --max-time 5, grep Cairn-specific not generic Electron --- electron/runtime/model-manager.ts | 12 +++++++++--- electron/runtime/server.ts | 13 ++++++++++--- scripts/capture-runtime-baseline.sh | 8 ++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/electron/runtime/model-manager.ts b/electron/runtime/model-manager.ts index 97abe30f..568b2f3a 100644 --- a/electron/runtime/model-manager.ts +++ b/electron/runtime/model-manager.ts @@ -54,8 +54,9 @@ export interface DownloadResult { * Compute SHA256 hash of a file. Returns hex lowercase or null on error. */ export function computeFileSha256(filePath: string): string | null { + let fd: number | null = null; try { - const fd = fs.openSync(filePath, "r"); + fd = fs.openSync(filePath, "r"); const hash = crypto.createHash("sha256"); const buf = Buffer.alloc(64 * 1024); let bytesRead: number; @@ -63,10 +64,13 @@ export function computeFileSha256(filePath: string): string | null { bytesRead = fs.readSync(fd, buf, 0, buf.length, null); if (bytesRead > 0) hash.update(buf.subarray(0, bytesRead)); } while (bytesRead > 0); - fs.closeSync(fd); return hash.digest("hex"); } catch { return null; + } finally { + if (fd !== null) { + try { fs.closeSync(fd); } catch { /* ignore */ } + } } } @@ -111,7 +115,9 @@ export function readManifest(manifestPath: string): Record): void { try { fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); - fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); + const tmpPath = manifestPath + ".tmp"; + fs.writeFileSync(tmpPath, JSON.stringify(manifest, null, 2), "utf8"); + fs.renameSync(tmpPath, manifestPath); } catch (e) { console.error("[model-manager] Failed to write manifest:", e); } diff --git a/electron/runtime/server.ts b/electron/runtime/server.ts index fa22fbfe..d48bd92a 100644 --- a/electron/runtime/server.ts +++ b/electron/runtime/server.ts @@ -249,6 +249,7 @@ async function run(): Promise { try { const body = await readBody(req); const { modelId, useMirror } = JSON.parse(body) as { modelId: string; useMirror?: boolean }; + if (!modelId) { sendJson(res, 400, { error: "modelId is required" }); return; } await llamaAdapter.installModel(modelId, useMirror); sendJson(res, 200, { success: true }); } catch (e) { @@ -260,6 +261,7 @@ async function run(): Promise { try { const body = await readBody(req); const { modelId } = JSON.parse(body) as { modelId: string }; + if (!modelId) { sendJson(res, 400, { error: "modelId is required" }); return; } llamaAdapter.removeModel(modelId); sendJson(res, 200, { success: true }); } catch (e) { @@ -279,8 +281,12 @@ async function run(): Promise { return; } if (req.method === "POST" && req.url === "/v1/llm/server/stop") { - await llamaAdapter.stop(); - sendJson(res, 200, { success: true }); + try { + await llamaAdapter.stop(); + sendJson(res, 200, { success: true }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } return; } if (req.method === "GET" && req.url === "/v1/llm/server/status") { @@ -345,7 +351,8 @@ async function run(): Promise { body, }); // Pipe the response directly to support streaming (stream: true) - res.writeHead(proxyRes.status, { "Content-Type": "application/json" }); + const contentType = proxyRes.headers.get("content-type") ?? "application/json"; + res.writeHead(proxyRes.status, { "Content-Type": contentType }); if (proxyRes.body) { Readable.fromWeb(proxyRes.body).pipe(res); } else { diff --git a/scripts/capture-runtime-baseline.sh b/scripts/capture-runtime-baseline.sh index 3ad7a283..d2728427 100755 --- a/scripts/capture-runtime-baseline.sh +++ b/scripts/capture-runtime-baseline.sh @@ -49,7 +49,7 @@ echo "Cairn version: $(node -p 'require("./package.json").version' 2>/dev/null | echo "" >> "$OUT" section "1. PROCESS SNAPSHOT (RSS in KB, %MEM, %CPU, CMD)" -ps aux | grep -iE "cairn|llama|embed|onnx|transformers|runtime-server|Electron" \ +ps aux | grep -iE "cairn|llama|embed|onnx|transformers|runtime-server|Cairn Helper|Cairn.app" \ | grep -v grep \ | grep -v "opencode" \ | grep -v "capture-runtime" \ @@ -57,7 +57,7 @@ ps aux | grep -iE "cairn|llama|embed|onnx|transformers|runtime-server|Electron" | tee -a "$OUT" || echo "(no matching processes)" | tee -a "$OUT" section "2. RSS SUMMARY PER PROCESS FAMILY" -for fam in "Electron Helper" "runtime-server" "llama-server" "cairn-mcp" "node.*runtime-server"; do +for fam in "Cairn Helper" "runtime-server" "llama-server" "cairn-mcp" "node.*runtime-server"; do rss=$(ps aux | grep -iE "$fam" | grep -v grep | awk '{sum+=$6} END {print sum}') if [ -n "$rss" ] && [ "$rss" != "0" ]; then echo " $fam: $(( rss / 1024 )) MB" | tee -a "$OUT" @@ -65,7 +65,7 @@ for fam in "Electron Helper" "runtime-server" "llama-server" "cairn-mcp" "node.* done section "3. TOTAL RSS (all Cairn-related)" -total_rss=$(ps aux | grep -iE "cairn|llama|Electron|runtime-server" | grep -v grep | grep -v "opencode" | awk '{sum+=$6} END {print sum}') +total_rss=$(ps aux | grep -iE "cairn|llama|Cairn Helper|Cairn.app|runtime-server" | grep -v grep | grep -v "opencode" | awk '{sum+=$6} END {print sum}') echo " Total: $(( ${total_rss:-0} / 1024 )) MB" | tee -a "$OUT" section "4. ON-DISK FOOTPRINT" @@ -132,7 +132,7 @@ for d in "${USERDATA_DIRS[@]:-$USERDATA}"; do done if [ -n "$RUNTIME_PORT" ]; then echo " /health response:" | tee -a "$OUT" - curl -s "http://127.0.0.1:$RUNTIME_PORT/health" 2>/dev/null | python3 -m json.tool 2>/dev/null | sed 's/^/ /' | tee -a "$OUT" || echo " (failed to reach runtime)" | tee -a "$OUT" + curl -s --max-time 5 "http://127.0.0.1:$RUNTIME_PORT/health" 2>/dev/null | python3 -m json.tool 2>/dev/null | sed 's/^/ /' | tee -a "$OUT" || echo " (failed to reach runtime)" | tee -a "$OUT" else echo " (runtime port file not found — runtime may not be running)" | tee -a "$OUT" fi From f35bb0fbc46ffaa53437709a417b0132345a2b3c Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Sun, 21 Jun 2026 22:04:11 -0400 Subject: [PATCH 5/7] refactor: remove dead ReindexModal component (replaced by splash boot sequence) --- src/components/layout/ReindexModal.tsx | 202 ------------------------- 1 file changed, 202 deletions(-) delete mode 100644 src/components/layout/ReindexModal.tsx diff --git a/src/components/layout/ReindexModal.tsx b/src/components/layout/ReindexModal.tsx deleted file mode 100644 index 4d39880b..00000000 --- a/src/components/layout/ReindexModal.tsx +++ /dev/null @@ -1,202 +0,0 @@ -"use client"; - -/** - * ReindexModal — shown on app startup when embeddings were created with a - * different model than the current default. Offers to reindex all notes. - * - * Triggered by checking embeddings:needsReindex IPC on mount. If embeddings - * are disabled or all embeddings match the current model, the modal stays - * hidden. - */ - -import React, { useState, useEffect, useCallback } from "react"; -import { Loader2, CheckCircle2, AlertTriangle, RefreshCw, X } from "lucide-react"; -import { useCairnStore } from "@/store"; - -type Phase = "hidden" | "pending" | "running" | "done" | "error"; - -interface ProgressEvent { - modelId: string; - status: string; - progress?: number; - loaded?: number; - total?: number; - error?: string; -} - -export function ReindexModal() { - const [phase, setPhase] = useState("hidden"); - const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); - const [error, setError] = useState(null); - - useEffect(() => { - const electron = window.electron; - if (!electron?.embeddings?.needsReindex) return; - - electron.embeddings.needsReindex().then((result) => { - if (result.needed) { - setPhase("pending"); - } - }).catch(() => { - // stay hidden on error - }); - }, []); - - useEffect(() => { - const e = window.electron; - if (!e?.embeddings?.models?.onProgress) return; - const unsub = e.embeddings.models.onProgress((ev: ProgressEvent) => { - if (ev.status === "progress" && typeof ev.loaded === "number" && typeof ev.total === "number") { - if (phase !== "running") return; - setProgress({ done: ev.loaded, total: ev.total }); - } else if (ev.status === "done") { - setPhase((prev) => (prev === "running" ? "done" : prev)); - } else if (ev.status === "error") { - if (phase !== "running") return; - setError(ev.error ?? "Unknown error"); - setPhase("error"); - } - }); - return () => { unsub(); }; - }, [phase]); - - const handleReindex = useCallback(async () => { - const electron = window.electron; - if (!electron?.embeddings?.reindex) return; - const state = useCairnStore.getState(); - const workspaceId = state.workspaces[0]?.id; - if (!workspaceId) { - setError("No active workspace"); - setPhase("error"); - return; - } - setPhase("running"); - setProgress({ done: 0, total: 0 }); - try { - await electron.embeddings.reindex(workspaceId); - setPhase("done"); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setPhase("error"); - } - }, []); - - const handleDismiss = useCallback(() => { - setPhase("hidden"); - }, []); - - if (phase === "hidden") return null; - - const pct = progress && progress.total > 0 - ? Math.round((progress.done / progress.total) * 100) - : phase === "running" ? 0 : 100; - - return ( -
-
- {/* Header */} -
-
-

- {phase === "done" ? ( - - ) : phase === "error" ? ( - - ) : phase === "running" ? ( - - ) : ( - - )} - Embedding Model Updated -

- {phase === "pending" && ( - - )} -
-

- {phase === "pending" && - "The embedding model has changed. Your notes need to be re-indexed with the new model for semantic search to work correctly."} - {phase === "running" && - "Re-indexing notes — please don't close the app."} - {phase === "done" && - "All notes have been re-indexed successfully."} - {phase === "error" && - "An error occurred during re-indexing. You can retry from Settings > Embeddings."} -

-
- - {/* Progress */} - {(phase === "running" || phase === "done") && ( -
-
-
-
- {progress && progress.total > 0 && ( -

- {progress.done} / {progress.total} notes {phase === "running" ? "…" : ""} -

- )} -
- )} - - {/* Error */} - {phase === "error" && error && ( -
-
- {error} -
-
- )} - - {/* Footer */} -
- {phase === "pending" && ( - <> - - - - )} - {(phase === "done" || phase === "error") && ( - - )} -
-
-
- ); -} From 5b031f8c7884fb8fe1560c64635b2bc5a053481b Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Sun, 21 Jun 2026 22:15:08 -0400 Subject: [PATCH 6/7] Fix update logic. --- electron/splash/boot-sequence.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/electron/splash/boot-sequence.ts b/electron/splash/boot-sequence.ts index a0f3e15f..c7e412b6 100644 --- a/electron/splash/boot-sequence.ts +++ b/electron/splash/boot-sequence.ts @@ -63,7 +63,11 @@ export async function runBootSequence( try { splash.progress({ step: "update", label: "Checking for updates…", pct: 10 }); const result = await autoUpdater.checkForUpdates(); - if (result && result.updateInfo) { + // electron-updater always returns `updateInfo` (truthy) even when no + // update is available — only proceed when `isUpdateAvailable` is true. + // Without this check, running the same version locally as the published + // release would stall on "Downloading update v2.1.6…" for 300s. + if (result && result.isUpdateAvailable && result.updateInfo) { const version = result.updateInfo.version; splash.progress({ step: "update", From a707b59dbeb085d146f933f324b16943ca58a94b Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Sun, 21 Jun 2026 22:17:56 -0400 Subject: [PATCH 7/7] Update markdown-pipeline.bench.test.ts --- src/components/notes/markdown-pipeline.bench.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/notes/markdown-pipeline.bench.test.ts b/src/components/notes/markdown-pipeline.bench.test.ts index a2bd0b14..5a021fa6 100644 --- a/src/components/notes/markdown-pipeline.bench.test.ts +++ b/src/components/notes/markdown-pipeline.bench.test.ts @@ -87,7 +87,7 @@ function fmt(r: BenchResult): string { const CEILINGS: Record> = { "remark-parse": { small: 15, medium: 30, large: 90 }, - "remark-gfm+math": { small: 15, medium: 30, large: 80 }, + "remark-gfm+math": { small: 15, medium: 30, large: 90 }, "remark-callout": { small: 10, medium: 15, large: 60 }, "remark-promoteDisplay": { small: 10, medium: 15, large: 45 }, "remark→hast": { small: 15, medium: 45, large: 150 },