Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
13 changes: 13 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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[];
Expand All @@ -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 {
Expand Down
28 changes: 27 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`);
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions src/ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion src/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions src/llm.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Bun.serve>;
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<Extract<SummaryProvider, { type: "openai" }>> = {}): 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");
});
});
Loading