docs(28): create phase plan

This commit is contained in:
Nexus Dev 2026-04-02 16:50:58 +00:00
parent 5713d996bb
commit 9e7c2890d5
4 changed files with 656 additions and 2 deletions

View file

@ -43,7 +43,11 @@ Plans:
5. The agent config page shows Nexus-managed skills alongside Hermes native skills in a single unified list 5. The agent config page shows Nexus-managed skills alongside Hermes native skills in a single unified list
6. The dashboard agent card for a Hermes agent shows model name, memory usage, and native skill count 6. The dashboard agent card for a Hermes agent shows model name, memory usage, and native skill count
7. Token usage and estimated model cost are recorded per heartbeat and surfaced in the cost tracking view 7. Token usage and estimated model cost are recorded per heartbeat and surfaced in the cost tracking view
**Plans**: TBD **Plans:** 3 plans
Plans:
- [ ] 28-01-PLAN.md — Ollama service, routes, model catalog, and unit tests
- [ ] 28-02-PLAN.md — UI model selector dropdown, install callout, Hermes skill badge
- [ ] 28-03-PLAN.md — Hermes stateJson runtime data and dashboard HermesRuntimeCard
**UI hint**: yes **UI hint**: yes
### Phase 29: Default Provider & End-to-End ### Phase 29: Default Provider & End-to-End
@ -89,5 +93,5 @@ All 16 v1 requirements are mapped to exactly one phase. No orphans.
| Phase | Milestone | Plans Complete | Status | Completed | | Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------| |-------|-----------|----------------|--------|-----------|
| 27. Hermes Adapter | v1.4 | 1/1 | Complete | 2026-04-02 | | 27. Hermes Adapter | v1.4 | 1/1 | Complete | 2026-04-02 |
| 28. Ollama Integration & Agent Surface | v1.4 | 0/? | Not started | - | | 28. Ollama Integration & Agent Surface | v1.4 | 0/3 | Not started | - |
| 29. Default Provider & End-to-End | v1.4 | 0/? | Not started | - | | 29. Default Provider & End-to-End | v1.4 | 0/? | Not started | - |

View file

@ -0,0 +1,194 @@
---
phase: 28-ollama-integration
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- server/src/services/ollama.ts
- server/src/routes/ollama.ts
- server/src/routes/index.ts
- server/src/app.ts
- server/src/data/ollama-model-catalog.json
- server/src/__tests__/ollama-service.test.ts
autonomous: true
requirements: [OLLA-01, OLLA-02, OLLA-04, OLLA-05]
must_haves:
truths:
- "detectOllama() returns installed:true + version when Ollama responds at /api/version"
- "detectOllama() returns installed:false + installUrl when Ollama is absent or times out"
- "listOllamaModels() returns model list with parameterSize, family, quantization from /api/tags"
- "getRecommendedModel() returns highest-quality model that fits within 75% system RAM"
- "GET /companies/:companyId/ollama/status returns OllamaStatus JSON"
- "GET /companies/:companyId/ollama/models returns OllamaModel[] + ramGb"
artifacts:
- path: "server/src/services/ollama.ts"
provides: "Ollama detection, model listing, recommendation logic"
exports: ["detectOllama", "listOllamaModels", "OllamaStatus", "OllamaModel"]
- path: "server/src/routes/ollama.ts"
provides: "HTTP routes for Ollama status and model listing"
exports: ["ollamaRoutes"]
- path: "server/src/data/ollama-model-catalog.json"
provides: "Static model catalog for RAM-based recommendations"
contains: "qwen2.5-coder"
- path: "server/src/__tests__/ollama-service.test.ts"
provides: "Unit tests for ollamaService"
min_lines: 60
key_links:
- from: "server/src/routes/ollama.ts"
to: "server/src/services/ollama.ts"
via: "import detectOllama, listOllamaModels"
pattern: "import.*from.*services/ollama"
- from: "server/src/app.ts"
to: "server/src/routes/ollama.ts"
via: "api.use(ollamaRoutes())"
pattern: "ollamaRoutes"
---
<objective>
Create the server-side Ollama integration service, HTTP routes, model catalog, and unit tests.
Purpose: Provides the backend API surface that the UI (Plan 02) will consume to detect Ollama, list available models, and recommend a model based on system RAM.
Output: Working server routes at /companies/:companyId/ollama/status and /models, plus unit tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/28-ollama-integration/28-RESEARCH.md
@server/src/app.ts
@server/src/routes/index.ts
@server/src/routes/agents.ts (for assertCompanyAccess pattern)
@ui/src/api/client.ts (for API client pattern reference)
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create ollamaService + model catalog + unit tests</name>
<files>server/src/services/ollama.ts, server/src/data/ollama-model-catalog.json, server/src/__tests__/ollama-service.test.ts</files>
<read_first>
- server/src/services/heartbeat.ts (lines 1-30 for import patterns)
- .planning/phases/28-ollama-integration/28-RESEARCH.md (Pattern 1, Pattern 5, Code Examples)
</read_first>
<behavior>
- detectOllama returns { installed: true, version: "0.5.x" } when fetch to /api/version succeeds
- detectOllama returns { installed: false, version: null, installUrl: "https://ollama.com/download" } when fetch rejects (ECONNREFUSED)
- detectOllama returns { installed: false, version: null, installUrl } when fetch times out (AbortController)
- listOllamaModels returns OllamaModel[] mapped from /api/tags response with name, parameterSize, quantization, sizeBytes, family
- listOllamaModels returns empty array when Ollama is absent
- getRecommendedModel marks the highest-quality model that fits within 75% of given RAM budget as recommended=true
- getRecommendedModel with 8GB RAM recommends a 7b model, not a 32b model
- Model catalog JSON contains at least qwen2, llama, mistral, phi, deepseek families
</behavior>
<action>
1. Create `server/src/data/ollama-model-catalog.json` with the static catalog from RESEARCH Pattern 5. Include families: qwen2, llama, mistral, phi, deepseek. Each variant has name, ramGb, vramGb, quality fields.
2. Create `server/src/services/ollama.ts`:
- `const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434"`
- `const OLLAMA_TIMEOUT_MS = 3000`
- `const INSTALL_URL = "https://ollama.com/download"`
- Export interface `OllamaStatus { installed: boolean; version: string | null; installUrl: string }`
- Export interface `OllamaModel { name: string; parameterSize: string; quantization: string; sizeBytes: number; family: string; recommended: boolean; recommendationReason: string | null }`
- Export `async function detectOllama(): Promise<OllamaStatus>` — fetch /api/version with AbortController 3s timeout. On success return installed:true + version. On any error return installed:false + installUrl.
- Export `async function listOllamaModels(): Promise<OllamaModel[]>` — fetch /api/tags, map response.models to OllamaModel[]. On error return [].
- Export `function getRecommendedModel(models: OllamaModel[], systemRamBytes: number): OllamaModel[]` — reads catalog JSON, computes usableRamGb = (systemRamBytes / 1024^3) * 0.75, for each model checks if a catalog entry matches by name and fits in RAM. Returns models with `recommended` field set. The highest-quality model within budget gets recommended=true + a recommendationReason string. All others get recommended=false.
- Anti-pattern: Do NOT poll in a loop. Do NOT hard-code localhost:11434 — always use OLLAMA_BASE_URL.
3. Create `server/src/__tests__/ollama-service.test.ts`:
- Mock global fetch using vi.stubGlobal("fetch", vi.fn())
- Test detectOllama success case (mock returns { version: "0.5.1" })
- Test detectOllama failure case (mock rejects with ECONNREFUSED)
- Test detectOllama timeout case (mock never resolves, verify AbortController fires)
- Test listOllamaModels success with mock /api/tags response matching OllamaTagsResponse shape from RESEARCH
- Test listOllamaModels returns [] on fetch error
- Test getRecommendedModel with 8GB RAM → recommends 7b-class model
- Test getRecommendedModel with 32GB RAM → recommends 32b-class model
- Test getRecommendedModel with models not in catalog → recommended=false for all
</action>
<verify>
<automated>cd /opt/nexus/server && npx vitest run src/__tests__/ollama-service.test.ts</automated>
</verify>
<acceptance_criteria>
- grep -q "detectOllama" server/src/services/ollama.ts
- grep -q "listOllamaModels" server/src/services/ollama.ts
- grep -q "getRecommendedModel" server/src/services/ollama.ts
- grep -q "OLLAMA_BASE_URL" server/src/services/ollama.ts
- grep -q "qwen2.5-coder" server/src/data/ollama-model-catalog.json
- grep -q "detectOllama" server/src/__tests__/ollama-service.test.ts
- grep -q "getRecommendedModel" server/src/__tests__/ollama-service.test.ts
</acceptance_criteria>
<done>All ollama service tests pass. detectOllama, listOllamaModels, and getRecommendedModel functions exported and tested. Model catalog JSON file exists with 5+ model families.</done>
</task>
<task type="auto">
<name>Task 2: Create Ollama HTTP routes and mount in app</name>
<files>server/src/routes/ollama.ts, server/src/routes/index.ts, server/src/app.ts</files>
<read_first>
- server/src/routes/agents.ts (lines 1-60 for route pattern, assertCompanyAccess usage)
- server/src/app.ts (lines 134-170 for route mounting pattern)
- server/src/routes/index.ts
</read_first>
<action>
1. Create `server/src/routes/ollama.ts`:
- Import Router from express, import assertCompanyAccess from "./authz.js", import detectOllama/listOllamaModels/getRecommendedModel from "../services/ollama.js"
- Import `os` for `os.totalmem()`
- Export function `ollamaRoutes()` returning Router
- `GET /companies/:companyId/ollama/status`:
- Call `assertCompanyAccess(req, companyId)`
- Call `detectOllama()`, return JSON response
- `GET /companies/:companyId/ollama/models`:
- Call `assertCompanyAccess(req, companyId)`
- Call `detectOllama()` first — if not installed, return `{ models: [], ramGb: 0 }`
- Call `listOllamaModels()`, then `getRecommendedModel(models, os.totalmem())`
- Return `{ models: enrichedModels, ramGb: Math.round(os.totalmem() / 1073741824) }`
- Wrap each handler in try/catch, return 500 on unexpected errors
2. Add `export { ollamaRoutes } from "./ollama.js"` to `server/src/routes/index.ts`
3. In `server/src/app.ts`:
- Add import: `import { ollamaRoutes } from "./routes/ollama.js"`
- Add `api.use(ollamaRoutes())` after the `api.use(agentRoutes(db))` line (around line 152)
</action>
<verify>
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "ollamaRoutes" server/src/routes/ollama.ts
- grep -q "assertCompanyAccess" server/src/routes/ollama.ts
- grep -q "/companies/:companyId/ollama/status" server/src/routes/ollama.ts
- grep -q "/companies/:companyId/ollama/models" server/src/routes/ollama.ts
- grep -q "ollamaRoutes" server/src/routes/index.ts
- grep -q "ollamaRoutes" server/src/app.ts
- grep -q "os.totalmem" server/src/routes/ollama.ts
</acceptance_criteria>
<done>Ollama routes mounted at /companies/:companyId/ollama/status and /models. TypeScript compiles without errors. Routes use assertCompanyAccess for auth and os.totalmem() for RAM detection.</done>
</task>
</tasks>
<verification>
- `cd /opt/nexus/server && npx vitest run src/__tests__/ollama-service.test.ts` — all tests pass
- `cd /opt/nexus/server && npx tsc --noEmit` — no type errors
- Ollama service gracefully returns installed:false when Ollama is not running (no crashes)
</verification>
<success_criteria>
- Ollama detection service exists with detectOllama, listOllamaModels, getRecommendedModel
- Model catalog JSON ships with 5+ model families
- HTTP routes mounted and accessible at /companies/:companyId/ollama/status and /models
- Unit tests cover success, failure, timeout, and recommendation scenarios
- All code compiles without TypeScript errors
</success_criteria>
<output>
After completion, create `.planning/phases/28-ollama-integration/28-01-SUMMARY.md`
</output>

View file

@ -0,0 +1,198 @@
---
phase: 28-ollama-integration
plan: 02
type: execute
wave: 2
depends_on: [28-01]
files_modified:
- ui/src/api/ollama.ts
- ui/src/adapters/hermes-local/config-fields.tsx
- ui/src/pages/AgentDetail.tsx
autonomous: true
requirements: [OLLA-02, OLLA-03, OLLA-05, HERM-05]
must_haves:
truths:
- "When Ollama is installed, the Hermes agent config shows a model dropdown listing all locally pulled models"
- "When an Ollama model is selected, adapterConfig saves model + provider:custom + base_url:http://localhost:11434/v1"
- "When Ollama is absent, the config shows an install callout with a link to https://ollama.com/download"
- "Recommended models are visually highlighted in the dropdown"
- "Hermes native skills show an 'Hermes skill' badge in the Skills tab"
- "Manual model entry still works as fallback when Ollama is absent"
artifacts:
- path: "ui/src/api/ollama.ts"
provides: "API client for Ollama status and model listing"
exports: ["ollamaApi"]
- path: "ui/src/adapters/hermes-local/config-fields.tsx"
provides: "Ollama model dropdown, install callout, manual fallback"
contains: "ollamaApi"
- path: "ui/src/pages/AgentDetail.tsx"
provides: "Hermes skill badge in AgentSkillsTab"
contains: "Hermes skill"
key_links:
- from: "ui/src/adapters/hermes-local/config-fields.tsx"
to: "ui/src/api/ollama.ts"
via: "useQuery + ollamaApi.status / ollamaApi.models"
pattern: "ollamaApi"
- from: "ui/src/adapters/hermes-local/config-fields.tsx"
to: "server/src/routes/ollama.ts"
via: "fetch /companies/:companyId/ollama/*"
pattern: "ollama"
---
<objective>
Create the UI surface for Ollama model selection in Hermes agent config and improve Hermes skill visibility.
Purpose: Users can discover, browse, and select local Ollama models when configuring a Hermes agent, with hardware-aware recommendations highlighted. When Ollama is absent, users see install instructions. Hermes native skills are clearly labeled in the Skills tab.
Output: Working model selector dropdown, install callout, and Hermes skill badges.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
@ui/src/adapters/hermes-local/config-fields.tsx
@ui/src/adapters/types.ts
@ui/src/api/client.ts
@ui/src/pages/AgentDetail.tsx (AgentSkillsTab around line 2362, unmanagedSkillRows around 2566)
<interfaces>
<!-- From Plan 01 server routes — endpoints the UI will call -->
GET /api/companies/:companyId/ollama/status
Response: { installed: boolean; version: string | null; installUrl: string }
GET /api/companies/:companyId/ollama/models
Response: { models: OllamaModel[]; ramGb: number }
Where OllamaModel = {
name: string; // e.g. "qwen2.5-coder:32b"
parameterSize: string; // e.g. "32.8B"
quantization: string; // e.g. "Q4_K_M"
sizeBytes: number;
family: string; // e.g. "qwen2"
recommended: boolean;
recommendationReason: string | null;
}
<!-- From ui/src/adapters/types.ts -->
AdapterConfigFieldsProps: { mode, isCreate, adapterType, values, set, config, eff, mark, models, hideInstructionsFile }
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ollamaApi client and enhance HermesLocalConfigFields with model dropdown</name>
<files>ui/src/api/ollama.ts, ui/src/adapters/hermes-local/config-fields.tsx</files>
<read_first>
- ui/src/api/client.ts (full file — for api.get pattern)
- ui/src/api/health.ts (for simple API client pattern)
- ui/src/adapters/hermes-local/config-fields.tsx (full file — current state)
- ui/src/adapters/types.ts (AdapterConfigFieldsProps interface)
- .planning/phases/28-ollama-integration/28-RESEARCH.md (Pattern 3, Pattern 4, Pitfall 1, Pitfall 4)
</read_first>
<action>
1. Create `ui/src/api/ollama.ts`:
- Import `api` from "./client" (the request helper)
- Define types: `OllamaStatus { installed: boolean; version: string | null; installUrl: string }`, `OllamaModel { name: string; parameterSize: string; quantization: string; sizeBytes: number; family: string; recommended: boolean; recommendationReason: string | null }`, `OllamaModelsResponse { models: OllamaModel[]; ramGb: number }`
- Export `ollamaApi` object with:
- `status(companyId: string): Promise<OllamaStatus>` — GET `/companies/${companyId}/ollama/status`
- `models(companyId: string): Promise<OllamaModelsResponse>` — GET `/companies/${companyId}/ollama/models`
2. Rewrite `ui/src/adapters/hermes-local/config-fields.tsx`:
- Add imports: `useQuery` from `@tanstack/react-query`, `ollamaApi` from `../../api/ollama`
- Need companyId: extract from URL params using `useParams` from react-router-dom, or accept via a context. Check existing config-fields patterns for how companyId is obtained — look at the component's parent to find how it gets companyId. If not available via props, use `useParams<{ companyId?: string }>()` matching the route pattern `/companies/:companyId/agents/...`.
- Add two queries (only enabled when companyId is truthy):
```
const { data: ollamaStatus } = useQuery({ queryKey: ["ollama", "status", companyId], queryFn: () => ollamaApi.status(companyId!), enabled: Boolean(companyId), staleTime: 60_000 })
const { data: ollamaModels } = useQuery({ queryKey: ["ollama", "models", companyId], queryFn: () => ollamaApi.models(companyId!), enabled: Boolean(companyId && ollamaStatus?.installed), staleTime: 60_000 })
```
- Replace the current free-text Model `<DraftInput>` with a hybrid control:
- **When ollamaStatus?.installed AND ollamaModels?.models.length > 0**: Render a `<select>` dropdown with an empty option ("Select a model..."), then each ollamaModels.models item as an `<option>`. Recommended models get a star prefix in label (e.g., "* qwen2.5-coder:7b (7B, Q4_K_M) - Recommended for your system"). Non-recommended models show plain label (e.g., "qwen2.5-coder:32b (32.8B, Q4_K_M)"). Add a final option "Other (manual entry)..." that switches to a text input.
- **When ollamaStatus?.installed === false**: Render an install callout div: "Ollama is not detected." with a link to ollamaStatus.installUrl ("Install Ollama"). Below the callout, keep the existing DraftInput for manual model entry (user may have a remote provider).
- **When ollamaStatus is loading or undefined**: Show the existing DraftInput as fallback.
- CRITICAL (Pitfall 1 + Pitfall 4): When an Ollama model is selected from the dropdown, set ALL THREE fields atomically:
- Create mode: `set!({ model: selectedModel, provider: "custom", base_url: "http://localhost:11434/v1" })`
- Edit mode: `mark("adapterConfig", "model", selectedModel)`, `mark("adapterConfig", "provider", "custom")`, `mark("adapterConfig", "base_url", "http://localhost:11434/v1")`
- When "Other (manual entry)" is selected or when using the fallback text input, do NOT set provider/base_url — user manages those fields themselves.
- Style the select with the existing `inputClass` CSS classes for consistency.
- Style the install callout: use `rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-sm` with an external link icon or similar.
</action>
<verify>
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "ollamaApi" ui/src/api/ollama.ts
- grep -q "status" ui/src/api/ollama.ts
- grep -q "models" ui/src/api/ollama.ts
- grep -q "ollamaApi" ui/src/adapters/hermes-local/config-fields.tsx
- grep -q "useQuery" ui/src/adapters/hermes-local/config-fields.tsx
- grep -q 'provider.*custom' ui/src/adapters/hermes-local/config-fields.tsx
- grep -q 'base_url.*localhost:11434' ui/src/adapters/hermes-local/config-fields.tsx
- grep -q "ollama.com/download" ui/src/adapters/hermes-local/config-fields.tsx
- grep -q "recommended" ui/src/adapters/hermes-local/config-fields.tsx
</acceptance_criteria>
<done>Hermes config shows Ollama model dropdown when Ollama is detected, install callout when absent, and manual fallback. Selecting an Ollama model sets provider:custom + base_url atomically. TypeScript compiles without errors.</done>
</task>
<task type="auto">
<name>Task 2: Add Hermes skill badge rendering in AgentSkillsTab</name>
<files>ui/src/pages/AgentDetail.tsx</files>
<read_first>
- ui/src/pages/AgentDetail.tsx (lines 2550-2600 for unmanagedSkillRows, lines 2950-2970 for originLabel rendering, lines 3050-3070 for unmanagedSkillRows section header)
</read_first>
<action>
Update the `AgentSkillsTab` component in `AgentDetail.tsx` to better distinguish Hermes native skills:
1. In the unmanagedSkillRows section header (around line 3063), change the label text to be conditional:
- If `agent.adapterType === "hermes_local"`: display `(${unmanagedSkillRows.length}) Hermes native skills & user-installed skills`
- Otherwise: keep existing text `(${unmanagedSkillRows.length}) User-installed skills, not managed by ${VOCAB.appName}`
2. In the `renderSkillRow` function (around line 2960-2961), where `skill.readOnly && skill.originLabel` renders a `<p>` with originLabel text:
- When `skill.originLabel === "Hermes skill"`, render a small inline badge instead of plain text:
`<span className="inline-flex items-center rounded-full bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-400">Hermes skill</span>`
- Keep the existing `<p>` rendering for other originLabel values unchanged.
3. The `unmanagedSkillRows` data already flows from the skill registry API which includes Hermes native skills with `originLabel: "Hermes skill"` and `readOnly: true`. No data changes needed.
</action>
<verify>
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- grep -q "Hermes skill" ui/src/pages/AgentDetail.tsx
- grep -q "hermes_local" ui/src/pages/AgentDetail.tsx
- grep -q "Hermes native" ui/src/pages/AgentDetail.tsx
- grep -q "purple" ui/src/pages/AgentDetail.tsx
</acceptance_criteria>
<done>Hermes native skills show a purple "Hermes skill" badge in the Skills tab. Section header indicates "Hermes native skills" when viewing a Hermes agent. TypeScript compiles without errors.</done>
</task>
</tasks>
<verification>
- `cd /opt/nexus/ui && npx tsc --noEmit` — no type errors
- Config-fields dropdown renders when Ollama is available (verified by code structure)
- Install callout renders when Ollama is absent (verified by code structure)
- Hermes skill badge uses distinct purple styling
</verification>
<success_criteria>
- ollamaApi client exists with status() and models() methods
- HermesLocalConfigFields shows model dropdown when Ollama detected, install callout when absent
- Selecting an Ollama model atomically sets model + provider:custom + base_url
- Manual model entry works as fallback
- Hermes native skills show "Hermes skill" badge in Skills tab
- All TypeScript compiles without errors
</success_criteria>
<output>
After completion, create `.planning/phases/28-ollama-integration/28-02-SUMMARY.md`
</output>

View file

@ -0,0 +1,258 @@
---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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)
<interfaces>
<!-- From heartbeat.ts updateRuntimeState signature -->
async function updateRuntimeState(
agent: typeof agents.$inferSelect,
run: typeof heartbeatRuns.$inferSelect,
result: AdapterExecutionResult,
session: { legacySessionId: string | null },
normalizedUsage?: UsageTotals | null,
): void
<!-- AdapterExecutionResult has: model?: string, usage?, costUsd?, resultJson? -->
<!-- stateJson patch shape for Hermes -->
{
hermesModel: string; // from result.model
hermesNativeSkillCount: number; // from skill registry query or UI-derived
hermesMemoryBytes: number | null; // from /api/ps size_vram
}
<!-- AgentOverview props -->
agent: AgentDetailRecord (has adapterType)
runtimeState?: AgentRuntimeState (has stateJson: Record<string, unknown>)
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Merge Hermes runtime data into stateJson after heartbeat</name>
<files>server/src/services/heartbeat.ts</files>
<read_first>
- 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)
</read_first>
<action>
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<number | null>`
- 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.
</action>
<verify>
<automated>cd /opt/nexus/server && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>After a Hermes heartbeat run, stateJson is updated with hermesModel and hermesMemoryBytes via jsonb merge. getOllamaMemoryUsage queries /api/ps gracefully. TypeScript compiles without errors.</done>
</task>
<task type="auto">
<name>Task 2: Create HermesRuntimeCard component in AgentOverview</name>
<files>ui/src/pages/AgentDetail.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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 (
<div className="rounded-lg border border-border p-4 space-y-3">
<h3 className="text-sm font-medium flex items-center gap-2">
Hermes Runtime
</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-muted-foreground">Model</p>
<p className="text-sm font-mono">{hermesModel ?? "Not set"}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Native Skills</p>
<p className="text-sm">{nativeSkillCount}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Memory (VRAM)</p>
<p className="text-sm">
{hermesMemoryBytes != null ? formatBytes(hermesMemoryBytes) : "Not loaded"}
</p>
</div>
</div>
</div>
);
}
```
2. In `AgentOverview` component (around line 1202, after the charts grid div), add:
```tsx
{agent.adapterType === "hermes_local" && runtimeState && (
<HermesRuntimeCard runtimeState={runtimeState} agentId={agentId} />
)}
```
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<string, unknown>` or similar. If it's typed more narrowly, cast as needed.
</action>
<verify>
<automated>cd /opt/nexus/ui && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
- `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)
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/28-ollama-integration/28-03-SUMMARY.md`
</output>