From 69517b373e13f55879c6545137a96814bbac7af0 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 22:55:23 +0000 Subject: [PATCH] =?UTF-8?q?[nexus]=20docs(30):=20research=20phase=2030=20?= =?UTF-8?q?=E2=80=94=20hardware=20detection=20+=20mode=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../30-RESEARCH.md | 659 ++++++++++++++++++ 1 file changed, 659 insertions(+) create mode 100644 .planning/phases/30-hardware-detection-mode-selection/30-RESEARCH.md diff --git a/.planning/phases/30-hardware-detection-mode-selection/30-RESEARCH.md b/.planning/phases/30-hardware-detection-mode-selection/30-RESEARCH.md new file mode 100644 index 00000000..708121be --- /dev/null +++ b/.planning/phases/30-hardware-detection-mode-selection/30-RESEARCH.md @@ -0,0 +1,659 @@ +# 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)