diff --git a/CLAUDE.md b/CLAUDE.md index 80dce9ab..659d8966 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ pnpm db:migrate:remote # Apply to production D1 (requires auth) - **Cowork `result` billing**: each `type: "result"` record is one completed host-loop run's final bill — `usage`, `modelUsage`, `total_cost_usd` and `duration_ms` are per-run, not cumulative. Session totals are the sum over UUID-deduplicated results; never mix them with assistant `message.usage` snapshots (those are partial streams and undercount output badly), and never add `usage` to `modelUsage` or `total_cost_usd` to `modelUsage.*.costUSD`. Audits with no `result` records fall back to assistant snapshots plus the timestamp-gap duration estimate. `system/api_retry` maps to `apiErrors` with attempt metadata only — the raw error text is never stored. - **Provider-reported cost**: `ProviderParseResult.reportedCostUsd` wins over the local pricing table in `transform.ts`, which is what makes cost available for model generations `pricing.ts` does not know. - **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/`. +- **Hermes**: Sessions live in SQLite at `~/.hermes/state.db` (FTS5-backed), plus one DB per named profile at `~/.hermes/profiles//state.db`. Discovery scans all of them (`hermesDbPaths()` in `packages/provider-hermes/src/hermes/sqlite.ts`), dedups by session id, and writes `#session:` marker paths; parse resolves the right DB from the marker before falling back to a scan. `hermesRootDir()` mirrors Hermes's `get_default_hermes_root()`: `HERMES_HOME` inside `~/.hermes` is profile mode (root stays `~/.hermes`); outside it, that path itself is the root. Caveat: sql.js reads raw file bytes and cannot replay un-checkpointed `-wal` frames, so sessions still sitting in the WAL (long-running gateway) are invisible until Hermes checkpoints. 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. diff --git a/packages/provider-hermes/src/hermes/discover.ts b/packages/provider-hermes/src/hermes/discover.ts index 070e5d3c..b2c8eba9 100644 --- a/packages/provider-hermes/src/hermes/discover.ts +++ b/packages/provider-hermes/src/hermes/discover.ts @@ -5,7 +5,7 @@ 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"; +import { hermesDataDir, hermesDbPath, openAllHermesDbs } from "./sqlite.js"; export const HERMES_PROVIDER = "hermes"; @@ -47,17 +47,34 @@ function rowValues(db: Database, sql: string, params: Record = {}): } export async function discoverHermesSessions(): Promise { - const opened = await openHermesDb(); - if (!opened) return []; - const { db } = opened; + const all = await openAllHermesDbs(); + if (all.length === 0) return []; + const sessions: SessionInfo[] = []; + const seen = new Set(); try { - return listSessionsFromDb(db); + for (const { db, dbPath } of all) { + const fromDb = listSessionsFromDb(db, dbPath); + for (const s of fromDb) { + if (seen.has(s.sessionId)) continue; + seen.add(s.sessionId); + sessions.push(s); + } + } } finally { - db.close(); + for (const { db } of all) { + try { + db.close(); + } catch { + // ignore + } + } } + // Deterministic ordering: newest last_activity first across profiles. + sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + return sessions; } -export function listSessionsFromDb(db: Database): SessionInfo[] { +export function listSessionsFromDb(db: Database, dbPathOverride?: string): SessionInfo[] { const rows = rowValues( db, ` @@ -77,11 +94,12 @@ export function listSessionsFromDb(db: Database): SessionInfo[] { const statsBySession = buildSessionStats(db, rows); const version = hermesVersion(); + const fallbackDbPath = dbPathOverride ?? hermesDbPath(); const sessions: SessionInfo[] = []; for (const row of rows) { const stats = statsBySession.get(row.id); if (!stats) continue; - const info = sessionInfoFromRow(row, stats, version); + const info = sessionInfoFromRow(row, stats, version, fallbackDbPath); if (info) sessions.push(info); } return sessions; @@ -180,14 +198,15 @@ function sessionInfoFromRow( row: HermesSessionRow, stats: SessionStats, version: string, + dbPath?: 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 resolvedDbPath = dbPath ?? hermesDbPath(); + const markerPath = `${resolvedDbPath}#session:${row.id}`; const lastActivity = row.last_activity_at ?? row.ended_at ?? row.started_at; return { diff --git a/packages/provider-hermes/src/hermes/parser.ts b/packages/provider-hermes/src/hermes/parser.ts index 2c88ec05..73641391 100644 --- a/packages/provider-hermes/src/hermes/parser.ts +++ b/packages/provider-hermes/src/hermes/parser.ts @@ -9,7 +9,13 @@ import type { TokenUsage, } from "@vibe-replay/provider-contract"; import { addParseWarning, compactWarningSample } from "@vibe-replay/provider-contract/warnings"; -import { openHermesDb, hermesDataDir, hermesDbPath } from "./sqlite.js"; +import { + hermesDataDir, + hermesDbPath, + hermesDbPaths, + openAllHermesDbs, + openHermesDb, +} from "./sqlite.js"; import { mapHermesToolArgs, mapHermesToolName } from "./tool-mapping.js"; interface HermesMessageRow { @@ -91,18 +97,76 @@ export async function parseHermesSession( ); } - const opened = await openHermesDb(); - if (!opened) { - throw new Error(`Hermes database not found at ${hermesDbPath()}`); + // Prefer the DB hinted by the marker path (fast path for the common case). + const hinted = hintedDbPath(paths, sessionInfo); + if (hinted) { + const opened = await openHermesDb(hinted); + if (opened) { + try { + const row = firstValue(opened.db, "SELECT id FROM sessions WHERE id = ?", { + sid: sessionId, + }); + if (row) return parseSessionFromDb(opened.db, sessionId, sessionInfo, opened.dbPath); + } finally { + opened.db.close(); + } + } + } + + const all = await openAllHermesDbs(); + if (all.length === 0) { + throw new Error( + `Hermes database not found (searched: ${hermesDbPaths().join(", ") || hermesDbPath()})`, + ); } - const { db } = opened; + // Find the winning DB while handles are live, close everything, then re-open + // just the winner fresh for parsing — keeps WASM handle ownership simple. + let winnerPath: string | undefined; + for (const entry of all) { + try { + const row = firstValue(entry.db, "SELECT id FROM sessions WHERE id = ?", { sid: sessionId }); + if (row) { + winnerPath = entry.dbPath; + break; + } + } catch { + // ignore per-DB probe errors + } + } + for (const { db } of all) { + try { + db.close(); + } catch { + // ignore + } + } + if (!winnerPath) { + throw new Error(`Hermes session '${sessionId}' not found in any known database`); + } + const opened = await openHermesDb(winnerPath); + if (!opened) throw new Error(`Hermes session '${sessionId}' not found in any known database`); try { - return parseSessionFromDb(db, sessionId, sessionInfo); + return parseSessionFromDb(opened.db, sessionId, sessionInfo, winnerPath); } finally { - db.close(); + opened.db.close(); } } +function hintedDbPath(paths: string[], sessionInfo?: SessionInfo): string | undefined { + for (const p of paths) { + const idx = p.indexOf("#session:"); + if (idx >= 0) { + const dbPath = p.slice(0, idx); + if (dbPath) return dbPath; + } + } + const fp = (sessionInfo as unknown as { filePath?: string })?.filePath; + if (typeof fp === "string" && fp.includes("#session:")) { + return fp.split("#session:")[0]; + } + return undefined; +} + /** * Extract the Hermes session id from the filePath markers written by * discovery (`#session:`), or from sessionInfo, or from a raw @@ -129,6 +193,7 @@ export function parseSessionFromDb( db: Database, sessionId: string, sessionInfo?: SessionInfo, + sourceDbPath?: string, ): ProviderParseResult { const session = firstValue(db, `SELECT * FROM sessions WHERE id = ?`, { sid: sessionId, @@ -258,8 +323,8 @@ export function parseSessionFromDb( const defaultSource: DataSourceInfo = { primary: "sqlite", - sources: [hermesDbPath()], - notes: ["Discovered from the Hermes SQLite database (~/.hermes/state.db)."], + sources: [sourceDbPath ?? hermesDbPath()], + notes: ["Discovered from the Hermes SQLite database."], }; return { diff --git a/packages/provider-hermes/src/hermes/sqlite.ts b/packages/provider-hermes/src/hermes/sqlite.ts index c301679b..4f711e06 100644 --- a/packages/provider-hermes/src/hermes/sqlite.ts +++ b/packages/provider-hermes/src/hermes/sqlite.ts @@ -1,8 +1,10 @@ /// +import { existsSync, readdirSync, realpathSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { Database, SqlJsStatic } from "sql.js"; +import type { Dirent } from "node:fs"; export const HERMES_DIRNAME = ".hermes"; export const HERMES_DB_FILENAME = "state.db"; @@ -19,6 +21,64 @@ export function hermesDbPath(): string { return join(hermesDataDir(), HERMES_DB_FILENAME); } +function resolveExisting(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +/** + * Root directory that holds the default DB and the named profiles — mirrors + * Hermes's own `get_default_hermes_root()`: + * + * - No `HERMES_HOME`: root is `~/.hermes`. + * - `HERMES_HOME` inside `~/.hermes` (profile mode, e.g. a path under + * `~/.hermes/profiles/`): root stays `~/.hermes` so all profiles are visible. + * - `HERMES_HOME` outside `~/.hermes` (Docker/custom deployment): root is + * `HERMES_HOME` itself — unless it points at `/profiles/`, in + * which case root is that grandparent. + */ +export function hermesRootDir(): string { + const envHome = process.env.HERMES_HOME; + const nativeHome = join(homedir(), HERMES_DIRNAME); + if (!envHome) return nativeHome; + const envPath = resolveExisting(resolve(envHome)); + const rel = relative(resolveExisting(nativeHome), envPath); + if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) { + return nativeHome; + } + if (basename(dirname(envPath)) === "profiles") { + return dirname(dirname(envPath)); + } + return envPath; +} + +/** + * All `state.db` paths that belong to this Hermes install: the default home + * plus every named profile's DB (`/profiles//state.db`). Missing + * locations are skipped so partial installs still work. + */ +export function hermesDbPaths(): string[] { + const out: string[] = []; + const defaultDb = join(hermesRootDir(), HERMES_DB_FILENAME); + if (existsSync(defaultDb)) out.push(defaultDb); + const profilesDir = join(hermesRootDir(), "profiles"); + let entries: Dirent[] = []; + try { + entries = readdirSync(profilesDir, { withFileTypes: true }); + } catch { + // no profiles dir — default DB only + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const p = join(profilesDir, entry.name, HERMES_DB_FILENAME); + if (existsSync(p)) out.push(p); + } + return out; +} + export function createRetryableInit(factory: () => Promise): () => Promise { let promise: Promise | null = null; return async () => { @@ -67,6 +127,21 @@ export async function openHermesDb( } } +/** + * Open each known Hermes DB (default + profiles). Useful for discovery and + * for parsing a session that could live in any profile. + */ +export async function openAllHermesDbs(): Promise> { + const paths = hermesDbPaths(); + if (paths.length === 0) return []; + const out: Array<{ db: Database; dbPath: string }> = []; + for (const p of paths) { + const opened = await openHermesDb(p); + if (opened) out.push(opened); + } + return out; +} + /** 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 index e9dbf245..dbf32637 100644 --- a/packages/provider-hermes/src/hermes/tool-mapping.ts +++ b/packages/provider-hermes/src/hermes/tool-mapping.ts @@ -25,6 +25,20 @@ export function mapHermesToolName(name: string): string { execute_code: "ExecuteCode", session_search: "SessionSearch", text_to_speech: "TextToSpeech", + // Browser + cron + memory tools: no special scene handling in the + // transform yet, but CamelCase ids read better than raw snake_case in + // tool lists and labels. + browser_navigate: "BrowserNavigate", + browser_click: "BrowserClick", + browser_type: "BrowserType", + browser_snapshot: "BrowserSnapshot", + browser_console: "BrowserConsole", + browser_scroll: "BrowserScroll", + browser_press: "BrowserPress", + browser_get_images: "BrowserImages", + cronjob: "Cron", + memory: "Memory", + codex: "Codex", }; return mapping[name] || name; } diff --git a/packages/provider-hermes/src/sql-js.d.ts b/packages/provider-hermes/src/sql-js.d.ts index 89b4aec6..8d3ed593 100644 --- a/packages/provider-hermes/src/sql-js.d.ts +++ b/packages/provider-hermes/src/sql-js.d.ts @@ -19,6 +19,8 @@ declare module "sql.js" { exec(sql: string, params?: any[]): QueryExecResult[]; prepare(sql: string): Statement; run(sql: string, params?: any[] | Record): Database; + /** Dump the database contents as SQLite file bytes (e.g. for persisting to disk). */ + export(): Uint8Array; close(): void; } diff --git a/packages/provider-hermes/test/discover.test.ts b/packages/provider-hermes/test/discover.test.ts index 358f1d6c..b604916e 100644 --- a/packages/provider-hermes/test/discover.test.ts +++ b/packages/provider-hermes/test/discover.test.ts @@ -1,5 +1,8 @@ +import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { listSessionsFromDb } from "../src/hermes/discover.js"; +import { discoverHermesSessions, listSessionsFromDb } from "../src/hermes/discover.js"; import { resolveSessionId } from "../src/hermes/parser.js"; import { buildHermesDb, toolCallsFor } from "./helpers/db.js"; @@ -124,3 +127,120 @@ describe("hermes discover", () => { expect(resolveSessionId(["/some/other/path.jsonl"])).toBeUndefined(); }); }); + +describe("hermes multi-profile discovery", () => { + it("merges sessions from the default DB and profile DBs with correct marker paths", async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), "vibe-replay-hermes-"))); + const prevHermesHome = process.env.HERMES_HOME; + process.env.HERMES_HOME = home; + try { + mkdirSync(join(home, "profiles", "codex"), { recursive: true }); + + // Default DB: one legacy session. + const defaultDb = await buildHermesDb({ + sessions: [ + { + id: "20260801_090000_default", + cwd: "/Users/test/legacy", + startedAt: 1_700_000_000, + lastActivityAt: 1_700_000_100, + }, + ], + messages: [ + { + id: 1, + sessionId: "20260801_090000_default", + role: "user", + content: "legacy default session", + timestamp: 1_700_000_001, + }, + ], + }); + writeFileSync(join(home, "state.db"), defaultDb.export()); + defaultDb.close(); + + // Profile DB: one newer session. + const profileDb = await buildHermesDb({ + sessions: [ + { + id: "20260822_222249_profile", + cwd: "/Users/test/codex", + startedAt: 1_800_000_000, + lastActivityAt: 1_800_000_200, + }, + ], + messages: [ + { + id: 1, + sessionId: "20260822_222249_profile", + role: "user", + content: "active profile session", + timestamp: 1_800_000_001, + }, + ], + }); + writeFileSync(join(home, "profiles", "codex", "state.db"), profileDb.export()); + profileDb.close(); + + const sessions = await discoverHermesSessions(); + expect(sessions).toHaveLength(2); + + const byId = new Map(sessions.map((s) => [s.sessionId, s])); + expect(byId.get("20260822_222249_profile")?.filePath).toBe( + `${join(home, "profiles", "codex", "state.db")}#session:20260822_222249_profile`, + ); + expect(byId.get("20260801_090000_default")?.filePath).toBe( + `${join(home, "state.db")}#session:20260801_090000_default`, + ); + + // Newest activity first across profiles. + expect(sessions[0].sessionId).toBe("20260822_222249_profile"); + } finally { + if (prevHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prevHermesHome; + rmSync(home, { recursive: true, force: true }); + } + }); + + it("deduplicates a session id present in more than one database", async () => { + const home = realpathSync(mkdtempSync(join(tmpdir(), "vibe-replay-hermes-"))); + const prevHermesHome = process.env.HERMES_HOME; + process.env.HERMES_HOME = home; + try { + mkdirSync(join(home, "profiles", "copy"), { recursive: true }); + + for (const dir of [home, join(home, "profiles", "copy")]) { + const db = await buildHermesDb({ + sessions: [ + { + id: "20260803_100000_dupe", + cwd: "/Users/test/project", + startedAt: 1_750_000_000, + lastActivityAt: 1_750_000_100, + }, + ], + messages: [ + { + id: 1, + sessionId: "20260803_100000_dupe", + role: "user", + content: "duplicated session", + timestamp: 1_750_000_001, + }, + ], + }); + writeFileSync(join(dir, "state.db"), db.export()); + db.close(); + } + + const sessions = await discoverHermesSessions(); + expect(sessions).toHaveLength(1); + // The default DB is scanned first and wins the dedup. + expect(sessions[0].filePath.startsWith(`${join(home, "state.db")}#session:`)).toBe(true); + } finally { + if (prevHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prevHermesHome; + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/provider-hermes/test/parser.test.ts b/packages/provider-hermes/test/parser.test.ts index d463b7e6..fc91af25 100644 --- a/packages/provider-hermes/test/parser.test.ts +++ b/packages/provider-hermes/test/parser.test.ts @@ -379,4 +379,27 @@ describe("hermes parser", () => { db.close(); } }); + + it("reports the producing database path in dataSourceInfo when provided", async () => { + const db = await buildHermesDb({ + sessions: [baseSession], + messages: [ + { + id: 1, + sessionId: baseSession.id, + role: "user", + content: "profile session prompt", + timestamp: 1_800_000_001, + }, + ], + }); + + try { + const profileDbPath = "/Users/test/.hermes/profiles/codex/state.db"; + const result = parseSessionFromDb(db, baseSession.id, undefined, profileDbPath); + expect(result.dataSourceInfo?.sources).toEqual([profileDbPath]); + } finally { + db.close(); + } + }); }); diff --git a/packages/provider-hermes/test/sqlite.test.ts b/packages/provider-hermes/test/sqlite.test.ts new file mode 100644 index 00000000..85dadc08 --- /dev/null +++ b/packages/provider-hermes/test/sqlite.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { isHermesSessionId } from "../src/hermes/sqlite.js"; + +describe("isHermesSessionId", () => { + it("recognizes timestamp-prefixed session ids", () => { + expect(isHermesSessionId("20260804_202053_7b0f72")).toBe(true); + expect(isHermesSessionId("20260822_222249_29dd93")).toBe(true); + }); + + it("recognizes legacy session_ prefixed ids", () => { + expect(isHermesSessionId("session_abc123")).toBe(true); + }); + + it("rejects unrelated strings", () => { + expect(isHermesSessionId("ses_111")).toBe(false); + expect(isHermesSessionId("/some/path.jsonl")).toBe(false); + expect(isHermesSessionId("")).toBe(false); + }); +});