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
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
**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
### 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 |
|-------|-----------|----------------|--------|-----------|
| 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 | - |

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>