From 8cc6cdab06d321709301e6b74d375b7f0844224e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 13:04:33 +0000 Subject: [PATCH 1/3] fix: prune old log entries to stop unbounded admiral.db growth log_entries had no retention policy, only a manual per-profile clear route, so a long-running agent logs every LLM call/tool call/response forever (reported: 18GB db). Cap each profile to its most recent 20,000 entries, trimmed opportunistically every ~200 inserts. Closes #5 --- src/server/lib/db.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/server/lib/db.ts b/src/server/lib/db.ts index ebdc1ac..d2242a3 100644 --- a/src/server/lib/db.ts +++ b/src/server/lib/db.ts @@ -192,11 +192,29 @@ export function deleteProfile(id: string): void { // --- Log CRUD --- +// Every LLM call, tool call, and server response gets logged here with no +// other cap, so admiral.db grows unbounded over a long-running agent +// (reported: 18GB). Trim old rows past this cap, checked probabilistically +// on insert so we're not running a DELETE on every single log write. +const LOG_ENTRY_CAP_PER_PROFILE = 20_000 +const LOG_TRIM_CHECK_INTERVAL = 200 + export function addLogEntry(profileId: string, type: string, summary: string, detail?: string): number { - const result = getDb().query( + const db = getDb() + const result = db.query( 'INSERT INTO log_entries (profile_id, type, summary, detail) VALUES (?, ?, ?, ?)' ).run(profileId, type, summary, detail ?? null) - return Number(result.lastInsertRowid) + const id = Number(result.lastInsertRowid) + if (id % LOG_TRIM_CHECK_INTERVAL === 0) trimLogEntries(profileId) + return id +} + +function trimLogEntries(profileId: string): void { + getDb().query( + `DELETE FROM log_entries WHERE profile_id = ? AND id NOT IN ( + SELECT id FROM log_entries WHERE profile_id = ? ORDER BY id DESC LIMIT ? + )` + ).run(profileId, profileId, LOG_ENTRY_CAP_PER_PROFILE) } export function getLogEntries(profileId: string, afterId?: number, limit: number = 100): LogEntry[] { From 79b1680bafe15f4bb823a4f84aa734b97913b07a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 13:05:45 +0000 Subject: [PATCH 2/3] chore: bump @mariozechner/pi-ai to 0.73.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was pinned to "latest" but locked at 0.55.1 (18 minor versions behind) — model picker was missing every Claude model newer than opus-4-6/sonnet-4-6. Pin to a real caret range instead of "latest" so upgrades are deliberate. Also adds @opentelemetry/api as an explicit dependency: pi-ai 0.73.1 pulls in the Mistral SDK, which has an optional peer import on it that bun's --compile bundler resolves statically regardless of the "optional" peerDependenciesMeta flag, breaking the production build without it. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9b4fe9a..e176e14 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "./admiral" }, "dependencies": { - "@mariozechner/pi-ai": "latest", + "@mariozechner/pi-ai": "^0.73.1", + "@opentelemetry/api": "^1.9.1", "@tanstack/react-virtual": "^3.13.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", From 6569bf3479fe3b75df1a5b1acba52e34c0f90e04 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 13:09:12 +0000 Subject: [PATCH 3/3] chore: add @types/bun, fix tsc errors, add PR CI workflow The project had no @types/bun devDependency and no CI beyond the tag-triggered release build, so `tsc --noEmit` failed out of the box on a fresh clone (bun:sqlite/bun:test/ImportMeta.dir unresolved) and nothing caught type or build regressions on PRs. This bumping pi-ai just now silently broke `bun build --compile` (missing optional @opentelemetry/api peer dep) with no CI to catch it. - Add @types/bun so tsc resolves Bun's runtime APIs - Fix real type-safety gaps this uncovered: several `resp.json()` results flowing as unknown into typed code (http.ts, http_v2.ts, mcp.ts, schema.ts, routes/models.ts), a bogus type cast in agent-manager.ts's slimGameState, an untyped SQLite bindings spread in db.ts, and a nonexistent `comment` field on hono's SSEMessage (routes/logs.ts) that TS silently let through as an unused, no-op property - Add `typecheck`/`test` package.json scripts - Add .github/workflows/ci.yml: typecheck + test + build on every PR --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++ package.json | 5 ++++- src/server/lib/agent-manager.ts | 7 +++--- src/server/lib/connections/http.ts | 6 +++--- src/server/lib/connections/http_v2.ts | 6 +++--- src/server/lib/connections/mcp.ts | 2 +- src/server/lib/db.ts | 3 ++- src/server/lib/schema.ts | 2 +- src/server/routes/logs.ts | 2 +- src/server/routes/models.ts | 4 ++-- 10 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57ec9e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun test + + - name: Build + run: bun run build diff --git a/package.json b/package.json index e176e14..40209e8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "concurrently -n server,vite -c blue,magenta \"bun run src/server/index.ts\" \"cd src/frontend && bunx vite dev --port 3030\"", "build": "bun run scripts/build.ts", - "start": "./admiral" + "start": "./admiral", + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p src/frontend/tsconfig.json", + "test": "bun test" }, "dependencies": { "@mariozechner/pi-ai": "^0.73.1", @@ -25,6 +27,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.2.0", "@tailwindcss/vite": "^4.2.0", + "@types/bun": "^1.3.14", "@types/node": "^25.3.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/server/lib/agent-manager.ts b/src/server/lib/agent-manager.ts index ec8694c..0ebf787 100644 --- a/src/server/lib/agent-manager.ts +++ b/src/server/lib/agent-manager.ts @@ -22,10 +22,9 @@ type SlimGameState = { function slimGameState(raw: Record | null): SlimGameState { if (!raw) return null - const gs = raw as Record & { cargo?: unknown[]; current_ammo?: unknown; magazine_size?: unknown }> - const player = gs.player as Record | undefined - const ship = gs.ship as Record & { cargo?: unknown[] } | undefined - const modules = gs.modules as Array> | undefined + const player = raw.player as Record | undefined + const ship = raw.ship as (Record & { cargo?: unknown[] }) | undefined + const modules = raw.modules as Array & { current_ammo?: unknown; magazine_size?: unknown }> | undefined return { credits: player?.credits, system: player?.current_system, diff --git a/src/server/lib/connections/http.ts b/src/server/lib/connections/http.ts index a1a1cee..1da8c04 100644 --- a/src/server/lib/connections/http.ts +++ b/src/server/lib/connections/http.ts @@ -149,7 +149,7 @@ export class HttpConnection implements GameConnection { }) if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`) - const data = await resp.json() + const data = await resp.json() as { session?: ApiSession } if (data.session) { this.session = data.session } else { @@ -194,9 +194,9 @@ export class HttpConnection implements GameConnection { } try { - const data = await resp.json() + const data = await resp.json() as CommandResult & { session?: ApiSession } if (data.session) this.session = data.session - return data as CommandResult + return data } catch { return { error: { code: 'http_error', message: `HTTP ${resp.status}` } } } diff --git a/src/server/lib/connections/http_v2.ts b/src/server/lib/connections/http_v2.ts index ae9d002..d2578af 100644 --- a/src/server/lib/connections/http_v2.ts +++ b/src/server/lib/connections/http_v2.ts @@ -226,7 +226,7 @@ export class HttpV2Connection implements GameConnection { }) if (!resp.ok) throw new Error(`Failed to create session: ${resp.status}`) - const data = await resp.json() + const data = await resp.json() as { session?: ApiSession } if (data.session) { this.session = data.session } else { @@ -280,12 +280,12 @@ export class HttpV2Connection implements GameConnection { } try { - const data = await resp.json() + const data = await resp.json() as CommandResult & { session?: ApiSession } if (data.session) this.session = data.session // v2 returns { result: , structuredContent: } // Keep both: result (text) goes to the LLM, structuredContent is used // for cacheGameState and player data display. - return data as CommandResult + return data } catch { return { error: { code: 'http_error', message: `HTTP ${resp.status}` } } } diff --git a/src/server/lib/connections/mcp.ts b/src/server/lib/connections/mcp.ts index 8314208..3272793 100644 --- a/src/server/lib/connections/mcp.ts +++ b/src/server/lib/connections/mcp.ts @@ -160,7 +160,7 @@ export class McpConnection implements GameConnection { return { error: { message: 'No matching response in SSE stream' } } } - return await resp.json() + return await resp.json() as { result?: unknown; error?: { code?: number; message: string } } } private async sendNotification(method: string, params: unknown): Promise { diff --git a/src/server/lib/db.ts b/src/server/lib/db.ts index d2242a3..5af930d 100644 --- a/src/server/lib/db.ts +++ b/src/server/lib/db.ts @@ -1,4 +1,5 @@ import { Database } from 'bun:sqlite' +import type { SQLQueryBindings } from 'bun:sqlite' import path from 'path' import fs from 'fs' import type { Provider, Profile, LogEntry } from '../../shared/types' @@ -182,7 +183,7 @@ export function updateProfile(id: string, updates: Partial): Profile | sets.push("updated_at = datetime('now')") vals.push(id) - getDb().query(`UPDATE profiles SET ${sets.join(', ')} WHERE id = ?`).run(...vals) + getDb().query(`UPDATE profiles SET ${sets.join(', ')} WHERE id = ?`).run(...(vals as SQLQueryBindings[])) return getProfile(id) } diff --git a/src/server/lib/schema.ts b/src/server/lib/schema.ts index 466e198..0ccadef 100644 --- a/src/server/lib/schema.ts +++ b/src/server/lib/schema.ts @@ -42,7 +42,7 @@ export async function fetchOpenApiSpec( } throw new Error(`HTTP ${resp.status}`) } - const spec = await resp.json() + const spec = await resp.json() as Record // Cache on success try { setPreference(cacheKey, JSON.stringify(spec)) diff --git a/src/server/routes/logs.ts b/src/server/routes/logs.ts index 12473a3..9bdde2a 100644 --- a/src/server/routes/logs.ts +++ b/src/server/routes/logs.ts @@ -58,7 +58,7 @@ logs.get('/:id/logs', async (c) => { // Heartbeat - must be well under the Bun.serve idleTimeout (120s) const heartbeat = setInterval(() => { if (closed) { clearInterval(heartbeat); return } - stream.writeSSE({ data: '', comment: 'heartbeat' }).catch(() => { closed = true }) + stream.writeSSE({ data: '' }).catch(() => { closed = true }) }, 30000) // Keep the stream open until client disconnects diff --git a/src/server/routes/models.ts b/src/server/routes/models.ts index 53ed7ff..249d1a4 100644 --- a/src/server/routes/models.ts +++ b/src/server/routes/models.ts @@ -90,7 +90,7 @@ async function fetchOllamaModels(baseUrl?: string): Promise { try { const resp = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(5000) }) if (!resp.ok) return [] - const data = await resp.json() + const data = await resp.json() as Record const modelList = data.models as { name: string }[] | undefined return (modelList || []).map(m => m.name).sort() } catch { @@ -105,7 +105,7 @@ async function fetchOpenAICompatModels(apiUrl: string, apiKey?: string): Promise const resp = await fetch(apiUrl, { headers, signal: AbortSignal.timeout(10000) }) if (!resp.ok) return [] - const data = await resp.json() + const data = await resp.json() as Record const modelList = data.data as { id: string }[] | undefined return (modelList || []).map(m => m.id).sort() } catch {