# Phase 30: Hardware Detection + Mode Selection - Research **Researched:** 2026-04-02 **Domain:** Server-side hardware probing (Node.js `os` + `systeminformation` v5), unauthenticated Express route, Apple Silicon unified memory, model catalog extension, Nexus mode state, NexusOnboardingWizard multi-step preparation **Confidence:** HIGH ## Summary Phase 30 is the foundation of the v1.5 onboarding stack. It adds four things: (1) an unauthenticated hardware probe endpoint that works before any board auth token exists; (2) Apple Silicon unified memory handling with the 0.75 multiplier and correct copy; (3) an extended model recommendation catalog keyed to hardware tier (GPU / Apple Silicon / CPU-only); and (4) a mode selector (Personal AI Assistant / Project Builder / Both) whose choice is persisted and gates downstream UI. The existing codebase has a solid base: `os.totalmem()` is already used in `ollamaRoutes` and `getRecommendedModel()`, the 0.75 multiplier is already applied in `getRecommendedModel()`, and the Ollama model catalog is an on-disk JSON file that can be extended. Two gaps need to be closed before the next phase: the probe endpoint for hardware detection must work without board auth (Pitfall 14 from PITFALLS.md), and there is no `nexus_mode` persistence layer yet. The state constraint is hard: no new DB tables. Mode is stored as a Nexus-namespaced key inside a new **file-backed JSON** at `data/nexus-settings.json` in the instance root, read/written by a new `nexusSettingsService`. This avoids touching the `.strict()` Zod schema on `instance_settings.general` (adding a key to that schema would require changes to both `@paperclipai/shared` and the routes — an unnecessary upstream conflict surface). The file-backed approach mirrors the `config.json` pattern already present in the project. **Primary recommendation:** Add `GET /api/system/providers` (unauthenticated) for hardware probe; create `server/src/services/hardware.ts` using `os` + `systeminformation@5` for GPU detection; extend `ollama-model-catalog.json` with hardware tier + PRD models; add `server/src/services/nexus-settings.ts` for file-backed mode persistence; build `ModeSelector` + `HardwareSummaryStep` as new onboarding step components. ## User Constraints (from CONTEXT.md) ### Locked Decisions All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions. ### Claude's Discretion All implementation choices are at Claude's discretion. ### Deferred Ideas (OUT OF SCOPE) None — discuss phase skipped. Additional locked decisions from STATE.md (established at roadmap): - No DB schema changes — all state in existing JSONB fields or file-backed JSON - Apple Silicon: use `os.freemem()` × 0.75 for VRAM estimate; label as "unified memory" not "VRAM"; use `systeminformation` v5 (not v6) - Unauthenticated `GET /system/providers` endpoint required for pre-auth hardware probe - Mode persisted in `instance_settings.general.nexus` namespace (ARCHITECTURE.md) — however, `.strict()` constraint means a file-backed alternative is safer (see below) ## Phase Requirements | ID | Description | Research Support | |----|-------------|------------------| | ONBD-01 | User can select mode (Personal AI Assistant / Project Builder / Both) during onboarding | New `ModeSelector` component in `NexusOnboardingWizard`; mode persisted via `nexusSettingsService` | | ONBD-02 | System auto-detects GPU, RAM, and Apple Silicon unified memory within 5 seconds | New `hardwareService` + `GET /api/system/providers` (unauthenticated); `systeminformation@5` for GPU on Linux/macOS; Apple Silicon flagged via CPU brand string | | ONBD-03 | System recommends best local model from pre-built JSON database based on detected hardware | Extend `ollama-model-catalog.json` with PRD models (Bonsai, Qwen 3) and tier field; update `getRecommendedModel()` to use hardware tier | | ONBD-07 | Local AI framed as privacy premium ("runs entirely on your machine, no accounts, works offline") | `HardwareSummaryStep` component renders PRD copy verbatim; copy is gated to local AI path only | --- ## Standard Stack ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Node.js `os` | built-in | `totalmem()`, `freemem()`, `cpus()` for RAM + CPU brand | Already used in `ollamaRoutes`; zero-cost | | `systeminformation` | 5.31.5 (latest v5) | `graphics()` for GPU name + VRAM on Linux/macOS/Windows | STATE.md locked to v5 (not v6 — API breakage risk); not yet installed | | React | project version | UI components | Project standard | | Zod | project version | Schema validation for new settings | Already used throughout | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | `@tanstack/react-query` | project version | `useQuery` for hardware info hook | All other API queries use this | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | `systeminformation` | `system_profiler` shell out (macOS only) | `systeminformation` works cross-platform; shell out is macOS-only and adds timeout logic | | File-backed `nexus-settings.json` | `instance_settings.general.nexus` key | `.strict()` Zod schema blocks adding new keys without upstream changes; file-backed is safer for Nexus-only state | **Installation:** ```bash pnpm --filter server add systeminformation@5 ``` **Version verification:** `npm view systeminformation version` → `5.31.5` (confirmed 2026-04-02). v6 exists but STATE.md explicitly locks to v5. --- ## Architecture Patterns ### Recommended Project Structure Changes for this phase: ``` server/src/ ├── services/ │ ├── hardware.ts # NEW — hardwareService: detect GPU/RAM/Apple Silicon │ └── nexus-settings.ts # NEW — nexusSettingsService: file-backed mode persistence ├── routes/ │ └── hardware.ts # NEW — GET /api/system/providers (unauthenticated) ├── data/ │ └── ollama-model-catalog.json # MODIFIED — add tier field + PRD models (Bonsai, Qwen3) └── app.ts # MODIFIED — mount hardwareRoutes() ui/src/ ├── components/ │ ├── NexusOnboardingWizard.tsx # MODIFIED — add mode selector step + hardware step │ └── onboarding/ # NEW directory │ ├── ModeSelector.tsx # NEW — Personal AI / Project Builder / Both cards │ └── HardwareSummaryStep.tsx # NEW — displays GPU/RAM/unified memory + model rec ├── api/ │ └── hardware.ts # NEW — typed fetch wrapper for /api/system/providers └── hooks/ └── useHardwareInfo.ts # NEW — useQuery wrapper for hardware data ``` ### Pattern 1: Unauthenticated Hardware Probe Route **What:** `GET /api/system/providers` returns hardware detection results without requiring any auth. This is mounted **before** the `actorMiddleware` check, or with explicit no-auth bypass. **When to use:** During initial onboarding before any board auth exists. **Key insight:** In `local_trusted` deploymentMode, `actorMiddleware` already sets `req.actor = { type: "board" }` implicitly — so the probe works for free in local installs. For `authenticated` mode (fresh install before board claim), the probe must explicitly allow unauthenticated access, since `req.actor.type === "none"` until login. **Approach:** Mount a dedicated route before the `api` router that does NOT call `assertBoard`. Return an empty/safe result if hardware detection fails. ```typescript // server/src/routes/hardware.ts import os from "node:os"; import { Router } from "express"; import { hardwareService } from "../services/hardware.js"; export function hardwareRoutes() { const router = Router(); const svc = hardwareService(); // Unauthenticated — intentional. Hardware is a property of the machine, not the user. // Safe: returns read-only system info, no mutation, no secrets. router.get("/system/providers", async (_req, res) => { try { const info = await svc.detect(); res.json(info); } catch { // Graceful degradation — return minimal safe info res.json({ totalGb: Math.round(os.totalmem() / (1024 ** 3)), freeGb: Math.round(os.freemem() / (1024 ** 3)), platform: os.platform(), gpuName: null, gpuVramGb: null, unifiedMemory: false, hardwareTier: "cpu_only", }); } }); return router; } ``` **Mounting in app.ts** — add BEFORE `app.use("/api", api)`: ```typescript // Unauthenticated probe — must come before the /api router (which requires actorMiddleware) app.use("/api", hardwareRoutes()); ``` ### Pattern 2: Hardware Detection Service **What:** `hardwareService` uses Node.js `os` for RAM and `systeminformation` v5 for GPU. Apple Silicon detection via CPU brand string. **Apple Silicon identification:** `os.cpus()[0].model` on M-series Macs returns `"Apple M1"`, `"Apple M4"`, etc. Check `process.platform === "darwin"` AND `cpuModel.startsWith("Apple")`. **Unified memory handling:** On Apple Silicon, `os.totalmem()` IS the unified memory (shared CPU+GPU). Use `os.freemem() * 0.75` as usable headroom (matching existing `getRecommendedModel` logic). Label as "unified memory" in UI, never "VRAM". ```typescript // server/src/services/hardware.ts import os from "node:os"; import si from "systeminformation"; export type HardwareTier = "gpu" | "apple_silicon" | "cpu_only"; export interface HardwareInfo { totalGb: number; freeGb: number; usableGb: number; // freeGb * 0.75 — budget for model loading platform: NodeJS.Platform; gpuName: string | null; gpuVramGb: number | null; unifiedMemory: boolean; // true on Apple Silicon hardwareTier: HardwareTier; cpuModel: string | null; } export function hardwareService() { let cache: HardwareInfo | null = null; let cacheExpiry = 0; const CACHE_TTL_MS = 5 * 60 * 1000; async function detect(): Promise { if (cache && Date.now() < cacheExpiry) return cache; const totalBytes = os.totalmem(); const freeBytes = os.freemem(); const totalGb = totalBytes / (1024 ** 3); const freeGb = freeBytes / (1024 ** 3); const usableGb = freeGb * 0.75; const cpuModel = os.cpus()[0]?.model ?? null; const isAppleSilicon = process.platform === "darwin" && (cpuModel?.startsWith("Apple") ?? false); let gpuName: string | null = null; let gpuVramGb: number | null = null; if (!isAppleSilicon) { try { const graphics = await si.graphics(); const controller = graphics.controllers?.[0]; if (controller) { gpuName = controller.model ?? null; // si.graphics returns vram in MB gpuVramGb = controller.vram ? controller.vram / 1024 : null; } } catch { // systeminformation not available or GPU detection failed — graceful } } let hardwareTier: HardwareTier; if (isAppleSilicon) { hardwareTier = "apple_silicon"; } else if (gpuVramGb && gpuVramGb >= 4) { hardwareTier = "gpu"; } else { hardwareTier = "cpu_only"; } const result: HardwareInfo = { totalGb: Math.round(totalGb * 10) / 10, freeGb: Math.round(freeGb * 10) / 10, usableGb: Math.round(usableGb * 10) / 10, platform: process.platform, gpuName, gpuVramGb: gpuVramGb ? Math.round(gpuVramGb * 10) / 10 : null, unifiedMemory: isAppleSilicon, hardwareTier, cpuModel, }; cache = result; cacheExpiry = Date.now() + CACHE_TTL_MS; return result; } return { detect }; } ``` ### Pattern 3: Nexus Settings Service (File-Backed Mode Persistence) **What:** A new file-backed JSON service stores Nexus-specific settings (starting with `nexus_mode`) in `{instanceRoot}/data/nexus-settings.json`. This avoids modifying the `.strict()` Zod schema in `@paperclipai/shared`. **Why not `instance_settings.general`:** The schema at `packages/shared/src/validators/instance.ts` uses `.strict()`. Adding a new key would require changes in `@paperclipai/shared` (upstream package) to both the Zod schema and the TypeScript interface. That creates rebase conflict surface. File-backed JSON is identical to the existing `config.json` and `ollama-model-catalog.json` patterns. ```typescript // server/src/services/nexus-settings.ts import fs from "node:fs"; import path from "node:path"; import { z } from "zod"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; export const NEXUS_MODES = ["personal_ai", "project_builder", "both"] as const; export type NexusMode = (typeof NEXUS_MODES)[number]; const nexusSettingsSchema = z.object({ mode: z.enum(NEXUS_MODES).default("both"), }); export type NexusSettings = z.infer; function resolveNexusSettingsPath(): string { return path.resolve(resolvePaperclipInstanceRoot(), "data", "nexus-settings.json"); } export function nexusSettingsService() { function load(): NexusSettings { const filePath = resolveNexusSettingsPath(); try { const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")); return nexusSettingsSchema.parse(raw); } catch { return nexusSettingsSchema.parse({}); } } function save(settings: NexusSettings): void { const filePath = resolveNexusSettingsPath(); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(settings, null, 2), "utf-8"); } return { get: () => load(), set: (patch: Partial) => { const current = load(); const next = nexusSettingsSchema.parse({ ...current, ...patch }); save(next); return next; }, }; } ``` ### Pattern 4: Extended Model Catalog with Hardware Tier **What:** The existing `ollama-model-catalog.json` needs: (a) PRD models added (Bonsai 1.7B, Qwen 3 8B), (b) a `tier` field per variant so the recommendation service can filter by `hardwareTier`. **Current catalog gap:** The catalog has `ramGb` and `vramGb` but no `tier` field. The success criteria require the model recommendation to match an entry "for the detected hardware tier (GPU / Apple Silicon / CPU-only)". The catalog must express this. **Approach:** Add an optional `tier` array to each variant: `"tier": ["gpu", "apple_silicon", "cpu_only"]` (if absent, variant is valid for all tiers). Also add the PRD models missing from current catalog: Bonsai 1.7B (hf.co/unsloth/Bonsai-1.7B-1M-GGUF or custom), Qwen 3 8B. Note: Bonsai (1-bit quantization) is listed in the PRD but may not be in the official Ollama registry under that name. Use the closest available name or add as a catalog-only entry with a `downloadUrl` field for future use. For Phase 30, the catalog is extended for recommendation display even if the model isn't pullable yet. ### Pattern 5: Mode Selector UI Component **What:** `ModeSelector.tsx` presents three cards — Personal AI Assistant, Project Builder, Both (default) — using the existing shadcn/ui `Card` pattern. The selected mode is passed up to `NexusOnboardingWizard` and saved via a `POST /api/nexus/settings` call on wizard completion. ```typescript // ui/src/components/onboarding/ModeSelector.tsx import { cn } from "@/lib/utils"; type NexusMode = "personal_ai" | "project_builder" | "both"; interface ModeSelectorProps { value: NexusMode; onChange: (mode: NexusMode) => void; } const MODES = [ { id: "personal_ai" as NexusMode, label: "Personal AI Assistant", description: "Always available, persistent memory, private.", }, { id: "project_builder" as NexusMode, label: "Project Builder", description: "Brainstorm → PM → Engineer → shipped product.", }, { id: "both" as NexusMode, label: "Both (recommended)", description: "A conversation becomes a project with one click.", }, ]; export function ModeSelector({ value, onChange }: ModeSelectorProps) { return (
{MODES.map((mode) => ( ))}
); } ``` ### Pattern 6: Hardware Summary Step **What:** `HardwareSummaryStep.tsx` calls `GET /api/system/providers`, renders detected hardware, and shows the local AI privacy frame from the PRD. **ONBD-07 copy requirement** (PRD verbatim, display when local AI is viable): ``` Local AI (recommended for privacy) Runs entirely on your machine. No accounts. No tracking. Works offline. ``` Display rules: - Apple Silicon: show unified memory GB, use "unified memory" label (not VRAM) - GPU: show GPU name + VRAM, label as "GPU VRAM" - CPU-only: show RAM, warn "slower than GPU-accelerated models", recommend cloud ### Anti-Patterns to Avoid - **Gating `GET /system/providers` on board auth:** This creates the Pitfall 14 failure — fresh install gets 403, hardware probe silently fails, wizard shows wrong defaults. - **Using `os.totalmem()` directly as "available for models":** On Apple Silicon, the OS + apps consume 30–40% of unified memory. Always apply 0.75 multiplier to `freemem()` (not `totalmem()`). - **Adding `nexus` key to `instanceGeneralSettingsSchema`:** The schema uses `.strict()` — any extra key throws a Zod validation error. Use the file-backed service instead. - **Reporting Apple Silicon VRAM as a separate number from RAM:** Apple M-series chips have unified memory. Do not report `gpuVramGb` for Apple Silicon — set it to `null`, set `unifiedMemory: true`, and use `totalGb`/`usableGb` for recommendations. - **Using `systeminformation` v6:** STATE.md explicitly locks to v5. v6 has breaking changes. - **Including Bonsai models in the Ollama recommendation if they are not in the Ollama registry:** The catalog can list them for display, but the recommendation engine should only mark a model `recommended: true` if it can actually be pulled via Ollama. --- ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | GPU name + VRAM detection | Custom `/proc/nvidia-smi` or WMI parsing | `systeminformation@5 si.graphics()` | Cross-platform, handles NVIDIA/AMD/Intel; si already handles platform differences | | RAM detection | Any third-party RAM library | `os.totalmem()` + `os.freemem()` | Built-in, zero deps, accurate | | Mode persistence as new DB table | Drizzle migration + new table | `nexusSettingsService` file-backed JSON | No DB schema changes constraint; file pattern already established | | Model recommendation filtering | Custom tier logic | Extend existing `getRecommendedModel()` | Logic already correct; add tier filter as one additional condition | | Onboarding step components | Monolithic wizard with inline UI | Sub-components in `ui/src/components/onboarding/` | ARCHITECTURE.md established this pattern; Phase 32 adds more steps | **Key insight:** The hardware probe and model catalog are the only genuinely new functionality. Mode persistence is a simple file write. Most of the work is wiring existing pieces together correctly and avoiding the auth pitfall. --- ## Common Pitfalls ### Pitfall 1: Hardware Probe Blocked by Board Auth (Pitfall 14 from PITFALLS.md) **What goes wrong:** Fresh install, no board auth token yet. `GET /api/system/providers` returns 403. Wizard falls back to `cpu_only` tier for model recommendation. Mac Mini M4 user is told to use cloud because GPU/unified memory was not detected. **Why it happens:** All routes under `/api` in `app.ts` are mounted behind `actorMiddleware`. In `authenticated` deploymentMode, `req.actor.type === "none"` for unauthenticated requests. **How to avoid:** Mount the hardware route with `app.use("/api", hardwareRoutes())` **before** `app.use("/api", api)` in app.ts. In the route handler, do NOT call `assertBoard` or check `req.actor`. The route returns read-only machine information only. **Warning signs:** Browser network tab shows 403 on `/api/system/providers` during onboarding. ### Pitfall 2: Apple Silicon Reported as "0 GB VRAM" **What goes wrong:** `systeminformation` on macOS with Apple Silicon may return `vram: 0` for the GPU controller because there is no discrete VRAM chip — the GPU uses system RAM. The UI shows "0 GB VRAM" or model recommendation uses the wrong memory figure. **Why it happens:** `si.graphics()` returns `vram: 0` for Apple Silicon integrated GPU. This is technically correct but misleading for model recommendations. **How to avoid:** When `isAppleSilicon` is true, do not call `si.graphics()` at all. Set `gpuVramGb: null`, `unifiedMemory: true`. The recommendation engine uses `usableGb` (from `freemem() * 0.75`) instead of `gpuVramGb`. **Warning signs:** UI shows "GPU VRAM: 0 GB" on an M4 Mac Mini. ### Pitfall 3: `.strict()` Schema Blocks Nexus Mode Persistence via instance_settings **What goes wrong:** Attempt to store `mode` in `instance_settings.general.nexus` fails with a Zod validation error because the schema is `z.object({ censorUsernameInLogs: z.boolean() }).strict()`. Any key not in the schema is rejected. **Why it happens:** The shared package uses `.strict()` on both general and experimental settings schemas to prevent accumulation of unknown keys in the DB. **How to avoid:** Use `nexusSettingsService` (file-backed JSON at `{instanceRoot}/data/nexus-settings.json`). Add a `GET /api/nexus/settings` and `PATCH /api/nexus/settings` route. These ARE board-auth-gated (mode setting happens after the user is set up). **Warning signs:** Server logs a Zod error when the wizard tries to save mode; `updateGeneral()` silently discards the `nexus` key. ### Pitfall 4: Model Catalog Recommends Bonsai but Ollama Cannot Pull It **What goes wrong:** The PRD lists "Bonsai 1.7B (1-bit)" as a model. If added to the catalog with `name: "bonsai:1.7b"` and Ollama has no such model, `getRecommendedModel()` never finds a match (it only marks models the user already has installed as recommended). But if the catalog is used to generate a "we suggest pulling this" recommendation before pull, a non-pullable name breaks the Ollama pull command. **Why it happens:** The PRD model list mixes "models in Ollama registry" with "models we wish were in Ollama registry". Bonsai 1-bit quantization may only be available via a Hugging Face GGUF, not via `ollama pull`. **How to avoid:** For Phase 30, add Bonsai as a catalog entry with a `source: "huggingface"` flag (or just omit it from the recommendation engine). The catalog is displayed to users but the `getRecommendedModel()` function only recommends models the user has already pulled. Phase 30 does not need to pull models — just display what the hardware can run. **Warning signs:** `ollama pull bonsai:1.7b` returns 404; recommendation shows models with pull errors. ### Pitfall 5: 5-Second Timeout Not Met Due to `si.graphics()` on Linux **What goes wrong:** The success criterion requires the probe to return within 5 seconds. On Linux, `si.graphics()` may shell out to `lspci` or `nvidia-smi`. If those commands are not installed or produce slow output, the probe hangs. **Why it happens:** `systeminformation` uses platform-specific shell commands as fallback on Linux for GPU detection. Slow GPU drivers or missing `lspci` cause timeouts. **How to avoid:** Wrap `si.graphics()` in a `Promise.race()` with a 3-second timeout abort. If it times out, return `gpuName: null, gpuVramGb: null, hardwareTier: "cpu_only"` and continue. The 5-second budget for the overall probe response is achievable even with a 3-second GPU probe. **Warning signs:** `/api/system/providers` takes 6–10 seconds on Linux; `hardwareTier` always shows `cpu_only` even when a GPU is present. ### Pitfall 6: NexusOnboardingWizard Drift from Upstream OnboardingWizard **What goes wrong:** Phase 30 extends `NexusOnboardingWizard.tsx` with new steps. Upstream adds new props or context dependencies to `OnboardingWizard.tsx`. After the next upstream rebase, `NexusOnboardingWizard.tsx` silently misses those changes. **Why it happens:** Vite alias `src/components/OnboardingWizard` → `NexusOnboardingWizard` fully replaces the upstream component. Any upstream improvement is silently discarded. **How to avoid:** Phase 30 modifications to `NexusOnboardingWizard.tsx` must maintain the same export signature as `OnboardingWizard.tsx`. After each upstream rebase, diff `OnboardingWizard.tsx` for new hook usage. **Warning signs:** `pnpm dev` fails with "cannot find module" after rebase; wizard missing features added to upstream. --- ## Code Examples Verified patterns from existing codebase: ### Existing RAM + Recommendation Pattern (confirm before extending) ```typescript // server/src/services/ollama.ts (existing, confirmed) export function getRecommendedModel(models: OllamaModel[], systemRamBytes: number): OllamaModel[] { const usableRamGb = (systemRamBytes / (1024 * 1024 * 1024)) * 0.75; // ... catalog-based matching ... } // Called in ollamaRoutes.ts: const enrichedModels = getRecommendedModel(models, os.totalmem()); // NOTE: Phase 30 updates this to use os.freemem() for Apple Silicon path ``` ### Mounting Unauthenticated Routes Before the Protected api Router ```typescript // server/src/app.ts (MODIFIED pattern — add before app.use("/api", api)) // Source: existing health route pattern (health is also accessible without deep auth) app.use("/api", hardwareRoutes()); // unauthenticated — must come first app.use("/api", api); // authenticated api router ``` ### File-Backed JSON Service Pattern ```typescript // Source: config-file.ts + ollama.ts catalog load pattern (confirmed in codebase) import fs from "node:fs"; import path from "node:path"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; function resolveNexusSettingsPath(): string { return path.resolve(resolvePaperclipInstanceRoot(), "data", "nexus-settings.json"); } ``` ### systeminformation v5 Graphics Call ```typescript // Source: systeminformation v5 npm docs (verified: npm view systeminformation version → 5.31.5) import si from "systeminformation"; const graphics = await si.graphics(); // graphics.controllers[0].model → GPU name string // graphics.controllers[0].vram → VRAM in MB (integer) // Returns empty array if no GPU detected ``` ### NexusMode Constants (shared between server + UI) ```typescript // server/src/services/nexus-settings.ts export const NEXUS_MODES = ["personal_ai", "project_builder", "both"] as const; export type NexusMode = (typeof NEXUS_MODES)[number]; // UI: ui/src/api/hardware.ts export type NexusMode = "personal_ai" | "project_builder" | "both"; // (duplicated in UI since @paperclipai/shared is upstream-owned) ``` ### Extended Model Catalog JSON ```json // server/src/data/ollama-model-catalog.json (MODIFIED — add tier + PRD models) { "models": [ { "family": "qwen2", "variants": [ { "name": "qwen2.5-coder:7b", "ramGb": 5, "vramGb": 5, "quality": "fast", "tier": ["gpu", "apple_silicon", "cpu_only"] }, { "name": "qwen2.5-coder:14b", "ramGb": 10, "vramGb": 10, "quality": "balanced", "tier": ["gpu", "apple_silicon"] }, { "name": "qwen2.5-coder:32b", "ramGb": 22, "vramGb": 22, "quality": "best", "tier": ["gpu"] } ] }, { "family": "qwen3", "variants": [ { "name": "qwen3:8b", "ramGb": 5, "vramGb": 5, "quality": "balanced", "tier": ["gpu", "apple_silicon", "cpu_only"] } ] }, { "family": "llama", "variants": [ { "name": "llama3.2:3b", "ramGb": 3, "vramGb": 3, "quality": "fast", "tier": ["gpu", "apple_silicon", "cpu_only"] }, { "name": "llama3.1:8b", "ramGb": 6, "vramGb": 6, "quality": "balanced", "tier": ["gpu", "apple_silicon", "cpu_only"] }, { "name": "llama3.1:70b", "ramGb": 48, "vramGb": 48, "quality": "best", "tier": ["gpu"] } ] } ] } ``` --- ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Ollama routes require companyId (no pre-auth probe) | New `GET /api/system/providers` requires no auth | Phase 30 (this phase) | Enables pre-auth hardware detection | | `getRecommendedModel` uses `totalmem()` only | Use `freemem() * 0.75` for Apple Silicon, `totalmem() * 0.75` for GPU/CPU | Phase 30 | More accurate for loaded systems | | Single-step `NexusOnboardingWizard` | Multi-step with `ModeSelector` + `HardwareSummaryStep` | Phase 30 | Foundation for Phase 32 full wizard | | Model catalog: no tier field | Catalog has `tier` array per variant | Phase 30 | Enables tier-filtered recommendations | **Deprecated/outdated:** - `getRecommendedModel()` calling `os.totalmem()` directly — Phase 30 changes the call site to pass `os.freemem()` for Apple Silicon path; existing behavior preserved for non-Apple-Silicon. --- ## Open Questions 1. **Is `qwen3:8b` available in Ollama as of April 2026?** - What we know: Qwen 3 is listed in the PRD. Qwen 2.5 is in the current catalog. The Ollama registry is a moving target. - What's unclear: Whether the exact model tag is `qwen3:8b` or something else. - Recommendation: Add `qwen3:8b` to catalog with a note that the tag should be verified against the Ollama registry at ship time. The recommendation engine only marks models the user has pulled as recommended — a wrong tag just means the model won't be auto-recommended until the user pulls it. 2. **Should the Nexus settings route (`PATCH /api/nexus/settings`) be board-auth-gated?** - What we know: Mode selection happens during onboarding. In `local_trusted` mode, board auth is always present. In `authenticated` mode, the user has logged in by the time they see the wizard. - Recommendation: Yes, gate on board auth. The hardware probe is unauthenticated; mode persistence is not. The wizard saves mode on the final wizard-complete action, not on mode card click. 3. **Does the mode selector need to appear in settings post-onboarding?** - What we know: ROADMAP success criteria say the mode is "persisted" and "assistant-specific UI is hidden when Project Builder-only is chosen." - What's unclear: Whether Phase 30 needs a settings page entry point or just onboarding. - Recommendation: Phase 30 delivers mode selection in the onboarding wizard only. A settings page entry point is deferred to Phase 33 (which introduces `PersonalAssistantPage` and mode-gated UI). --- ## Environment Availability | Dependency | Required By | Available | Version | Fallback | |------------|------------|-----------|---------|----------| | Node.js `os` | RAM/CPU detection | ✓ | built-in | — | | `systeminformation` | GPU name + VRAM | ✗ (not installed) | 5.31.5 (latest v5) | Omit GPU name, return `null`, tier defaults to `cpu_only` | | `system_profiler` (macOS only) | Apple Silicon GPU model | ✓ on macOS, ✗ on Linux | macOS built-in | Use CPU brand string alone | | React | UI components | ✓ | project version | — | | Zod | Settings schema | ✓ | project version | — | | shadcn/ui `Card`, `Button` | ModeSelector UI | ✓ | project version | — | **Missing dependencies with no fallback:** - None that block execution. `systeminformation` absence degrades gracefully to `cpu_only` tier. **Missing dependencies with fallback:** - `systeminformation`: probe route gracefully omits GPU data if detection fails; hardware tier becomes `cpu_only`; model recommendation still works using RAM budget. --- ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | Vitest | | Config file | `server/vitest.config.ts` | | Quick run command | `pnpm --filter server test --run` | | Full suite command | `pnpm --filter server test --run && pnpm --filter ui test --run` | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | ONBD-02 | `hardwareService.detect()` returns `unifiedMemory: true` when CPU brand is "Apple M4" | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 | | ONBD-02 | `hardwareService.detect()` returns `hardwareTier: "cpu_only"` when no GPU detected | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 | | ONBD-02 | `GET /api/system/providers` returns 200 without board auth (unauthenticated request) | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 | | ONBD-02 | Probe returns within 5 seconds even when `si.graphics()` is unavailable | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 | | ONBD-03 | Extended catalog contains `qwen3:8b` and `tier` field | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 | | ONBD-03 | `getRecommendedModel()` with `gpu` tier only recommends GPU-tier models | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 | | ONBD-01 | `nexusSettingsService.set({ mode: "personal_ai" })` persists and is readable | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 | | ONBD-07 | `HardwareSummaryStep` renders privacy copy when tier is not `cpu_only` | unit (React Testing Library or Vitest) | `pnpm --filter ui test --run -- HardwareSummaryStep` | ❌ Wave 0 | ### Sampling Rate - **Per task commit:** `pnpm --filter server test --run` - **Per wave merge:** `pnpm --filter server test --run && pnpm --filter ui test --run` - **Phase gate:** Full suite green before `/gsd:verify-work` ### Wave 0 Gaps - [ ] `server/src/__tests__/30-hardware-detection.test.ts` — covers ONBD-01, ONBD-02, ONBD-03 server-side - [ ] `ui/src/components/onboarding/HardwareSummaryStep.test.tsx` — covers ONBD-07 copy render --- ## Sources ### Primary (HIGH confidence) - `/opt/nexus/server/src/services/ollama.ts` — existing `getRecommendedModel()`, 0.75 multiplier, `os.totalmem()` usage (confirmed) - `/opt/nexus/server/src/routes/ollama.ts` — existing company-scoped ollama routes; confirmed no unauthenticated pattern - `/opt/nexus/server/src/middleware/auth.ts` — `actorMiddleware` behavior in `local_trusted` vs `authenticated` mode (confirmed) - `/opt/nexus/server/src/app.ts` — route mounting order; confirmed `/api` router structure (confirmed) - `/opt/nexus/server/src/services/instance-settings.ts` — `updateGeneral()` uses `.strict()` schema; adding new keys would fail (confirmed) - `/opt/nexus/packages/shared/src/validators/instance.ts` — `.strict()` confirmed on line 5 - `/opt/nexus/server/src/home-paths.ts` — `resolvePaperclipInstanceRoot()` for file-backed JSON path (confirmed) - `/opt/nexus/server/src/data/ollama-model-catalog.json` — current catalog structure (confirmed; no tier field, no Bonsai/Qwen3) - `/opt/nexus/ui/src/components/NexusOnboardingWizard.tsx` — current single-step wizard; mode selector is absent (confirmed) - `/opt/nexus/.planning/STATE.md` — locked decisions: `systeminformation` v5, `freemem() * 0.75`, `GET /system/providers` unauthenticated - `/opt/nexus/.planning/research/ARCHITECTURE.md` — component map, `hardwareService` design, `nexus` namespace in instance settings (confirmed architecture intent) - `/opt/nexus/.planning/research/PITFALLS.md` — Pitfall 13 (Apple Silicon VRAM), Pitfall 14 (probe auth level) - `npm view systeminformation version` → `5.31.5` (confirmed current latest v5) ### Secondary (MEDIUM confidence) - `/home/mikkel/upload/nexus-v1.5-prd-onboarding-assistant.md` — PRD model list (Bonsai, Qwen 3, tier scenarios), ONBD-07 copy requirement - `systeminformation` v5 npm documentation — `si.graphics()` returns `controllers[].vram` in MB --- ## Metadata **Confidence breakdown:** - Standard stack: HIGH — `os` built-in confirmed; `systeminformation` version confirmed via npm; not yet installed (needs `pnpm add`) - Architecture: HIGH — all integration points confirmed via direct codebase reading; `.strict()` schema trap confirmed - Pitfalls: HIGH — all identified from direct code reading and confirmed PITFALLS.md analysis **Research date:** 2026-04-02 **Valid until:** 2026-05-02 (stable domain; `systeminformation` API stable in v5)