From 44a11a167b0ab79ce0a2a9705c211a09217e295c Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:31:27 -0400 Subject: [PATCH] feat: import OpenCode sessions and make the summary model configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions that were developed together because the first one made the second one worth having. OpenCode source adapter ----------------------- OpenCode keeps sessions in a single SQLite database rather than one file per session, so it cannot be scanned like the Claude Code and Codex sources. An opt-in adapter exports each session into a staging directory of JSONL files during `ingest`, which the normal scanner then picks up — leaving dedup, --force, grouping, summarization and the web UI untouched. Three things the implementation has to work around: - `opencode session list` only reports sessions belonging to the current working directory's project, so no single cwd can enumerate them all. Sessions are enumerated from OpenCode's database; transcripts still come from `opencode export`, which works from anywhere. - `opencode export` yields empty stdout over a pipe but is complete when redirected to a file, and a single export can run to megabytes. Both call sites go through one runToFile helper. - is_subagent was derived purely from Claude Code's /subagents/ path convention, so flat-staged files always read as 0. Formats that state it outright now win; Claude Code keeps path detection, because its parentSessionId also covers continuations and would otherwise misflag them. Projects are keyed off each session's working directory, so OpenCode and Claude Code work in the same repo on the same day lands in one journal entry. Configurable summary provider ----------------------------- Summaries and session titles were hardcoded to Claude Haiku via the Agent SDK. A summary_provider config block now selects any OpenAI-compatible endpoint, including a local llama.cpp server. Absent config keeps the previous behaviour. Both call sites route through the new provider, since leaving titles on Anthropic would silently contradict a configured provider, and the Claude auth preflight is skipped for non-Anthropic providers that authenticate with their own key. Entries record the model that actually produced them. API keys are referenced by environment variable name and never stored in config. Reasoning models spend completion tokens before emitting content, so an exhausted budget fails loudly rather than writing an empty summary. Co-Authored-By: Claude Opus 5 --- README.md | 51 ++++ src/config.test.ts | 36 +++ src/config.ts | 13 + src/index.ts | 28 +- src/ingest.test.ts | 24 ++ src/ingest.ts | 5 +- src/llm.test.ts | 97 +++++++ src/llm.ts | 119 +++++++++ src/opencode.test.ts | 267 +++++++++++++++++++ src/opencode.ts | 244 +++++++++++++++++ src/parser.test.ts | 59 ++++ src/parser.ts | 42 +++ src/summarize.test.ts | 46 +++- src/summarize.ts | 60 ++--- src/titles.ts | 31 +-- tests/fixtures/test-opencode-session-1.jsonl | 4 + tests/fixtures/test-opencode-subagent.jsonl | 3 + 17 files changed, 1064 insertions(+), 65 deletions(-) create mode 100644 src/llm.test.ts create mode 100644 src/llm.ts create mode 100644 src/opencode.test.ts create mode 100644 src/opencode.ts create mode 100644 tests/fixtures/test-opencode-session-1.jsonl create mode 100644 tests/fixtures/test-opencode-subagent.jsonl diff --git a/README.md b/README.md index 5f190f2..db02621 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,57 @@ Config lives at `~/.config/engineering-notebook/config.json`: | `summary_instructions` | Custom instructions appended to the LLM summarization prompt | `""` | | `remote_sources` | SSH remote sources to sync before ingesting | `[]` | | `auto_sync_interval` | Seconds between auto-syncs when serving | `60` | +| `opencode` | OpenCode session import (opt-in) | absent | +| `summary_provider` | Which model writes summaries and titles | Claude Haiku | + +### OpenCode sessions + +OpenCode keeps its sessions in a single SQLite database rather than one file per +session, so they cannot be scanned like Claude Code and Codex sources. Enabling +this block exports each session to a staging directory of JSONL files during +`ingest`, which the normal scanner then picks up: + +```json +{ + "opencode": { + "enabled": true, + "staging_dir": "~/.cache/engineering-notebook/opencode", + "max_count": 200 + } +} +``` + +Sessions are enumerated from OpenCode's database (`opencode session list` only +reports the current directory's project, so it cannot see them all) and their +transcripts are exported with `opencode export`. A manifest of last-seen update +times keeps repeat syncs cheap — only changed sessions are re-exported. Omit +`max_count` to take everything. + +Projects are keyed off each session's working directory, so OpenCode and Claude +Code work in the same repo on the same day lands in one journal entry. + +### Choosing a model + +Summaries and session titles are written by Claude Haiku through the Agent SDK +by default, which needs no API key. Any OpenAI-compatible endpoint can be used +instead — including a local llama.cpp server: + +```json +{ + "summary_provider": { + "type": "openai", + "base_url": "http://your-host:8001/v1", + "model": "gemma-4", + "api_key_env": "SPARK_API_KEY", + "max_tokens": 4000 + } +} +``` + +`api_key_env` names the environment variable holding the key — the key itself is +never stored in the config file. Reasoning models spend completion tokens +thinking before emitting any content, so keep `max_tokens` generous (it defaults +to 4000); too small a budget returns an empty response rather than a summary. ### Remote Sources diff --git a/src/config.test.ts b/src/config.test.ts index bf382f3..be911cc 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -82,3 +82,39 @@ describe("expandPath", () => { expect(expandPath("~")).toBe("~"); }); }); + +describe("opencode config", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "en-config-oc-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("is absent by default so the adapter stays opt-in", () => { + const config = loadConfig(join(tempDir, "nonexistent.json")); + expect(config.opencode).toBeUndefined(); + }); + + test("round-trips an opencode block from the config file", () => { + const configPath = join(tempDir, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + opencode: { + enabled: true, + staging_dir: "~/.cache/engineering-notebook/opencode", + max_count: 50, + }, + }) + ); + + const loaded = loadConfig(configPath); + expect(loaded.opencode?.enabled).toBe(true); + expect(loaded.opencode?.staging_dir).toBe("~/.cache/engineering-notebook/opencode"); + expect(loaded.opencode?.max_count).toBe(50); + }); +}); diff --git a/src/config.ts b/src/config.ts index ad6f62b..9188d7a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { dirname, join } from "path"; import { homedir } from "os"; +import type { SummaryProvider } from "./llm"; export type RemoteSource = { name: string; @@ -9,6 +10,14 @@ export type RemoteSource = { enabled: boolean; }; +export type OpenCodeConfig = { + enabled: boolean; + /** Where exported sessions are staged for the scanner. */ + staging_dir: string; + /** Limit to the N most recent sessions; omit for all. */ + max_count?: number; +}; + export type Config = { sources: string[]; exclude: string[]; @@ -18,6 +27,10 @@ export type Config = { summary_instructions: string; remote_sources: RemoteSource[]; auto_sync_interval: number; + /** Optional and absent by default: the OpenCode adapter is opt-in. */ + opencode?: OpenCodeConfig; + /** Which model writes journal entries and session titles. Claude by default. */ + summary_provider?: SummaryProvider; }; export function defaultConfig(): Config { diff --git a/src/index.ts b/src/index.ts index 394a1ae..db9ea23 100755 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,30 @@ switch (command) { } } + // Stage OpenCode sessions, which live in a SQLite DB rather than one file + // per session, into a scannable directory of JSONL files. + if (config.opencode?.enabled) { + const { syncOpenCodeSessions, cliRunner } = await import("./opencode"); + const stagingDir = expandPath(config.opencode.staging_dir); + console.log("Syncing OpenCode sessions..."); + try { + const ocResult = await syncOpenCodeSessions(stagingDir, cliRunner(), { + maxCount: config.opencode.max_count, + }); + console.log( + ` OpenCode: ${ocResult.written} exported, ${ocResult.skipped} unchanged` + ); + for (const err of ocResult.errors.slice(0, 5)) { + console.log(` ✗ ${err}`); + } + sources.push(stagingDir); + } catch (err) { + console.log( + ` ✗ OpenCode sync failed: ${err instanceof Error ? err.message : err}` + ); + } + } + console.log(`Scanning ${sources.length} source(s)...`); const files = scanSources(sources, config.exclude); console.log(`Found ${files.length} session file(s)`); @@ -73,9 +97,11 @@ switch (command) { } const { summarizeAll } = await import("./summarize"); + const { providerModel } = await import("./llm"); + console.log(`Summarizing with ${providerModel(config.summary_provider)}...`); const result = await summarizeAll(db, filterDate, filterProject, (done, total, group) => { console.log(`[${done + 1}/${total}] Summarizing ${group.projectName} (${group.date})...`); - }, config.day_start_hour, config.summary_instructions); + }, config.day_start_hour, config.summary_instructions, config.summary_provider); console.log(`Summarized: ${result.summarized}, Skipped: ${result.skipped}, Errors: ${result.errors.length}`); if (result.skipped > 0) { diff --git a/src/ingest.test.ts b/src/ingest.test.ts index 15586da..af93e1c 100644 --- a/src/ingest.test.ts +++ b/src/ingest.test.ts @@ -147,6 +147,30 @@ describe("ingestSessions", () => { expect(session?.is_subagent).toBe(0); }); + test("marks an OpenCode session with a parent as is_subagent=1 despite a flat path", () => { + const fixturePath = join(import.meta.dir, "../tests/fixtures/test-opencode-subagent.jsonl"); + const stagingDir = join(tempDir, "opencode-staging"); + mkdirSync(stagingDir, { recursive: true }); + const sessionFile = join(stagingDir, "ses_child9.jsonl"); + copyFileSync(fixturePath, sessionFile); + + ingestSessions([sessionFile], db); + const session = db.query("SELECT is_subagent FROM sessions").get() as { is_subagent: number } | null; + expect(session?.is_subagent).toBe(1); + }); + + test("marks a root OpenCode session as is_subagent=0", () => { + const fixturePath = join(import.meta.dir, "../tests/fixtures/test-opencode-session-1.jsonl"); + const stagingDir = join(tempDir, "opencode-staging"); + mkdirSync(stagingDir, { recursive: true }); + const sessionFile = join(stagingDir, "ses_abc123.jsonl"); + copyFileSync(fixturePath, sessionFile); + + ingestSessions([sessionFile], db); + const session = db.query("SELECT is_subagent FROM sessions").get() as { is_subagent: number } | null; + expect(session?.is_subagent).toBe(0); + }); + test("ingests a Codex session file into the database", () => { const fixturePath = join(import.meta.dir, "../tests/fixtures/test-codex-session-1.jsonl"); const codexDir = join(tempDir, "2026", "02", "24"); diff --git a/src/ingest.ts b/src/ingest.ts index 606dea0..14dcec6 100644 --- a/src/ingest.ts +++ b/src/ingest.ts @@ -127,7 +127,10 @@ export function ingestSessions( session.projectPath, session.projectName ); - const isSubagent = file.includes("/subagents/") ? 1 : 0; + // Formats that state it outright win; otherwise fall back to Claude + // Code's on-disk convention of nesting subagents under /subagents/. + const isSubagent = + (session.isSubagent ?? file.includes("/subagents/")) ? 1 : 0; insertSession.run( session.sessionId, session.parentSessionId, diff --git a/src/llm.test.ts b/src/llm.test.ts new file mode 100644 index 0000000..be7aede --- /dev/null +++ b/src/llm.test.ts @@ -0,0 +1,97 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { complete, providerModel, type SummaryProvider } from "./llm"; + +type Captured = { path: string; auth: string | null; body: any }; + +describe("complete (openai-compatible provider)", () => { + let server: ReturnType; + let captured: Captured | null; + let reply: any; + let status: number; + + beforeEach(() => { + captured = null; + status = 200; + reply = { + choices: [{ message: { content: "HEADLINE: did a thing" }, finish_reason: "stop" }], + }; + server = Bun.serve({ + port: 0, + async fetch(req) { + captured = { + path: new URL(req.url).pathname, + auth: req.headers.get("authorization"), + body: await req.json(), + }; + return new Response(JSON.stringify(reply), { + status, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + }); + + afterEach(() => server.stop(true)); + + function provider(extra: Partial> = {}): SummaryProvider { + return { + type: "openai", + base_url: `http://localhost:${server.port}/v1`, + model: "gemma-4", + ...extra, + }; + } + + test("posts to the chat completions endpoint and returns the content", async () => { + const text = await complete("summarize this", provider()); + + expect(captured!.path).toBe("/v1/chat/completions"); + expect(captured!.body.model).toBe("gemma-4"); + expect(captured!.body.messages[0].content).toBe("summarize this"); + expect(text).toBe("HEADLINE: did a thing"); + }); + + test("sends the bearer token from the configured env var", async () => { + process.env.TEST_SPARK_KEY = "sk-test-123"; + try { + await complete("x", provider({ api_key_env: "TEST_SPARK_KEY" })); + expect(captured!.auth).toBe("Bearer sk-test-123"); + } finally { + delete process.env.TEST_SPARK_KEY; + } + }); + + test("requests a token budget large enough for a reasoning model", async () => { + await complete("x", provider()); + // Gemma spends completion tokens on reasoning before emitting content. + expect(captured!.body.max_tokens).toBeGreaterThanOrEqual(2000); + }); + + test("honours an explicit max_tokens", async () => { + await complete("x", provider({ max_tokens: 8000 })); + expect(captured!.body.max_tokens).toBe(8000); + }); + + test("fails loudly when the model returns only reasoning and no content", async () => { + reply = { choices: [{ message: { content: "", reasoning_content: "thinking..." } }] }; + expect(complete("x", provider())).rejects.toThrow(/no content/i); + }); + + test("surfaces an HTTP error rather than returning empty text", async () => { + status = 401; + reply = { error: { message: "invalid api key" } }; + expect(complete("x", provider())).rejects.toThrow(/401/); + }); +}); + +describe("providerModel", () => { + test("names the configured openai model", () => { + expect( + providerModel({ type: "openai", base_url: "http://x/v1", model: "gemma-4" }) + ).toBe("gemma-4"); + }); + + test("falls back to the built-in Claude model when unconfigured", () => { + expect(providerModel(undefined)).toBe("claude-haiku-4-5-20251001"); + }); +}); diff --git a/src/llm.ts b/src/llm.ts new file mode 100644 index 0000000..c0b5dde --- /dev/null +++ b/src/llm.ts @@ -0,0 +1,119 @@ +/** + * Model provider for journal summarization and session titling. + * + * Defaults to Claude via the Agent SDK. Any OpenAI-compatible endpoint can be + * used instead — including a local llama.cpp server — by configuring a + * `summary_provider` block. + */ + +export const DEFAULT_CLAUDE_MODEL = "claude-haiku-4-5-20251001"; + +/** Reasoning models spend completion tokens thinking before emitting content. */ +const DEFAULT_MAX_TOKENS = 4000; + +export type SummaryProvider = + | { type: "anthropic"; model?: string } + | { + type: "openai"; + /** Endpoint root, e.g. http://host:8001/v1 */ + base_url: string; + model: string; + /** Name of the environment variable holding the API key. */ + api_key_env?: string; + max_tokens?: number; + }; + +/** The model name to record against generated entries. */ +export function providerModel(provider?: SummaryProvider): string { + if (!provider || provider.type === "anthropic") { + return provider?.model ?? DEFAULT_CLAUDE_MODEL; + } + return provider.model; +} + +async function completeOpenAI( + prompt: string, + provider: Extract +): Promise { + const url = `${provider.base_url.replace(/\/$/, "")}/chat/completions`; + const headers: Record = { "Content-Type": "application/json" }; + + const apiKey = provider.api_key_env ? process.env[provider.api_key_env] : undefined; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + model: provider.model, + messages: [{ role: "user", content: prompt }], + max_tokens: provider.max_tokens ?? DEFAULT_MAX_TOKENS, + }), + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error( + `${provider.model} request failed: ${res.status} ${res.statusText} ${detail.slice(0, 200)}` + ); + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const text = data.choices?.[0]?.message?.content ?? ""; + + if (!text.trim()) { + throw new Error( + `${provider.model} returned no content (reasoning models need a larger max_tokens budget)` + ); + } + + return text; +} + +async function completeAnthropic( + prompt: string, + provider?: Extract +): Promise { + const { query } = await import("@anthropic-ai/claude-agent-sdk"); + + const env = { ...process.env }; + delete env.CLAUDECODE; + + const result = query({ + prompt, + options: { + model: provider?.model ?? DEFAULT_CLAUDE_MODEL, + maxTurns: 1, + tools: [], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + persistSession: false, + env, + }, + }); + + let text = ""; + for await (const message of result) { + if (message.type === "assistant") { + for (const block of message.message.content) { + if ("text" in block && typeof block.text === "string") { + text += block.text; + } + } + } + } + + if (!text.trim()) throw new Error("Empty response from LLM"); + return text; +} + +/** Send a single prompt and return the model's text response. */ +export async function complete( + prompt: string, + provider?: SummaryProvider +): Promise { + if (provider?.type === "openai") return completeOpenAI(prompt, provider); + return completeAnthropic(prompt, provider); +} diff --git a/src/opencode.test.ts b/src/opencode.test.ts new file mode 100644 index 0000000..a5c829a --- /dev/null +++ b/src/opencode.test.ts @@ -0,0 +1,267 @@ +import { describe, test, expect } from "bun:test"; +import { + toJsonl, + syncOpenCodeSessions, + runToFile, + listSessionsFromDb, + type OpenCodeExport, + type OpenCodeRunner, +} from "./opencode"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, existsSync, readFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +function exportFixture(overrides: Partial = {}): OpenCodeExport { + return { + info: { + id: "ses_abc", + directory: "/Users/jesse/projects/myapp", + title: "Build the thing", + version: "1.18.0", + model: { providerID: "spark", id: "qwen3-coder" }, + time: { created: 1785256551718, updated: 1785256600000 }, + }, + messages: [ + { + info: { role: "user", time: { created: 1785256552352 } }, + parts: [{ type: "text", text: "hello" }], + }, + { + info: { role: "assistant", time: { created: 1785256553000 } }, + parts: [ + { type: "step-start" }, + { type: "text", text: "hi there" }, + { type: "step-finish" }, + ], + }, + ], + ...overrides, + }; +} + +describe("toJsonl", () => { + test("emits an opencode_meta record as the first line", () => { + const lines = toJsonl(exportFixture()).trim().split("\n"); + const meta = JSON.parse(lines[0]!); + + expect(meta.type).toBe("opencode_meta"); + expect(meta.payload.id).toBe("ses_abc"); + expect(meta.payload.cwd).toBe("/Users/jesse/projects/myapp"); + expect(meta.payload.model).toBe("spark/qwen3-coder"); + expect(meta.payload.parent_id).toBeNull(); + }); + + test("converts epoch millis to ISO timestamps", () => { + const lines = toJsonl(exportFixture()).trim().split("\n"); + const meta = JSON.parse(lines[0]!); + + expect(meta.timestamp).toBe(new Date(1785256551718).toISOString()); + }); + + test("emits one record per message with joined text parts", () => { + const lines = toJsonl(exportFixture()).trim().split("\n"); + + expect(lines.length).toBe(3); // meta + 2 messages + const user = JSON.parse(lines[1]!); + const assistant = JSON.parse(lines[2]!); + + expect(user.type).toBe("user"); + expect(user.message.content).toBe("hello"); + expect(assistant.type).toBe("assistant"); + expect(assistant.message.content).toBe("hi there"); + }); + + test("drops non-text parts such as step-start and step-finish", () => { + const lines = toJsonl(exportFixture()).trim().split("\n"); + const assistant = JSON.parse(lines[2]!); + + expect(assistant.message.content).not.toContain("step"); + }); + + test("skips messages that carry no text parts", () => { + const exp = exportFixture({ + messages: [ + { + info: { role: "assistant", time: { created: 1785256553000 } }, + parts: [{ type: "step-start" }, { type: "tool", text: undefined }], + }, + ], + }); + + const lines = toJsonl(exp).trim().split("\n"); + expect(lines.length).toBe(1); // meta only + }); + + test("carries parentID through as parent_id for subagent sessions", () => { + const exp = exportFixture(); + exp.info.parentID = "ses_parent"; + + const meta = JSON.parse(toJsonl(exp).trim().split("\n")[0]!); + expect(meta.payload.parent_id).toBe("ses_parent"); + }); +}); + +function fakeRunner( + sessions: Array<{ id: string; updated: number }>, + calls: string[] = [] +): OpenCodeRunner { + return { + async listSessions() { + return sessions.map((s) => ({ + id: s.id, + title: "t", + updated: s.updated, + created: 1785256551718, + directory: "/Users/jesse/projects/myapp", + })); + }, + async exportSession(id: string) { + calls.push(id); + const exp = exportFixture(); + exp.info.id = id; + return exp; + }, + }; +} + +describe("listSessionsFromDb", () => { + function makeDb(dir: string): string { + const path = join(dir, "opencode.db"); + const db = new Database(path); + db.exec(`CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT NOT NULL, + title TEXT NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL + )`); + db.exec(`INSERT INTO session VALUES + ('ses_a','global',NULL,'/Users/jesse/projects/one','First',100,200), + ('ses_b','global',NULL,'/Users/jesse/projects/two','Second',300,400), + ('ses_c','global','ses_a','/Users/jesse/projects/one','Child',500,600)`); + db.close(); + return path; + } + + test("enumerates sessions across every project, not just the cwd", () => { + const dir = mkdtempSync(join(tmpdir(), "oc-db-")); + try { + const sessions = listSessionsFromDb(makeDb(dir)); + expect(sessions.length).toBe(3); + expect(new Set(sessions.map((s) => s.directory)).size).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("returns the most recently updated sessions first", () => { + const dir = mkdtempSync(join(tmpdir(), "oc-db-")); + try { + const sessions = listSessionsFromDb(makeDb(dir)); + expect(sessions[0]!.id).toBe("ses_c"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("honours maxCount", () => { + const dir = mkdtempSync(join(tmpdir(), "oc-db-")); + try { + expect(listSessionsFromDb(makeDb(dir), 2).length).toBe(2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("returns nothing when the database is absent", () => { + expect(listSessionsFromDb(join(tmpdir(), "no-such-opencode.db"))).toEqual([]); + }); +}); + +describe("runToFile", () => { + test("captures stdout larger than a pipe buffer without truncation", () => { + // The opencode CLI yields empty stdout when spawned with a pipe, and + // exports run to megabytes, so command output is captured via a file. + const size = 2 * 1024 * 1024; + const out = runToFile("python3", [ + "-c", + `import sys; sys.stdout.write("x" * ${size})`, + ]); + expect(out.length).toBe(size); + }); + + test("returns the full text of small outputs", () => { + expect(runToFile("printf", ["hello"])).toBe("hello"); + }); +}); + +describe("syncOpenCodeSessions", () => { + let staging: string; + + function withStaging(fn: (dir: string) => Promise) { + return async () => { + staging = mkdtempSync(join(tmpdir(), "oc-staging-")); + try { + await fn(staging); + } finally { + rmSync(staging, { recursive: true, force: true }); + } + }; + } + + test( + "writes one .jsonl file per session", + withStaging(async (dir) => { + const runner = fakeRunner([ + { id: "ses_a", updated: 100 }, + { id: "ses_b", updated: 200 }, + ]); + + const result = await syncOpenCodeSessions(dir, runner); + + expect(result.written).toBe(2); + expect(existsSync(join(dir, "ses_a.jsonl"))).toBe(true); + expect(existsSync(join(dir, "ses_b.jsonl"))).toBe(true); + }) + ); + + test( + "writes parseable JSONL carrying the session id", + withStaging(async (dir) => { + await syncOpenCodeSessions(dir, fakeRunner([{ id: "ses_a", updated: 100 }])); + + const content = readFileSync(join(dir, "ses_a.jsonl"), "utf-8"); + const meta = JSON.parse(content.trim().split("\n")[0]!); + expect(meta.type).toBe("opencode_meta"); + expect(meta.payload.id).toBe("ses_a"); + }) + ); + + test( + "skips re-exporting a session whose updated time has not changed", + withStaging(async (dir) => { + const calls: string[] = []; + const sessions = [{ id: "ses_a", updated: 100 }]; + + await syncOpenCodeSessions(dir, fakeRunner(sessions, calls)); + const second = await syncOpenCodeSessions(dir, fakeRunner(sessions, calls)); + + expect(calls).toEqual(["ses_a"]); // exported once, not twice + expect(second.written).toBe(0); + expect(second.skipped).toBe(1); + }) + ); + + test( + "re-exports a session that has been updated since the last sync", + withStaging(async (dir) => { + const calls: string[] = []; + await syncOpenCodeSessions(dir, fakeRunner([{ id: "ses_a", updated: 100 }], calls)); + const second = await syncOpenCodeSessions( + dir, + fakeRunner([{ id: "ses_a", updated: 999 }], calls) + ); + + expect(calls).toEqual(["ses_a", "ses_a"]); + expect(second.written).toBe(1); + }) + ); +}); diff --git a/src/opencode.ts b/src/opencode.ts new file mode 100644 index 0000000..7538f47 --- /dev/null +++ b/src/opencode.ts @@ -0,0 +1,244 @@ +/** + * OpenCode source adapter. + * + * OpenCode keeps sessions in a single SQLite database rather than one file per + * session, so it cannot be scanned the way Claude Code and Codex sources are. + * Instead we shell out to the supported CLI (`opencode session list` / + * `opencode export`) and materialize one JSONL file per session into a staging + * directory that the normal scanner can pick up. + */ + +import { execFileSync } from "child_process"; +import { + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { join } from "path"; +import { tmpdir, homedir } from "os"; +import { Database } from "bun:sqlite"; + +export type OpenCodePart = { + type: string; + text?: string; +}; + +export type OpenCodeMessage = { + info: { + role: string; + time: { created: number }; + }; + parts: OpenCodePart[]; +}; + +export type OpenCodeExport = { + info: { + id: string; + directory: string; + title?: string; + version?: string; + parentID?: string; + model?: { providerID?: string; id?: string }; + time: { created: number; updated?: number }; + }; + messages: OpenCodeMessage[]; +}; + +function isoFromMillis(millis: number): string { + return new Date(millis).toISOString(); +} + +/** Join the text-bearing parts of a message, dropping step/tool bookkeeping. */ +function messageText(message: OpenCodeMessage): string { + return (message.parts || []) + .filter((part) => part.type === "text" && part.text) + .map((part) => part.text!) + .join("\n") + .trim(); +} + +/** + * Convert one `opencode export` payload into JSONL records that parser.ts + * understands: an `opencode_meta` header followed by Claude-Code-shaped + * user/assistant records. + */ +export function toJsonl(exp: OpenCodeExport): string { + const model = exp.info.model; + const lines: string[] = [ + JSON.stringify({ + type: "opencode_meta", + timestamp: isoFromMillis(exp.info.time.created), + payload: { + id: exp.info.id, + cwd: exp.info.directory, + title: exp.info.title ?? "", + version: exp.info.version ?? null, + parent_id: exp.info.parentID ?? null, + model: + model?.providerID && model?.id + ? `${model.providerID}/${model.id}` + : null, + }, + }), + ]; + + for (const message of exp.messages || []) { + const text = messageText(message); + if (!text) continue; + + const role = message.info.role === "assistant" ? "assistant" : "user"; + lines.push( + JSON.stringify({ + type: role, + timestamp: isoFromMillis(message.info.time.created), + message: { role, content: text }, + }) + ); + } + + return lines.join("\n") + "\n"; +} + +export type OpenCodeSessionSummary = { + id: string; + title: string; + created: number; + updated: number; + directory: string; +}; + +export type OpenCodeRunner = { + listSessions(maxCount?: number): Promise; + exportSession(id: string): Promise; +}; + +/** + * Run a command and return its stdout, captured via a temporary file. + * + * The opencode CLI writes nothing at all when its stdout is a pipe, and a + * single export can run to megabytes, so redirecting to a file is both the + * working and the safe way to capture output. + */ +export function runToFile(binary: string, args: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "oc-run-")); + const target = join(dir, "stdout"); + const fd = openSync(target, "w"); + try { + execFileSync(binary, args, { stdio: ["ignore", fd, "ignore"] }); + } finally { + closeSync(fd); + } + try { + return readFileSync(target, "utf-8"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +export function defaultOpenCodeDbPath(): string { + return join(homedir(), ".local/share/opencode/opencode.db"); +} + +/** + * Enumerate sessions straight from OpenCode's database. + * + * `opencode session list` only reports sessions belonging to the current + * working directory's project, so it cannot see a whole tailnet of projects + * from wherever ingest happens to run. Reading the session table is the only + * way to enumerate everything; transcript content still comes from the CLI. + */ +export function listSessionsFromDb( + dbPath = defaultOpenCodeDbPath(), + maxCount?: number +): OpenCodeSessionSummary[] { + if (!existsSync(dbPath)) return []; + + const db = new Database(dbPath, { readonly: true }); + try { + const limit = maxCount ? ` LIMIT ${Number(maxCount)}` : ""; + const rows = db + .query( + `SELECT id, title, directory, time_created AS created, time_updated AS updated + FROM session + ORDER BY time_updated DESC${limit}` + ) + .all() as OpenCodeSessionSummary[]; + return rows; + } finally { + db.close(); + } +} + +/** Runner backed by OpenCode's database (listing) and CLI (export). */ +export function cliRunner( + binary = "opencode", + dbPath = defaultOpenCodeDbPath() +): OpenCodeRunner { + return { + async listSessions(maxCount?: number) { + return listSessionsFromDb(dbPath, maxCount); + }, + + async exportSession(id: string) { + return JSON.parse(runToFile(binary, ["export", id])) as OpenCodeExport; + }, + }; +} + +type Manifest = Record; + +function readManifest(path: string): Manifest { + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf-8")) as Manifest; + } catch { + return {}; + } +} + +/** + * Export OpenCode sessions into `stagingDir` as one JSONL file per session. + * + * A manifest of last-seen `updated` times keeps repeat syncs cheap: only + * sessions that changed since the previous run are re-exported. + */ +export async function syncOpenCodeSessions( + stagingDir: string, + runner: OpenCodeRunner, + opts: { maxCount?: number } = {} +): Promise<{ written: number; skipped: number; errors: string[] }> { + mkdirSync(stagingDir, { recursive: true }); + const manifestPath = join(stagingDir, "manifest.json"); + const manifest = readManifest(manifestPath); + + let written = 0; + let skipped = 0; + const errors: string[] = []; + + const sessions = await runner.listSessions(opts.maxCount); + + for (const session of sessions) { + const target = join(stagingDir, `${session.id}.jsonl`); + if (manifest[session.id] === session.updated && existsSync(target)) { + skipped++; + continue; + } + + try { + const exported = await runner.exportSession(session.id); + writeFileSync(target, toJsonl(exported)); + manifest[session.id] = session.updated; + written++; + } catch (err) { + errors.push(`${session.id}: ${err instanceof Error ? err.message : err}`); + } + } + + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + return { written, skipped, errors }; +} diff --git a/src/parser.test.ts b/src/parser.test.ts index 38b1665..43f8c3a 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -6,6 +6,65 @@ const fixturePath = join(import.meta.dir, "../tests/fixtures/test-session-1.json const codexFixturePath = join(import.meta.dir, "../tests/fixtures/test-codex-session-1.jsonl"); const subagentFixturePath = join(import.meta.dir, "../tests/fixtures/parent-session-id/subagents/agent-aba4e4e.jsonl"); const commandFixturePath = join(import.meta.dir, "../tests/fixtures/test-command-messages.jsonl"); +const opencodeFixturePath = join(import.meta.dir, "../tests/fixtures/test-opencode-session-1.jsonl"); +const opencodeSubagentFixturePath = join(import.meta.dir, "../tests/fixtures/test-opencode-subagent.jsonl"); + +describe("parseSession (OpenCode format)", () => { + test("extracts session metadata from the opencode_meta record", () => { + const session = parseSession(opencodeFixturePath); + expect(session.sessionId).toBe("ses_abc123"); + expect(session.projectPath).toBe("/Users/jesse/projects/myapp"); + expect(session.projectName).toBe("myapp"); + expect(session.version).toBe("1.18.0"); + expect(session.assistantDisplayName).toBe("OpenCode"); + }); + + test("parses user and assistant messages", () => { + const session = parseSession(opencodeFixturePath); + expect(session.messages.length).toBe(3); + expect(session.messages[0]!.role).toBe("user"); + expect(session.messages[0]!.text).toBe("Add retry logic to the fetch helper"); + expect(session.messages[1]!.role).toBe("assistant"); + }); + + test("uses record timestamps for session bounds", () => { + const session = parseSession(opencodeFixturePath); + expect(session.startedAt).toBe("2026-07-06T14:22:31.718Z"); + expect(session.endedAt).toBe("2026-07-06T14:25:10.000Z"); + }); + + test("leaves parentSessionId null for a root session", () => { + const session = parseSession(opencodeFixturePath); + expect(session.parentSessionId).toBeNull(); + }); + + test("sets parentSessionId for a subagent session", () => { + const session = parseSession(opencodeSubagentFixturePath); + expect(session.parentSessionId).toBe("ses_abc123"); + }); + + test("flags a session with a parent as a subagent", () => { + const session = parseSession(opencodeSubagentFixturePath); + expect(session.isSubagent).toBe(true); + }); + + test("does not flag a root session as a subagent", () => { + const session = parseSession(opencodeFixturePath); + expect(session.isSubagent).toBe(false); + }); + + test("leaves isSubagent undefined for Claude Code sessions so path detection still applies", () => { + // Claude Code records a parentSessionId for continuations as well as + // subagents, so the parser must not claim to know which this is. + const session = parseSession(fixturePath); + expect(session.isSubagent).toBeUndefined(); + }); + + test("does not misdetect OpenCode sessions as Codex", () => { + const session = parseSession(opencodeFixturePath); + expect(session.assistantDisplayName).not.toBe("Codex"); + }); +}); describe("parseSession", () => { test("extracts session metadata", () => { diff --git a/src/parser.ts b/src/parser.ts index 787543d..459f613 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -22,6 +22,12 @@ export type ParsedSession = { endedAt: string | null; messages: ParsedMessage[]; messageCount: number; + /** + * Whether this session is a subagent run, when the format states it + * outright. Left undefined for Claude Code, whose parentSessionId covers + * continuations too and so is decided from the file path instead. + */ + isSubagent?: boolean; toMarkdown: () => string; }; @@ -41,6 +47,19 @@ type RawRecord = { isCompactSummary?: boolean; }; +type OpenCodeMetaRecord = { + type: string; + timestamp?: string; + payload?: { + id?: string; + cwd?: string; + title?: string; + version?: string; + parent_id?: string | null; + model?: string | null; + }; +}; + type CodexRecord = { type: string; timestamp?: string; @@ -173,6 +192,7 @@ export function parseSession(filePath: string): ParsedSession { let lastTimestamp: string | null = null; const messages: ParsedMessage[] = []; let codexFormat = false; + let openCodeFormat = false; let assistantDisplayName = "Claude"; for (const line of lines) { @@ -183,6 +203,27 @@ export function parseSession(filePath: string): ParsedSession { continue; // skip malformed lines } + // OpenCode sessions are staged by the adapter as an `opencode_meta` header + // followed by Claude-Code-shaped message records, so only the header needs + // handling here — the message records fall through to the normal path. + const openCodeRecord = parsed as OpenCodeMetaRecord; + if (openCodeRecord.type === "opencode_meta") { + assistantDisplayName = "OpenCode"; + openCodeFormat = true; + + if (openCodeRecord.timestamp) { + if (!firstTimestamp) firstTimestamp = openCodeRecord.timestamp; + lastTimestamp = openCodeRecord.timestamp; + } + + const payload = openCodeRecord.payload; + if (payload?.id) sessionId = payload.id; + if (payload?.cwd && !projectPath) projectPath = payload.cwd; + if (payload?.version && !version) version = payload.version; + if (payload?.parent_id) parentSessionId = payload.parent_id; + continue; + } + const codexRecord = parsed as CodexRecord; if (codexRecord.type === "session_meta" || codexFormat) { codexFormat = true; @@ -302,6 +343,7 @@ export function parseSession(filePath: string): ParsedSession { endedAt: lastTimestamp || null, messages, messageCount: messages.length, + isSubagent: openCodeFormat ? parentSessionId !== null : undefined, toMarkdown() { const startTime = firstTimestamp ? formatTime(firstTimestamp) : "??:??"; const endTime = lastTimestamp ? formatTime(lastTimestamp) : "??:??"; diff --git a/src/summarize.test.ts b/src/summarize.test.ts index 492dd90..2acb9ca 100644 --- a/src/summarize.test.ts +++ b/src/summarize.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { groupSessionsByDateAndProject, buildSummaryPrompt, parseSummaryResponse, logicalDate, splitConversationByDay } from "./summarize"; +import { groupSessionsByDateAndProject, buildSummaryPrompt, parseSummaryResponse, logicalDate, splitConversationByDay, upsertJournalEntry } from "./summarize"; import { initDb, closeDb } from "./db"; import { mkdtempSync, rmSync } from "fs"; import { join } from "path"; @@ -256,3 +256,47 @@ describe("groupSessionsByDateAndProject - midnight spanning", () => { expect(groups[0]!.conversations[0]).toContain("Morning review"); }); }); + +describe("upsertJournalEntry model attribution", () => { + let tempDir: string; + let db: ReturnType; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "notebook-model-")); + db = initDb(join(tempDir, "test.db")); + db.exec(`INSERT INTO projects (id, path, display_name) VALUES ('myapp', '/p/myapp', 'myapp')`); + }); + + afterEach(() => { + closeDb(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + const group = { + date: "2026-07-31", + projectId: "myapp", + projectName: "myapp", + sessionIds: ["s1"], + conversations: ["conv"], + }; + + test("records the model that actually produced the entry", () => { + upsertJournalEntry(db, group, { + skipped: false, + headline: "h", + summary: "s", + topics: [], + openQuestions: [], + }, "gemma-4"); + + const row = db.query("SELECT model_used FROM journal_entries").get() as { model_used: string }; + expect(row.model_used).toBe("gemma-4"); + }); + + test("records the model on skip stubs too", () => { + upsertJournalEntry(db, group, { skipped: true, skipReason: "nothing" }, "gemma-4"); + + const row = db.query("SELECT model_used FROM journal_entries").get() as { model_used: string }; + expect(row.model_used).toBe("gemma-4"); + }); +}); diff --git a/src/summarize.ts b/src/summarize.ts index 88fd136..3979f8b 100644 --- a/src/summarize.ts +++ b/src/summarize.ts @@ -1,6 +1,7 @@ import { Database } from "bun:sqlite"; +import { complete, providerModel, DEFAULT_CLAUDE_MODEL, type SummaryProvider } from "./llm"; -const SUMMARIZE_MODEL = "claude-haiku-4-5-20251001"; +const SUMMARIZE_MODEL = DEFAULT_CLAUDE_MODEL; async function readStream(stream: ReadableStream | null): Promise { if (!stream) return ""; @@ -337,7 +338,8 @@ export function parseSummaryResponse(response: string): SummaryResult { export function upsertJournalEntry( db: Database, group: SessionGroup, - result: SummaryResult + result: SummaryResult, + modelUsed: string = SUMMARIZE_MODEL ): void { if (result.skipped) { db.prepare( @@ -354,7 +356,7 @@ export function upsertJournalEntry( group.projectId, JSON.stringify(group.sessionIds), result.skipReason || "No reason given", - SUMMARIZE_MODEL + modelUsed ); return; } @@ -380,7 +382,7 @@ export function upsertJournalEntry( result.summary, JSON.stringify(result.topics), JSON.stringify(result.openQuestions), - SUMMARIZE_MODEL + modelUsed ); } @@ -388,47 +390,16 @@ export function upsertJournalEntry( export async function summarizeGroup( group: SessionGroup, db: Database, - summaryInstructions?: string + summaryInstructions?: string, + provider?: SummaryProvider ): Promise<{ skipped: boolean; skipReason?: string }> { - const { query } = await import("@anthropic-ai/claude-agent-sdk"); const prompt = buildSummaryPrompt(group, summaryInstructions); - let responseText = ""; - - const env = { ...process.env }; - delete env.CLAUDECODE; - - const result = query({ - prompt, - options: { - model: SUMMARIZE_MODEL, - maxTurns: 1, - tools: [], - permissionMode: "bypassPermissions", - allowDangerouslySkipPermissions: true, - persistSession: false, - env, - }, - }); - - for await (const message of result) { - if (message.type === "assistant") { - const content = message.message.content; - for (const block of content) { - if ("text" in block && typeof block.text === "string") { - responseText += block.text; - } - } - } - } - - if (!responseText.trim()) { - throw new Error("Empty response from LLM"); - } + const responseText = await complete(prompt, provider); const parsed = parseSummaryResponse(responseText); - upsertJournalEntry(db, group, parsed); + upsertJournalEntry(db, group, parsed, providerModel(provider)); // Give each summarized session a Desktop-style title if it doesn't already // have one (Desktop/user titles and existing generated ones are left alone). @@ -437,7 +408,7 @@ export async function summarizeGroup( try { const row = db.query("SELECT title, COALESCE(is_subagent, 0) AS sub FROM sessions WHERE id = ?").get(sid) as | { title: string | null; sub: number } | null; - if (row && !row.title && !row.sub) await titleSession(db, sid); + if (row && !row.title && !row.sub) await titleSession(db, sid, provider); } catch { // title generation is best-effort; never fail a summary over it } @@ -453,7 +424,8 @@ export async function summarizeAll( filterProject?: string, onProgress?: (done: number, total: number, group: SessionGroup) => void, dayStartHour: number = 5, - summaryInstructions?: string + summaryInstructions?: string, + provider?: SummaryProvider ): Promise<{ summarized: number; skipped: number; skipReasons: string[]; errors: string[] }> { const groups = groupSessionsByDateAndProject(db, filterDate, filterProject, dayStartHour); let summarized = 0; @@ -470,7 +442,9 @@ export async function summarizeAll( // no Desktop data / reader unavailable — fine, we'll generate as needed } - if (groups.length > 0) { + // Only Claude needs a local login; an OpenAI-compatible endpoint authenticates + // with its own key, so don't block on Claude auth there. + if (groups.length > 0 && provider?.type !== "openai") { const authIssue = await getClaudeAuthIssue(); if (authIssue) { errors.push(authIssue); @@ -481,7 +455,7 @@ export async function summarizeAll( for (const group of groups) { try { onProgress?.(summarized + skipped, groups.length, group); - const result = await summarizeGroup(group, db, summaryInstructions); + const result = await summarizeGroup(group, db, summaryInstructions, provider); if (result.skipped) { skipped++; if (result.skipReason) skipReasons.push(result.skipReason); diff --git a/src/titles.ts b/src/titles.ts index 02afff5..cf9ac5c 100644 --- a/src/titles.ts +++ b/src/titles.ts @@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"; import { existsSync, readFileSync, readdirSync } from "fs"; import { join } from "path"; import { defaultSessionsDir } from "./desktop-groups"; +import { complete, type SummaryProvider } from "./llm"; const TITLE_MODEL = "claude-haiku-4-5-20251001"; @@ -51,38 +52,30 @@ export function applyDesktopTitles(db: Database, titles: DesktopTitle[] = readDe } /** Generate a short Desktop-style title from a session's conversation text. */ -export async function generateTitle(conversationMarkdown: string): Promise { - const { query } = await import("@anthropic-ai/claude-agent-sdk"); +export async function generateTitle( + conversationMarkdown: string, + provider?: SummaryProvider +): Promise { const snippet = conversationMarkdown.slice(0, 6000); const prompt = `Below is a coding-assistant session transcript. Write a concise title of 3-8 words that names what the session is about, in the style of an app's auto-generated chat title. Output ONLY the title — no quotes, no trailing punctuation, no preamble.\n\n---\n${snippet}\n---\n\nTitle:`; - const env = { ...process.env }; - delete env.CLAUDECODE; - - let text = ""; - const result = query({ - prompt, - options: { model: TITLE_MODEL, maxTurns: 1, tools: [], permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, persistSession: false, env }, - }); - for await (const message of result) { - if (message.type === "assistant") { - for (const block of message.message.content) { - if ("text" in block && typeof block.text === "string") text += block.text; - } - } - } + const text = await complete(prompt, provider ?? { type: "anthropic", model: TITLE_MODEL }); // First non-empty line, stripped of surrounding quotes/trailing punctuation. const line = text.split("\n").map((l) => l.trim()).find((l) => l.length > 0) || ""; return line.replace(/^["'`]+|["'`]+$/g, "").replace(/[.\s]+$/, "").slice(0, 120); } /** Generate + store a title for one session that lacks one. Returns the title (or null if no content). */ -export async function titleSession(db: Database, sessionId: string): Promise { +export async function titleSession( + db: Database, + sessionId: string, + provider?: SummaryProvider +): Promise { const row = db.query( `SELECT c.conversation_markdown AS md FROM sessions s LEFT JOIN conversations c ON c.session_id = s.id WHERE s.id = ?` ).get(sessionId) as { md: string | null } | null; if (!row || !row.md || !row.md.trim()) return null; - const title = await generateTitle(row.md); + const title = await generateTitle(row.md, provider); if (!title) return null; db.query("UPDATE sessions SET title = ?, title_source = 'generated' WHERE id = ?").run(title, sessionId); return title; diff --git a/tests/fixtures/test-opencode-session-1.jsonl b/tests/fixtures/test-opencode-session-1.jsonl new file mode 100644 index 0000000..fd1a388 --- /dev/null +++ b/tests/fixtures/test-opencode-session-1.jsonl @@ -0,0 +1,4 @@ +{"type":"opencode_meta","timestamp":"2026-07-06T14:22:31.718Z","payload":{"id":"ses_abc123","cwd":"/Users/jesse/projects/myapp","title":"Build the thing","version":"1.18.0","parent_id":null,"model":"spark/qwen3-coder"}} +{"type":"user","timestamp":"2026-07-06T14:22:32.352Z","message":{"role":"user","content":"Add retry logic to the fetch helper"}} +{"type":"assistant","timestamp":"2026-07-06T14:22:33.000Z","message":{"role":"assistant","content":"Added a three-attempt retry with backoff."}} +{"type":"user","timestamp":"2026-07-06T14:25:10.000Z","message":{"role":"user","content":"Ship it"}} diff --git a/tests/fixtures/test-opencode-subagent.jsonl b/tests/fixtures/test-opencode-subagent.jsonl new file mode 100644 index 0000000..0a42748 --- /dev/null +++ b/tests/fixtures/test-opencode-subagent.jsonl @@ -0,0 +1,3 @@ +{"type":"opencode_meta","timestamp":"2026-07-06T15:00:00.000Z","payload":{"id":"ses_child9","cwd":"/Users/jesse/projects/myapp","title":"Search the codebase","version":"1.18.0","parent_id":"ses_abc123","model":"spark/qwen3-coder"}} +{"type":"user","timestamp":"2026-07-06T15:00:01.000Z","message":{"role":"user","content":"Find all callers of fetchWithRetry"}} +{"type":"assistant","timestamp":"2026-07-06T15:00:09.000Z","message":{"role":"assistant","content":"Three callers, all in src/net/."}}