diff --git a/CLAUDE.md b/CLAUDE.md index def8b204..d50a9ffe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## What is this -vibe-replay turns AI coding sessions into animated, interactive web replays as self-contained HTML files. Supports Claude Code, Cursor, and Codex. +vibe-replay turns AI coding sessions into animated, interactive web replays as self-contained HTML files. Supports Claude Code, Cursor, Codex, OpenCode, and Hermes. pnpm monorepo: `packages/cli` (npm: `vibe-replay`), `packages/viewer` (React → single HTML), `packages/types` (shared types), `website/` (Astro), `cloudflare/` (Workers API). @@ -55,7 +55,8 @@ pnpm db:migrate:remote # Apply to production D1 (requires auth) - **Multi-file sessions**: Claude Code `/resume` creates new JSONL files. Parser accepts `string | string[]` and merges by slug+project. - **Cursor tri-source**: Sessions come from SQLite `store.db` (primary), `globalStorage/state.vscdb`, or JSONL (fallback). Discovery merges all sources. DB data is source of truth; JSONL supplements missing thinking/images. - **Cursor SDK**: SDK agents (TypeScript `@cursor/sdk`) write to `~/.cursor/projects//sdk-agent-store//index.db` (tables: `agents`, `runs`, `run_events`) and a parallel JSONL transcript at `agent-transcripts//.jsonl`. The transcript is the source of user prompts (SDK doesn't store them in events) and the SDK index.db supplies tool *results*, structured per-run timing, and per-turn model. See `packages/provider-cursor/src/cursor/sdk-reader.ts`. Detection is by sessionId prefix `agent-` — IDE chat sessions (UUID-only) skip the SDK SQLite probe. -- **opencode**: Sessions live in a SQLite DB at `~/.local/share/opencode/opencode.db` (`%LOCALAPPDATA%\opencode` on Windows, `OPENCODE_DATA` env var wins). Tables: `session`/`message`/`part`; role/user and tool payloads are JSON in `message.data`/`part.data`. Discovery writes `#session:` marker paths. Parse is SQLite-backed (`parseSessionFromDb`), so background scan uses a lightweight path from discovery-computed stats (`buildLightweightOpencodeScanResult` in `scanner.ts`) to avoid opening the DB per session. `sql.js` named-param binds need their `:`-prefix in the key — this provider uses positional `?` params instead. See `packages/provider-opencode/`. +- **opencode**: Sessions live in a SQLite DB at `~/.local/share/opencode/opencode.db` (`%LOCALAPPDATA%\\opencode` on Windows, `OPENCODE_DATA` env var wins). Tables: `session`/`message`/`part`; role/user and tool payloads are JSON in `message.data`/`part.data`. Discovery writes `#session:` marker paths. Parse is SQLite-backed (`parseSessionFromDb`), so background scan uses a lightweight path from discovery-computed stats (`buildLightweightOpencodeScanResult` in `scanner.ts`) to avoid opening the DB per session. `sql.js` named-param binds need their `:`-prefix in the key — this provider uses positional `?` params instead. See `packages/provider-opencode/`. +- **Hermes**: Sessions live in SQLite at `~/.hermes/state.db` (FTS5-backed). Tables: `sessions` (token/cost/git columns maintained by Hermes) + `messages` (OpenAI-style: assistant rows carry `tool_calls` JSON + `reasoning`; tool rows carry `tool_name` + `tool_call_id` + result content). Discovery writes `#session:` marker paths and reads `~/.hermes/.update_check` for the version. Context compaction is recorded two ways: `compacted=1` rows (pre-compaction history, kept in full) and a user row prefixed `[CONTEXT COMPACTION` (→ `subtype: "compaction-summary"`, mirroring claude-code). Parse is SQLite-backed, so background scan uses `buildLightweightHermesScanResult` in `scanner.ts`. Tool names map to the viewer vocabulary in `tool-mapping.ts`. See `packages/provider-hermes/`. - **Skip `progress` lines**: These are subagent streaming artifacts in JSONL. - **sql.js (WASM)**: Used instead of native SQLite bindings for portability — no C++ compiler needed. - **Session discovery cache**: CLI picker + local dashboard use file cache at `~/.vibe-replay/cache/*.json` (stale-while-refresh UX). Cache validity is tied to CLI release version (`CLI_VERSION`) plus envelope version, so caches auto-invalidate across releases. Keep cache writes best-effort and never block generation/parsing on cache failures. @@ -103,6 +104,7 @@ If tag/release is updated but `packages/cli/package.json` is not, CLI will still | Provider interface | `packages/provider-contract/src/index.ts` (contract) / `packages/providers-default/src/index.ts` (registry) | | Cursor SDK reader | `packages/provider-cursor/src/cursor/sdk-reader.ts` | | opencode provider | `packages/provider-opencode/src/opencode/` | +| Hermes provider | `packages/provider-hermes/src/hermes/` | | Viewer entry | `packages/viewer/src/App.tsx` | | Playback engine (pure) | `packages/viewer/src/engine/` | | Playback hook | `packages/viewer/src/hooks/usePlayback.ts` | diff --git a/package.json b/package.json index ec790d72..24475fb7 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:e2e": "vitest run --config e2e/vitest.config.ts", "lint": "oxlint --fix packages/ website/src cloudflare/src && oxfmt packages/ website/src cloudflare/src", "lint:check": "oxlint packages/ website/src cloudflare/src && oxfmt --check packages/ website/src cloudflare/src", - "typecheck": "tsc --noEmit -p packages/types/tsconfig.json && tsc --noEmit -p packages/provider-contract/tsconfig.json && tsc --noEmit -p packages/provider-core/tsconfig.json && tsc --noEmit -p packages/provider-claude-code/tsconfig.json && tsc --noEmit -p packages/provider-codex/tsconfig.json && tsc --noEmit -p packages/provider-cursor/tsconfig.json && tsc --noEmit -p packages/provider-opencode/tsconfig.json && tsc --noEmit -p packages/provider-pi/tsconfig.json && tsc --noEmit -p packages/providers-default/tsconfig.json && tsc --noEmit -p packages/replay-core/tsconfig.json && tsc --noEmit -p packages/cli/tsconfig.json && tsc --noEmit -p packages/viewer/tsconfig.json && tsc --noEmit -p cloudflare/tsconfig.json && pnpm --filter @vibe-replay/website check", + "typecheck": "tsc --noEmit -p packages/types/tsconfig.json && tsc --noEmit -p packages/provider-contract/tsconfig.json && tsc --noEmit -p packages/provider-core/tsconfig.json && tsc --noEmit -p packages/provider-claude-code/tsconfig.json && tsc --noEmit -p packages/provider-codex/tsconfig.json && tsc --noEmit -p packages/provider-cursor/tsconfig.json && tsc --noEmit -p packages/provider-hermes/tsconfig.json && tsc --noEmit -p packages/provider-opencode/tsconfig.json && tsc --noEmit -p packages/provider-pi/tsconfig.json && tsc --noEmit -p packages/providers-default/tsconfig.json && tsc --noEmit -p packages/replay-core/tsconfig.json && tsc --noEmit -p packages/cli/tsconfig.json && tsc --noEmit -p packages/viewer/tsconfig.json && tsc --noEmit -p cloudflare/tsconfig.json && pnpm --filter @vibe-replay/website check", "fmt": "oxfmt packages/ website/src cloudflare/src", "prepare": "lefthook install" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index aa8a36f8..1047e6ac 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,7 @@ "@vibe-replay/provider-contract": "workspace:*", "@vibe-replay/provider-core": "workspace:*", "@vibe-replay/provider-cursor": "workspace:*", + "@vibe-replay/provider-hermes": "workspace:*", "@vibe-replay/provider-opencode": "workspace:*", "@vibe-replay/provider-pi": "workspace:*", "@vibe-replay/providers-default": "workspace:*", diff --git a/packages/cli/src/feedback.ts b/packages/cli/src/feedback.ts index 3d96a614..b434471f 100644 --- a/packages/cli/src/feedback.ts +++ b/packages/cli/src/feedback.ts @@ -37,7 +37,7 @@ function spawnTool(command: string, args: string[], options: SpawnOptions): Pipe // --------------------------------------------------------------------------- export interface FeedbackTool { - name: "claude" | "agent" | "opencode"; + name: "claude" | "agent" | "opencode" | "hermes"; command: string; } @@ -81,7 +81,7 @@ export interface FeedbackResult { // Detection // --------------------------------------------------------------------------- -const TOOL_PRIORITY: FeedbackTool["name"][] = ["claude", "agent", "opencode"]; +const TOOL_PRIORITY: FeedbackTool["name"][] = ["claude", "agent", "opencode", "hermes"]; /** Detect available AI CLI tools and pick a default by priority. */ export async function detectFeedbackTools(): Promise<{ @@ -95,6 +95,7 @@ export async function detectFeedbackTools(): Promise<{ ...(!insideClaude ? [{ name: "claude" as const, cmd: "claude" }] : []), { name: "agent" as const, cmd: "agent" }, { name: "opencode" as const, cmd: "opencode" }, + { name: "hermes" as const, cmd: "hermes" }, ]; const tools: FeedbackTool[] = []; @@ -371,6 +372,9 @@ async function executeFeedback(prompt: string, tool: FeedbackTool): Promise { }); } +function runHermes(prompt: string, cmd: string): Promise { + return new Promise((resolve, reject) => { + // `hermes chat -q` runs a single query non-interactively; `-Q` (quiet) + // suppresses the banner/spinner so stdout is the final response only. + const proc = spawnTool(cmd, ["chat", "-q", prompt, "-Q", "--no-restore-cwd"], { + env: { ...process.env, NO_COLOR: "1", TERM: "dumb" }, + timeout: 600_000, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + + proc.on("close", (code) => { + if (code === 0) { + resolve(stripAnsi(stdout).trim()); + } else { + reject(new Error(`hermes exited ${code}: ${stripAnsi(stderr).slice(0, 500)}`)); + } + }); + + proc.on("error", (err) => reject(new Error(`Failed to start hermes: ${err.message}`))); + }); +} + function runAgent(prompt: string, cmd: string): Promise { return new Promise((resolve, reject) => { const proc = spawnTool(cmd, ["-p", "--output-format", "json", "--mode", "ask", "--trust"], { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7ef4ffe2..83b9ce76 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -197,9 +197,11 @@ function formatProviderBadge(provider: string): string { ? chalk.hex("#B392F0")("codex") : provider === "opencode" ? chalk.hex("#22C55E")("opencode") - : provider === "pi" - ? chalk.hex("#14B8A6")("pi") - : chalk.yellow(provider); + : provider === "hermes" + ? chalk.hex("#F85149")("hermes") + : provider === "pi" + ? chalk.hex("#14B8A6")("pi") + : chalk.yellow(provider); } function printParseWarnings(warnings?: ParseWarning[]): void { diff --git a/packages/cli/src/scanner.ts b/packages/cli/src/scanner.ts index 28bb76a0..b1fe1729 100644 --- a/packages/cli/src/scanner.ts +++ b/packages/cli/src/scanner.ts @@ -375,6 +375,9 @@ export async function scanSession(input: ScanInput): Promise if (input.provider === "opencode") { return buildLightweightOpencodeScanResult(input); } + if (input.provider === "hermes") { + return buildLightweightHermesScanResult(input); + } let startTime: string | undefined; let endTime: string | undefined; @@ -831,6 +834,34 @@ function buildLightweightOpencodeScanResult(input: ScanInput): SessionScanResult }; } +function buildLightweightHermesScanResult(input: ScanInput): SessionScanResult { + const firstPrompt = input.firstPrompt || input.title; + return { + sessionId: input.sessionId, + provider: input.provider, + project: input.project, + slug: input.slug, + title: input.title, + firstPrompt, + startTime: input.timestamp, + promptCount: input.discoveryPromptCount ?? (firstPrompt ? 1 : 0), + toolCallCount: input.discoveryToolCallCount ?? 0, + editCount: input.discoveryEditCount ?? 0, + filesModified: [], + model: input.discoveryModel, + durationMs: input.discoveryDurationMs, + tokenUsage: input.discoveryTokenUsage, + costEstimate: input.discoveryCostEstimate, + subAgentCount: 0, + apiErrorCount: 0, + compactionCount: 0, + dataSource: "sqlite", + dataQualityNotes: [ + "Hermes details are read from its SQLite database (~/.hermes/state.db); rich per-file edit counts are resolved when a replay is generated.", + ], + }; +} + function buildScanResultFromParsed( input: ScanInput, parsed: ProviderParseResult, diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index eedaf06d..2a0690a8 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -2415,6 +2415,8 @@ export async function startServer( return auth.stdout.includes("0 credentials") ? "no credentials" : "configured"; }, ), + hermes: () => + checkCli("hermes", "Hermes", "AI feedback via headless mode", "hermes", ["--version"]), }; const requestedTool = c.req.query("tool"); @@ -2711,7 +2713,10 @@ export async function startServer( const requestedToolName = typeof body.toolName === "string" ? body.toolName : undefined; const detected = await detectFeedbackTools(); if (detected.tools.length === 0) { - return c.json({ error: "No AI CLI tool available (claude, agent, or opencode)" }, 400); + return c.json( + { error: "No AI CLI tool available (claude, agent, opencode, or hermes)" }, + 400, + ); } const tool = requestedToolName ? detected.tools.find((t) => t.name === requestedToolName) || null @@ -2779,7 +2784,10 @@ export async function startServer( .catch(() => ({})); const detected = await detectFeedbackTools(); if (detected.tools.length === 0) { - return c.json({ error: "No AI CLI tool available (claude, agent, or opencode)" }, 400); + return c.json( + { error: "No AI CLI tool available (claude, agent, opencode, or hermes)" }, + 400, + ); } const toolName = typeof body.toolName === "string" ? body.toolName : undefined; const tool = toolName @@ -2835,7 +2843,10 @@ export async function startServer( .catch(() => ({})); const detected = await detectFeedbackTools(); if (detected.tools.length === 0) { - return c.json({ error: "No AI CLI tool available (claude, agent, or opencode)" }, 400); + return c.json( + { error: "No AI CLI tool available (claude, agent, opencode, or hermes)" }, + 400, + ); } const toolName = typeof body.toolName === "string" ? body.toolName : undefined; const tool = toolName diff --git a/packages/cli/test/provider-contract-package.test.ts b/packages/cli/test/provider-contract-package.test.ts index 64bc9afe..bba77937 100644 --- a/packages/cli/test/provider-contract-package.test.ts +++ b/packages/cli/test/provider-contract-package.test.ts @@ -33,6 +33,7 @@ describe("provider contract package boundary", () => { "claude-code", "codex", "cursor", + "hermes", "opencode", "pi", ]); diff --git a/packages/provider-hermes/package.json b/packages/provider-hermes/package.json new file mode 100644 index 00000000..eb66fdbf --- /dev/null +++ b/packages/provider-hermes/package.json @@ -0,0 +1,50 @@ +{ + "name": "@vibe-replay/provider-hermes", + "version": "0.0.1", + "private": true, + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "exports": { + ".": { + "types": "./src/hermes/index.ts", + "import": "./src/hermes/index.ts" + }, + "./config": { + "types": "./src/hermes/config.ts", + "import": "./src/hermes/config.ts" + }, + "./discover": { + "types": "./src/hermes/discover.ts", + "import": "./src/hermes/discover.ts" + }, + "./parser": { + "types": "./src/hermes/parser.ts", + "import": "./src/hermes/parser.ts" + }, + "./sqlite": { + "types": "./src/hermes/sqlite.ts", + "import": "./src/hermes/sqlite.ts" + }, + "./tool-mapping": { + "types": "./src/hermes/tool-mapping.ts", + "import": "./src/hermes/tool-mapping.ts" + } + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@vibe-replay/provider-contract": "workspace:*", + "@vibe-replay/provider-core": "workspace:*", + "@vibe-replay/types": "workspace:*", + "sql.js": "^1.14.1" + }, + "devDependencies": { + "@types/node": "^25.9.3", + "@vibe-replay/replay-core": "workspace:*", + "vitest": "^4.1.9" + } +} diff --git a/packages/provider-hermes/src/hermes/config.ts b/packages/provider-hermes/src/hermes/config.ts new file mode 100644 index 00000000..f975eb9c --- /dev/null +++ b/packages/provider-hermes/src/hermes/config.ts @@ -0,0 +1,3 @@ +import { hermesDataDir, hermesDbPath } from "./sqlite.js"; + +export { hermesDataDir, hermesDbPath }; diff --git a/packages/provider-hermes/src/hermes/discover.ts b/packages/provider-hermes/src/hermes/discover.ts new file mode 100644 index 00000000..070e5d3c --- /dev/null +++ b/packages/provider-hermes/src/hermes/discover.ts @@ -0,0 +1,257 @@ +/// +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Database } from "sql.js"; +import { cleanPromptText } from "@vibe-replay/provider-core/clean-prompt"; +import type { SessionInfo } from "@vibe-replay/provider-contract"; +import { shortenPath } from "@vibe-replay/provider-core/utils"; +import { hermesDataDir, hermesDbPath, openHermesDb } from "./sqlite.js"; + +export const HERMES_PROVIDER = "hermes"; + +export interface HermesSessionRow { + id: string; + source: string | null; + title: string | null; + cwd: string | null; + model: string | null; + started_at: number; + ended_at: number | null; + last_activity_at: number | null; + message_count: number | null; + tool_call_count: number | null; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; + cache_write_tokens: number | null; + reasoning_tokens: number | null; + estimated_cost_usd: number | null; + git_branch: string | null; + git_repo_root: string | null; + parent_session_id: string | null; + profile_name: string | null; + pinned: number | null; + end_reason: string | null; +} + +function rowValues(db: Database, sql: string, params: Record = {}): any[] { + const stmt = db.prepare(sql); + try { + stmt.bind(Object.values(params)); + const rows: any[] = []; + while (stmt.step()) rows.push(stmt.getAsObject()); + return rows; + } finally { + stmt.free(); + } +} + +export async function discoverHermesSessions(): Promise { + const opened = await openHermesDb(); + if (!opened) return []; + const { db } = opened; + try { + return listSessionsFromDb(db); + } finally { + db.close(); + } +} + +export function listSessionsFromDb(db: Database): SessionInfo[] { + const rows = rowValues( + db, + ` + SELECT id, source, title, cwd, model, started_at, ended_at, last_activity_at, + message_count, tool_call_count, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, + estimated_cost_usd, git_branch, git_repo_root, parent_session_id, + profile_name, pinned, end_reason + FROM sessions + ORDER BY COALESCE(last_activity_at, ended_at, started_at) DESC + `, + ) as HermesSessionRow[]; + + // Aggregate stats are computed with a handful of GROUP BY queries (one per + // metric) instead of per-session round trips, so discovery scales with the + // number of distinct queries rather than the number of sessions. + const statsBySession = buildSessionStats(db, rows); + + const version = hermesVersion(); + const sessions: SessionInfo[] = []; + for (const row of rows) { + const stats = statsBySession.get(row.id); + if (!stats) continue; + const info = sessionInfoFromRow(row, stats, version); + if (info) sessions.push(info); + } + return sessions; +} + +interface SessionStats { + messageCount: number; + promptCount: number; + toolCallCount: number; + editCountEst: number; + firstPrompt: string; +} + +function buildSessionStats(db: Database, rows: HermesSessionRow[]): Map { + // message/tool counts come straight from the sessions row columns that + // Hermes maintains; prompts and edits need message-level scans. + const promptCounts = countBySession( + db, + ` + SELECT session_id, count(*) AS c + FROM messages + WHERE role = 'user' + AND content IS NOT NULL + AND length(trim(content)) > 0 + GROUP BY session_id + `, + ); + const editToolCounts = countBySession( + db, + ` + SELECT session_id, count(*) AS c + FROM messages + WHERE role = 'assistant' + AND ( + tool_calls LIKE '%"name":"patch"%' + OR tool_calls LIKE '%"name": "patch"%' + OR tool_calls LIKE '%"name":"write_file"%' + OR tool_calls LIKE '%"name": "write_file"%' + ) + GROUP BY session_id + `, + ); + const firstPrompts = firstUserPrompts(db); + + const map = new Map(); + for (const row of rows) { + if (!row.id) continue; + map.set(row.id, { + messageCount: Number(row.message_count ?? 0), + promptCount: promptCounts.get(row.id) ?? 0, + toolCallCount: Number(row.tool_call_count ?? 0), + editCountEst: editToolCounts.get(row.id) ?? 0, + firstPrompt: firstPrompts.get(row.id) ?? "", + }); + } + return map; +} + +/** Map session_id → aggregate count for a `GROUP BY session_id` query. */ +function countBySession(db: Database, sql: string): Map { + const map = new Map(); + for (const row of rowValues(db, sql)) { + const id = String(row.session_id ?? ""); + if (!id) continue; + map.set(id, Number(row.c ?? 0)); + } + return map; +} + +/** Map session_id → the first non-empty user prompt (earliest by rowid). */ +function firstUserPrompts(db: Database): Map { + const map = new Map(); + const rows = rowValues( + db, + ` + SELECT m.session_id, m.content + FROM messages m + WHERE m.id IN ( + SELECT MIN(id) FROM messages + WHERE role = 'user' + AND content IS NOT NULL + AND length(trim(content)) > 0 + GROUP BY session_id + ) + `, + ); + for (const row of rows) { + const sessionId = String(row.session_id ?? ""); + if (!sessionId || map.has(sessionId)) continue; + map.set(sessionId, String(row.content ?? "")); + } + return map; +} + +function sessionInfoFromRow( + row: HermesSessionRow, + stats: SessionStats, + version: string, +): SessionInfo | null { + if (!row.id) return null; + + const firstPrompt = cleanPromptText(stats.firstPrompt); + if (!firstPrompt) return null; + + const dbPath = hermesDbPath(); + const markerPath = `${dbPath}#session:${row.id}`; + const lastActivity = row.last_activity_at ?? row.ended_at ?? row.started_at; + + return { + provider: HERMES_PROVIDER, + sessionId: row.id, + slug: row.id, + title: row.title || undefined, + project: shortenPath(row.cwd || ""), + cwd: row.cwd || "", + version, + gitBranch: row.git_branch || undefined, + gitRepo: undefined, + timestamp: toIsoMs(lastActivity) || new Date().toISOString(), + lineCount: stats.messageCount, + fileSize: 0, + filePath: markerPath, + filePaths: [markerPath], + hasSqlite: true, + firstPrompt, + prompts: [firstPrompt], + promptCount: stats.promptCount, + toolCallCount: stats.toolCallCount, + model: row.model || undefined, + durationMsEst: + row.started_at > 0 && lastActivity > row.started_at + ? toMillis(lastActivity) - toMillis(row.started_at) + : undefined, + editCountEst: stats.editCountEst, + isStarred: row.pinned === 1, + }; +} + +let cachedVersion: string | undefined; + +/** + * Hermes version, read from the update-check stamp Hermes itself writes + * (`~/.hermes/.update_check` → `{"ver": "0.20.0", ...}`). Cached after the + * first read; empty string when the file is missing or unparseable. + */ +export function hermesVersion(): string { + if (cachedVersion !== undefined) return cachedVersion; + try { + const raw = readFileSync(join(hermesDataDir(), ".update_check"), "utf8"); + const parsed = JSON.parse(raw) as { ver?: unknown }; + cachedVersion = typeof parsed.ver === "string" ? parsed.ver : ""; + } catch { + cachedVersion = ""; + } + return cachedVersion; +} + +/** + * Hermes timestamps are Unix seconds in current builds; older or future + * builds may store milliseconds. Normalize both to milliseconds so callers + * (ISO timestamps and duration math) agree on units. + */ +function toMillis(value: number): number { + return value < 1_577_836_800_000 ? value * 1000 : value; +} + +function toIsoMs(value?: number): string | undefined { + if (!value || value <= 0) return undefined; + const d = new Date(toMillis(value)); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +export { hermesDataDir }; diff --git a/packages/provider-hermes/src/hermes/index.ts b/packages/provider-hermes/src/hermes/index.ts new file mode 100644 index 00000000..c84dc2ba --- /dev/null +++ b/packages/provider-hermes/src/hermes/index.ts @@ -0,0 +1,10 @@ +import type { Provider } from "@vibe-replay/provider-contract"; +import { discoverHermesSessions } from "./discover.js"; +import { parseHermesSession } from "./parser.js"; + +export const hermesProvider: Provider = { + name: "hermes", + displayName: "Hermes", + discover: discoverHermesSessions, + parse: parseHermesSession, +}; diff --git a/packages/provider-hermes/src/hermes/parser.ts b/packages/provider-hermes/src/hermes/parser.ts new file mode 100644 index 00000000..2c88ec05 --- /dev/null +++ b/packages/provider-hermes/src/hermes/parser.ts @@ -0,0 +1,378 @@ +/// +import type { Database } from "sql.js"; +import { estimateActiveDuration } from "@vibe-replay/provider-core/duration"; +import type { ContentBlock, ParsedTurn, SessionInfo } from "@vibe-replay/provider-contract"; +import type { + Compaction, + DataSourceInfo, + ProviderParseResult, + TokenUsage, +} from "@vibe-replay/provider-contract"; +import { addParseWarning, compactWarningSample } from "@vibe-replay/provider-contract/warnings"; +import { openHermesDb, hermesDataDir, hermesDbPath } from "./sqlite.js"; +import { mapHermesToolArgs, mapHermesToolName } from "./tool-mapping.js"; + +interface HermesMessageRow { + id: number; + role: string; + content: string | null; + tool_call_id: string | null; + tool_calls: string | null; + tool_name: string | null; + timestamp: number; + finish_reason: string | null; + reasoning_content: string | null; + compacted: number | null; +} + +interface HermesSessionRow { + id: string; + title: string | null; + cwd: string | null; + model: string | null; + started_at: number; + ended_at: number | null; + last_activity_at: number | null; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; + cache_write_tokens: number | null; + reasoning_tokens: number | null; + git_branch: string | null; + git_repo_root: string | null; + end_reason: string | null; +} + +interface HermesModelUsageRow { + model: string; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + reasoning_tokens: number; +} + +interface HermesToolCall { + id?: string; + call_id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; +} + +function rowValues(db: Database, sql: string, params: Record = {}): any[] { + const stmt = db.prepare(sql); + try { + stmt.bind(Object.values(params)); + const rows: any[] = []; + while (stmt.step()) rows.push(stmt.getAsObject()); + return rows; + } finally { + stmt.free(); + } +} + +function firstValue(db: Database, sql: string, params: Record = {}): any { + const rows = rowValues(db, sql, params); + return rows[0] ?? null; +} + +export async function parseHermesSession( + filePaths: string | string[], + sessionInfo?: SessionInfo, +): Promise { + const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; + const sessionId = resolveSessionId(paths, sessionInfo); + if (!sessionId) { + throw new Error( + "hermes parse requires a session id (pass a Hermes session path like `~/.hermes/state.db#session:` or a `--session ` id)", + ); + } + + const opened = await openHermesDb(); + if (!opened) { + throw new Error(`Hermes database not found at ${hermesDbPath()}`); + } + const { db } = opened; + try { + return parseSessionFromDb(db, sessionId, sessionInfo); + } finally { + db.close(); + } +} + +/** + * Extract the Hermes session id from the filePath markers written by + * discovery (`#session:`), or from sessionInfo, or from a raw + * Hermes session id passed directly on the CLI. + */ +export function resolveSessionId(paths: string[], sessionInfo?: SessionInfo): string | undefined { + if (sessionInfo?.sessionId) return sessionInfo.sessionId; + for (const path of paths) { + const marker = "#session:"; + const idx = path.indexOf(marker); + if (idx >= 0) { + const id = path + .slice(idx + marker.length) + .split(/[/?#]/, 1)[0] + ?.trim(); + if (id) return id; + } + if (/^\d{8}_\d{6}_/.test(path) || path.startsWith("session_")) return path; + } + return undefined; +} + +export function parseSessionFromDb( + db: Database, + sessionId: string, + sessionInfo?: SessionInfo, +): ProviderParseResult { + const session = firstValue(db, `SELECT * FROM sessions WHERE id = ?`, { + sid: sessionId, + }) as HermesSessionRow | null; + + const messages = rowValues( + db, + ` + SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, + finish_reason, reasoning_content, compacted + FROM messages + WHERE session_id = ? + ORDER BY timestamp ASC, id ASC + `, + { sid: sessionId }, + ) as HermesMessageRow[]; + + const turns: ParsedTurn[] = []; + const allTimestamps: string[] = []; + const compactions: Compaction[] = []; + const skillsUsed = new Set(); + let totalTokens: TokenUsage | undefined; + let startTime: string | undefined; + let endTime: string | undefined; + const cwd = session?.cwd || sessionInfo?.cwd || ""; + const model = sessionInfo?.model || session?.model || undefined; + const parseWarnings: NonNullable = []; + let truncatedResponses = 0; + + // Track assistant tool_use blocks awaiting their role='tool' result, keyed + // by call id so parallel tool calls each resolve to the right block. + const pendingResults = new Map>(); + + for (const message of messages) { + const timestamp = toIsoMs(message.timestamp); + if (timestamp) { + allTimestamps.push(timestamp); + if (!startTime) startTime = timestamp; + endTime = timestamp; + } + + if (message.role === "user") { + const text = (message.content || "").trim(); + if (!text) continue; // skip empty/injected user rows + // Hermes records context compaction as a user row whose content is the + // generated summary, prefixed with a fixed marker. Mirror claude-code's + // isCompactSummary handling so the viewer renders a compaction scene. + const isCompaction = text.startsWith("[CONTEXT COMPACTION"); + turns.push({ + role: "user", + ...(isCompaction ? { subtype: "compaction-summary" as const } : {}), + timestamp, + blocks: [{ type: "text", text }], + }); + continue; + } + + if (message.role === "assistant") { + const blocks: ContentBlock[] = []; + const thinking = (message.reasoning_content || "").trim(); + if (thinking) blocks.push({ type: "thinking", thinking }); + const text = (message.content || "").trim(); + if (text) blocks.push({ type: "text", text }); + + const toolCalls = parseToolCalls(message.tool_calls, message.id, parseWarnings); + for (const call of toolCalls) { + const rawName = call.function?.name || ""; + const input = parseArguments(call.function?.arguments, message.id, parseWarnings); + const name = mapHermesToolName(rawName); + const mappedInput = mapHermesToolArgs(rawName, input); + const block: Extract = { + type: "tool_use", + id: call.call_id || call.id || `hermes-${rawName}-${message.id}`, + name, + input: mappedInput, + }; + blocks.push(block); + pendingResults.set(block.id, block); + + if (rawName === "skill_view") { + const skillName = typeof mappedInput.name === "string" ? mappedInput.name : ""; + if (skillName) skillsUsed.add(skillName); + } + } + + // Count truncation even when the row carried no renderable blocks — a + // response cut off by max_tokens before any content is stored is still a + // truncated response. + if (message.finish_reason === "max_tokens") truncatedResponses++; + if (blocks.length === 0) continue; + turns.push({ + role: "assistant", + timestamp, + blocks, + ...(model ? { model } : {}), + }); + continue; + } + + if (message.role === "tool") { + const block = message.tool_call_id ? pendingResults.get(message.tool_call_id) : undefined; + if (!block) continue; // orphan result (no matching tool_use) — drop + const result = (message.content || "").trim(); + if (result) block._result = result; + pendingResults.delete(block.id); + continue; + } + + // role='session_meta' and anything unknown: skip + } + + // Hermes compression marks the pre-compaction transcript rows compacted=1 + // (they stay in the DB, so the replay keeps full history). Record a single + // compaction event so the viewer can annotate the context change. + const firstCompacted = messages.find((m) => m.compacted === 1); + if (firstCompacted) { + compactions.push({ + timestamp: toIsoMs(firstCompacted.timestamp) || startTime || new Date().toISOString(), + trigger: "hermes-compaction", + }); + } + + const tokenUsage = usageFromSession(session); + if (tokenUsage) totalTokens = tokenUsage; + + const tokenUsageByModel = usageByModelFromDb(db, sessionId); + + const defaultSource: DataSourceInfo = { + primary: "sqlite", + sources: [hermesDbPath()], + notes: ["Discovered from the Hermes SQLite database (~/.hermes/state.db)."], + }; + + return { + sessionId: session?.id || sessionId, + slug: session?.id || sessionId.slice(0, 8), + title: session?.title || sessionInfo?.title, + cwd, + model, + startTime, + endTime, + totalDurationMs: estimateActiveDuration(allTimestamps), + turns, + dataSource: "sqlite", + dataSourceInfo: defaultSource, + ...(totalTokens ? { tokenUsage: totalTokens } : {}), + ...(tokenUsageByModel && Object.keys(tokenUsageByModel).length > 0 + ? { tokenUsageByModel } + : {}), + compactions: compactions.length > 0 ? compactions : undefined, + gitBranch: session?.git_branch || undefined, + ...(skillsUsed.size > 0 ? { skillsUsed: Array.from(skillsUsed) } : {}), + parseWarnings: parseWarnings.length > 0 ? parseWarnings : undefined, + ...(truncatedResponses > 0 ? { truncatedResponses } : {}), + }; +} + +function parseToolCalls( + raw: string | null, + messageId: number, + warnings: NonNullable, +): HermesToolCall[] { + if (!raw || !raw.trim()) return []; + try { + const parsed = JSON.parse(raw) as HermesToolCall[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + addParseWarning(warnings, { + kind: "malformed-json", + source: "hermes message", + message: `message ${messageId}: unparseable tool_calls, skipped`, + sample: compactWarningSample(raw), + }); + return []; + } +} + +function parseArguments( + raw: string | undefined, + messageId: number, + warnings: NonNullable, +): Record { + if (!raw || !raw.trim()) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + addParseWarning(warnings, { + kind: "malformed-json", + source: "hermes message", + message: `message ${messageId}: unparseable tool arguments, using empty input`, + sample: compactWarningSample(raw), + }); + return {}; + } +} + +function usageFromSession(session: HermesSessionRow | null): TokenUsage | undefined { + if (!session) return undefined; + const usage: TokenUsage = { + inputTokens: Number(session.input_tokens ?? 0), + outputTokens: Number(session.output_tokens ?? 0), + cacheCreationTokens: Number(session.cache_write_tokens ?? 0), + cacheReadTokens: Number(session.cache_read_tokens ?? 0), + }; + if (Object.values(usage).every((v) => v === 0)) return undefined; + return usage; +} + +/** Per-model token usage from the session_model_usage table (Hermes ≥ 0.20). */ +function usageByModelFromDb( + db: Database, + sessionId: string, +): Record | undefined { + const rows = rowValues( + db, + ` + SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens + FROM session_model_usage + WHERE session_id = ? + `, + { sid: sessionId }, + ) as HermesModelUsageRow[]; + if (rows.length === 0) return undefined; + + const byModel: Record = {}; + for (const row of rows) { + const usage: TokenUsage = { + inputTokens: Number(row.input_tokens ?? 0), + outputTokens: Number(row.output_tokens ?? 0), + cacheCreationTokens: Number(row.cache_write_tokens ?? 0), + cacheReadTokens: Number(row.cache_read_tokens ?? 0), + }; + byModel[row.model || "unknown"] = usage; + } + return byModel; +} + +function toIsoMs(value?: number): string | undefined { + if (!value || value <= 0) return undefined; + const millis = value < 1_577_836_800_000 ? value * 1000 : value; + const d = new Date(millis); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +export { hermesDataDir }; diff --git a/packages/provider-hermes/src/hermes/sqlite.ts b/packages/provider-hermes/src/hermes/sqlite.ts new file mode 100644 index 00000000..c301679b --- /dev/null +++ b/packages/provider-hermes/src/hermes/sqlite.ts @@ -0,0 +1,73 @@ +/// +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Database, SqlJsStatic } from "sql.js"; + +export const HERMES_DIRNAME = ".hermes"; +export const HERMES_DB_FILENAME = "state.db"; + +/** + * Resolve the Hermes data directory — `~/.hermes` by default. An explicit + * `HERMES_HOME` env var wins (mirrors how Hermes itself resolves its state). + */ +export function hermesDataDir(): string { + return process.env.HERMES_HOME || join(homedir(), HERMES_DIRNAME); +} + +export function hermesDbPath(): string { + return join(hermesDataDir(), HERMES_DB_FILENAME); +} + +export function createRetryableInit(factory: () => Promise): () => Promise { + let promise: Promise | null = null; + return async () => { + if (!promise) { + promise = factory().catch((err) => { + promise = null; + throw err; + }); + } + return promise; + }; +} + +const getSqlJs = createRetryableInit(async () => { + const mod = await import("sql.js"); + return mod.default(); +}); + +/** + * Open the Hermes SQLite database with sql.js (WASM). Returns null when the + * DB file is missing or unreadable, or when the table layout is unknown. + */ +export async function openHermesDb( + dbPath = hermesDbPath(), +): Promise<{ db: Database; dbPath: string } | null> { + let db: Database | null = null; + try { + const SQL = await getSqlJs(); + const buffer = await readFile(dbPath); + if (buffer.length < 1024) return null; + db = new SQL.Database(buffer); + // Probe the table layout before handing the handle out: a state.db from an + // older or unrelated Hermes build could open fine but crash later queries. + const probe = db.exec( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('sessions','messages')", + ); + if (probe.length === 0 || probe[0].values.length !== 2) { + db.close(); + db = null; + return null; + } + return { db, dbPath }; + } catch { + db?.close(); + return null; + } +} + +/** True when the session id looks like a Hermes session id (`YYYYMMDD_HHMMSS_...`). */ +export function isHermesSessionId(value: string): boolean { + return /^\d{8}_\d{6}_/.test(value) || value.startsWith("session_"); +} diff --git a/packages/provider-hermes/src/hermes/tool-mapping.ts b/packages/provider-hermes/src/hermes/tool-mapping.ts new file mode 100644 index 00000000..e9dbf245 --- /dev/null +++ b/packages/provider-hermes/src/hermes/tool-mapping.ts @@ -0,0 +1,77 @@ +/** + * Hermes stores tool calls under their implementation name (lowercase, e.g. + * `terminal`, `write_file`, `patch`). The viewer's transform layer keys off + * canonical names like `Bash`, `Write`, `Edit` to build shell output and + * diff scenes, so map Hermes tool names onto that shared vocabulary. + * Unrecognized tools (MCP servers, future builtins) pass through unchanged. + */ +export function mapHermesToolName(name: string): string { + const mapping: Record = { + terminal: "Bash", + read_file: "Read", + write_file: "Write", + patch: "Edit", + search_files: "Grep", + web_search: "WebSearch", + web_extract: "WebFetch", + delegate_task: "Agent", + clarify: "AskQuestion", + todo: "TodoWrite", + skill_view: "Skill", + skill_manage: "Skill", + skills_list: "Skill", + vision_analyze: "Vision", + computer_use: "ComputerUse", + execute_code: "ExecuteCode", + session_search: "SessionSearch", + text_to_speech: "TextToSpeech", + }; + return mapping[name] || name; +} + +/** + * Normalize Hermes tool input into the field names the transform expects. + * Hermes's file tools use `path`; the transform wants `file_path`. The + * `patch` tool (replace mode) is the analogue of Claude's `Edit`, so its + * old/new strings map onto the diff scene fields. + */ +export function mapHermesToolArgs(toolName: string, input: unknown): Record { + const obj = + input && typeof input === "object" && !Array.isArray(input) + ? (input as Record) + : null; + if (!obj) return obj || {}; + + if (toolName === "patch") { + return { + file_path: obj.path ?? "", + old_string: obj.old_string ?? "", + new_string: obj.new_string ?? "", + }; + } + if (toolName === "write_file") { + return { + file_path: obj.path ?? "", + content: obj.content ?? "", + }; + } + if (toolName === "read_file") { + return { + file_path: obj.path ?? "", + }; + } + if (toolName === "terminal") { + return { + command: obj.command ?? "", + ...(typeof obj.workdir === "string" ? { workdir: obj.workdir } : {}), + }; + } + if (toolName === "delegate_task") { + return { + description: obj.goal ?? "", + prompt: obj.context ?? "", + subagent_type: obj.role === "orchestrator" ? "orchestrator" : "subagent", + }; + } + return obj; +} diff --git a/packages/provider-hermes/src/sql-js.d.ts b/packages/provider-hermes/src/sql-js.d.ts new file mode 100644 index 00000000..89b4aec6 --- /dev/null +++ b/packages/provider-hermes/src/sql-js.d.ts @@ -0,0 +1,30 @@ +declare module "sql.js" { + type SqlValue = string | number | null | Uint8Array; + + interface QueryExecResult { + columns: string[]; + values: SqlValue[][]; + } + + interface Statement { + bind(params?: any[] | Record): boolean; + step(): boolean; + get(): any[]; + getAsObject(): Record; + reset(): void; + free(): void; + } + + interface Database { + exec(sql: string, params?: any[]): QueryExecResult[]; + prepare(sql: string): Statement; + run(sql: string, params?: any[] | Record): Database; + close(): void; + } + + interface SqlJsStatic { + Database: new (data?: ArrayLike | Buffer | null) => Database; + } + + export default function initSqlJs(config?: Record): Promise; +} diff --git a/packages/provider-hermes/test/discover.test.ts b/packages/provider-hermes/test/discover.test.ts new file mode 100644 index 00000000..358f1d6c --- /dev/null +++ b/packages/provider-hermes/test/discover.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { listSessionsFromDb } from "../src/hermes/discover.js"; +import { resolveSessionId } from "../src/hermes/parser.js"; +import { buildHermesDb, toolCallsFor } from "./helpers/db.js"; + +describe("hermes discover", () => { + it("lists sessions with stats and marker filePaths", async () => { + const db = await buildHermesDb({ + sessions: [ + { + id: "20260804_202053_7b0f72", + title: "Add Hermes support", + cwd: "/Users/test/project", + model: "deepseek-v4-flash-free", + startedAt: 1_800_000_000, + lastActivityAt: 1_800_000_050, + messageCount: 10, + toolCallCount: 4, + }, + { + id: "20260803_100000_abc123", + title: null, + cwd: "/Users/test/other", + startedAt: 1_700_000_000, + lastActivityAt: 1_700_000_100, + messageCount: 3, + toolCallCount: 1, + }, + ], + messages: [ + { + id: 1, + sessionId: "20260804_202053_7b0f72", + role: "user", + content: "Add Hermes support to vibe-replay", + timestamp: 1_800_000_001, + }, + { + id: 2, + sessionId: "20260804_202053_7b0f72", + role: "assistant", + toolCalls: toolCallsFor([{ id: "c1", name: "patch", args: { path: "a.ts" } }]), + timestamp: 1_800_000_002, + }, + { + id: 3, + sessionId: "20260804_202053_7b0f72", + role: "user", + content: "second prompt", + timestamp: 1_800_000_003, + }, + { + id: 4, + sessionId: "20260803_100000_abc123", + role: "user", + content: "", + timestamp: 1_700_000_001, + }, + ], + }); + + try { + const sessions = listSessionsFromDb(db); + + // Only the session with a non-empty first prompt is listed. + expect(sessions).toHaveLength(1); + const s = sessions[0]; + expect(s.provider).toBe("hermes"); + expect(s.sessionId).toBe("20260804_202053_7b0f72"); + expect(s.slug).toBe("20260804_202053_7b0f72"); + expect(s.title).toBe("Add Hermes support"); + expect(s.project).toBe("/Users/test/project"); + expect(s.cwd).toBe("/Users/test/project"); + expect(s.model).toBe("deepseek-v4-flash-free"); + expect(s.promptCount).toBe(2); + expect(s.toolCallCount).toBe(4); + expect(s.editCountEst).toBe(1); + expect(s.firstPrompt).toBe("Add Hermes support to vibe-replay"); + expect(s.timestamp).toBe("2027-01-15T08:00:50.000Z"); + expect(s.durationMsEst).toBe(50_000); + expect(s.hasSqlite).toBe(true); + expect(s.filePath).toContain("#session:20260804_202053_7b0f72"); + expect(s.isStarred).toBe(false); + } finally { + db.close(); + } + }); + + it("maps pinned sessions to isStarred", async () => { + const db = await buildHermesDb({ + sessions: [ + { + id: "20260801_120000_pin123", + cwd: "/Users/test/pinned", + startedAt: 1_600_000_000, + pinned: 1, + }, + ], + messages: [ + { + id: 1, + sessionId: "20260801_120000_pin123", + role: "user", + content: "pinned session prompt", + timestamp: 1_600_000_001, + }, + ], + }); + + try { + const sessions = listSessionsFromDb(db); + expect(sessions).toHaveLength(1); + expect(sessions[0].isStarred).toBe(true); + } finally { + db.close(); + } + }); + + it("resolves session ids from markers and raw ids", () => { + expect(resolveSessionId(["~/.hermes/state.db#session:20260804_202053_7b0f72"])).toBe( + "20260804_202053_7b0f72", + ); + expect(resolveSessionId(["20260804_202053_7b0f72"])).toBe("20260804_202053_7b0f72"); + expect(resolveSessionId(["/some/other/path.jsonl"])).toBeUndefined(); + }); +}); diff --git a/packages/provider-hermes/test/helpers/db.ts b/packages/provider-hermes/test/helpers/db.ts new file mode 100644 index 00000000..06deea2e --- /dev/null +++ b/packages/provider-hermes/test/helpers/db.ts @@ -0,0 +1,222 @@ +import initSqlJs, { type Database } from "sql.js"; +import type { SqlJsStatic } from "sql.js"; + +let SQL: SqlJsStatic | undefined; +async function getSql(): Promise { + if (!SQL) SQL = await initSqlJs(); + return SQL; +} + +export interface HermesSessionSeed { + id: string; + title?: string | null; + cwd?: string | null; + model?: string | null; + startedAt?: number; + endedAt?: number | null; + lastActivityAt?: number | null; + messageCount?: number; + toolCallCount?: number; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + gitBranch?: string | null; + gitRepoRoot?: string | null; + pinned?: number; +} + +export interface HermesMessageSeed { + id: number; + sessionId: string; + role: "user" | "assistant" | "tool" | "session_meta"; + content?: string | null; + toolCallId?: string | null; + toolCalls?: unknown | null; + toolName?: string | null; + timestamp?: number; + finishReason?: string | null; + reasoningContent?: string | null; + compacted?: number; +} + +/** + * Build an in-memory sql.js Database shaped like Hermes's `state.db` + * (sessions / messages / session_model_usage tables) and seed it. + */ +export async function buildHermesDb(seeds: { + sessions?: HermesSessionSeed[]; + messages: HermesMessageSeed[]; + modelUsage?: Array<{ + sessionId: string; + model: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + reasoningTokens?: number; + }>; +}): Promise { + const sql = await getSql(); + const db = new sql.Database(); + + db.run(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + title TEXT, + cwd TEXT, + model TEXT, + started_at REAL NOT NULL, + ended_at REAL, + last_activity_at REAL, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + estimated_cost_usd REAL, + git_branch TEXT, + git_repo_root TEXT, + parent_session_id TEXT, + profile_name TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + end_reason TEXT + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + display_kind TEXT, + display_metadata TEXT, + active INTEGER NOT NULL DEFAULT 1, + compacted INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE session_model_usage ( + session_id TEXT NOT NULL, + model TEXT NOT NULL, + billing_provider TEXT NOT NULL DEFAULT '', + billing_base_url TEXT NOT NULL DEFAULT '', + billing_mode TEXT NOT NULL DEFAULT '', + task TEXT NOT NULL DEFAULT '', + api_call_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost_usd REAL NOT NULL DEFAULT 0, + actual_cost_usd REAL NOT NULL DEFAULT 0, + cost_status TEXT, + cost_source TEXT, + first_seen REAL, + last_seen REAL, + PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task) + ); + CREATE INDEX idx_messages_session ON messages(session_id, timestamp); + `); + + for (const s of seeds.sessions || []) { + const startedAt = s.startedAt ?? 1_800_000_000; + db.run( + `INSERT INTO sessions ( + id, source, title, cwd, model, started_at, ended_at, last_activity_at, + message_count, tool_call_count, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens, reasoning_tokens, + git_branch, git_repo_root, pinned + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + s.id, + "cli", + s.title ?? null, + s.cwd ?? "/Users/test/project", + s.model ?? "deepseek-v4-flash-free", + startedAt, + s.endedAt ?? null, + s.lastActivityAt ?? null, + s.messageCount ?? 0, + s.toolCallCount ?? 0, + s.inputTokens ?? 0, + s.outputTokens ?? 0, + s.cacheReadTokens ?? 0, + s.cacheWriteTokens ?? 0, + s.reasoningTokens ?? 0, + s.gitBranch ?? null, + s.gitRepoRoot ?? null, + s.pinned ?? 0, + ], + ); + } + + for (const m of seeds.messages) { + db.run( + `INSERT INTO messages ( + id, session_id, role, content, tool_call_id, tool_calls, tool_name, + timestamp, finish_reason, reasoning_content, compacted + ) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [ + m.id, + m.sessionId, + m.role, + m.content ?? null, + m.toolCallId ?? null, + m.toolCalls != null ? JSON.stringify(m.toolCalls) : null, + m.toolName ?? null, + m.timestamp ?? 1_800_000_000, + m.finishReason ?? null, + m.reasoningContent ?? null, + m.compacted ?? 0, + ], + ); + } + + for (const u of seeds.modelUsage || []) { + db.run( + `INSERT INTO session_model_usage ( + session_id, model, input_tokens, output_tokens, cache_read_tokens, + cache_write_tokens, reasoning_tokens + ) VALUES (?,?,?,?,?,?,?)`, + [ + u.sessionId, + u.model, + u.inputTokens ?? 0, + u.outputTokens ?? 0, + u.cacheReadTokens ?? 0, + u.cacheWriteTokens ?? 0, + u.reasoningTokens ?? 0, + ], + ); + } + + return db; +} + +/** OpenAI-style tool_calls payload as stored on assistant rows. */ +export function toolCallsFor( + calls: Array<{ id: string; name: string; args: Record }>, +): Array<{ + id: string; + call_id: string; + type: "function"; + function: { name: string; arguments: string }; +}> { + return calls.map((c) => ({ + id: c.id, + call_id: c.id, + type: "function" as const, + function: { name: c.name, arguments: JSON.stringify(c.args) }, + })); +} diff --git a/packages/provider-hermes/test/parser.test.ts b/packages/provider-hermes/test/parser.test.ts new file mode 100644 index 00000000..d463b7e6 --- /dev/null +++ b/packages/provider-hermes/test/parser.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from "vitest"; +import { parseSessionFromDb } from "../src/hermes/parser.js"; +import { mapHermesToolArgs, mapHermesToolName } from "../src/hermes/tool-mapping.js"; +import { buildHermesDb, toolCallsFor } from "./helpers/db.js"; + +const baseSession = { + id: "20260804_202053_7b0f72", + title: "Add Hermes support", + cwd: "/Users/test/project", + model: "deepseek-v4-flash-free", +}; + +describe("hermes parser", () => { + it("parses user/assistant/tool turns with thinking, text, and mapped tool blocks", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "user", + content: "Please add Hermes support", + timestamp: 1_800_000_001, + }, + { + id: 2, + sessionId: baseSession.id, + role: "assistant", + reasoningContent: "Let me inspect the provider architecture.", + content: "I'll inspect the existing providers first.", + toolCalls: toolCallsFor([ + { id: "call_1", name: "terminal", args: { command: "ls providers" } }, + ]), + finishReason: "tool_calls", + timestamp: 1_800_000_002, + }, + { + id: 3, + sessionId: baseSession.id, + role: "tool", + toolName: "terminal", + toolCallId: "call_1", + content: "cursor codex", + timestamp: 1_800_000_003, + }, + ], + }); + + try { + const result = parseSessionFromDb(db, baseSession.id); + + expect(result.sessionId).toBe(baseSession.id); + expect(result.slug).toBe(baseSession.id); + expect(result.title).toBe("Add Hermes support"); + expect(result.cwd).toBe("/Users/test/project"); + expect(result.model).toBe("deepseek-v4-flash-free"); + expect(result.turns).toHaveLength(2); + + const user = result.turns[0]; + expect(user.role).toBe("user"); + expect(user.blocks[0]).toMatchObject({ type: "text", text: "Please add Hermes support" }); + + const assistant = result.turns[1]; + expect(assistant.blocks[0]).toMatchObject({ + type: "thinking", + thinking: "Let me inspect the provider architecture.", + }); + expect(assistant.blocks[1]).toMatchObject({ + type: "text", + text: "I'll inspect the existing providers first.", + }); + + const tool = assistant.blocks.find((b) => b.type === "tool_use"); + expect(tool).toMatchObject({ + type: "tool_use", + name: "Bash", + id: "call_1", + input: { command: "ls providers" }, + _result: "cursor codex", + }); + + expect(result.startTime).toBe("2027-01-15T08:00:01.000Z"); + expect(result.endTime).toBe("2027-01-15T08:00:03.000Z"); + expect(result.dataSource).toBe("sqlite"); + expect(result.dataSourceInfo?.primary).toBe("sqlite"); + } finally { + db.close(); + } + }); + + it("aggregates token usage from session and per-model rows", async () => { + const db = await buildHermesDb({ + sessions: [ + { + ...baseSession, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 10, + }, + ], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "user", + content: "hi", + timestamp: 1_800_000_001, + }, + ], + modelUsage: [ + { + sessionId: baseSession.id, + model: "deepseek-v4-flash-free", + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 10, + }, + ], + }); + + try { + const result = parseSessionFromDb(db, baseSession.id); + expect(result.tokenUsage).toMatchObject({ + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheCreationTokens: 10, + }); + expect(result.tokenUsageByModel?.["deepseek-v4-flash-free"]).toMatchObject({ + inputTokens: 100, + outputTokens: 20, + }); + } finally { + db.close(); + } + }); + + it("pairs parallel tool calls with their results by call id", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "assistant", + toolCalls: toolCallsFor([ + { id: "call_a", name: "search_files", args: { pattern: "foo", path: "src" } }, + { id: "call_b", name: "read_file", args: { path: "src/x.ts" } }, + ]), + finishReason: "tool_calls", + timestamp: 1_800_000_001, + }, + { + id: 2, + sessionId: baseSession.id, + role: "tool", + toolName: "search_files", + toolCallId: "call_a", + content: "matches-a", + timestamp: 1_800_000_002, + }, + { + id: 3, + sessionId: baseSession.id, + role: "tool", + toolName: "read_file", + toolCallId: "call_b", + content: "file body", + timestamp: 1_800_000_003, + }, + ], + }); + + try { + const result = parseSessionFromDb(db, baseSession.id); + const tools = result.turns[0]?.blocks.filter((b) => b.type === "tool_use") ?? []; + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ id: "call_a", name: "Grep", _result: "matches-a" }); + expect(tools[1]).toMatchObject({ id: "call_b", name: "Read", _result: "file body" }); + } finally { + db.close(); + } + }); + + it("maps Hermes tool names onto the viewer vocabulary", () => { + expect(mapHermesToolName("terminal")).toBe("Bash"); + expect(mapHermesToolName("write_file")).toBe("Write"); + expect(mapHermesToolName("patch")).toBe("Edit"); + expect(mapHermesToolName("delegate_task")).toBe("Agent"); + expect(mapHermesToolName("clarify")).toBe("AskQuestion"); + expect(mapHermesToolName("unknown_tool")).toBe("unknown_tool"); + + expect( + mapHermesToolArgs("patch", { path: "src/a.ts", old_string: "a", new_string: "b" }), + ).toEqual({ + file_path: "src/a.ts", + old_string: "a", + new_string: "b", + }); + expect(mapHermesToolArgs("write_file", { path: "src/b.ts", content: "hi" })).toEqual({ + file_path: "src/b.ts", + content: "hi", + }); + expect(mapHermesToolArgs("read_file", { path: "src/c.ts" })).toEqual({ + file_path: "src/c.ts", + }); + expect(mapHermesToolArgs("terminal", { command: "pwd" })).toEqual({ command: "pwd" }); + expect(mapHermesToolArgs("delegate_task", { goal: "g", context: "c" })).toMatchObject({ + description: "g", + prompt: "c", + subagent_type: "subagent", + }); + }); + + it("skips session_meta and empty user rows, drops orphan tool results", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { id: 1, sessionId: baseSession.id, role: "session_meta", timestamp: 1_800_000_001 }, + { id: 2, sessionId: baseSession.id, role: "user", content: "", timestamp: 1_800_000_002 }, + { + id: 3, + sessionId: baseSession.id, + role: "user", + content: "real prompt", + timestamp: 1_800_000_003, + }, + { + id: 4, + sessionId: baseSession.id, + role: "tool", + toolName: "terminal", + toolCallId: "missing_call", + content: "orphan", + timestamp: 1_800_000_004, + }, + ], + }); + + try { + const result = parseSessionFromDb(db, baseSession.id); + expect(result.turns).toHaveLength(1); + expect(result.turns[0]).toMatchObject({ + role: "user", + blocks: [{ type: "text", text: "real prompt" }], + }); + } finally { + db.close(); + } + }); + + it("records a compaction event when rows are marked compacted", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "user", + content: "early prompt", + compacted: 1, + timestamp: 1_800_000_001, + }, + { + id: 2, + sessionId: baseSession.id, + role: "user", + content: "post-compaction summary", + timestamp: 1_800_000_002, + }, + ], + }); + + try { + const result = parseSessionFromDb(db, baseSession.id); + expect(result.compactions).toHaveLength(1); + expect(result.compactions?.[0]).toMatchObject({ trigger: "hermes-compaction" }); + // Compacted history stays in the replay; the summary renders as a user turn. + expect(result.turns).toHaveLength(2); + } finally { + db.close(); + } + }); + + it("marks context-compaction summary rows as compaction-summary turns", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "user", + content: "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.", + timestamp: 1_800_000_001, + }, + ], + }); + + try { + const result = parseSessionFromDb(db, baseSession.id); + expect(result.turns).toHaveLength(1); + expect(result.turns[0]).toMatchObject({ + role: "user", + subtype: "compaction-summary", + }); + } finally { + db.close(); + } + }); + + it("counts truncated responses and collects skill names", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "assistant", + content: "cut off", + finishReason: "max_tokens", + timestamp: 1_800_000_001, + }, + { + id: 2, + sessionId: baseSession.id, + role: "assistant", + toolCalls: toolCallsFor([{ id: "call_s", name: "skill_view", args: { name: "replay" } }]), + finishReason: "tool_calls", + timestamp: 1_800_000_002, + }, + { + id: 3, + sessionId: baseSession.id, + role: "tool", + toolName: "skill_view", + toolCallId: "call_s", + content: "skill content", + timestamp: 1_800_000_003, + }, + ], + }); + + try { + const result = parseSessionFromDb(db, baseSession.id); + expect(result.truncatedResponses).toBe(1); + expect(result.skillsUsed).toEqual(["replay"]); + } finally { + db.close(); + } + }); + + it("reports parseWarnings for unparseable tool_calls", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "assistant", + toolCalls: { id: "call_x" }, + timestamp: 1_800_000_001, + }, + ], + }); + // Corrupt the stored tool_calls JSON directly to simulate a malformed row. + db.run("UPDATE messages SET tool_calls = 'not json{' WHERE id = 1"); + + try { + const result = parseSessionFromDb(db, baseSession.id); + expect(result.turns).toHaveLength(0); + expect(result.parseWarnings).toHaveLength(1); + expect(result.parseWarnings?.[0]).toMatchObject({ + kind: "malformed-json", + source: "hermes message", + }); + } finally { + db.close(); + } + }); +}); diff --git a/packages/provider-hermes/tsconfig.json b/packages/provider-hermes/tsconfig.json new file mode 100644 index 00000000..9d3fdee1 --- /dev/null +++ b/packages/provider-hermes/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/providers-default/package.json b/packages/providers-default/package.json index a0dabca7..d575a242 100644 --- a/packages/providers-default/package.json +++ b/packages/providers-default/package.json @@ -21,6 +21,7 @@ "@vibe-replay/provider-codex": "workspace:*", "@vibe-replay/provider-contract": "workspace:*", "@vibe-replay/provider-cursor": "workspace:*", + "@vibe-replay/provider-hermes": "workspace:*", "@vibe-replay/provider-opencode": "workspace:*", "@vibe-replay/provider-pi": "workspace:*" }, diff --git a/packages/providers-default/src/index.test.ts b/packages/providers-default/src/index.test.ts index 69b21c4b..83ff066a 100644 --- a/packages/providers-default/src/index.test.ts +++ b/packages/providers-default/src/index.test.ts @@ -27,6 +27,7 @@ describe("default provider registry", () => { "claude-code", "codex", "cursor", + "hermes", "opencode", "pi", ]); diff --git a/packages/providers-default/src/index.ts b/packages/providers-default/src/index.ts index 97605f94..e6396cc7 100644 --- a/packages/providers-default/src/index.ts +++ b/packages/providers-default/src/index.ts @@ -3,6 +3,7 @@ import { claudeCoworkProvider } from "@vibe-replay/provider-claude-code/claude-c import { claudeDesktopProvider } from "@vibe-replay/provider-claude-code/claude-desktop"; import { codexProvider } from "@vibe-replay/provider-codex"; import { cursorProvider } from "@vibe-replay/provider-cursor"; +import { hermesProvider } from "@vibe-replay/provider-hermes"; import { opencodeProvider } from "@vibe-replay/provider-opencode"; import { piProvider } from "@vibe-replay/provider-pi"; import type { Provider, SessionInfo } from "@vibe-replay/provider-contract"; @@ -13,6 +14,7 @@ const providers: Provider[] = [ claudeCodeProvider, codexProvider, cursorProvider, + hermesProvider, opencodeProvider, piProvider, ]; @@ -27,6 +29,7 @@ const PROVIDER_PRIORITY = [ "claude-code", "codex", "cursor", + "hermes", "opencode", "pi", ]; diff --git a/packages/viewer/src/components/DashboardHome.tsx b/packages/viewer/src/components/DashboardHome.tsx index f1244dc7..f6af49f3 100644 --- a/packages/viewer/src/components/DashboardHome.tsx +++ b/packages/viewer/src/components/DashboardHome.tsx @@ -728,6 +728,7 @@ const SYSTEM_TOOLS: Array> = [ { name: "claude", label: "Claude Code", purpose: "AI feedback via headless mode" }, { name: "cursor", label: "Cursor CLI", purpose: "AI feedback via AI Studio" }, { name: "opencode", label: "OpenCode", purpose: "AI feedback via headless mode" }, + { name: "hermes", label: "Hermes", purpose: "AI feedback via headless mode" }, ]; function SystemChecksSection() { diff --git a/packages/viewer/src/components/__tests__/dashboard-utils.test.ts b/packages/viewer/src/components/__tests__/dashboard-utils.test.ts index fbcd820d..81168158 100644 --- a/packages/viewer/src/components/__tests__/dashboard-utils.test.ts +++ b/packages/viewer/src/components/__tests__/dashboard-utils.test.ts @@ -451,6 +451,14 @@ describe("provider display helpers", () => { expect(providerFamily("opencode")).toBe("green"); }); + it("labels Hermes as a first-class provider", () => { + expect(providerBadgeLabel("hermes")).toBe("Hermes"); + expect(providerDisplayName("hermes")).toBe("Hermes"); + expect(providerBadgeClass("hermes")).toBe("bg-terminal-red-subtle text-terminal-red"); + expect(providerBarClass("hermes")).toBe("bg-terminal-red"); + expect(providerFamily("hermes")).toBe("red"); + }); + it("gives each Claude sub-kind a distinct warm hue", () => { expect(providerFamily("claude-code")).toBe("orange"); expect(providerFamily("claude-cowork")).toBe("sienna"); diff --git a/packages/viewer/src/components/dashboard-utils.ts b/packages/viewer/src/components/dashboard-utils.ts index a3350f75..ae5da91b 100644 --- a/packages/viewer/src/components/dashboard-utils.ts +++ b/packages/viewer/src/components/dashboard-utils.ts @@ -476,6 +476,7 @@ export const PROVIDER_BADGE_COLORS: Record = { codex: "bg-terminal-purple-subtle text-terminal-purple", cursor: "bg-terminal-blue-subtle text-terminal-blue", opencode: "bg-terminal-green-subtle text-terminal-green", + hermes: "bg-terminal-red-subtle text-terminal-red", pi: "bg-terminal-cyan-subtle text-terminal-cyan", }; @@ -486,6 +487,7 @@ const PROVIDER_BADGE_LABELS: Record = { codex: "Codex", cursor: "Cursor", opencode: "OpenCode", + hermes: "Hermes", pi: "Pi", }; @@ -498,6 +500,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { codex: "Codex", cursor: "Cursor", opencode: "OpenCode", + hermes: "Hermes", pi: "Pi", }; @@ -510,6 +513,7 @@ const PROVIDER_BAR_COLORS: Record = { codex: "bg-terminal-purple", cursor: "bg-terminal-blue", opencode: "bg-terminal-green", + hermes: "bg-terminal-red", pi: "bg-terminal-cyan", }; @@ -518,7 +522,7 @@ const PROVIDER_BAR_COLORS: Record = { // Tailwind's static-class extraction happy and avoids template-string bugs. const PROVIDER_FAMILY: Record< string, - "orange" | "sienna" | "yellow" | "blue" | "purple" | "cyan" | "green" | "dim" + "orange" | "sienna" | "yellow" | "blue" | "purple" | "cyan" | "green" | "red" | "dim" > = { "claude-code": "orange", "claude-desktop": "yellow", @@ -526,12 +530,13 @@ const PROVIDER_FAMILY: Record< codex: "purple", cursor: "blue", opencode: "green", + hermes: "red", pi: "cyan", }; export function providerFamily( provider: string, -): "orange" | "sienna" | "yellow" | "blue" | "purple" | "cyan" | "green" | "dim" { +): "orange" | "sienna" | "yellow" | "blue" | "purple" | "cyan" | "green" | "red" | "dim" { return PROVIDER_FAMILY[provider] || "dim"; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c7ceeba..aeb96932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: '@vibe-replay/provider-cursor': specifier: workspace:* version: link:../provider-cursor + '@vibe-replay/provider-hermes': + specifier: workspace:* + version: link:../provider-hermes '@vibe-replay/provider-opencode': specifier: workspace:* version: link:../provider-opencode @@ -253,6 +256,31 @@ importers: specifier: ^4.1.9 version: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@26.1.0)(vite@7.3.1(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/provider-hermes: + dependencies: + '@vibe-replay/provider-contract': + specifier: workspace:* + version: link:../provider-contract + '@vibe-replay/provider-core': + specifier: workspace:* + version: link:../provider-core + '@vibe-replay/types': + specifier: workspace:* + version: link:../types + sql.js: + specifier: ^1.14.1 + version: 1.14.1 + devDependencies: + '@types/node': + specifier: ^25.9.3 + version: 25.9.4 + '@vibe-replay/replay-core': + specifier: workspace:* + version: link:../replay-core + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.4)(@vitest/coverage-v8@4.1.9)(jsdom@26.1.0)(vite@7.3.1(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/provider-opencode: dependencies: '@vibe-replay/provider-contract': @@ -311,6 +339,9 @@ importers: '@vibe-replay/provider-cursor': specifier: workspace:* version: link:../provider-cursor + '@vibe-replay/provider-hermes': + specifier: workspace:* + version: link:../provider-hermes '@vibe-replay/provider-opencode': specifier: workspace:* version: link:../provider-opencode