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/__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/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/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 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/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/__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() }) }) diff --git a/src/renderer/src/components/pro/proCatalog.ts b/src/renderer/src/components/pro/proCatalog.ts index 9a670272..64d24065 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', @@ -76,7 +82,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', @@ -161,7 +171,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 +277,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/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: 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/__tests__/proCatalog.lookup.test.ts b/src/renderer/src/lib/__tests__/proCatalog.lookup.test.ts index bb53790b..0bb51ee3 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', 'reflect', '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) 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