diff --git a/apps/desktop/src/main/appStateStore.test.ts b/apps/desktop/src/main/appStateStore.test.ts index c5debd5..e42facb 100644 --- a/apps/desktop/src/main/appStateStore.test.ts +++ b/apps/desktop/src/main/appStateStore.test.ts @@ -82,6 +82,7 @@ describe("AppStateStore", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, }); store.setWindowState({ width: 1440, height: 920, x: 48, y: 72, isMaximized: false }); @@ -113,6 +114,7 @@ describe("AppStateStore", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, }); expect(reloaded.getWindowState()).toEqual({ @@ -185,7 +187,7 @@ describe("AppStateStore", () => { expect(store.getPaneState()).toEqual({ projectPaneWidth: 300, sessionPaneWidth: 360, - projectProviders: ["claude", "codex", "gemini", "cursor"], + projectProviders: ["claude", "codex", "gemini", "cursor", "opencode"], historyCategories: ["assistant"], }); }); diff --git a/apps/desktop/src/main/appStateStore.ts b/apps/desktop/src/main/appStateStore.ts index b2d93d7..bd90367 100644 --- a/apps/desktop/src/main/appStateStore.ts +++ b/apps/desktop/src/main/appStateStore.ts @@ -428,6 +428,7 @@ function sanitizeSystemMessageRegexRules(value: unknown): Record { geminiHistoryRoot: null, geminiProjectsPath: null, cursorRoot: "/cursor/root", + opencodeDbPath: "/mock/.local/share/opencode/opencode.db", }, initializeDatabase: mockInitializeDatabase, resolveSystemMessageRegexRules: mockResolveSystemMessageRegexRules, @@ -233,6 +234,7 @@ describe("bootstrapMainProcess", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, }; @@ -329,6 +331,7 @@ describe("bootstrapMainProcess", () => { geminiHistoryRoot: "/Users/test/.gemini/history", geminiProjectsPath: "/Users/test/.gemini/projects.json", cursorRoot: "/cursor/root", + opencodeDbPath: "/mock/.local/share/opencode/opencode.db", }); const projectPayload = { providers: ["claude"], query: "" }; @@ -493,6 +496,7 @@ describe("bootstrapMainProcess", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, }); @@ -529,6 +533,7 @@ describe("bootstrapMainProcess", () => { "/gemini/root", "/Users/test/.gemini/history", "/cursor/root", + "/mock/.local/share/opencode", ], backend: "kqueue", }); @@ -539,6 +544,7 @@ describe("bootstrapMainProcess", () => { "/gemini/root", "/Users/test/.gemini/history", "/cursor/root", + "/mock/.local/share/opencode", ], expect.any(Function), expect.objectContaining({ @@ -627,6 +633,7 @@ describe("bootstrapMainProcess", () => { "/gemini/root", "/Users/test/.gemini/history", "/cursor/root", + "/mock/.local/share/opencode", ], expect.any(Function), expect.objectContaining({ @@ -642,6 +649,7 @@ describe("bootstrapMainProcess", () => { "/gemini/root", "/Users/test/.gemini/history", "/cursor/root", + "/mock/.local/share/opencode", ], expect.any(Function), expect.objectContaining({ diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index f8d3db1..3d11f33 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -24,6 +24,7 @@ import { } from "./fileWatcherService"; import { WorkerIndexingRunner } from "./indexingRunner"; import { registerIpcHandlers } from "./ipc"; +import { initializeOpenCodeReaders } from "./openCodeReaders"; import { WatchStatsStore } from "./watchStatsStore"; const MIN_ZOOM_PERCENT = 60; @@ -65,6 +66,7 @@ export async function bootstrapMainProcess( const dbBootstrap = initializeDatabase(dbPath); initializeBookmarkStore(bookmarksDbPath); + initializeOpenCodeReaders(); const watchStatsStore = new WatchStatsStore(); const indexingRunner = new WorkerIndexingRunner(dbPath, { bookmarksDbPath, @@ -107,6 +109,7 @@ export async function bootstrapMainProcess( DEFAULT_DISCOVERY_CONFIG.geminiRoot, geminiHistoryRoot, DEFAULT_DISCOVERY_CONFIG.cursorRoot, + dirname(DEFAULT_DISCOVERY_CONFIG.opencodeDbPath), ]; registerIpcHandlers(ipcMain, { "app:getHealth": () => ({ @@ -128,6 +131,7 @@ export async function bootstrapMainProcess( geminiHistoryRoot, geminiProjectsPath, cursorRoot: DEFAULT_DISCOVERY_CONFIG.cursorRoot, + opencodeDbPath: DEFAULT_DISCOVERY_CONFIG.opencodeDbPath, }, }), "db:getSchemaVersion": () => ({ @@ -404,6 +408,7 @@ function getAllowedOpenInFileManagerRoots(input: { addRoot(input.geminiProjectsPath); addRoot(dirname(input.geminiProjectsPath)); addRoot(DEFAULT_DISCOVERY_CONFIG.cursorRoot); + addRoot(dirname(DEFAULT_DISCOVERY_CONFIG.opencodeDbPath)); try { // Indexed project paths are dynamic, so fold them into the static provider/app roots cache. diff --git a/apps/desktop/src/main/indexingRunner.test.ts b/apps/desktop/src/main/indexingRunner.test.ts index 41e7ebc..e81f64d 100644 --- a/apps/desktop/src/main/indexingRunner.test.ts +++ b/apps/desktop/src/main/indexingRunner.test.ts @@ -8,7 +8,7 @@ type WorkerMessage = | { type: "file-issue"; issue: { - provider: "claude" | "codex" | "gemini" | "cursor"; + provider: "claude" | "codex" | "gemini" | "cursor" | "opencode"; sessionId: string; filePath: string; stage: "read" | "parse" | "persist"; @@ -18,7 +18,7 @@ type WorkerMessage = | { type: "notice"; notice: { - provider: "claude" | "codex" | "gemini" | "cursor"; + provider: "claude" | "codex" | "gemini" | "cursor" | "opencode"; sessionId: string; filePath: string; stage: "read" | "parse" | "persist"; @@ -38,6 +38,7 @@ type WorkerRequest = { codex?: string[]; gemini?: string[]; cursor?: string[]; + opencode?: string[]; }; }; @@ -219,6 +220,7 @@ describe("WorkerIndexingRunner", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }), }); @@ -233,6 +235,7 @@ describe("WorkerIndexingRunner", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, }, {}, diff --git a/apps/desktop/src/main/indexingWorker.ts b/apps/desktop/src/main/indexingWorker.ts index 5f9c464..f8cd8ed 100644 --- a/apps/desktop/src/main/indexingWorker.ts +++ b/apps/desktop/src/main/indexingWorker.ts @@ -8,6 +8,8 @@ import { runIncrementalIndexing, } from "@codetrail/core"; +import { initializeOpenCodeReaders } from "./openCodeReaders"; + type IncrementalRequest = { kind: "incremental"; dbPath: string; @@ -129,6 +131,8 @@ function handleRequest(request: IndexingWorkerRequest): void { } } +initializeOpenCodeReaders(); + if (parentPort) { parentPort.on("message", (request: IndexingWorkerRequest) => { handleRequest(request); diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 74c02c0..fdb4d39 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -35,6 +35,7 @@ describe("registerIpcHandlers", () => { geminiHistoryRoot: "/Users/test/.gemini/history", geminiProjectsPath: "/Users/test/.gemini/projects.json", cursorRoot: "/Users/test/.cursor/projects", + opencodeDbPath: "/mock/.local/share/opencode/opencode.db", }, }), "db:getSchemaVersion": () => ({ schemaVersion: 1 }), @@ -188,6 +189,7 @@ describe("registerIpcHandlers", () => { geminiHistoryRoot: "/Users/test/.gemini/history", geminiProjectsPath: "/Users/test/.gemini/projects.json", cursorRoot: "/Users/test/.cursor/projects", + opencodeDbPath: "/mock/.local/share/opencode/opencode.db", }, }), "db:getSchemaVersion": () => ({ schemaVersion: 1 }), diff --git a/apps/desktop/src/main/openCodeReaders.ts b/apps/desktop/src/main/openCodeReaders.ts new file mode 100644 index 0000000..1e12158 --- /dev/null +++ b/apps/desktop/src/main/openCodeReaders.ts @@ -0,0 +1,157 @@ +import { existsSync } from "node:fs"; + +import Database from "better-sqlite3"; + +import { setOpenCodeDbReader, setOpenCodeMessagePartReader } from "@codetrail/core"; + +function openReadonlyDb(dbPath: string): InstanceType | null { + if (!existsSync(dbPath)) { + return null; + } + try { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + db.pragma("journal_mode = WAL"); + return db; + } catch { + return null; + } +} + +export function initializeOpenCodeReaders(): void { + setOpenCodeDbReader({ + readSessions: (dbPath) => { + const db = openReadonlyDb(dbPath); + if (!db) { + return []; + } + try { + return db + .prepare( + `SELECT id, project_id, parent_id, title, directory, + time_created, time_updated + FROM session + ORDER BY time_updated DESC`, + ) + .all() as Array<{ + id: string; + project_id: string; + parent_id: string | null; + title: string; + directory: string; + time_created: number; + time_updated: number; + }>; + } finally { + db.close(); + } + }, + readProjects: (dbPath) => { + const db = openReadonlyDb(dbPath); + if (!db) { + return []; + } + try { + return db.prepare("SELECT id, worktree, name FROM project").all() as Array<{ + id: string; + worktree: string; + name: string | null; + }>; + } finally { + db.close(); + } + }, + }); + + setOpenCodeMessagePartReader({ + readSessionMessagesWithParts: (dbPath, sessionId) => { + const db = openReadonlyDb(dbPath); + if (!db) { + return []; + } + try { + const messages = db + .prepare( + `SELECT id, session_id, time_created, data + FROM message + WHERE session_id = ? + ORDER BY time_created ASC, id ASC`, + ) + .all(sessionId) as Array<{ + id: string; + session_id: string; + time_created: number; + data: string; + }>; + + const parts = db + .prepare( + `SELECT id, message_id, data + FROM part + WHERE session_id = ? + ORDER BY message_id, id`, + ) + .all(sessionId) as Array<{ + id: string; + message_id: string; + data: string; + }>; + + const partsByMessage = new Map(); + for (const part of parts) { + const existing = partsByMessage.get(part.message_id) ?? []; + existing.push(part); + partsByMessage.set(part.message_id, existing); + } + + return messages.map((msg) => { + let msgData: Record = {}; + try { + msgData = JSON.parse(msg.data) as Record; + } catch { + // ignore + } + + const time = msgData.time as Record | undefined; + const tokens = msgData.tokens as Record | undefined; + const path = msgData.path as Record | undefined; + const nestedModel = msgData.model as Record | undefined; + + const messageParts = partsByMessage.get(msg.id) ?? []; + return { + messageId: msg.id, + role: (msgData.role as string) ?? "user", + timeCreated: msg.time_created, + timeCompleted: + typeof time?.completed === "number" ? (time.completed as number) : null, + modelId: + (msgData.modelID as string) ?? + (nestedModel?.modelID as string) ?? + null, + providerId: + (msgData.providerID as string) ?? + (nestedModel?.providerID as string) ?? + null, + cwd: (path?.cwd as string) ?? null, + tokenInput: typeof tokens?.input === "number" ? (tokens.input as number) : null, + tokenOutput: typeof tokens?.output === "number" ? (tokens.output as number) : null, + parts: messageParts.map((p) => { + let partData: Record = {}; + try { + partData = JSON.parse(p.data) as Record; + } catch { + // ignore + } + return { + id: p.id, + type: (partData.type as string) ?? "unknown", + data: p.data, + }; + }), + }; + }); + } finally { + db.close(); + } + }, + }); +} diff --git a/apps/desktop/src/renderer/app/constants.ts b/apps/desktop/src/renderer/app/constants.ts index 3621a65..68ca7ff 100644 --- a/apps/desktop/src/renderer/app/constants.ts +++ b/apps/desktop/src/renderer/app/constants.ts @@ -64,6 +64,7 @@ export const EMPTY_SYSTEM_MESSAGE_REGEX_RULES: SystemMessageRegexRules = { codex: [], gemini: [], cursor: [], + opencode: [], }; export const SHORTCUT_ITEMS = [ diff --git a/apps/desktop/src/renderer/components/SettingsView.test.tsx b/apps/desktop/src/renderer/components/SettingsView.test.tsx index b3f6bbf..f222f93 100644 --- a/apps/desktop/src/renderer/components/SettingsView.test.tsx +++ b/apps/desktop/src/renderer/components/SettingsView.test.tsx @@ -35,6 +35,7 @@ const info = { geminiHistoryRoot: "/Users/test/.gemini/history", geminiProjectsPath: "/Users/test/.gemini/projects.json", cursorRoot: "/Users/test/.cursor/projects", + opencodeDbPath: "/mock/.local/share/opencode/opencode.db", }, }; @@ -111,6 +112,7 @@ function createBaseProps() { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, onAddSystemMessageRegexRule: vi.fn(), onUpdateSystemMessageRegexRule: vi.fn(), diff --git a/apps/desktop/src/renderer/components/SettingsView.tsx b/apps/desktop/src/renderer/components/SettingsView.tsx index b161768..f6e2c8f 100644 --- a/apps/desktop/src/renderer/components/SettingsView.tsx +++ b/apps/desktop/src/renderer/components/SettingsView.tsx @@ -129,6 +129,7 @@ export function SettingsView({ provider: "gemini", }, { label: "Cursor root", value: info.discovery.cursorRoot, provider: "cursor" }, + { label: "OpenCode DB", value: info.discovery.opencodeDbPath, provider: "opencode" }, ] : []; diff --git a/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx b/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx index 96510b1..d8afb3f 100644 --- a/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx +++ b/apps/desktop/src/renderer/components/history/ProjectPane.test.tsx @@ -50,8 +50,8 @@ describe("ProjectPane", () => { collapsed={false} projectQueryInput="" projectProviders={["claude", "codex"]} - providers={["claude", "codex", "gemini", "cursor"]} - projectProviderCounts={{ claude: 1, codex: 1, gemini: 0, cursor: 0 }} + providers={["claude", "codex", "gemini", "cursor", "opencode"]} + projectProviderCounts={{ claude: 1, codex: 1, gemini: 0, cursor: 0, opencode: 0 }} onToggleCollapsed={onToggleCollapsed} onProjectQueryChange={onProjectQueryChange} onToggleProvider={onToggleProvider} @@ -97,8 +97,8 @@ describe("ProjectPane", () => { collapsed={true} projectQueryInput="" projectProviders={["claude"]} - providers={["claude", "codex", "gemini", "cursor"]} - projectProviderCounts={{ claude: 1, codex: 1, gemini: 0, cursor: 0 }} + providers={["claude", "codex", "gemini", "cursor", "opencode"]} + projectProviderCounts={{ claude: 1, codex: 1, gemini: 0, cursor: 0, opencode: 0 }} onToggleCollapsed={vi.fn()} onProjectQueryChange={vi.fn()} onToggleProvider={vi.fn()} diff --git a/apps/desktop/src/renderer/features/useHistoryInteractions.ts b/apps/desktop/src/renderer/features/useHistoryInteractions.ts index aff9bff..3f601af 100644 --- a/apps/desktop/src/renderer/features/useHistoryInteractions.ts +++ b/apps/desktop/src/renderer/features/useHistoryInteractions.ts @@ -144,7 +144,7 @@ export function useHistoryInteractions({ sessionSearchInputRef: RefObject; loadProjects: () => Promise; loadSessions: () => Promise; - setProjectProviders: Dispatch>; + setProjectProviders: Dispatch>; setProjectQueryInput: Dispatch>; prettyProvider: (provider: ProjectSummary["provider"]) => string; refreshContextRef: MutableRefObject; diff --git a/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx b/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx index 35ac380..86bcbda 100644 --- a/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx +++ b/apps/desktop/src/renderer/hooks/usePaneStateSync.test.tsx @@ -57,6 +57,7 @@ function Harness({ logError }: { logError: (context: string, error: unknown) => codex: [], gemini: [], cursor: [], + opencode: [], }); const sessionScrollTopRef = useRef(0); const pendingRestoredSessionScrollRef = useRef<{ @@ -171,6 +172,7 @@ describe("usePaneStateSync", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, }; } @@ -200,6 +202,7 @@ describe("usePaneStateSync", () => { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, }); expect(logError).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/renderer/lib/viewUtils.ts b/apps/desktop/src/renderer/lib/viewUtils.ts index 56fe877..93fc971 100644 --- a/apps/desktop/src/renderer/lib/viewUtils.ts +++ b/apps/desktop/src/renderer/lib/viewUtils.ts @@ -11,6 +11,7 @@ export const PROVIDER_LABELS: Record = { codex: "Codex", gemini: "Gemini", cursor: "Cursor", + opencode: "OpenCode", }; export const CATEGORY_LABELS: Record = { @@ -82,7 +83,7 @@ export function prettyProvider(provider: Provider): string { } export function countProviders(values: Provider[]): Record { - const counts: Record = { claude: 0, codex: 0, gemini: 0, cursor: 0 }; + const counts: Record = { claude: 0, codex: 0, gemini: 0, cursor: 0, opencode: 0 }; for (const value of values) { counts[value] += 1; } diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 33aa0fc..cc874c1 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -35,6 +35,9 @@ --accent-red: #dc2626; --accent-red-dim: rgba(220, 38, 38, 0.08); --accent-red-border: rgba(220, 38, 38, 0.18); + --accent-teal: #0891b2; + --accent-teal-dim: rgba(8, 145, 178, 0.08); + --accent-teal-border: rgba(8, 145, 178, 0.2); --accent-muted: #64748b; --accent-muted-dim: rgba(100, 116, 139, 0.08); --accent-muted-border: rgba(100, 116, 139, 0.18); @@ -158,6 +161,9 @@ --accent-red: #f08080; --accent-red-dim: rgba(240, 128, 128, 0.14); --accent-red-border: rgba(240, 128, 128, 0.22); + --accent-teal: #22d3ee; + --accent-teal-dim: rgba(34, 211, 238, 0.14); + --accent-teal-border: rgba(34, 211, 238, 0.22); --accent-muted: #a8b0c0; --accent-muted-dim: rgba(148, 163, 184, 0.1); --accent-muted-border: rgba(148, 163, 184, 0.18); @@ -274,6 +280,9 @@ --accent-red: #cc6666; --accent-red-dim: rgba(204, 102, 102, 0.14); --accent-red-border: rgba(204, 102, 102, 0.24); + --accent-teal: #8abeb7; + --accent-teal-dim: rgba(138, 190, 183, 0.14); + --accent-teal-border: rgba(138, 190, 183, 0.24); --accent-muted: #969896; --accent-muted-dim: rgba(150, 152, 150, 0.1); --accent-muted-border: rgba(150, 152, 150, 0.18); @@ -388,6 +397,9 @@ --accent-red: #f38ba8; --accent-red-dim: rgba(243, 139, 168, 0.14); --accent-red-border: rgba(243, 139, 168, 0.24); + --accent-teal: #94e2d5; + --accent-teal-dim: rgba(148, 226, 213, 0.14); + --accent-teal-border: rgba(148, 226, 213, 0.24); --accent-muted: #a6adc8; --accent-muted-dim: rgba(166, 173, 200, 0.1); --accent-muted-border: rgba(166, 173, 200, 0.18); @@ -1165,6 +1177,12 @@ body.platform-macos .titlebar-left { color: var(--accent-green); } +.tag-opencode, +.provider-chip.provider-opencode { + background: var(--accent-teal-dim); + color: var(--accent-teal); +} + .tag-codex.active, .provider-chip.active.provider-codex { background: var(--accent-blue); @@ -1190,6 +1208,12 @@ body.platform-macos .titlebar-left { color: #ffffff; } +.tag-opencode.active, +.provider-chip.active.provider-opencode { + background: var(--accent-teal); + color: #ffffff; +} + .list-scroll, .project-list, .session-list, @@ -1319,6 +1343,11 @@ body.platform-macos .titlebar-left { color: var(--accent-green); } +.meta-tag.opencode { + background: var(--accent-teal-dim); + color: var(--accent-teal); +} + .sessions-count { font-size: 11px; font-weight: 600; @@ -2099,6 +2128,10 @@ body.platform-macos .titlebar-left { color: var(--accent-green); } +.provider-label.provider-opencode { + color: var(--accent-teal); +} + .message-header-actions { display: inline-flex; align-items: center; @@ -2803,6 +2836,11 @@ mark { color: var(--accent-green); } +.settings-provider-opencode { + background: var(--accent-teal-dim); + color: var(--accent-teal); +} + .settings-value { font-family: var(--font-mono); font-size: 12px; diff --git a/apps/desktop/src/shared/uiPreferences.ts b/apps/desktop/src/shared/uiPreferences.ts index 36e7e88..1c359c2 100644 --- a/apps/desktop/src/shared/uiPreferences.ts +++ b/apps/desktop/src/shared/uiPreferences.ts @@ -41,7 +41,7 @@ export type RegularFontSize = | "18px" | "20px"; -export const UI_PROVIDER_VALUES: Provider[] = ["claude", "codex", "gemini", "cursor"]; +export const UI_PROVIDER_VALUES: Provider[] = ["claude", "codex", "gemini", "cursor", "opencode"]; export const UI_MESSAGE_CATEGORY_VALUES: MessageCategory[] = [ "user", diff --git a/packages/core/src/contracts/canonical.ts b/packages/core/src/contracts/canonical.ts index 678ea38..09e680c 100644 --- a/packages/core/src/contracts/canonical.ts +++ b/packages/core/src/contracts/canonical.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const providerSchema = z.enum(["claude", "codex", "gemini", "cursor"]); +export const providerSchema = z.enum(["claude", "codex", "gemini", "cursor", "opencode"]); export type Provider = z.infer; export const PROVIDER_VALUES = providerSchema.options; diff --git a/packages/core/src/contracts/ipc.test.ts b/packages/core/src/contracts/ipc.test.ts index cd5b181..5fcfb71 100644 --- a/packages/core/src/contracts/ipc.test.ts +++ b/packages/core/src/contracts/ipc.test.ts @@ -33,6 +33,7 @@ const channelExamples: Record = { geminiHistoryRoot: "/home/user/.gemini/history", geminiProjectsPath: "/home/user/.gemini/projects.json", cursorRoot: "/home/user/.cursor/projects", + opencodeDbPath: "/home/user/.local/share/opencode/opencode.db", }, }, }, @@ -213,6 +214,7 @@ const channelExamples: Record = { codex: ["^"], gemini: [], cursor: [], + opencode: [], }, autoScrollEnabled: false, }, diff --git a/packages/core/src/contracts/ipc.ts b/packages/core/src/contracts/ipc.ts index 66e02d8..a8f45df 100644 --- a/packages/core/src/contracts/ipc.ts +++ b/packages/core/src/contracts/ipc.ts @@ -145,6 +145,7 @@ const systemMessageRegexRulesSchema = z.object({ codex: z.array(z.string()), gemini: z.array(z.string()), cursor: z.array(z.string()), + opencode: z.array(z.string()), }); // Single source of truth for pane state fields. The non-nullable base schema is used @@ -206,6 +207,7 @@ const settingsInfoResponseSchema = z.object({ geminiHistoryRoot: z.string().min(1), geminiProjectsPath: z.string().min(1), cursorRoot: z.string().min(1), + opencodeDbPath: z.string().min(1), }), }); diff --git a/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts b/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts index a6e1a3a..4faae33 100644 --- a/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts +++ b/packages/core/src/discovery/discoverSessionFiles.pythonFixtures.test.ts @@ -14,6 +14,7 @@ describe("discoverSessionFiles python fixtures", () => { geminiHistoryRoot: join(fixturesRoot, "gemini", "history"), geminiProjectsPath: join(fixturesRoot, "gemini", "projects.json"), cursorRoot: join(fixturesRoot, "cursor", "projects"), + opencodeDbPath: join(fixturesRoot, ".local", "share", "opencode", "opencode.db"), includeClaudeSubagents: false, }); diff --git a/packages/core/src/discovery/discoverSessionFiles.test.ts b/packages/core/src/discovery/discoverSessionFiles.test.ts index ddba085..2025359 100644 --- a/packages/core/src/discovery/discoverSessionFiles.test.ts +++ b/packages/core/src/discovery/discoverSessionFiles.test.ts @@ -96,6 +96,7 @@ describe("discoverSessionFiles", () => { geminiHistoryRoot, geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), + opencodeDbPath: join(dir, ".local", "share", "opencode", "opencode.db"), includeClaudeSubagents: true, }); @@ -124,6 +125,7 @@ describe("discoverSessionFiles", () => { geminiHistoryRoot, geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), + opencodeDbPath: join(dir, ".local", "share", "opencode", "opencode.db"), includeClaudeSubagents: false, }); @@ -189,6 +191,7 @@ describe("discoverSessionFiles", () => { geminiHistoryRoot: join(dir, ".gemini", "history"), geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot, + opencodeDbPath: join(dir, ".local", "share", "opencode", "opencode.db"), includeClaudeSubagents: false, }); diff --git a/packages/core/src/discovery/discoverSessionFiles.ts b/packages/core/src/discovery/discoverSessionFiles.ts index 6cff569..31a6703 100644 --- a/packages/core/src/discovery/discoverSessionFiles.ts +++ b/packages/core/src/discovery/discoverSessionFiles.ts @@ -72,6 +72,7 @@ export const DEFAULT_DISCOVERY_CONFIG: DiscoveryConfig = { geminiHistoryRoot: join(homedir(), ".gemini", "history"), geminiProjectsPath: join(homedir(), ".gemini", "projects.json"), cursorRoot: join(homedir(), ".cursor", "projects"), + opencodeDbPath: join(homedir(), ".local", "share", "opencode", "opencode.db"), includeClaudeSubagents: false, }; @@ -89,6 +90,7 @@ export function discoverSessionFiles( ...discoverCodexFiles(config, resolvedDependencies), ...discoverGeminiFiles(config, geminiResolution, resolvedDependencies), ...discoverCursorFiles(config, resolvedDependencies), + ...discoverOpenCodeFiles(config, resolvedDependencies), ].sort((left, right) => { const byMtime = right.fileMtimeMs - left.fileMtimeMs; if (byMtime !== 0) { @@ -402,6 +404,169 @@ function discoverCursorFiles( return discovered; } +type OpenCodeSessionRow = { + id: string; + project_id: string; + title: string; + directory: string; + time_created: number; + time_updated: number; + parent_id: string | null; +}; + +type OpenCodeProjectRow = { + id: string; + worktree: string; + name: string | null; +}; + +export type OpenCodeDbReader = { + readSessions: (dbPath: string) => OpenCodeSessionRow[]; + readProjects: (dbPath: string) => OpenCodeProjectRow[]; +}; + +let _opencodeDbReader: OpenCodeDbReader | null = null; + +export function setOpenCodeDbReader(reader: OpenCodeDbReader): void { + _opencodeDbReader = reader; +} + +export function getOpenCodeDbReader(): OpenCodeDbReader | null { + return _opencodeDbReader; +} + +export const OPENCODE_SESSION_SEPARATOR = "#session:"; + +function openCodeVirtualPath(dbPath: string, sessionId: string): string { + return `${dbPath}${OPENCODE_SESSION_SEPARATOR}${sessionId}`; +} + +export function parseOpenCodeVirtualPath( + filePath: string, +): { dbPath: string; sessionId: string } | null { + const idx = filePath.indexOf(OPENCODE_SESSION_SEPARATOR); + if (idx < 0) { + return null; + } + return { + dbPath: filePath.slice(0, idx), + sessionId: filePath.slice(idx + OPENCODE_SESSION_SEPARATOR.length), + }; +} + +function discoverOpenCodeFiles( + config: DiscoveryConfig, + dependencies: ResolvedDiscoveryDependencies, +): DiscoveredSessionFile[] { + if (!config.opencodeDbPath || !dependencies.fs.existsSync(config.opencodeDbPath)) { + return []; + } + + const reader = _opencodeDbReader; + if (!reader) { + return []; + } + + const discovered: DiscoveredSessionFile[] = []; + + try { + const projects = reader.readProjects(config.opencodeDbPath); + const projectMap = new Map(); + for (const project of projects) { + projectMap.set(project.id, project); + } + + const sessions = reader.readSessions(config.opencodeDbPath); + for (const session of sessions) { + if (session.parent_id) { + continue; + } + + const virtualPath = openCodeVirtualPath(config.opencodeDbPath, session.id); + const project = projectMap.get(session.project_id); + const projectPath = project?.worktree ?? session.directory ?? ""; + const sessionIdentity = providerSessionIdentity("opencode", session.id, virtualPath); + + discovered.push({ + provider: "opencode", + projectPath, + projectName: projectNameFromPath(projectPath), + sessionIdentity, + sourceSessionId: session.id, + filePath: virtualPath, + fileSize: session.time_updated, + fileMtimeMs: session.time_updated, + metadata: { + includeInHistory: true, + isSubagent: false, + unresolvedProject: !projectPath, + gitBranch: null, + cwd: projectPath || null, + title: session.title || null, + }, + }); + } + } catch { + // OpenCode DB may be locked or corrupt; reduce coverage, don't abort. + } + + return discovered; +} + +function discoverSingleOpenCodeFile( + filePath: string, + config: DiscoveryConfig, + _dependencies: ResolvedDiscoveryDependencies, +): DiscoveredSessionFile | null { + if (!config.opencodeDbPath) { + return null; + } + + const parsed = parseOpenCodeVirtualPath(filePath); + if (!parsed) { + return null; + } + + const reader = _opencodeDbReader; + if (!reader) { + return null; + } + + try { + const sessions = reader.readSessions(config.opencodeDbPath); + const session = sessions.find((s) => s.id === parsed.sessionId); + if (!session) { + return null; + } + + const projects = reader.readProjects(config.opencodeDbPath); + const project = projects.find((p) => p.id === session.project_id); + const projectPath = project?.worktree ?? session.directory ?? ""; + const sessionIdentity = providerSessionIdentity("opencode", session.id, filePath); + + return { + provider: "opencode", + projectPath, + projectName: projectNameFromPath(projectPath), + sessionIdentity, + sourceSessionId: session.id, + filePath, + fileSize: session.time_updated, + fileMtimeMs: session.time_updated, + metadata: { + includeInHistory: true, + isSubagent: false, + unresolvedProject: !projectPath, + gitBranch: null, + cwd: projectPath || null, + title: session.title || null, + }, + }; + } catch { + return null; + } +} + /** * Determines which provider a single file belongs to and constructs a {@link DiscoveredSessionFile} * using the same rules as the per-provider discover functions. Returns `null` if the file is @@ -433,6 +598,12 @@ export function discoverSingleFile( if (filePath.startsWith(`${config.cursorRoot}/`)) { return discoverSingleCursorFile(filePath, config, resolved); } + if ( + config.opencodeDbPath && + (filePath === config.opencodeDbPath || filePath.startsWith(`${config.opencodeDbPath}#`)) + ) { + return discoverSingleOpenCodeFile(filePath, config, resolved); + } return null; } @@ -1058,7 +1229,7 @@ function projectNameFromPath(projectPath: string): string { } function providerSessionIdentity( - provider: "codex" | "gemini" | "cursor", + provider: "codex" | "gemini" | "cursor" | "opencode", sourceSessionId: string, filePath: string, ): string { diff --git a/packages/core/src/discovery/discoverSingleFile.test.ts b/packages/core/src/discovery/discoverSingleFile.test.ts index 9fd612b..5acab3b 100644 --- a/packages/core/src/discovery/discoverSingleFile.test.ts +++ b/packages/core/src/discovery/discoverSingleFile.test.ts @@ -15,6 +15,7 @@ function makeConfig(dir: string): DiscoveryConfig { geminiHistoryRoot: join(dir, ".gemini", "history"), geminiProjectsPath: join(dir, ".gemini", "projects.json"), cursorRoot: join(dir, ".cursor", "projects"), + opencodeDbPath: join(dir, ".local", "share", "opencode", "opencode.db"), includeClaudeSubagents: false, }; } diff --git a/packages/core/src/discovery/types.ts b/packages/core/src/discovery/types.ts index 385d091..7f4aadb 100644 --- a/packages/core/src/discovery/types.ts +++ b/packages/core/src/discovery/types.ts @@ -15,6 +15,7 @@ export type DiscoveredSessionFile = { unresolvedProject: boolean; gitBranch: string | null; cwd: string | null; + title?: string | null; }; }; @@ -25,6 +26,7 @@ export type DiscoveryConfig = { geminiHistoryRoot?: string; geminiProjectsPath?: string; cursorRoot: string; + opencodeDbPath: string; includeClaudeSubagents: boolean; }; diff --git a/packages/core/src/indexing/indexSessions.ts b/packages/core/src/indexing/indexSessions.ts index 3b50e26..4a6f5b0 100644 --- a/packages/core/src/indexing/indexSessions.ts +++ b/packages/core/src/indexing/indexSessions.ts @@ -18,6 +18,7 @@ import { type DiscoveryConfig, discoverSessionFiles, discoverSingleFile, + parseOpenCodeVirtualPath, } from "../discovery"; import { type ParserDiagnostic, parseSession, parseSessionEvent } from "../parsing"; import { asArray, asRecord, readString } from "../parsing/helpers"; @@ -576,7 +577,7 @@ function processDiscoveredFiles(args: { try { const fileDiagnostics = - discovered.provider === "gemini" + discovered.provider === "gemini" || discovered.provider === "opencode" ? indexMaterializedSessionFile({ db: args.db, discovered, @@ -691,7 +692,8 @@ function indexMaterializedSessionFile(args: { args.discovered.provider, args.discovered.fileMtimeMs, ); - const sessionTitle = deriveSessionTitle(messagesWithTimestamps); + const sessionTitle = + deriveSessionTitle(messagesWithTimestamps) || args.discovered.metadata.title || ""; const modelNames = sourceMeta.models.join(","); const aggregate = buildSessionAggregate( messagesWithTimestamps.map((message) => ({ @@ -1695,7 +1697,7 @@ function shouldResumeFromCheckpoint(args: { if (!args.existing || !args.checkpoint || !args.existingSessionId) { return false; } - if (args.discovered.provider === "gemini") { + if (args.discovered.provider === "gemini" || args.discovered.provider === "opencode") { return false; } if (args.existingSessionId !== args.expectedSessionDbId) { @@ -1963,6 +1965,11 @@ function readProviderSource( }; } + // OpenCode stores sessions in its own SQLite DB; read messages+parts and synthesize events. + if (provider === "opencode") { + return readOpenCodeSource(filePath); + } + const lines = readFileText(filePath) .split(/\r?\n/) .map((line) => line.trim()) @@ -1987,6 +1994,82 @@ function readProviderSource( } } +export type OpenCodeMessagePartReader = { + readSessionMessagesWithParts: ( + dbPath: string, + sessionId: string, + ) => Array<{ + messageId: string; + role: string; + timeCreated: number; + timeCompleted: number | null; + modelId: string | null; + providerId: string | null; + cwd: string | null; + tokenInput: number | null; + tokenOutput: number | null; + parts: Array<{ id: string; type: string; data: string }>; + }>; +}; + +let _opencodeMessagePartReader: OpenCodeMessagePartReader | null = null; + +export function setOpenCodeMessagePartReader(reader: OpenCodeMessagePartReader): void { + _opencodeMessagePartReader = reader; +} + +function readOpenCodeSource(filePath: string): { + rawPayload: unknown[]; + parsePayload: unknown[]; +} | null { + const parsed = parseOpenCodeVirtualPath(filePath); + if (!parsed) { + return null; + } + + const reader = _opencodeMessagePartReader; + if (!reader) { + return null; + } + + try { + const messages = reader.readSessionMessagesWithParts(parsed.dbPath, parsed.sessionId); + const events: unknown[] = []; + + for (const message of messages) { + const parts: unknown[] = []; + for (const part of message.parts) { + try { + parts.push(JSON.parse(part.data)); + } catch { + parts.push({ type: part.type, text: part.data }); + } + } + + events.push({ + messageId: message.messageId, + role: message.role, + timestamp: new Date(message.timeCreated).toISOString(), + completedAt: message.timeCompleted + ? new Date(message.timeCompleted).toISOString() + : null, + model: message.modelId, + providerId: message.providerId, + cwd: message.cwd, + usage: { + input_tokens: message.tokenInput, + output_tokens: message.tokenOutput, + }, + parts, + }); + } + + return { rawPayload: events, parsePayload: events }; + } catch { + return null; + } +} + function extractSourceMetadata( provider: Provider, payload: unknown[] | Record, @@ -2068,6 +2151,20 @@ function extractSourceMetadata( } } + if (provider === "opencode") { + for (const entry of asArray(payload)) { + const record = asRecord(entry); + if (!record) { + continue; + } + const model = readString(record.model); + if (model) { + models.add(model); + } + cwd ??= readString(record.cwd); + } + } + return { models: [...models].sort(), gitBranch, @@ -2255,6 +2352,7 @@ function compileSystemMessageRules(overrides?: SystemMessageRegexRuleOverrides): codex: [], gemini: [], cursor: [], + opencode: [], }; let invalidCount = 0; diff --git a/packages/core/src/indexing/systemMessageRules.test.ts b/packages/core/src/indexing/systemMessageRules.test.ts index db14d56..8fbd94f 100644 --- a/packages/core/src/indexing/systemMessageRules.test.ts +++ b/packages/core/src/indexing/systemMessageRules.test.ts @@ -22,6 +22,7 @@ describe("systemMessageRules", () => { codex: [], gemini: [], cursor: [], + opencode: [], }); }); diff --git a/packages/core/src/indexing/systemMessageRules.ts b/packages/core/src/indexing/systemMessageRules.ts index 2cb4818..32e8127 100644 --- a/packages/core/src/indexing/systemMessageRules.ts +++ b/packages/core/src/indexing/systemMessageRules.ts @@ -11,6 +11,7 @@ export const DEFAULT_SYSTEM_MESSAGE_REGEX_RULES: SystemMessageRegexRules = { ], gemini: [], cursor: [], + opencode: [], }; export function resolveSystemMessageRegexRules( @@ -21,6 +22,7 @@ export function resolveSystemMessageRegexRules( codex: [...DEFAULT_SYSTEM_MESSAGE_REGEX_RULES.codex], gemini: [...DEFAULT_SYSTEM_MESSAGE_REGEX_RULES.gemini], cursor: [...DEFAULT_SYSTEM_MESSAGE_REGEX_RULES.cursor], + opencode: [...DEFAULT_SYSTEM_MESSAGE_REGEX_RULES.opencode], }; if (!overrides) { diff --git a/packages/core/src/parsing/providerParsers.ts b/packages/core/src/parsing/providerParsers.ts index 07cd7ae..e1b433b 100644 --- a/packages/core/src/parsing/providerParsers.ts +++ b/packages/core/src/parsing/providerParsers.ts @@ -99,6 +99,10 @@ export function parseProviderEvent(args: ParseProviderEventArgs): ParseProviderE return parseCursorEvent(args); } + if (args.provider === "opencode") { + return parseOpenCodeEvent(args); + } + return parseGeminiEvent(args); } @@ -379,6 +383,160 @@ function parseCursorEvent(args: ParseProviderEventArgs): ParseProviderEventResul }; } +function parseOpenCodeEvent(args: ParseProviderEventArgs): ParseProviderEventResult { + const { provider, sessionId, eventIndex, event, diagnostics, sequence } = args; + const output: ParsedProviderMessage[] = []; + const eventRecord = asRecord(event); + if (!eventRecord) { + return { + messages: output, + nextSequence: pushNonObjectEvent({ + output, + provider, + sessionId, + eventIndex, + event, + diagnostics, + sequence, + }), + }; + } + + const role = lowerString(eventRecord.role); + const messageId = readString(eventRecord.messageId); + const parts = asArray(eventRecord.parts); + + if (parts.length === 0) { + return { messages: output, nextSequence: sequence }; + } + + const createdAt = extractEventTimestamp(eventRecord); + const usage = extractTokenUsage(eventRecord); + const segments: EventSegment[] = []; + + for (const part of parts) { + const partRecord = asRecord(part); + if (!partRecord) { + continue; + } + + const partType = lowerString(partRecord.type); + const text = readString(partRecord.text) ?? ""; + + if (partType === "text") { + if (text.length > 0) { + const category = role === "user" ? "user" : "assistant"; + segments.push({ category, content: text }); + } + continue; + } + + if (partType === "reasoning") { + if (text.length > 0) { + segments.push({ category: "thinking", content: text }); + } + continue; + } + + if (partType === "tool") { + const state = asRecord(partRecord.state); + const toolName = readString(partRecord.tool) ?? "tool"; + const input = state ? asRecord(state.input) : null; + const toolOutput = state ? readString(state.output) : null; + const status = state ? readString(state.status) : null; + + segments.push({ + category: isLikelyEditOperation(toolName) ? "tool_edit" : "tool_use", + content: serializeUnknown({ + type: "tool_use", + id: readString(partRecord.callID) ?? `${sessionId}:tool:${sequence}`, + name: toolName, + input: input ?? {}, + }), + }); + + if (toolOutput && status === "completed") { + const durationMs = extractOpenCodeToolDuration(state); + segments.push({ + category: "tool_result", + content: toolOutput, + operationDurationMs: durationMs, + operationDurationSource: durationMs !== null ? "native" : null, + operationDurationConfidence: durationMs !== null ? "high" : null, + }); + } + continue; + } + + if (partType === "patch" || partType === "file") { + const filename = readString(partRecord.filename) ?? ""; + const files = asArray(partRecord.files); + const content = + files.length > 0 + ? files.map((f) => readString(f) ?? "").join(", ") + : filename || serializeUnknown(partRecord); + if (content.length > 0) { + segments.push({ category: "tool_edit", content }); + } + continue; + } + + if (partType === "step-start" || partType === "step-finish") { + if (partType === "step-finish") { + const tokens = asRecord(partRecord.tokens); + if (tokens) { + const tokenInput = + typeof tokens.input === "number" ? tokens.input : null; + const tokenOutput = + typeof tokens.output === "number" ? tokens.output : null; + if (tokenInput !== null || tokenOutput !== null) { + // Token info is captured via the message-level usage extraction + } + } + } + continue; + } + } + + const normalizedSegments = dedupeSegments(segments); + if (normalizedSegments.length === 0) { + return { messages: output, nextSequence: sequence }; + } + + return { + messages: output, + nextSequence: pushSplitMessages({ + output, + sessionId, + sequence, + baseId: messageId, + createdAt, + tokenUsage: usage, + segments: normalizedSegments, + fallbackRaw: event, + }), + }; +} + +function extractOpenCodeToolDuration( + state: Record | null | undefined, +): number | null { + if (!state) { + return null; + } + const time = asRecord(state.time); + if (!time) { + return null; + } + const start = typeof time.start === "number" ? time.start : null; + const end = typeof time.end === "number" ? time.end : null; + if (start === null || end === null) { + return null; + } + const durationMs = end - start; + return durationMs > 0 ? durationMs : null; +} + function parseCursorSegments(role: string | null, event: Record): EventSegment[] { const segments: EventSegment[] = []; const contentBlocks = asArray(event.content); diff --git a/packages/core/src/testing/inMemory.test.ts b/packages/core/src/testing/inMemory.test.ts index 7798a78..3e0b26e 100644 --- a/packages/core/src/testing/inMemory.test.ts +++ b/packages/core/src/testing/inMemory.test.ts @@ -64,6 +64,7 @@ describe("core testing helpers", () => { geminiHistoryRoot: "/fixtures/.gemini/history", geminiProjectsPath: "/fixtures/.gemini/projects.json", cursorRoot: "/fixtures/.cursor/projects", + opencodeDbPath: "/fixtures/.local/share/opencode/opencode.db", includeClaudeSubagents: false, }, { fs: fs.toDiscoveryFileSystem() },