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/."}}