From f9eb43df47240c8e8cc0b0ea1ae9068658c73309 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Fri, 31 Jul 2026 12:39:40 +0530 Subject: [PATCH 1/6] feat(pro): Reflect live on Windows Reflect is pure aggregation over observations the capture pipeline already writes - it adds no capture of its own. Replay's port put those observations on Windows, so Reflect needs no Windows implementation, only the gate flip. Audit found zero platform coupling on the whole path: crm/reflect.ts imports only core getDB, ./schema and ./utils; its IPC (crm:day-reflection, crm:week-reflection) is platform-free; ReflectScreen carries no native code. The only native dep on the path is better-sqlite3, already proven on Windows by core. Catalog copy and the screen have no Mac/Cmd/Option strings, so no copy neutralization was needed. The single catalog edit is sufficient for the same reason it was for Replay: nav lock is entitlement-only (locked: !isPro) and the screen gate routes through proFeatureComingSoon, with no core file special-casing the route - so nav, gating and copy all light up from the one source of truth. Tests: WIN_PORTED is parameterized, so adding reflect derives four assertions (live on win32; win32-only and not linux by implication; the "exactly the ported features are win32-supported" invariant; and that proFeatureComingSoon does not gate it). Reflect's own logic keeps its existing 13-case real-DB integration coverage. Verified: npm test 3161 passed; node, web and pro typechecks clean; eslint clean on both touched files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015U3TnnNULxfCb4TsjAGjiC --- src/renderer/src/components/pro/proCatalog.ts | 6 +++++- src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/pro/proCatalog.ts b/src/renderer/src/components/pro/proCatalog.ts index 9a670272..62e197c1 100644 --- a/src/renderer/src/components/pro/proCatalog.ts +++ b/src/renderer/src/components/pro/proCatalog.ts @@ -76,7 +76,11 @@ export const PRO_FEATURES: ProFeature[] = [ 'Focus vs. distraction trends', 'All computed locally — never uploaded' ], - platforms: ['darwin'] + // Ported to Windows: Reflect adds no capture of its own — it is pure aggregation + // over observations the capture pipeline already writes, which Replay's port put + // on Windows. The whole path (crm/reflect.ts, its IPC, ReflectScreen) carries no + // platform-native code and reaches SQLite through the same getDB core uses. + platforms: ['darwin', 'win32'] }, { route: 'replay', diff --git a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts index bb53790b..69db6987 100644 --- a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts +++ b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts @@ -38,7 +38,7 @@ const winPorted = (route: string): ProFeature => ({ // asserted against the catalog so a flipped `platforms` and this list can't drift. // Module-scoped because both the featureSupportsPlatform and proFeatureComingSoon // describes read it — the gate and the capability check must agree on one list. -const WIN_PORTED = new Set(['vault', 'clipboard', 'replay']) +const WIN_PORTED = new Set(['vault', 'clipboard', 'replay', 'reflect']) describe('getProFeature', () => { it('returns the matching feature for a known route', () => { From fe12d1d8f1240c9fd4873a257d7f7c8c2c25e5f8 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Fri, 31 Jul 2026 15:50:03 +0530 Subject: [PATCH 2/6] fix(llm): load persisted settings lazily, not in the constructor LLMService read its persisted state (active model + user settings) from the constructor. `llm` is a module-level singleton, so it is constructed while index.ts's IMPORTS are still evaluating - which under ESM completes before index.ts's own body runs unifyUserDataPath() -> app.setPath('userData', ...). Every path resolved at construction therefore pointed at the PRE-override profile. Two real consequences: 1. Production: at construction the canonical-dir migration ("My Memories" / "my-memories" -> "Off Grid AI Desktop") has not run yet, so a user's saved settings and active model could be silently missed and replaced by defaults. 2. Harness: an OFFGRID_USER_DATA temp profile was ignored outright. A probe confirmed the constructor resolving the REAL profile while OFFGRID_USER_DATA pointed at the temp dir. This is what made e2e/settings-sections.spec.ts "resource mode survives a relaunch" fail - the setting persisted correctly but was never read back. Writes never had the bug: persist() goes through the settingsFile getter, which resolves late. This was read-side-only asymmetry. It is also the exact hazard the activeModelFile / settingsFile getters were introduced to avoid (see the comment at llm.ts:98) - calling resolveModel() and reading the settings file from the constructor defeated them. Fix: drop the constructor and load once, lazily, via ensureLoaded(), wired into the ten public entry points that depend on persisted state or model paths. hasVision/modelsExist/activeModelInfo keep their deliberate resolveModel() call so a newly activated model is still picked up. Tests: 5 cases in llm-lazy-settings-load.test.ts, built on the configureRuntime seam so they reproduce the production shape (construct first, choose the profile second). Includes a two-profile case that pins the defect directly - configure A, construct, switch to B, and assert B is read. 4 of the 5 fail without this change and all 5 pass with it. e2e/settings-sections.spec.ts is 3/3 (was 2/3). Verified: npm test 3163 passed; node + web typechecks clean; eslint 0 errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015U3TnnNULxfCb4TsjAGjiC --- .../__tests__/llm-lazy-settings-load.test.ts | 110 ++++++++++++++++++ src/main/llm.ts | 34 +++++- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/main/__tests__/llm-lazy-settings-load.test.ts diff --git a/src/main/__tests__/llm-lazy-settings-load.test.ts b/src/main/__tests__/llm-lazy-settings-load.test.ts new file mode 100644 index 00000000..8bd9b7f1 --- /dev/null +++ b/src/main/__tests__/llm-lazy-settings-load.test.ts @@ -0,0 +1,110 @@ +// Regression: LLMService must read its persisted state LAZILY, not in the constructor. +// +// `llm` is a module-level singleton (`export const llm = new LLMService()`), so it is +// constructed while index.ts's IMPORTS are still evaluating — which under ESM finishes +// BEFORE index.ts's own body runs `unifyUserDataPath()` → `app.setPath('userData', …)`. +// Any path resolved during construction therefore points at the PRE-override profile. +// +// Two real consequences, both of which these tests pin: +// 1. Production: the canonical-dir migration ("My Memories" / "my-memories" → +// "Off Grid AI Desktop") has not run yet at construction, so the user's saved +// settings and active model were silently missed and replaced by defaults. +// 2. E2E/harness: an OFFGRID_USER_DATA temp profile was ignored entirely — which is +// what made `settings-sections.spec.ts` "resource mode survives a relaunch" fail. +// A probe confirmed the constructor resolving the REAL profile while +// OFFGRID_USER_DATA pointed at the temp dir. +// +// Writes never had the bug: `persist()` goes through the `settingsFile` getter, which +// resolves late. These tests assert the READ side now behaves the same way, by doing +// what production does — construct FIRST, point the data dir somewhere SECOND. +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { LLMService } from '../llm' +import { configureRuntime } from '../runtime-env' + +let tmp: string + +/** Write an llm-settings.json into the models dir of a data dir, as `persist()` would. */ +const seedSettings = (dataDir: string, settings: Record): void => { + const modelsDir = path.join(dataDir, 'models') + fs.mkdirSync(modelsDir, { recursive: true }) + fs.writeFileSync(path.join(modelsDir, 'llm-settings.json'), JSON.stringify(settings)) +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-llm-lazy-')) +}) + +afterEach(() => { + // Release the override so a later test isn't pinned to a deleted temp dir. + configureRuntime({ dataDir: undefined }) + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +describe('LLMService reads persisted settings lazily (not at construction)', () => { + it('picks up a data dir configured AFTER the instance was constructed', () => { + // Construct FIRST — mirrors the module-level singleton being built during imports. + const svc = new LLMService() + // ...then point the runtime at the profile, as index.ts's body does later. + seedSettings(tmp, { performanceMode: 'extreme', temperature: 0.42 }) + configureRuntime({ dataDir: tmp }) + + const s = svc.getSettings() + expect(s.performanceMode).toBe('extreme') + expect(s.temperature).toBe(0.42) + }) + + it('survives the relaunch shape: persisted mode is read back by a fresh instance', () => { + // What settings-sections.spec.ts "resource mode survives a relaunch" exercises: + // one process writes the mode, the next process constructs and must read it back. + seedSettings(tmp, { performanceMode: 'conservative' }) + const relaunched = new LLMService() + configureRuntime({ dataDir: tmp }) + + expect(relaunched.getSettings().performanceMode).toBe('conservative') + }) + + it('loads once and does not re-read after the first access', () => { + seedSettings(tmp, { performanceMode: 'conservative' }) + const svc = new LLMService() + configureRuntime({ dataDir: tmp }) + expect(svc.getSettings().performanceMode).toBe('conservative') + + // A later on-disk edit must NOT leak in: the load is once-only, so in-memory state + // stays authoritative until something explicitly persists. This guards against + // turning the lazy guard into a read-on-every-call, which would re-read the file + // on every getSettings and clobber unsaved in-memory changes. + seedSettings(tmp, { performanceMode: 'extreme' }) + expect(svc.getSettings().performanceMode).toBe('conservative') + }) + + it('falls back to defaults when the profile has no settings file', () => { + const svc = new LLMService() + configureRuntime({ dataDir: tmp }) // seeded with nothing + expect(svc.getSettings().performanceMode).toBe('balanced') + }) + + // The direct guard on the defect, stated behaviourally rather than by spying on fs: + // if construction reads eagerly, it reads the profile configured AT THAT MOMENT. + // Point the runtime at profile A, construct, then switch to profile B before first + // use — a lazy reader returns B, an eager one returns A. This is the exact shape of + // the production bug (construct during imports, real profile chosen afterwards). + it('reads the profile configured at FIRST USE, not the one present at construction', () => { + const other = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-llm-lazy-other-')) + try { + seedSettings(other, { performanceMode: 'extreme' }) // profile A + seedSettings(tmp, { performanceMode: 'conservative' }) // profile B + + configureRuntime({ dataDir: other }) // A is current... + const svc = new LLMService() // ...at construction + configureRuntime({ dataDir: tmp }) // the override lands afterwards + + // Eager construction would have pinned 'extreme' from profile A. + expect(svc.getSettings().performanceMode).toBe('conservative') + } finally { + fs.rmSync(other, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/llm.ts b/src/main/llm.ts index bfec6f99..3057ee1b 100644 --- a/src/main/llm.ts +++ b/src/main/llm.ts @@ -149,8 +149,30 @@ export class LLMService { return path.join(getModelsDir(), 'llm-settings.json') } - constructor() { + /** Whether the persisted state (active model + user settings) has been read yet. */ + private loaded = false + + /** Read persisted state ONCE, on first use — never from the constructor. + * + * `llm` is a module-level singleton, so it is constructed while index.ts's IMPORTS + * are still evaluating, which under ESM completes before index.ts's own body runs + * `unifyUserDataPath()` → `app.setPath('userData', …)`. Resolving paths at + * construction therefore reads the PRE-override profile: an OFFGRID_USER_DATA + * harness dir is ignored, and in production the canonical-dir migration ("My + * Memories" / "my-memories" → "Off Grid AI Desktop") has not happened yet, so the + * user's active model and saved settings are silently missed and replaced by + * defaults. Writes never had this bug — `persist()` goes through the settingsFile + * getter, which resolves late. This is exactly the hazard the activeModelFile / + * settingsFile getters were introduced to avoid; calling resolveModel() and reading + * the settings file from the constructor defeated them. */ + private ensureLoaded(): void { + if (this.loaded) return + this.loaded = true this.resolveModel() + this.loadPersistedSettings() + } + + private loadPersistedSettings(): void { try { const s = JSON.parse(fs.readFileSync(this.settingsFile, 'utf-8')) if (typeof s.temperature === 'number') this.temperature = s.temperature @@ -205,6 +227,7 @@ export class LLMService { /** The model's trained context window, or null if unknown — exposed so the UI can offer the * slider up to the model's own maximum instead of a hardcoded cap. */ modelMaxContext(): number | null { + this.ensureLoaded() return this.trainedContext() } @@ -258,10 +281,12 @@ export class LLMService { /** The EFFECTIVE (RAM-clamped) context window the server is actually running * with — the real ceiling for prompt + tools + answer. */ effectiveContextSize(): number { + this.ensureLoaded() return this.safeCtxSize(this.ctxSize) } getSettings(): LlmSettings { + this.ensureLoaded() return { temperature: this.temperature, ctxSize: this.ctxSize, @@ -289,6 +314,7 @@ export class LLMService { * `buildLaunchArgs` (single source of truth) after applying the impure RAM clamp, * so `_doInit` and tests build args the same way. */ launchArgs(): string[] { + this.ensureLoaded() return this.launchArgsFor(this.safeCtxSize(this.ctxSize), this.gpuLayers) } @@ -352,6 +378,7 @@ export class LLMService { /** Update inference settings; respawns the server if any launch-time arg changed * (context, KV-cache type, flash-attn, GPU layers, threads, batch). */ async setSettings(s: LlmSettings): Promise { + this.ensureLoaded() // Granular launch-time fields the user sets in THIS patch become pinned: a mode // preset (now or on a future restart / mode re-pick) must NOT clobber them. Pin // BEFORE applying the preset so an explicit q8_0 in the same patch survives. @@ -453,6 +480,7 @@ export class LLMService { /** Switch the active model without terminating a generation already using it. */ reloadModel(): void { + this.ensureLoaded() if (this.activeGenerations > 0) { this.modelReloadPending = true return @@ -481,6 +509,7 @@ export class LLMService { // on mmproj wrongly kept "Setup Required" up for an activated vision model.) /** Whether the active chat model can read images (has a vision projector / mmproj). */ hasVision(): boolean { + this.ensureLoaded() this.resolveModel() return !!this.mmProjPath && fs.existsSync(this.mmProjPath) } @@ -496,6 +525,7 @@ export class LLMService { } modelsExist(): boolean { + this.ensureLoaded() this.resolveModel() return fs.existsSync(this.modelPath) } @@ -510,6 +540,7 @@ export class LLMService { * loaded it yet (otherwise an idle/headless gateway reports no chat model). * Returns null when no model is downloaded. */ activeModelInfo(): { id: string; vision: boolean } | null { + this.ensureLoaded() this.resolveModel() if (!fs.existsSync(this.modelPath)) return null let id = path.basename(this.modelPath) @@ -531,6 +562,7 @@ export class LLMService { } async init(): Promise { + this.ensureLoaded() if (this.paused) { // A chat/tool turn needs the LLM NOW, but it's paused for a resident image // server (unified memory can't hold both). Ask the image server to evict From 2c78ba29166c21762eb62e54f145f426690cb2d6 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Fri, 31 Jul 2026 16:04:55 +0530 Subject: [PATCH 3/6] feat(pro): Day and Notifications live on Windows Two features in one PR because Notifications has no content without Day: proactive.ts builds its notifications from getDayPlan / getEventPrep (both from ahead.ts) plus listUpcomingEvents, so porting Notifications alone would have lit up an empty surface on Windows. Neither needs a pro-side change. A sweep for osascript|process.platform|darwin|win32|pgrep|pkill|execFile|spawn returns zero hits across day.ts, day-layout.ts, ahead.ts, ahead-heuristics.ts, calendar.ts, DayView.tsx, TodoCard.tsx, notify.ts, proactive.ts, proactive-window.ts, NotificationList.tsx, notification-target.ts and notification-routing.ts. No Mac/macOS/Cmd/Option copy in any of those surfaces or in either catalog entry, so no neutralization was needed. Day's two data sources both work on Windows now: the calendar comes from connectors (HTTP), and the activity half reads observations, which Replay's port put there. Notification delivery is Electron's Notification guarded by isSupported(), and core already calls setAppUserModelId (index.ts:301) - which Windows REQUIRES for a toast to appear at all. Also fixes the landing-screen landmine, which Day forced. App.tsx opened on `isPro && isMac() ? 'day' : 'models'` - a platform decision living OUTSIDE the capability seam. It was right only by accident: it agreed with the catalog while Day was macOS-only, and would have stranded a ported Day on Windows, with nav and gating lighting Day up from `platforms` while the landing screen still asked isMac(). The decision moves into proCatalog as a pure landingView(platform, isPro), so the landing screen can never disagree with nav and gating again. App.tsx no longer imports isMac. Tests: WIN_PORTED gains day + notifications, deriving eight assertions from the parameterized suite. Plus a new five-case landingView describe, including the stranded-Day regression guard (landingView('win32', true) must be 'day' - the old isMac rule returns 'models') and a DRY case asserting landingView agrees with the day feature's own platforms list on every platform. Verified by reverting to the old rule: two tests fail. Verified: npm test 3169 passed; node + web + pro typechecks clean; eslint 0 new errors (App.tsx's 9 are pre-existing, identical count on main). e2e 73 passed / 1 failed, the one failure being settings-sections "resource mode survives a relaunch" - a pre-existing core bug on main, fixed separately in #73 and not reachable from this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015U3TnnNULxfCb4TsjAGjiC --- src/renderer/src/App.tsx | 11 ++--- src/renderer/src/components/pro/proCatalog.ts | 35 ++++++++++++++- .../lib/__tests__/proCatalog.lookup.test.ts | 43 ++++++++++++++++++- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3e9ec678..d475c94d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,8 +17,8 @@ import type { SearchHit } from './types' import { loadProFeaturesRenderer } from './bootstrap/loadProFeaturesRenderer' import { renderProView, type ProViewContext } from './bootstrap/proView' import { UpgradeScreen } from './components/pro/UpgradeScreen' -import { getProFeature, proFeatureComingSoon } from './components/pro/proCatalog' -import { currentPlatform, isMac } from './lib/device' +import { getProFeature, proFeatureComingSoon, landingView } from './components/pro/proCatalog' +import { currentPlatform } from './lib/device' import { NotificationProvider } from './hooks/NotificationProvider' import { useNotifications } from './hooks/useNotifications' import { ToastProvider } from './hooks/ToastProvider' @@ -226,9 +226,10 @@ function AppContent() { } }, []) - // Free users land on Models (download a model first, with the sidebar to - // explore); Mac Pro users land on Day. Never land on a locked or unavailable tab. - const [viewMode, setViewMode] = useState(isPro && isMac() ? 'day' : 'models') + // Where to open: derived from the per-feature capability seam (see landingView), so + // the landing screen can never disagree with nav and gating about whether Day is + // available on this platform. + const [viewMode, setViewMode] = useState(landingView(currentPlatform(), isPro)) const [selectedSessionId, setSelectedSessionId] = useState(null) const [selectedMemoryId, setSelectedMemoryId] = useState(null) // Version of a downloaded-and-staged update (null = none). Surfaced as a banner diff --git a/src/renderer/src/components/pro/proCatalog.ts b/src/renderer/src/components/pro/proCatalog.ts index 9a670272..35012cb2 100644 --- a/src/renderer/src/components/pro/proCatalog.ts +++ b/src/renderer/src/components/pro/proCatalog.ts @@ -62,7 +62,13 @@ export const PRO_FEATURES: ProFeature[] = [ 'Per-meeting prep: who’s in it and your open items', 'Priorities surfaced from what you actually did' ], - platforms: ['darwin'] + // Ported to Windows: the whole Day path is portable - day.ts, day-layout.ts, + // ahead.ts and calendar.ts carry no platform-native code. Its two data sources + // both work on Windows now: the calendar comes from connectors (HTTP), and the + // activity half reads observations, which Replay's port put on Windows. Landing + // on Day now routes through `landingView` rather than an `isMac()` check, so nav, + // gating, copy and the landing screen all agree. + platforms: ['darwin', 'win32'] }, { route: 'reflect', @@ -161,7 +167,12 @@ export const PRO_FEATURES: ProFeature[] = [ 'Approval queue for actions', 'Auto-extracted to-dos' ], - platforms: ['darwin'] + // Ported to Windows alongside Day, which produces its content: proactive.ts builds + // notifications from getDayPlan / getEventPrep, so shipping this without Day would + // have delivered an empty surface. Delivery is Electron's Notification (guarded by + // isSupported()), and core already sets the AppUserModelID that Windows requires + // for a toast to appear at all. notify.ts / proactive.ts carry no platform code. + platforms: ['darwin', 'win32'] }, { route: 'voice', @@ -262,3 +273,23 @@ export function proFeatureComingSoon( } return !featureSupportsPlatform(feature, platform) } + +/** + * Which view the app should OPEN on. Free users land on Models (they need a model + * before anything else works); Pro users land on Day — but only where Day is + * actually available. + * + * This lives here, beside the seam, because it is a per-feature platform decision and + * `platforms` is the single source of truth for those. It previously sat in App.tsx as + * `isPro && isMac() ? 'day' : 'models'`, which was right only by accident: it agreed + * with the catalog while Day was macOS-only, and would have stranded a ported Day on + * Windows — nav and gating would light Day up from `platforms` while the landing + * screen still asked `isMac()`. Route the decision through the seam so porting a + * feature never leaves a second place to update. + * + * Never land on a locked or unavailable tab. + */ +export function landingView(platform: DevicePlatform, isPro: boolean): 'day' | 'models' { + const day = getProFeature('day') + return isPro && day && featureSupportsPlatform(day, platform) ? 'day' : 'models' +} diff --git a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts index bb53790b..e2197667 100644 --- a/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts +++ b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts @@ -10,6 +10,7 @@ import { featureSupportsPlatform, proComingSoonHere, proFeatureComingSoon, + landingView, PRO_FEATURES, PRO_PAY_URL, type ProFeature @@ -38,7 +39,7 @@ const winPorted = (route: string): ProFeature => ({ // asserted against the catalog so a flipped `platforms` and this list can't drift. // Module-scoped because both the featureSupportsPlatform and proFeatureComingSoon // describes read it — the gate and the capability check must agree on one list. -const WIN_PORTED = new Set(['vault', 'clipboard', 'replay']) +const WIN_PORTED = new Set(['vault', 'clipboard', 'replay', 'day', 'notifications']) describe('getProFeature', () => { it('returns the matching feature for a known route', () => { @@ -169,6 +170,46 @@ describe('proFeatureComingSoon', () => { }) }) +describe('landingView reads the seam, not isMac (the stranded-Day guard)', () => { + it('sends free users to Models on every platform (they need a model first)', () => { + for (const p of ['darwin', 'win32', 'linux', 'unknown'] as const) { + expect(landingView(p, false), `free on ${p}`).toBe('models') + } + }) + + it('lands a Pro user on Day on macOS', () => { + expect(landingView('darwin', true)).toBe('day') + }) + + // THE regression guard for the rule this replaced. The old landing default was + // `isPro && isMac() ? 'day' : 'models'`, which returns 'models' on win32 no matter + // what the catalog says. With Day ported, nav and gating light it up from + // `platforms` — so an isMac-based landing screen would strand a Pro Windows user on + // Models. This fails the moment anything reintroduces that check. + it('lands a Pro user on Day on Windows now that Day is ported', () => { + expect(landingView('win32', true)).toBe('day') + }) + + // The other half: not "any non-Mac gets Day" either. Day is not ported to linux, so + // a Pro linux user must NOT be dropped onto an unavailable tab. + it('does not land a Pro user on Day where Day is unsupported', () => { + expect(landingView('linux', true)).toBe('models') + expect(landingView('unknown', true)).toBe('models') + }) + + // DRY: assert against the catalog rather than re-hardcoding the platform list, so + // this test and `platforms` can never drift. Porting Day to a new platform updates + // both sides from the one edit. + it('agrees with the day feature’s own platforms list on every platform', () => { + const day = getProFeature('day') + expect(day).toBeDefined() + for (const p of ['darwin', 'win32', 'linux', 'unknown'] as const) { + const expected = featureSupportsPlatform(day!, p) ? 'day' : 'models' + expect(landingView(p, true), `pro landing on ${p}`).toBe(expected) + } + }) +}) + describe('PRO_FEATURES data integrity', () => { it('has a non-empty catalog', () => { expect(PRO_FEATURES.length).toBeGreaterThan(0) From 6d632465de48e34b2f6b0dc8fe2fe97a4c72f4db Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Thu, 6 Aug 2026 13:16:51 +0530 Subject: [PATCH 4/6] feat(win): show Capture & Proactive settings sections on Windows The Windows Pro port migrated the feature nav to the per-feature capability seam but left the Settings screen on the old blanket !isMac gate, so on Windows the Capture health panel (frame/observation counts) and the Proactive-delivery toggle it hosts were hidden behind a 'Pro on macOS' placeholder - even though the capture engine runs and Notifications is nav-enabled. Render the registered Capture section on every platform where Pro is active; the placeholder is now only the free-build upsell. Drop the stale macOnly flags on the capture/proactive settings slots. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xr51GbAPDvPYL5yhXj4gva --- src/renderer/src/components/Settings.tsx | 11 ++++++++--- src/renderer/src/components/pro/proSettingsCatalog.ts | 10 ++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 6bba7184..59d3e008 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -101,15 +101,20 @@ export function Settings(): React.ReactElement { summary="See capture health, recover pending frames, and control model scheduling in one place." delay={0.14} > - {CaptureContribution && !(proComingSoon && currentPlatform() !== 'darwin') ? ( + {/* Capture runs wherever Pro is active (macOS + Windows), so render the real + registered section on every platform - the engine, its status, and the + Proactive-delivery toggle it hosts are all ported. The placeholder is only for + the free build, where pro never registers a contribution. Previously this was + gated to darwin, which stranded Windows Pro users with no capture controls and no + way to see the frame/observation health even though capture was running. */} + {CaptureContribution ? ( ) : (
Pro - Screen capture, backlog recovery, and proactive delivery are available with Pro on - macOS. + Screen capture, backlog recovery, and proactive delivery are part of Pro.
)} diff --git a/src/renderer/src/components/pro/proSettingsCatalog.ts b/src/renderer/src/components/pro/proSettingsCatalog.ts index bdf94288..ac906d0e 100644 --- a/src/renderer/src/components/pro/proSettingsCatalog.ts +++ b/src/renderer/src/components/pro/proSettingsCatalog.ts @@ -30,9 +30,8 @@ export const PRO_SETTINGS_SLOTS: ProSettingsSlot[] = [ { id: 'capture', delay: 0.14, - macOnly: true, - comingSoonDescription: - 'Screen capture controls are available on Mac today. Support for this device is coming soon.', + // Ported to Windows alongside Replay/Day/Reflect - the capture engine runs on every Pro + // platform, so this control (and the Proactive-delivery toggle it hosts) is no longer Mac-only. placeholder: { title: 'Capture', description: @@ -51,9 +50,8 @@ export const PRO_SETTINGS_SLOTS: ProSettingsSlot[] = [ { id: 'proactive', delay: 0.18, - macOnly: true, - comingSoonDescription: - 'Morning briefings and meeting alerts are available on Mac and phone today. Support for this device is coming soon.', + // Ported to Windows with the Notifications feature (native Electron notifications work on + // win32). Rendered inside the Capture section, so it follows the same cross-platform rule. placeholder: { title: 'Proactive delivery', description: From 1475b136faed6ac2be79f7af4afbac8b65454279 Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Thu, 6 Aug 2026 13:16:51 +0530 Subject: [PATCH 5/6] feat(capture): name a too-small context as the reason observations stop A model context window near the 2048 clamp floor overflows the observation distill prompt, silently halting frame->observation processing so Day and Reflect never populate - with no user-facing reason. Add MIN_OBSERVATION_CTX (4096) as the shared floor the pipeline needs, a pure isContextOverflowError classifier so the distill can treat an overflow as terminal (not retry forever), and a Settings context-window hint that warns when the effective window is below that floor. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xr51GbAPDvPYL5yhXj4gva --- src/main/__tests__/llama-error.test.ts | 25 ++++++++++++++++++- src/main/llama-error.ts | 20 +++++++++++++++ .../src/lib/__tests__/ctx-options.test.ts | 13 ++++++++++ src/renderer/src/lib/ctx-options.ts | 9 +++++++ src/shared/llm-defaults.ts | 9 +++++++ 5 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/main/__tests__/llama-error.test.ts b/src/main/__tests__/llama-error.test.ts index 27ae595e..a3fd74eb 100644 --- a/src/main/__tests__/llama-error.test.ts +++ b/src/main/__tests__/llama-error.test.ts @@ -6,7 +6,7 @@ * code-signing problem for days. This maps the real stderr to a clear reason. */ import { describe, it, expect } from 'vitest' -import { classifyLlamaError } from '../llama-error' +import { classifyLlamaError, isContextOverflowError } from '../llama-error' describe('classifyLlamaError', () => { it('flags an engine too old for the model architecture (the reported bug)', () => { @@ -119,3 +119,26 @@ main: exiting due to model loading error` expect(classifyLlamaError('')).toBeNull() }) }) + +describe('isContextOverflowError', () => { + it('detects the "exceeds the available context size" family (the observed silent-fail cause)', () => { + expect( + isContextOverflowError( + 'the request exceeds the available context size. try increasing the context size or enable context shift' + ) + ).toBe(true) + }) + + it('detects prompt/input too-long phrasings across engine versions', () => { + expect(isContextOverflowError('input is too large to process')).toBe(true) + expect(isContextOverflowError('prompt is too long for this context')).toBe(true) + expect(isContextOverflowError('the prompt is larger than the context window')).toBe(true) + expect(isContextOverflowError('requested tokens (5000) exceed context window (2048)')).toBe(true) + }) + + it('is not fooled by an unreachable / dead engine (that must stay retryable)', () => { + expect(isContextOverflowError('fetch failed: ECONNREFUSED 127.0.0.1:8439')).toBe(false) + expect(isContextOverflowError('llama-server is not running')).toBe(false) + expect(isContextOverflowError('')).toBe(false) + }) +}) diff --git a/src/main/llama-error.ts b/src/main/llama-error.ts index 6bcb5329..76c25083 100644 --- a/src/main/llama-error.ts +++ b/src/main/llama-error.ts @@ -27,6 +27,26 @@ export function modelPortConflictReason(port: number): string { return `Model engine port ${port} is already owned by another Off Grid AI Desktop instance. Close the other app, development server, or capture run, then restart Chat model in Settings.` } +/** + * True when a chat/completions failure is the model rejecting a prompt that does not fit the + * running context window (n_ctx). Distinct from a dead/unreachable engine: retrying is useless + * until the context is raised or the prompt shrunk, so callers treat this as TERMINAL rather + * than backing off forever. Pure + Electron-free so it is unit-tested. llama-server phrases this + * a few ways across versions ("the request exceeds the available context size", "input is too + * large", "prompt is too long", "n_ctx" overflow), so match the family, not one string. + */ +export function isContextOverflowError(text: string): boolean { + const s = (text || '').toLowerCase() + if (!s.trim()) return false + return ( + /exceed(s|ed)?\s+the\s+(available\s+)?context/.test(s) || + /context\s+(size|window|length)\s+(exceeded|too\s+small)/.test(s) || + /(prompt|input)\s+(is\s+)?(too\s+(long|large)|larger\s+than)/.test(s) || + /(tokens?|prompt)\b.*\bexceed(s|ed)?\b.*\b(n_?ctx|context)/.test(s) || + /requested\s+tokens.*exceed.*context/.test(s) + ) +} + /** * Classify the most recent llama-server stderr. Returns null if nothing in the * text looks like a known fatal cause (so callers can fall back to a generic diff --git a/src/renderer/src/lib/__tests__/ctx-options.test.ts b/src/renderer/src/lib/__tests__/ctx-options.test.ts index fcab9507..19c6d4f5 100644 --- a/src/renderer/src/lib/__tests__/ctx-options.test.ts +++ b/src/renderer/src/lib/__tests__/ctx-options.test.ts @@ -58,4 +58,17 @@ describe('contextWindowHint', () => { 'Capped to this' ) }) + + it('warns that a small context stops on-device observations (the silent-fail cause)', () => { + const hint = contextWindowHint({ ctxSize: 2048, effectiveCtxSize: 2048, modelMaxCtx: 131072 }) + expect(hint).toContain('observations (Day, Reflect) may stop processing') + expect(hint).toContain('at least 4K') + }) + + it('warns on the small EFFECTIVE window even when the user picked a large value (RAM clamped below the floor)', () => { + // A big requested ctx clamped by RAM to below the observation floor must warn about the + // consequence, not just say "clamped" — this is exactly how it fails silently. + const hint = contextWindowHint({ ctxSize: 16384, effectiveCtxSize: 2048 }) + expect(hint).toContain('observations (Day, Reflect) may stop processing') + }) }) diff --git a/src/renderer/src/lib/ctx-options.ts b/src/renderer/src/lib/ctx-options.ts index e48da538..54967e7a 100644 --- a/src/renderer/src/lib/ctx-options.ts +++ b/src/renderer/src/lib/ctx-options.ts @@ -1,3 +1,5 @@ +import { MIN_OBSERVATION_CTX } from '@offgrid/core/shared/llm-defaults' + // The context-window choices the Settings picker offers. We bound the base ladder by the model's // TRAINED maximum (from GGUF metadata, surfaced by the backend as modelMaxCtx): offering a window // the model wasn't trained for is pointless — the engine caps it back down — and misleading. The @@ -37,6 +39,13 @@ export function contextWindowHint(opts: { if (modelMaxCtx && modelMaxCtx > 0 && ctxSize && ctxSize > modelMaxCtx) { return `Capped to this model's trained ${asK(modelMaxCtx)} window - it wasn't trained to go higher.` } + // The EFFECTIVE window (after the RAM clamp) is what the engine actually runs with, so a value + // the model can't fit its distill prompt into silently stops screen-capture observations. Warn + // before that happens - this is the most consequential hint, so it wins over the ones below. + const effective = effectiveCtxSize && effectiveCtxSize > 0 ? effectiveCtxSize : ctxSize + if (effective && effective > 0 && effective < MIN_OBSERVATION_CTX) { + return `At ${asK(effective)} the context is small - on-device observations (Day, Reflect) may stop processing. Raise it to at least ${asK(MIN_OBSERVATION_CTX)}.` + } if (effectiveCtxSize && ctxSize && effectiveCtxSize < ctxSize) { return `Clamped to ${asK(effectiveCtxSize)} for your RAM (a larger value would risk a memory-overcommit freeze). Quantize the KV cache below to raise this.` } diff --git a/src/shared/llm-defaults.ts b/src/shared/llm-defaults.ts index 7a4465d5..ed398d8c 100644 --- a/src/shared/llm-defaults.ts +++ b/src/shared/llm-defaults.ts @@ -7,6 +7,15 @@ export const DEFAULT_CTX_SIZE = 16384 +// The smallest EFFECTIVE context window the on-device capture pipeline needs to distill a +// screen frame into an observation. The distill prompt (system instructions + the KNOWN +// ENTITIES list + up to ~4000 chars of frame text + the reserved output tokens) overflows a +// window near the 2048 clamp floor, which silently stops observations - so Day and Reflect +// never populate. Used to WARN in Settings and to classify the failure, not to hard-block: +// a short frame can still fit under this, and a RAM-constrained machine must not lose capture +// entirely. Keep in sync with the distill prompt budget in pro's crm/extract.ts. +export const MIN_OBSERVATION_CTX = 4096 + // Max-output sentinel: the setting value meaning "auto" — let a reply run until the model emits its // natural stop (EOS) or the context window fills, rather than a fixed token cap that truncated long // answers. Stored as 0 (a literal 0-token cap is meaningless) and mapped to the engine's unlimited From 7c528a0a94f72a97fe95fb50916abbe067e495ef Mon Sep 17 00:00:00 2001 From: Anurag-Wednesday Date: Thu, 6 Aug 2026 13:26:49 +0530 Subject: [PATCH 6/6] test(win): assert Windows Pro renders the registered Capture section Update the D31 registry-seam test that asserted the old Mac-only gate (capture withheld on win32) to the new behavior: capture is ported to Windows, so its registered section renders on win32 and the free-build placeholder does not. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Xr51GbAPDvPYL5yhXj4gva --- .../__tests__/Settings.pro-sections.test.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/__tests__/Settings.pro-sections.test.tsx b/src/renderer/src/components/__tests__/Settings.pro-sections.test.tsx index 45ac9761..90801ae3 100644 --- a/src/renderer/src/components/__tests__/Settings.pro-sections.test.tsx +++ b/src/renderer/src/components/__tests__/Settings.pro-sections.test.tsx @@ -83,7 +83,11 @@ describe('Settings pro-section registry seam (D31)', () => { ).toBeNull() }) - it('Windows Pro build withholds native capture while keeping account sections available', async () => { + it('Windows Pro build renders the registered capture section (capture is ported to Windows)', async () => { + // Capture, Day, Reflect and Proactive delivery run on Windows Pro now, so the Settings + // Capture section must render its registered owner on win32 exactly like macOS - not fall + // back to the "Pro on macOS" placeholder. Guards the fix for the gate that stayed Mac-only + // after the feature nav was ported, hiding the frame/observation health panel on Windows. vi.resetModules() stubApi('win32') const { registerSettingsSection } = await import('../../bootstrap/sectionRegistry') @@ -102,10 +106,9 @@ describe('Settings pro-section registry seam (D31)', () => { await waitFor(() => expect(screen.getByTestId('fake-identity')).toBeTruthy()) await user.click(screen.getByText('Capture & processing')) - expect(screen.queryByTestId('fake-capture')).toBeNull() - expect( - screen.getByText(/screen capture, backlog recovery, and proactive delivery/i) - ).toBeTruthy() + await waitFor(() => expect(screen.getByTestId('fake-capture')).toBeTruthy()) + // The free-build "part of Pro" placeholder must NOT show for an entitled Windows user. + expect(screen.queryByText(/screen capture, backlog recovery, and proactive delivery/i)).toBeNull() expect(screen.getByText('Processing priority')).toBeTruthy() }) })