--- phase: 28-ollama-integration plan: 03 type: execute wave: 2 depends_on: [28-01] files_modified: - server/src/services/heartbeat.ts - ui/src/pages/AgentDetail.tsx autonomous: true requirements: [HERM-06, HERM-07] must_haves: truths: - "After a Hermes heartbeat completes, stateJson contains hermesModel and hermesNativeSkillCount" - "Dashboard AgentOverview shows model name and native skill count for Hermes agents" - "Memory usage from /api/ps is shown when Ollama model is actively loaded" - "Cost tracking shows $0.00 for Ollama-based runs (correct behavior, no code change needed)" - "stateJson merge uses spread/jsonb concat, never overwrites existing fields" artifacts: - path: "server/src/services/heartbeat.ts" provides: "Hermes stateJson merge in updateRuntimeState" contains: "hermesModel" - path: "ui/src/pages/AgentDetail.tsx" provides: "HermesRuntimeCard component in AgentOverview" contains: "HermesRuntimeCard" key_links: - from: "server/src/services/heartbeat.ts" to: "server/src/services/ollama.ts" via: "import for /api/ps memory query" pattern: "ollama" - from: "ui/src/pages/AgentDetail.tsx" to: "agentRuntimeState.stateJson" via: "runtimeState.stateJson?.hermesModel" pattern: "hermesModel" --- Wire Hermes runtime data into stateJson after heartbeat runs and render it in the dashboard. Purpose: Users see Hermes-specific information (model name, native skill count, memory usage) on the agent dashboard. Cost tracking for Ollama-based runs correctly shows $0.00 (already wired, just needs verification). Output: stateJson populated after Hermes runs, HermesRuntimeCard visible in AgentOverview. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/28-ollama-integration/28-RESEARCH.md @.planning/phases/28-ollama-integration/28-01-SUMMARY.md @server/src/services/heartbeat.ts (updateRuntimeState around line 1846) @server/src/services/ollama.ts (from Plan 01) @ui/src/pages/AgentDetail.tsx (AgentOverview around line 1168) async function updateRuntimeState( agent: typeof agents.$inferSelect, run: typeof heartbeatRuns.$inferSelect, result: AdapterExecutionResult, session: { legacySessionId: string | null }, normalizedUsage?: UsageTotals | null, ): void { hermesModel: string; // from result.model hermesNativeSkillCount: number; // from skill registry query or UI-derived hermesMemoryBytes: number | null; // from /api/ps size_vram } agent: AgentDetailRecord (has adapterType) runtimeState?: AgentRuntimeState (has stateJson: Record) Task 1: Merge Hermes runtime data into stateJson after heartbeat server/src/services/heartbeat.ts - server/src/services/heartbeat.ts (lines 1846-1900 for updateRuntimeState, lines 2690-2710 for where updateRuntimeState is called) - server/src/services/ollama.ts (for import path — Plan 01 created this) - .planning/phases/28-ollama-integration/28-RESEARCH.md (Pitfall 3 — stateJson merge, Pitfall 5 — /api/ps empty case, Pitfall 6 — cost tracking) Modify `updateRuntimeState` in `server/src/services/heartbeat.ts` to merge Hermes-specific data into stateJson: 1. At the top of heartbeat.ts, add: `import { getOllamaMemoryUsage } from "../services/ollama.js"` (this function needs to be added to ollama.ts first — see step 3). 2. After the existing `db.update(agentRuntimeState).set(...)` call (around line 1879), add a conditional block: ```typescript if (agent.adapterType === "hermes_local") { const hermesModel = result.model ?? null; // Query Ollama /api/ps for memory usage (non-blocking, best-effort) let hermesMemoryBytes: number | null = null; try { hermesMemoryBytes = await getOllamaMemoryUsage(hermesModel); } catch { // Ollama may not be running or model not loaded — acceptable } // Merge into stateJson using jsonb concat to avoid overwriting other fields await db .update(agentRuntimeState) .set({ stateJson: sql`COALESCE(${agentRuntimeState.stateJson}, '{}'::jsonb) || ${JSON.stringify({ hermesModel, hermesMemoryBytes, })}::jsonb`, }) .where(eq(agentRuntimeState.agentId, agent.id)); } ``` 3. Add `getOllamaMemoryUsage` function to `server/src/services/ollama.ts` (modify the file created in Plan 01): - `export async function getOllamaMemoryUsage(modelName: string | null): Promise` - Fetch `${OLLAMA_BASE_URL}/api/ps` with 3s AbortController timeout - Parse response as OllamaPsResponse - Find the model matching `modelName` in the response.models array - Return `size_vram` from the match, or null if no match / empty array / error - On any error, return null (graceful degradation per Pitfall 5) 4. HERM-06 verification: The existing cost tracking code in updateRuntimeState already handles Hermes correctly: - `result.costUsd` is undefined for local Ollama → `normalizeBilledCostCents(undefined, ...)` returns 0 - Token usage may or may not be emitted by Hermes → cost event created only if `hasTokenUsage` - No code changes needed for HERM-06 — just verify the path works by reading the code. NOTE: hermesNativeSkillCount is NOT stored in stateJson. Per RESEARCH Open Question 3, the UI will derive this from the existing `agentsApi.skills(agentId)` query (which already syncs Hermes native skills via skillRegistryService). This avoids cross-DB complexity in the heartbeat path. cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20 - grep -q "hermesModel" server/src/services/heartbeat.ts - grep -q "hermesMemoryBytes" server/src/services/heartbeat.ts - grep -q "hermes_local" server/src/services/heartbeat.ts - grep -q "COALESCE" server/src/services/heartbeat.ts - grep -q "getOllamaMemoryUsage" server/src/services/ollama.ts - grep -q "api/ps" server/src/services/ollama.ts After a Hermes heartbeat run, stateJson is updated with hermesModel and hermesMemoryBytes via jsonb merge. getOllamaMemoryUsage queries /api/ps gracefully. TypeScript compiles without errors. Task 2: Create HermesRuntimeCard component in AgentOverview ui/src/pages/AgentDetail.tsx - ui/src/pages/AgentDetail.tsx (lines 1168-1230 for AgentOverview structure, lines 1002-1010 for AgentOverview props passed) - ui/src/pages/AgentDetail.tsx (lines 2362-2380 for AgentSkillsTab — to see how skills query works) - .planning/phases/28-ollama-integration/28-RESEARCH.md (HERM-07 section) Add a `HermesRuntimeCard` component to `AgentDetail.tsx` and render it in `AgentOverview`: 1. Create `HermesRuntimeCard` function component (define it near AgentOverview, around line 1160): ```typescript function HermesRuntimeCard({ runtimeState, agentId }: { runtimeState: AgentRuntimeState; agentId: string }) { const hermesModel = runtimeState.stateJson?.hermesModel as string | undefined; const hermesMemoryBytes = runtimeState.stateJson?.hermesMemoryBytes as number | null | undefined; // Derive native skill count from existing skills query const { data: skillsData } = useQuery({ queryKey: ["agents", agentId, "skills"], queryFn: () => agentsApi.skills(agentId), }); const nativeSkillCount = useMemo(() => { if (!skillsData?.adapterEntries) return 0; return skillsData.adapterEntries.filter((e: any) => e.originLabel === "Hermes skill").length; }, [skillsData]); const formatBytes = (bytes: number) => { const gb = bytes / (1024 * 1024 * 1024); return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB`; }; return (

Hermes Runtime

Model

{hermesModel ?? "Not set"}

Native Skills

{nativeSkillCount}

Memory (VRAM)

{hermesMemoryBytes != null ? formatBytes(hermesMemoryBytes) : "Not loaded"}

); } ``` 2. In `AgentOverview` component (around line 1202, after the charts grid div), add: ```tsx {agent.adapterType === "hermes_local" && runtimeState && ( )} ``` 3. Ensure imports: `useMemo` should already be imported. `agentsApi` should already be imported. `useQuery` should already be imported. If not, add the necessary imports. 4. Check the `AgentRuntimeState` type — stateJson should be typed as `Record` or similar. If it's typed more narrowly, cast as needed.
cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20 - grep -q "HermesRuntimeCard" ui/src/pages/AgentDetail.tsx - grep -q "hermesModel" ui/src/pages/AgentDetail.tsx - grep -q "hermesMemoryBytes" ui/src/pages/AgentDetail.tsx - grep -q "Native Skills" ui/src/pages/AgentDetail.tsx - grep -q "hermes_local.*runtimeState" ui/src/pages/AgentDetail.tsx - grep -q "Hermes Runtime" ui/src/pages/AgentDetail.tsx HermesRuntimeCard renders in AgentOverview for Hermes agents, showing model name, native skill count (from skills API), and memory usage (from stateJson). TypeScript compiles without errors.
- `cd /opt/nexus/server && npx tsc --noEmit` — no type errors - `cd /opt/nexus/ui && npx tsc --noEmit` — no type errors - stateJson merge uses COALESCE + jsonb concat (not overwrite) - HermesRuntimeCard gated by adapterType === "hermes_local" - Cost tracking path verified (no changes needed per HERM-06) - Hermes heartbeat runs populate stateJson with hermesModel and hermesMemoryBytes - HermesRuntimeCard displays in AgentOverview for Hermes agents - Native skill count derived from skills API (no cross-DB query in heartbeat) - Memory usage shows "Not loaded" when Ollama model isn't active - Cost tracking correctly shows $0.00 for Ollama runs (existing behavior, verified) - All code compiles without TypeScript errors After completion, create `.planning/phases/28-ollama-integration/28-03-SUMMARY.md`