initial implementation: brain-dump session server with dual LLM backends

Headless Koa v3 server exposing REST + SSE around the brain-dump skill:
- AgentBackend adapter seam with two implementations: Claude Agent SDK
  (skill loading, session resume via sdk_session_id) and a hand-rolled
  OpenAI-compatible tool-calling loop (vault-scoped file tools, SKILL.md
  injected into the system prompt, history persisted in SQLite)
- session CRUD + one-turn-at-a-time SSE streaming (text deltas, file_write
  events, turn_complete), per-session lock, abort on disconnect
- node:sqlite storage (sessions, messages, turn_events) with migrations
- vault allow-list + symlink-aware path-escape prevention (incl. dangling
  symlink defense), read-only git status/diff endpoints, configurable CORS
- deterministic mock chat-completions server (scripts/mock-openai.ts) for
  end-to-end testing without a real model

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:48:17 +00:00
co-authored by Claude
parent d579ac744e
commit a5f62512e8
34 changed files with 5092 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
*.sqlite
*.sqlite-*
config.jsonc
dist
+100
View File
@@ -1,2 +1,102 @@
# braindump # braindump
A headless server that runs brain-dump note-taking sessions into an Obsidian-style vault, exposing a web API meant to be consumed by a web UI or Obsidian plugin. An LLM agent interviews you conversationally and writes linked markdown notes into the vault as the conversation progresses, following the `brain-dump` skill (incremental writes, 13 questions per message, no extrapolation, TODO markers, `projects/<name>/` layout with wikilinks).
Two interchangeable LLM backends, selected per session:
- **`claude-sdk`** — the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk). Gets file tools, skill loading (`~/.claude/skills/brain-dump`), and session resume from the SDK. Uses your Anthropic credentials.
- **`openai-compat`** — any OpenAI-compatible chat-completions endpoint (openrouter, vllm, ollama). A built-in agent loop supplies vault-scoped file tools and injects the skill into the system prompt.
## Requirements
- Node ≥ 22 (uses `node:sqlite`)
- For `claude-sdk`: Anthropic credentials (the SDK inherits Claude Code auth or `ANTHROPIC_API_KEY`)
## Setup
```sh
npm install
cp config.example.jsonc config.jsonc # edit: vaults, backends, models
npm run web # boots, auto-migrates the db, listens
```
Config is JSONC; path from `BRAINDUMP_CONFIG` (default `./config.jsonc`). `"${ENV_VAR}"` string values are resolved from the environment; `~` is expanded in paths. Only vaults listed in `vaults` are writable — that allow-list is the write fence.
> **Note:** an exported `PORT` env var overrides the config file's `port` (watch out under code-server, which exports `PORT` globally). `BRAINDUMP_DB` overrides `dbPath`.
There is **no auth** — put it behind an authenticating reverse proxy. CORS origins are configurable (`app://obsidian.md` is in the example for Obsidian plugins).
## API
Base path `/api`. Errors are `{"ok":false,"error":"<code>"}`.
| Method | Path | Description |
|---|---|---|
| GET | `/health` | liveness |
| GET | `/vaults` | configured vaults |
| POST | `/sessions` | create session — `{vault, topic, backend?, model?}` |
| GET | `/sessions[?status=]` | list sessions |
| GET | `/sessions/:id` | session + transcript |
| PATCH | `/sessions/:id` | update `{topic?, status?}` |
| DELETE | `/sessions/:id[?hard=true]` | archive (soft) or delete row; never touches vault files |
| POST | `/sessions/:id/messages` | send a user message — **response is an SSE stream** for the turn |
| GET | `/sessions/:id/file?path=` | read a vault file (path-validated) |
| GET | `/sessions/:id/git/status` | `git status --porcelain`, parsed |
| GET | `/sessions/:id/git/diff[?path=]` | raw diff (read-only; commits are always left to you) |
### The turn stream
`POST /sessions/:id/messages` with `{"text": "..."}` responds with `text/event-stream`:
```
event: text data: {"delta":"What's the goal of the project?"}
event: tool_start data: {"tool":"write_file","path":"projects/x/x.md"}
event: file_write data: {"path":"projects/x/x.md","bytes":412}
event: tool_end data: {"tool":"write_file","ok":true}
event: assistant_message data: {"text":"...full assistant text..."}
event: turn_complete data: {"turnCount":1}
```
Consume with `fetch` + a ReadableStream reader (or `curl -N`). One turn at a time per session — a concurrent POST gets `409 turn_in_progress`. Closing the connection aborts the turn; files already written stay (writes are incremental by design). Heartbeat comments keep proxies from timing out.
## Curl walkthrough
```sh
# create a session
SID=$(curl -s localhost:3000/api/sessions -H 'content-type: application/json' \
-d '{"vault":"brain","topic":"my-project","backend":"claude-sdk"}' | jq -r .session.id)
# first turn — watch text stream and files land
curl -N localhost:3000/api/sessions/$SID/messages -H 'content-type: application/json' \
-d '{"text":"Brain dump my-project: a CLI tool that does X, written in Go, status: early prototype."}'
# what changed in the vault?
curl -s localhost:3000/api/sessions/$SID/git/status | jq
curl -s "localhost:3000/api/sessions/$SID/file?path=projects/my-project/my-project.md" | jq -r .content
# keep going — the agent asks 1-3 questions per turn and extends the notes
curl -N localhost:3000/api/sessions/$SID/messages -H 'content-type: application/json' \
-d '{"text":"It also has a TUI mode. TODO on my side: pick a license."}'
# review + commit yourself when happy (braindump never commits)
git -C ~/git/brain status
```
## Development
```sh
npm run typecheck
npm test # pathSafety + Db unit tests
PORT=11500 npx tsx scripts/mock-openai.ts # deterministic mock chat-completions server
```
`scripts/mock-openai.ts` scripts a full tool-calling session (including a vault-escape probe when the user message contains `ESCAPE`), so the whole openai-compat path can be tested end-to-end without a real model — point `backends.openai-compat.baseUrl` at it.
### Layout
- `src/agent/``AgentBackend` interface + the two adapters (`claudeSdk/`, `openaiCompat/`) and the stub
- `src/vault/` — vault allow-list resolution, `pathSafety` (symlink-aware escape prevention), read-only git helpers
- `src/webapi/` — Koa router, SSE plumbing, middleware
- `src/db/``node:sqlite` wrapper + migrations (sessions, messages, turn_events)
Backends own message persistence: `openai-compat` stores its full replayable history in SQLite; `claude-sdk` stores display rows and resumes via the SDK's own transcript (`sdk_session_id`).
+18
View File
@@ -0,0 +1,18 @@
{
"port": 3000,
"dbPath": "./braindump.sqlite",
"cors": { "origins": ["app://obsidian.md", "http://localhost:5173"] },
"skillPath": "~/.claude/skills/brain-dump/SKILL.md",
"defaultBackend": "claude-sdk",
"vaults": [{ "name": "brain", "path": "~/git/brain" }],
"backends": {
"claude-sdk": { "model": "claude-sonnet-5", "permissionMode": "acceptEdits" },
"openai-compat": {
"baseUrl": "http://localhost:11434/v1",
"apiKey": "${OPENROUTER_API_KEY}",
"model": "qwen2.5-coder:32b",
// max tool-call loop iterations per turn (optional, default 32)
"maxToolIterations": 32
}
}
}
+2554
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "braindump",
"version": "0.1.0",
"description": "Headless web API around the brain-dump skill",
"type": "module",
"author": "Codinget <codinget@codi.moe>",
"license": "ISC",
"engines": {
"node": ">=22"
},
"scripts": {
"web": "tsx src/bin/braindump-web.ts",
"migrate": "tsx src/bin/braindump-migrate.ts",
"typecheck": "tsc --noEmit",
"test": "tsx --test src/**/*.test.ts"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.212",
"@koa/bodyparser": "^6.0.0",
"@koa/router": "^14.0.0",
"koa": "^3.0.1"
},
"devDependencies": {
"@types/koa": "^2.15.0",
"@types/koa__router": "^12.0.4",
"@types/node": "^24.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Deterministic mock of an OpenAI-compatible /v1/chat/completions endpoint that
* speaks streaming SSE, for end-to-end testing the openai-compat backend without
* a real model. tsx-runnable:
*
* PORT=11500 tsx scripts/mock-openai.ts
*
* Scripting (decided per request from the message history it receives):
* - If the latest user message contains "ESCAPE", the first assistant turn
* emits a write_file tool_call with path "../escape.md" (vault-escape probe).
* - Otherwise the first assistant turn streams some text and a write_file
* tool_call for projects/mock/mock.md (arguments split across fragments, and
* id/name only on the first fragment, to exercise streamed-tool_call parsing).
* - Once the history's last message is a tool result, it streams a final text
* and finishes with finish_reason "stop".
*
* It logs a compact summary of every request's message roles to stderr so an
* e2e harness can assert that a second turn replays prior history.
*/
import http from "node:http";
const PORT = Number(process.env.PORT ?? 11500);
interface InMsg {
role: string;
content: string | null;
tool_calls?: unknown[];
tool_call_id?: string;
}
function sseChunk(obj: unknown): string {
return `data: ${JSON.stringify(obj)}\n\n`;
}
function baseDelta(delta: unknown, finish: string | null = null) {
return {
id: "chatcmpl-mock",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "mock-model",
choices: [{ index: 0, delta, finish_reason: finish }],
};
}
const server = http.createServer((req, res) => {
if (req.method !== "POST" || !req.url?.includes("/chat/completions")) {
res.writeHead(404).end("not found");
return;
}
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
let messages: InMsg[] = [];
try {
messages = (JSON.parse(body).messages ?? []) as InMsg[];
} catch {
/* ignore */
}
const roles = messages.map((m) => m.role).join(",");
const userMsgs = messages.filter((m) => m.role === "user").length;
process.stderr.write(`[mock] request roles=[${roles}] userMessages=${userMsgs}\n`);
const last = messages[messages.length - 1];
const lastUser = [...messages].reverse().find((m) => m.role === "user");
const wantEscape = typeof lastUser?.content === "string" && lastUser.content.includes("ESCAPE");
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
if (last?.role === "tool") {
// Second iteration: finish the turn.
res.write(sseChunk(baseDelta({ role: "assistant", content: "" })));
res.write(sseChunk(baseDelta({ content: "Done — I captured that into the vault. " })));
res.write(sseChunk(baseDelta({ content: "What else should we add? (1) goals? (2) stack?" })));
res.write(sseChunk(baseDelta({}, "stop")));
res.write("data: [DONE]\n\n");
res.end();
return;
}
// First iteration: stream text + a write_file tool_call across fragments.
const targetPath = wantEscape ? "../escape.md" : "projects/mock/mock.md";
const fileContent = "---\ntags:\n - mock\n - mock/moc\n---\n\n# Mock\n\nCaptured by the mock backend.\n\n## TODO / Ideas\n\n<!-- TODO: confirm details -->\n";
const argsFull = JSON.stringify({ path: targetPath, content: fileContent });
const mid = Math.floor(argsFull.length / 2);
res.write(sseChunk(baseDelta({ role: "assistant", content: "" })));
res.write(sseChunk(baseDelta({ content: "Got it. Let me start the note. " })));
// tool_call fragment 1: id + name + first half of arguments.
res.write(
sseChunk(
baseDelta({
tool_calls: [
{ index: 0, id: "call_mock_1", type: "function", function: { name: "write_file", arguments: argsFull.slice(0, mid) } },
],
})
)
);
// tool_call fragment 2: remaining arguments only (no id/name).
res.write(
sseChunk(
baseDelta({
tool_calls: [{ index: 0, function: { arguments: argsFull.slice(mid) } }],
})
)
);
res.write(sseChunk(baseDelta({}, "tool_calls")));
res.write("data: [DONE]\n\n");
res.end();
});
});
server.listen(PORT, () => {
process.stderr.write(`[mock] listening on http://localhost:${PORT}\n`);
});
+6
View File
@@ -0,0 +1,6 @@
import type { AgentEvent } from "./events.js";
export interface AgentBackend {
readonly kind: "claude-sdk" | "openai-compat" | "stub";
runTurn(input: { userText: string; signal: AbortSignal }): AsyncGenerator<AgentEvent>;
}
+65
View File
@@ -0,0 +1,65 @@
import type { AgentBackend } from "./AgentBackend.js";
import type { AgentEvent } from "./events.js";
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
const onAbort = () => {
clearTimeout(timer);
resolve();
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Canned backend used for M3 (session REST + SSE plumbing) before the real
* claude-sdk / openai-compat backends land. Emits a fixed event sequence
* with small delays to exercise streaming, and honors abort by stopping
* early. Does not touch the filesystem — the file_write event is
* synthetic/pretend.
*/
export class StubBackend implements AgentBackend {
readonly kind = "stub" as const;
async *runTurn({
userText,
signal,
}: {
userText: string;
signal: AbortSignal;
}): AsyncGenerator<AgentEvent> {
const chunks = [`Stub response to: `, userText.slice(0, 40), " (stubbed, no real work done)"];
for (const delta of chunks) {
if (signal.aborted) return;
await sleep(100, signal);
if (signal.aborted) return;
yield { type: "text", delta };
}
if (signal.aborted) return;
await sleep(100, signal);
if (signal.aborted) return;
yield { type: "tool_start", tool: "Write", path: "projects/stub/stub.md" };
if (signal.aborted) return;
await sleep(100, signal);
if (signal.aborted) return;
yield { type: "file_write", path: "projects/stub/stub.md", bytes: 42 };
if (signal.aborted) return;
await sleep(100, signal);
if (signal.aborted) return;
yield { type: "tool_end", tool: "Write", ok: true };
if (signal.aborted) return;
await sleep(100, signal);
if (signal.aborted) return;
const fullText = chunks.join("");
yield { type: "assistant_message", text: fullText };
if (signal.aborted) return;
yield { type: "turn_complete" };
}
}
+184
View File
@@ -0,0 +1,184 @@
import path from "node:path";
import fs from "node:fs";
import { query, type Options, type PermissionMode } from "@anthropic-ai/claude-agent-sdk";
import type { AgentBackend } from "../AgentBackend.js";
import type { AgentEvent } from "../events.js";
import type { Config } from "../../config/types.js";
import type { Db, SessionRow } from "../../db/Db.js";
const ALLOWED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep"];
const FILE_TOOLS = new Set(["Write", "Edit"]);
/**
* Claude Agent SDK backend. Delegates the agent loop, tool execution and
* transcript to the bundled Claude Code CLI via `query()`. The SDK keeps its
* own transcript keyed by session id (persisted here as `sdk_session_id` for
* `resume`); this backend additionally writes display-only user/assistant
* mirror rows into the `messages` table so the transcript endpoint has content.
*/
export class ClaudeSdkBackend implements AgentBackend {
readonly kind = "claude-sdk" as const;
#session: SessionRow;
#db: Db;
#model?: string;
#permissionMode: PermissionMode;
#skillPath: string;
constructor(session: SessionRow, cfg: Config, db: Db) {
this.#session = session;
this.#db = db;
const bc = cfg.backends["claude-sdk"];
this.#model = session.model ?? bc?.model;
this.#permissionMode = (bc?.permissionMode as PermissionMode | undefined) ?? "acceptEdits";
this.#skillPath = cfg.skillPath;
}
#toVaultRelative(absOrRel: string): { path: string; outside: boolean } {
const root = path.resolve(this.#session.vault_path);
const abs = path.isAbsolute(absOrRel) ? absOrRel : path.resolve(root, absOrRel);
const rel = path.relative(root, abs);
const outside = rel === "" || rel.startsWith("..") || path.isAbsolute(rel);
return { path: outside ? absOrRel : rel, outside };
}
async *runTurn({
userText,
signal,
}: {
userText: string;
signal: AbortSignal;
}): AsyncGenerator<AgentEvent> {
const turn = this.#session.turn_count + 1;
const vaultRoot = this.#session.vault_path;
// Display-only user mirror row (seq 0).
this.#db.createMessage({ sessionId: this.#session.id, turn, seq: 0, role: "user", content: userText });
const abortController = new AbortController();
const onAbort = () => abortController.abort();
if (signal.aborted) abortController.abort();
else signal.addEventListener("abort", onAbort, { once: true });
const append = `You are running a headless "brain dump" note-taking session into the Obsidian-style vault at ${vaultRoot} (your cwd). Follow the brain-dump skill (${this.#skillPath}): write notes incrementally as you go, ask only 1-3 targeted questions per message, never extrapolate beyond what the user said, flag gaps as TODO markers, use projects/<name>/ layout with a map-of-content root file and [[wikilinks]], and do not commit to git.`;
const options: Options = {
cwd: vaultRoot,
resume: this.#session.sdk_session_id ?? undefined,
permissionMode: this.#permissionMode,
allowedTools: ALLOWED_TOOLS,
settingSources: ["user"],
systemPrompt: { type: "preset", preset: "claude_code", append },
includePartialMessages: true,
abortController,
...(this.#model ? { model: this.#model } : {}),
};
// Track tool_use blocks awaiting their result so file_write/tool_end can be
// emitted with an accurate byte count once the file exists on disk.
const pendingTools = new Map<string, { tool: string; path?: string; outside: boolean }>();
let sessionId: string | undefined = this.#session.sdk_session_id ?? undefined;
let finalText = "";
try {
for await (const message of query({ prompt: userText, options })) {
if (signal.aborted) break;
switch (message.type) {
case "system": {
if (message.subtype === "init" && message.session_id) sessionId = message.session_id;
break;
}
case "stream_event": {
// Partial text streaming (content_block_delta with text_delta).
const ev = message.event as {
type?: string;
delta?: { type?: string; text?: string };
};
if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && ev.delta.text) {
yield { type: "text", delta: ev.delta.text };
}
break;
}
case "assistant": {
const content = (message.message?.content ?? []) as unknown as Array<Record<string, unknown>>;
for (const block of content) {
if (block.type === "tool_use") {
const name = String(block.name ?? "");
const id = String(block.id ?? "");
const input = (block.input ?? {}) as Record<string, unknown>;
const rawPath = typeof input.file_path === "string" ? input.file_path : undefined;
let rel: string | undefined;
let outside = false;
if (rawPath) {
const r = this.#toVaultRelative(rawPath);
rel = r.path;
outside = r.outside;
if (outside) {
console.warn(`[claude-sdk] tool ${name} path outside vault: ${rawPath}`);
}
}
if (FILE_TOOLS.has(name)) {
pendingTools.set(id, { tool: name, path: rel, outside });
yield { type: "tool_start", tool: name, ...(rel ? { path: rel } : {}) };
}
}
}
break;
}
case "user": {
// Tool results arrive as a user message with tool_result blocks.
const content = (message.message?.content ?? []) as unknown;
if (Array.isArray(content)) {
for (const block of content as Array<Record<string, unknown>>) {
if (block.type === "tool_result") {
const id = String(block.tool_use_id ?? "");
const pending = pendingTools.get(id);
if (pending) {
pendingTools.delete(id);
const ok = block.is_error !== true;
if (ok && pending.path && !pending.outside) {
let bytes = 0;
try {
bytes = fs.statSync(path.resolve(vaultRoot, pending.path)).size;
} catch {
/* file may have been deleted; report 0 */
}
yield { type: "file_write", path: pending.path, bytes };
}
yield { type: "tool_end", tool: pending.tool, ok };
}
}
}
}
break;
}
case "result": {
if (message.session_id) sessionId = message.session_id;
if (message.subtype === "success" && typeof message.result === "string") {
finalText = message.result;
}
break;
}
default:
break;
}
}
} catch (e) {
if (signal.aborted || (e as Error).name === "AbortError") return;
yield { type: "error", message: `claude-sdk query failed: ${(e as Error).message}` };
return;
} finally {
signal.removeEventListener("abort", onAbort);
}
if (signal.aborted) return;
if (finalText) {
// Display-only assistant mirror row (seq 1).
this.#db.createMessage({ sessionId: this.#session.id, turn, seq: 1, role: "assistant", content: finalText });
yield { type: "assistant_message", text: finalText };
}
yield { type: "turn_complete", sdkSessionId: sessionId };
}
}
+8
View File
@@ -0,0 +1,8 @@
export type AgentEvent =
| { type: "text"; delta: string }
| { type: "tool_start"; tool: string; path?: string }
| { type: "file_write"; path: string; bytes: number } // vault-relative
| { type: "tool_end"; tool: string; ok: boolean }
| { type: "assistant_message"; text: string }
| { type: "turn_complete"; sdkSessionId?: string }
| { type: "error"; message: string };
+309
View File
@@ -0,0 +1,309 @@
import type { AgentBackend } from "../AgentBackend.js";
import type { AgentEvent } from "../events.js";
import type { Config } from "../../config/types.js";
import type { Db, MessageRow, SessionRow } from "../../db/Db.js";
import { executeTool } from "./tools.js";
import { toolSchemas } from "./toolSchemas.js";
import { buildSystemPrompt } from "./systemPrompt.js";
const DEFAULT_MAX_ITERATIONS = 32;
interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | null;
tool_calls?: ChatToolCall[];
tool_call_id?: string;
}
interface ChatToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
/** Accumulator for a streamed tool_call, keyed by its delta index. */
interface ToolCallAccum {
id: string;
name: string;
args: string;
}
/**
* Hand-rolled OpenAI-compatible agent backend. Owns full message-history
* persistence for its sessions (the router does not persist message rows for
* this backend): loads prior history from SQLite, appends the user turn, runs
* the chat-completions tool loop, and writes every user/assistant/tool row.
*/
export class OpenAiBackend implements AgentBackend {
readonly kind = "openai-compat" as const;
#session: SessionRow;
#db: Db;
#baseUrl: string;
#apiKey?: string;
#model: string;
#maxIterations: number;
#systemPrompt: string;
constructor(session: SessionRow, cfg: Config, db: Db) {
this.#session = session;
this.#db = db;
const bc = cfg.backends["openai-compat"];
if (!bc) throw new Error("openai-compat backend is not configured");
this.#baseUrl = bc.baseUrl.replace(/\/$/, "");
this.#apiKey = bc.apiKey;
this.#model = session.model ?? bc.model;
this.#maxIterations = bc.maxToolIterations ?? DEFAULT_MAX_ITERATIONS;
this.#systemPrompt = buildSystemPrompt(cfg.skillPath, session.vault_path);
}
/** Rebuild chat-completions messages from persisted rows of prior turns. */
#loadHistory(): ChatMessage[] {
const rows = this.#db.listMessages(this.#session.id);
const out: ChatMessage[] = [];
for (const row of rows) {
if (row.role === "user") {
out.push({ role: "user", content: row.content ?? "" });
} else if (row.role === "assistant") {
const msg: ChatMessage = { role: "assistant", content: row.content ?? null };
if (row.tool_calls) {
try {
msg.tool_calls = JSON.parse(row.tool_calls) as ChatToolCall[];
} catch {
/* ignore malformed */
}
}
// OpenAI requires content:null (not "") when tool_calls present.
if (msg.tool_calls && !msg.content) msg.content = null;
out.push(msg);
} else if (row.role === "tool") {
let toolCallId = "";
if (row.tool_calls) {
try {
toolCallId = (JSON.parse(row.tool_calls) as { tool_call_id?: string }).tool_call_id ?? "";
} catch {
/* ignore */
}
}
out.push({ role: "tool", content: row.content ?? "", tool_call_id: toolCallId });
}
}
return out;
}
async *runTurn({
userText,
signal,
}: {
userText: string;
signal: AbortSignal;
}): AsyncGenerator<AgentEvent> {
const turn = this.#session.turn_count + 1;
let seq = 0;
const vaultRoot = this.#session.vault_path;
const messages: ChatMessage[] = [
{ role: "system", content: this.#systemPrompt },
...this.#loadHistory(),
{ role: "user", content: userText },
];
// Persist the user message (seq 0).
this.#db.createMessage({ sessionId: this.#session.id, turn, seq: seq++, role: "user", content: userText });
for (let iter = 0; iter < this.#maxIterations; iter++) {
if (signal.aborted) return;
let assistantText = "";
const toolCalls = new Map<number, ToolCallAccum>();
let finishReason: string | null = null;
try {
const res = await fetch(`${this.#baseUrl}/chat/completions`, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
...(this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {}),
},
body: JSON.stringify({
model: this.#model,
stream: true,
messages,
tools: toolSchemas,
}),
});
if (!res.ok || !res.body) {
const detail = res.body ? await res.text().catch(() => "") : "";
yield { type: "error", message: `chat/completions HTTP ${res.status}: ${detail.slice(0, 500)}` };
return;
}
for await (const data of parseSseStream(res.body, signal)) {
if (data === "[DONE]") break;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
continue;
}
const choice = (parsed as { choices?: unknown[] }).choices?.[0] as
| { delta?: { content?: string; tool_calls?: unknown[] }; finish_reason?: string | null }
| undefined;
if (!choice) continue;
const delta = choice.delta;
if (delta?.content) {
assistantText += delta.content;
yield { type: "text", delta: delta.content };
}
if (delta?.tool_calls) {
for (const tcRaw of delta.tool_calls) {
const tc = tcRaw as {
index?: number;
id?: string;
function?: { name?: string; arguments?: string };
};
const idx = tc.index ?? 0;
let accum = toolCalls.get(idx);
if (!accum) {
accum = { id: "", name: "", args: "" };
toolCalls.set(idx, accum);
}
if (tc.id) accum.id = tc.id;
if (tc.function?.name) accum.name = tc.function.name;
if (tc.function?.arguments) accum.args += tc.function.arguments;
}
}
if (choice.finish_reason) finishReason = choice.finish_reason;
}
} catch (e) {
if (signal.aborted || (e as Error).name === "AbortError") return;
yield { type: "error", message: `openai-compat request failed: ${(e as Error).message}` };
return;
}
if (signal.aborted) return;
const orderedCalls = [...toolCalls.entries()].sort((a, b) => a[0] - b[0]).map(([, v]) => v);
const hasToolCalls = orderedCalls.length > 0;
// Assemble + persist the assistant message for this step.
const assistantMsg: ChatMessage = { role: "assistant", content: assistantText || (hasToolCalls ? null : "") };
if (hasToolCalls) {
assistantMsg.tool_calls = orderedCalls.map((c) => ({
id: c.id,
type: "function" as const,
function: { name: c.name, arguments: c.args || "{}" },
}));
}
messages.push(assistantMsg);
this.#db.createMessage({
sessionId: this.#session.id,
turn,
seq: seq++,
role: "assistant",
content: assistantText,
toolCalls: hasToolCalls ? assistantMsg.tool_calls : undefined,
});
if (finishReason === "tool_calls" || hasToolCalls) {
for (const call of orderedCalls) {
let args: Record<string, unknown> = {};
let parseError = false;
try {
args = call.args ? (JSON.parse(call.args) as Record<string, unknown>) : {};
} catch {
parseError = true;
}
const pathArg = typeof args.path === "string" ? args.path : undefined;
yield { type: "tool_start", tool: call.name, ...(pathArg ? { path: pathArg } : {}) };
const result = parseError
? { content: `Error: could not parse arguments as JSON: ${call.args}` }
: executeTool(vaultRoot, call.name, args, (msg) => console.warn(`[openai-compat] ${msg}`));
const ok = !result.content.startsWith("Error");
if (result.write) {
yield { type: "file_write", path: result.write.path, bytes: result.write.bytes };
}
yield { type: "tool_end", tool: call.name, ok };
messages.push({ role: "tool", content: result.content, tool_call_id: call.id });
this.#db.createMessage({
sessionId: this.#session.id,
turn,
seq: seq++,
role: "tool",
content: result.content,
toolName: call.name,
toolCalls: { tool_call_id: call.id },
});
}
continue; // next iteration
}
// finish_reason === "stop" (or no tool calls): the turn is done.
yield { type: "assistant_message", text: assistantText };
yield { type: "turn_complete" };
return;
}
yield {
type: "error",
message: `Tool loop exceeded ${this.#maxIterations} iterations without completing.`,
};
}
}
/**
* Parse an SSE (data:) stream from a web ReadableStream into individual data
* payloads. Handles multi-line events and CRLF; stops on abort.
*/
async function* parseSseStream(
body: ReadableStream<Uint8Array>,
signal: AbortSignal
): AsyncGenerator<string> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
if (signal.aborted) return;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sepIndex: number;
// Events are separated by a blank line (\n\n, tolerate \r\n\r\n).
while ((sepIndex = indexOfEventSep(buffer)) !== -1) {
const rawEvent = buffer.slice(0, sepIndex);
buffer = buffer.slice(sepIndex).replace(/^(\r?\n){2}/, "");
const dataLines = rawEvent
.split(/\r?\n/)
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).replace(/^ /, ""));
if (dataLines.length) yield dataLines.join("\n");
}
}
// Flush any trailing event without a terminating blank line.
const rest = buffer.trim();
if (rest) {
const dataLines = rest
.split(/\r?\n/)
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).replace(/^ /, ""));
if (dataLines.length) yield dataLines.join("\n");
}
} finally {
reader.cancel().catch(() => {});
}
}
function indexOfEventSep(buffer: string): number {
const lf = buffer.indexOf("\n\n");
const crlf = buffer.indexOf("\r\n\r\n");
if (lf === -1) return crlf;
if (crlf === -1) return lf;
return Math.min(lf, crlf);
}
+80
View File
@@ -0,0 +1,80 @@
import fs from "node:fs";
import path from "node:path";
const IGNORED_DIRS = new Set([".git", ".obsidian"]);
/** A shallow (depth-limited) listing of the vault so the model sees structure. */
function shallowListing(vaultRoot: string, maxDepth = 2): string {
const lines: string[] = [];
function walk(dir: string, depth: number, prefix: string): void {
if (depth > maxDepth) return;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
if (entry.isDirectory()) {
lines.push(`${prefix}${entry.name}/`);
walk(path.join(dir, entry.name), depth + 1, `${prefix} `);
} else if (entry.isFile()) {
lines.push(`${prefix}${entry.name}`);
}
}
}
walk(path.resolve(vaultRoot), 1, "");
return lines.length ? lines.join("\n") : "(vault is empty)";
}
/**
* Compose the system prompt for the openai-compat backend from:
* (a) the literal contents of the brain-dump SKILL.md (behavioral spec),
* (b) the vault root path + a shallow directory listing,
* (c) the tool-use protocol and reinforcement of the skill's key rules.
*/
export function buildSystemPrompt(skillPath: string, vaultRoot: string): string {
let skill = "";
try {
skill = fs.readFileSync(skillPath, "utf8");
} catch {
skill = "(brain-dump skill file could not be read; follow the rules below.)";
}
const listing = shallowListing(vaultRoot);
return `You are running a headless "brain dump" note-taking session, capturing a conversation with the user into linked markdown notes in their Obsidian-style vault. You MUST follow the brain-dump skill specification below exactly.
===== BEGIN brain-dump SKILL.md =====
${skill}
===== END brain-dump SKILL.md =====
## Vault
Vault root: ${vaultRoot}
All file paths you pass to tools are RELATIVE to this root. Current structure (shallow):
${listing}
## Tools
You have four vault-scoped tools. Use them to inspect and write notes — you cannot touch anything outside the vault.
- list_files(subdir?) — discover existing structure. Do this before creating new files so you extend rather than duplicate.
- read_file(path) — read an existing note before editing it.
- write_file(path, content) — create or overwrite a file (parents auto-created).
- edit_file(path, old_string, new_string) — exact-string replacement for incremental updates; old_string must be unique in the file.
## How to work (reinforcing the skill)
- Write files AS YOU GO after each meaningful chunk of information, not all at once at the end. Use edit_file for incremental additions to a file you already created.
- Ask only 13 targeted questions per message. This is a conversation, not an intake form. Put your questions in your normal text reply (not in a tool call).
- NEVER extrapolate or invent facts. Only write what the user explicitly told you. If something is implied but unconfirmed, ask instead of guessing.
- Flag gaps as explicit TODO markers (a "## TODO / Ideas" section or inline "<!-- TODO: confirm X -->") rather than silently omitting or guessing.
- Give each topic/project its own subdirectory: projects/<name>/. The root map-of-content file is projects/<name>/<name>.md; link out to sibling files with Obsidian wikilinks like [[architecture]] (no path, no .md extension).
- Use YAML frontmatter tags following the vault's existing taxonomy where visible.
- Do not commit to git. Do not offer to unless asked.
When you have captured the current chunk and want to ask the user for more, stop calling tools and reply with your questions in plain text.`;
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Chat-completions "tools" definitions for the hand-rolled openai-compat agent
* loop. Kept deliberately small — the four vault-scoped file operations the
* brain-dump skill needs. All paths are vault-relative; the implementations in
* tools.ts fence every access through assertInsideVault.
*/
export interface ChatTool {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
export const toolSchemas: ChatTool[] = [
{
type: "function",
function: {
name: "list_files",
description:
"List markdown/other files in the vault, recursively, as vault-relative paths. Ignores .git and .obsidian. Use this to discover existing structure before writing.",
parameters: {
type: "object",
properties: {
subdir: {
type: "string",
description:
"Optional vault-relative subdirectory to list under (e.g. \"projects/foo\"). Omit to list the whole vault.",
},
},
required: [],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "read_file",
description: "Read the full contents of a vault file as UTF-8 text.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the file to read.",
},
},
required: ["path"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "write_file",
description:
"Create or overwrite a vault file with the given content. Parent directories are created automatically. Returns the number of bytes written.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to write (e.g. \"projects/foo/foo.md\").",
},
content: {
type: "string",
description: "Full UTF-8 file content.",
},
},
required: ["path", "content"],
additionalProperties: false,
},
},
},
{
type: "function",
function: {
name: "edit_file",
description:
"Replace an exact substring in an existing vault file. old_string must occur exactly once, or the edit fails. Prefer this for incremental updates over rewriting the whole file.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the file to edit.",
},
old_string: {
type: "string",
description: "The exact text to find (must be unique in the file).",
},
new_string: {
type: "string",
description: "The replacement text.",
},
},
required: ["path", "old_string", "new_string"],
additionalProperties: false,
},
},
},
];
+126
View File
@@ -0,0 +1,126 @@
import fs from "node:fs";
import path from "node:path";
import { assertInsideVault } from "../../vault/pathSafety.js";
import { VaultEscapeError } from "../../errors.js";
/**
* Result of executing a tool call: `content` is the string handed back to the
* model as the tool result (errors included, so the model can recover), and
* `write` (when set) describes a file that was created/modified so the caller
* can emit a `file_write` AgentEvent.
*/
export interface ToolExecResult {
content: string;
write?: { path: string; bytes: number };
}
const IGNORED_DIRS = new Set([".git", ".obsidian"]);
function listFilesRecursive(root: string, dir: string, out: string[]): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
listFilesRecursive(root, abs, out);
} else if (entry.isFile()) {
out.push(path.relative(root, abs));
}
}
}
/**
* Execute a single tool call against the vault. Never throws for expected
* failure modes (missing file, ambiguous edit, vault escape) — those come back
* as an error string in `content` so the model can adapt. `onEscape` is invoked
* for logging when a vault-escape attempt is caught.
*/
export function executeTool(
vaultRoot: string,
name: string,
args: Record<string, unknown>,
onEscape?: (msg: string) => void
): ToolExecResult {
try {
switch (name) {
case "list_files": {
const subdir = typeof args.subdir === "string" && args.subdir ? args.subdir : ".";
const base = subdir === "." ? path.resolve(vaultRoot) : assertInsideVault(vaultRoot, subdir);
const out: string[] = [];
listFilesRecursive(path.resolve(vaultRoot), base, out);
out.sort();
return { content: out.length ? out.join("\n") : "(no files)" };
}
case "read_file": {
if (typeof args.path !== "string" || !args.path) {
return { content: "Error: read_file requires a 'path' string argument." };
}
const abs = assertInsideVault(vaultRoot, args.path);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
return { content: `Error: no file at ${args.path}` };
}
return { content: fs.readFileSync(abs, "utf8") };
}
case "write_file": {
if (typeof args.path !== "string" || !args.path) {
return { content: "Error: write_file requires a 'path' string argument." };
}
if (typeof args.content !== "string") {
return { content: "Error: write_file requires a 'content' string argument." };
}
const abs = assertInsideVault(vaultRoot, args.path);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, args.content, "utf8");
const bytes = Buffer.byteLength(args.content, "utf8");
return {
content: `Wrote ${bytes} bytes to ${args.path}`,
write: { path: args.path, bytes },
};
}
case "edit_file": {
if (typeof args.path !== "string" || !args.path) {
return { content: "Error: edit_file requires a 'path' string argument." };
}
if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
return { content: "Error: edit_file requires 'old_string' and 'new_string' string arguments." };
}
const abs = assertInsideVault(vaultRoot, args.path);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
return { content: `Error: no file at ${args.path} to edit` };
}
const original = fs.readFileSync(abs, "utf8");
const parts = original.split(args.old_string);
const occurrences = parts.length - 1;
if (occurrences === 0) {
return { content: `Error: old_string not found in ${args.path}; nothing replaced.` };
}
if (occurrences > 1) {
return {
content: `Error: old_string occurs ${occurrences} times in ${args.path}; must be unique. Add more surrounding context.`,
};
}
const updated = parts.join(args.new_string);
fs.writeFileSync(abs, updated, "utf8");
const bytes = Buffer.byteLength(updated, "utf8");
return {
content: `Edited ${args.path} (${bytes} bytes after edit)`,
write: { path: args.path, bytes },
};
}
default:
return { content: `Error: unknown tool "${name}"` };
}
} catch (e) {
if (e instanceof VaultEscapeError) {
const msg = `Vault-escape attempt blocked in ${name}: ${e.message}`;
onEscape?.(msg);
return { content: `Error: ${e.message}` };
}
return { content: `Error executing ${name}: ${(e as Error).message}` };
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { Config } from "../config/types.js";
import type { Db, SessionRow } from "../db/Db.js";
import type { AgentBackend } from "./AgentBackend.js";
import { StubBackend } from "./StubBackend.js";
import { OpenAiBackend } from "./openaiCompat/OpenAiBackend.js";
import { ClaudeSdkBackend } from "./claudeSdk/ClaudeSdkBackend.js";
/**
* Backend selection point, keyed off `session.backend`. Each real backend owns
* its own `messages`-table persistence (see the note in apirouter): the
* openai-compat loop persists full history (user/assistant/tool rows), and the
* claude-sdk adapter persists display-only user/assistant mirror rows while the
* SDK keeps the real transcript under its `sdk_session_id`.
*
* "stub" remains constructible for tests/plumbing but is never selected by the
* router (session.backend is validated to claude-sdk | openai-compat).
*/
export function buildBackend(session: SessionRow, cfg: Config, db: Db): AgentBackend {
switch (session.backend) {
case "openai-compat":
return new OpenAiBackend(session, cfg, db);
case "claude-sdk":
return new ClaudeSdkBackend(session, cfg, db);
case "stub":
return new StubBackend();
default:
throw new Error(`Unknown backend "${session.backend}" for session ${session.id}`);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Db } from "../db/Db.js";
import { loadConfig } from "../config/config.js";
function resolveDbPath(): string {
const argPath = process.argv[2];
if (argPath) return argPath;
if (process.env.BRAINDUMP_DB) return process.env.BRAINDUMP_DB;
const cfg = loadConfig();
return cfg.dbPath;
}
const dbPath = resolveDbPath();
console.log(`Migrating database at ${dbPath}`);
const db = new Db(dbPath);
const { applied } = db.migrate();
if (applied.length === 0) {
console.log("Nothing to do, database already up to date");
} else {
console.log(`Applied ${applied.length} migration(s): ${applied.join(", ")}`);
}
db.close();
+28
View File
@@ -0,0 +1,28 @@
import Koa from "koa";
import { loadConfig } from "../config/config.js";
import { Db } from "../db/Db.js";
import { apirouter } from "../webapi/apirouter.js";
import { logRequests } from "../webapi/middleware/logRequests.js";
import { cors } from "../webapi/middleware/cors.js";
import { convertError } from "../webapi/middleware/convertError.js";
const cfg = loadConfig();
const db = new Db(cfg.dbPath);
const { applied } = db.migrate();
if (applied.length) {
console.log(`Applied ${applied.length} pending migration(s) at boot: ${applied.join(", ")}`);
}
const app = new Koa();
const router = apirouter({ db, cfg });
app.use(logRequests);
app.use(cors(cfg.cors));
app.use(convertError);
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(cfg.port, () => {
console.log(`braindump listening on http://localhost:${cfg.port}`);
});
+238
View File
@@ -0,0 +1,238 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { InvalidError } from "../errors.js";
import type { Config, VaultConfig } from "./types.js";
/**
* Tolerant JSONC comment stripper. Removes // line comments and /* block
* comments while leaving comment-like sequences inside string literals
* alone. Does not handle every edge case of JSON (e.g. no support for
* escaped-backslash-before-quote ambiguity beyond the standard \\ rule),
* but is good enough for hand-authored config files.
*/
export function stripJsonComments(input: string): string {
let out = "";
let i = 0;
const n = input.length;
let inString = false;
let stringQuote = "";
while (i < n) {
const ch = input[i];
const next = input[i + 1];
if (inString) {
out += ch;
if (ch === "\\") {
// preserve escaped char as-is
if (i + 1 < n) {
out += input[i + 1];
i += 2;
continue;
}
} else if (ch === stringQuote) {
inString = false;
}
i++;
continue;
}
if (ch === '"' || ch === "'") {
inString = true;
stringQuote = ch;
out += ch;
i++;
continue;
}
if (ch === "/" && next === "/") {
// line comment: skip to end of line
i += 2;
while (i < n && input[i] !== "\n") i++;
continue;
}
if (ch === "/" && next === "*") {
// block comment: skip to closing */
i += 2;
while (i < n && !(input[i] === "*" && input[i + 1] === "/")) i++;
i += 2;
continue;
}
out += ch;
i++;
}
return out;
}
function expandTilde(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
const ENV_REF_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
/**
* Resolve "${ENV_VAR}" placeholder strings against process.env, recursively,
* through plain objects and arrays. Non-matching strings pass through
* unchanged. Throws if the referenced env var is unset, unless `optional`
* returns true for the current JSON-pointer-ish path.
*/
function resolveEnvRefs(value: unknown, pathParts: string[], optional: (p: string) => boolean): unknown {
if (typeof value === "string") {
const m = ENV_REF_RE.exec(value);
if (!m) return value;
const varName = m[1]!;
const resolved = process.env[varName];
if (resolved === undefined) {
const dotted = pathParts.join(".");
if (optional(dotted)) return undefined;
throw new InvalidError(
`Config field "${dotted}" references env var "${varName}" which is not set`
);
}
return resolved;
}
if (Array.isArray(value)) {
return value.map((v, idx) => resolveEnvRefs(v, [...pathParts, String(idx)], optional));
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[k] = resolveEnvRefs(v, [...pathParts, k], optional);
}
return out;
}
return value;
}
const OPTIONAL_ENV_FIELDS = new Set(["backends.openai-compat.apiKey"]);
function isOptionalEnvField(dotted: string): boolean {
return OPTIONAL_ENV_FIELDS.has(dotted);
}
function validateVault(v: unknown, idx: number, errors: string[]): VaultConfig | null {
if (!v || typeof v !== "object") {
errors.push(`vaults[${idx}] must be an object`);
return null;
}
const obj = v as Record<string, unknown>;
if (typeof obj.name !== "string" || !obj.name) {
errors.push(`vaults[${idx}].name must be a non-empty string`);
}
if (typeof obj.path !== "string" || !obj.path) {
errors.push(`vaults[${idx}].path must be a non-empty string`);
}
if (errors.length) return null;
return {
name: obj.name as string,
path: path.resolve(expandTilde(obj.path as string)),
};
}
export function parseConfig(raw: unknown): Config {
const errors: string[] = [];
if (!raw || typeof raw !== "object") {
throw new InvalidError("Config must be a JSON object");
}
const obj = raw as Record<string, unknown>;
const port = obj.port === undefined ? 3000 : obj.port;
if (typeof port !== "number") errors.push("port must be a number");
const dbPath = obj.dbPath;
if (typeof dbPath !== "string" || !dbPath) errors.push("dbPath must be a non-empty string");
const cors = obj.cors;
let corsOrigins: string[] = [];
if (cors !== undefined) {
if (
!cors ||
typeof cors !== "object" ||
!Array.isArray((cors as Record<string, unknown>).origins)
) {
errors.push("cors.origins must be an array of strings");
} else {
corsOrigins = (cors as { origins: unknown[] }).origins.map((o) => String(o));
}
}
const skillPath = obj.skillPath;
if (typeof skillPath !== "string" || !skillPath) errors.push("skillPath must be a non-empty string");
const defaultBackend = obj.defaultBackend;
if (defaultBackend !== "claude-sdk" && defaultBackend !== "openai-compat") {
errors.push('defaultBackend must be "claude-sdk" or "openai-compat"');
}
const vaultsRaw = obj.vaults;
const vaults: VaultConfig[] = [];
if (!Array.isArray(vaultsRaw) || vaultsRaw.length === 0) {
errors.push("vaults must be a non-empty array");
} else {
vaultsRaw.forEach((v, idx) => {
const parsed = validateVault(v, idx, errors);
if (parsed) vaults.push(parsed);
});
}
const backendsRaw = obj.backends;
if (!backendsRaw || typeof backendsRaw !== "object") {
errors.push("backends must be an object");
}
const backends = (backendsRaw as Config["backends"]) ?? {};
if (defaultBackend === "openai-compat") {
const oc = backends["openai-compat"];
if (!oc || typeof oc.baseUrl !== "string" || typeof oc.model !== "string") {
errors.push("backends.openai-compat.{baseUrl,model} are required when defaultBackend is openai-compat");
}
}
if (errors.length) {
throw new InvalidError(`Invalid config:\n- ${errors.join("\n- ")}`);
}
return {
port: port as number,
dbPath: path.resolve(expandTilde(dbPath as string)),
cors: { origins: corsOrigins },
skillPath: path.resolve(expandTilde(skillPath as string)),
defaultBackend: defaultBackend as Config["defaultBackend"],
vaults,
backends,
};
}
export function loadConfig(): Config {
const configPath = path.resolve(process.env.BRAINDUMP_CONFIG ?? "./config.jsonc");
if (!fs.existsSync(configPath)) {
throw new InvalidError(`Config file not found at ${configPath} (set BRAINDUMP_CONFIG to override)`);
}
const raw = fs.readFileSync(configPath, "utf8");
const stripped = stripJsonComments(raw);
let json: unknown;
try {
json = JSON.parse(stripped);
} catch (e) {
throw new InvalidError(`Failed to parse ${configPath} as JSONC: ${(e as Error).message}`);
}
const withEnv = resolveEnvRefs(json, [], isOptionalEnvField);
const cfg = parseConfig(withEnv);
if (process.env.PORT) {
const p = Number(process.env.PORT);
if (!Number.isNaN(p)) cfg.port = p;
}
if (process.env.BRAINDUMP_DB) {
cfg.dbPath = path.resolve(expandTilde(process.env.BRAINDUMP_DB));
}
return cfg;
}
+34
View File
@@ -0,0 +1,34 @@
export interface VaultConfig {
name: string;
path: string;
}
export interface ClaudeSdkBackendConfig {
model?: string;
permissionMode?: string;
}
export interface OpenAiCompatBackendConfig {
baseUrl: string;
apiKey?: string;
model: string;
/** Max tool-execution iterations per turn before the loop errors out. Default 32. */
maxToolIterations?: number;
}
export interface CorsConfig {
origins: string[];
}
export interface Config {
port: number;
dbPath: string;
cors: CorsConfig;
skillPath: string;
defaultBackend: "claude-sdk" | "openai-compat";
vaults: VaultConfig[];
backends: {
"claude-sdk"?: ClaudeSdkBackendConfig;
"openai-compat"?: OpenAiCompatBackendConfig;
};
}
+106
View File
@@ -0,0 +1,106 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Db } from "./Db.js";
import { NotFoundError } from "../errors.js";
function tmpDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-db-"));
return path.join(dir, "test.sqlite");
}
test("migrate is idempotent and creates schema", () => {
const db = new Db(tmpDbPath());
const first = db.migrate();
assert.ok(first.applied.length > 0);
const second = db.migrate();
assert.deepEqual(second.applied, []);
db.close();
});
test("session create/list/get/patch/archive/hard-delete", () => {
const db = new Db(tmpDbPath());
db.migrate();
const session = db.createSession({
vault: "brain",
vaultPath: "/tmp/brain",
topic: "test topic",
backend: "stub",
model: "stub-model",
});
assert.ok(session.id.startsWith("sess_"));
assert.equal(session.status, "active");
assert.equal(session.turn_count, 0);
const fetched = db.getSession(session.id);
assert.equal(fetched.id, session.id);
const listed = db.listSessions();
assert.equal(listed.length, 1);
const patched = db.updateSession(session.id, { topic: "new topic", turn_count: 2 });
assert.equal(patched.topic, "new topic");
assert.equal(patched.turn_count, 2);
db.deleteSession(session.id, false);
const archived = db.getSession(session.id);
assert.equal(archived.status, "archived");
const activeOnly = db.listSessions("active");
assert.equal(activeOnly.length, 0);
const archivedOnly = db.listSessions("archived");
assert.equal(archivedOnly.length, 1);
db.deleteSession(session.id, true);
assert.throws(() => db.getSession(session.id), NotFoundError);
db.close();
});
test("messages insert and list ordering by (turn, seq)", () => {
const db = new Db(tmpDbPath());
db.migrate();
const session = db.createSession({
vault: "brain",
vaultPath: "/tmp/brain",
backend: "stub",
});
db.createMessage({ sessionId: session.id, turn: 1, seq: 2, role: "assistant", content: "b" });
db.createMessage({ sessionId: session.id, turn: 1, seq: 1, role: "user", content: "a" });
db.createMessage({ sessionId: session.id, turn: 0, seq: 1, role: "user", content: "z" });
const messages = db.listMessages(session.id);
assert.equal(messages.length, 3);
assert.deepEqual(
messages.map((m) => [m.turn, m.seq, m.content]),
[
[0, 1, "z"],
[1, 1, "a"],
[1, 2, "b"],
]
);
db.close();
});
test("turn events append and list, filtered by turn", () => {
const db = new Db(tmpDbPath());
db.migrate();
const session = db.createSession({
vault: "brain",
vaultPath: "/tmp/brain",
backend: "stub",
});
db.appendTurnEvent({ sessionId: session.id, turn: 1, kind: "text", data: { delta: "hi" } });
db.appendTurnEvent({ sessionId: session.id, turn: 2, kind: "text", data: { delta: "bye" } });
assert.equal(db.listTurnEvents(session.id).length, 2);
assert.equal(db.listTurnEvents(session.id, 1).length, 1);
db.close();
});
+274
View File
@@ -0,0 +1,274 @@
import { DatabaseSync } from "node:sqlite";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { migrations } from "./migrations.js";
import { NotFoundError } from "../errors.js";
export interface SessionRow {
id: string;
vault: string;
vault_path: string;
topic: string | null;
backend: string;
model: string | null;
sdk_session_id: string | null;
status: string;
turn_count: number;
created_at: string;
updated_at: string;
}
export interface MessageRow {
id: number;
session_id: string;
turn: number;
seq: number;
role: string;
content: string | null;
tool_calls: string | null;
tool_name: string | null;
created_at: string;
}
export interface TurnEventRow {
id: number;
session_id: string;
turn: number;
kind: string;
data: string | null;
created_at: string;
}
export interface CreateSessionInput {
vault: string;
vaultPath: string;
topic?: string | null;
backend: string;
model?: string | null;
}
export interface UpdateSessionPatch {
topic?: string | null;
status?: string;
sdk_session_id?: string | null;
model?: string | null;
turn_count?: number;
}
export interface CreateMessageInput {
sessionId: string;
turn: number;
seq: number;
role: string;
content?: string | null;
toolCalls?: unknown;
toolName?: string | null;
}
export interface AppendTurnEventInput {
sessionId: string;
turn: number;
kind: string;
data?: unknown;
}
function now(): string {
return new Date().toISOString();
}
export function runMigrations(db: DatabaseSync): { applied: string[] } {
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`);
const appliedRows = db
.prepare(`SELECT id FROM _migrations ORDER BY id ASC`)
.all() as { id: number }[];
const appliedIds = new Set(appliedRows.map((r) => r.id));
const applied: string[] = [];
migrations.forEach((sql, idx) => {
const id = idx + 1;
if (appliedIds.has(id)) return;
const name = `migration_${id}`;
db.exec(sql);
db.prepare(`INSERT INTO _migrations (id, name, applied_at) VALUES (?, ?, ?)`).run(
id,
name,
now()
);
applied.push(name);
});
return { applied };
}
export class Db {
#db: DatabaseSync;
constructor(dbPath: string) {
if (dbPath !== ":memory:") {
const dir = path.dirname(dbPath);
fs.mkdirSync(dir, { recursive: true });
}
this.#db = new DatabaseSync(dbPath);
this.#db.exec(`PRAGMA journal_mode = WAL;`);
this.#db.exec(`PRAGMA foreign_keys = ON;`);
}
migrate(): { applied: string[] } {
return runMigrations(this.#db);
}
close(): void {
this.#db.close();
}
// ---- sessions ----
createSession(input: CreateSessionInput): SessionRow {
const id = `sess_${crypto.randomUUID()}`;
const ts = now();
this.#db
.prepare(
`INSERT INTO sessions (id, vault, vault_path, topic, backend, model, sdk_session_id, status, turn_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NULL, 'active', 0, ?, ?)`
)
.run(
id,
input.vault,
input.vaultPath,
input.topic ?? null,
input.backend,
input.model ?? null,
ts,
ts
);
return this.getSession(id);
}
getSession(id: string): SessionRow {
const row = this.#db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as unknown as SessionRow | undefined;
if (!row) throw new NotFoundError(`No session with id ${id}`);
return row;
}
listSessions(status?: string): SessionRow[] {
if (status) {
return this.#db
.prepare(`SELECT * FROM sessions WHERE status = ? ORDER BY created_at DESC`)
.all(status) as unknown as SessionRow[];
}
return this.#db.prepare(`SELECT * FROM sessions ORDER BY created_at DESC`).all() as unknown as SessionRow[];
}
updateSession(id: string, patch: UpdateSessionPatch): SessionRow {
// ensure it exists first (throws NotFoundError otherwise)
this.getSession(id);
const sets: string[] = [];
const values: unknown[] = [];
if (patch.topic !== undefined) {
sets.push("topic = ?");
values.push(patch.topic);
}
if (patch.status !== undefined) {
sets.push("status = ?");
values.push(patch.status);
}
if (patch.sdk_session_id !== undefined) {
sets.push("sdk_session_id = ?");
values.push(patch.sdk_session_id);
}
if (patch.model !== undefined) {
sets.push("model = ?");
values.push(patch.model);
}
if (patch.turn_count !== undefined) {
sets.push("turn_count = ?");
values.push(patch.turn_count);
}
sets.push("updated_at = ?");
values.push(now());
values.push(id);
this.#db.prepare(`UPDATE sessions SET ${sets.join(", ")} WHERE id = ?`).run(...(values as []));
return this.getSession(id);
}
deleteSession(id: string, hard: boolean): void {
if (hard) {
const result = this.#db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
if (result.changes === 0) throw new NotFoundError(`No session with id ${id}`);
} else {
this.updateSession(id, { status: "archived" });
}
}
// ---- messages ----
createMessage(input: CreateMessageInput): MessageRow {
const ts = now();
const result = this.#db
.prepare(
`INSERT INTO messages (session_id, turn, seq, role, content, tool_calls, tool_name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
input.sessionId,
input.turn,
input.seq,
input.role,
input.content ?? null,
input.toolCalls !== undefined ? JSON.stringify(input.toolCalls) : null,
input.toolName ?? null,
ts
);
return this.#db
.prepare(`SELECT * FROM messages WHERE id = ?`)
.get(result.lastInsertRowid) as unknown as MessageRow;
}
listMessages(sessionId: string): MessageRow[] {
return this.#db
.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY turn ASC, seq ASC`)
.all(sessionId) as unknown as MessageRow[];
}
// ---- turn events ----
appendTurnEvent(input: AppendTurnEventInput): TurnEventRow {
const ts = now();
const result = this.#db
.prepare(
`INSERT INTO turn_events (session_id, turn, kind, data, created_at)
VALUES (?, ?, ?, ?, ?)`
)
.run(
input.sessionId,
input.turn,
input.kind,
input.data !== undefined ? JSON.stringify(input.data) : null,
ts
);
return this.#db
.prepare(`SELECT * FROM turn_events WHERE id = ?`)
.get(result.lastInsertRowid) as unknown as TurnEventRow;
}
listTurnEvents(sessionId: string, turn?: number): TurnEventRow[] {
if (turn !== undefined) {
return this.#db
.prepare(`SELECT * FROM turn_events WHERE session_id = ? AND turn = ? ORDER BY id ASC`)
.all(sessionId, turn) as unknown as TurnEventRow[];
}
return this.#db
.prepare(`SELECT * FROM turn_events WHERE session_id = ? ORDER BY id ASC`)
.all(sessionId) as unknown as TurnEventRow[];
}
}
+48
View File
@@ -0,0 +1,48 @@
export const migrations: string[] = [
`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
vault TEXT NOT NULL,
vault_path TEXT NOT NULL,
topic TEXT,
backend TEXT NOT NULL,
model TEXT,
sdk_session_id TEXT,
status TEXT NOT NULL DEFAULT 'active',
turn_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`,
`
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
turn INTEGER NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_calls TEXT,
tool_name TEXT,
created_at TEXT NOT NULL
);
`,
`
CREATE INDEX idx_messages_session_turn_seq
ON messages(session_id, turn, seq);
`,
`
CREATE TABLE turn_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
turn INTEGER NOT NULL,
kind TEXT NOT NULL,
data TEXT,
created_at TEXT NOT NULL
);
`,
`
CREATE INDEX idx_turn_events_session_turn
ON turn_events(session_id, turn);
`,
];
+11
View File
@@ -0,0 +1,11 @@
export class BraindumpError extends Error {}
export class NotFoundError extends BraindumpError {}
export class InvalidError extends BraindumpError {}
export class ConflictError extends BraindumpError {}
export class VaultEscapeError extends BraindumpError {}
export class TurnInProgressError extends BraindumpError {}
+20
View File
@@ -0,0 +1,20 @@
import type { Config, VaultConfig } from "../config/types.js";
import { NotFoundError } from "../errors.js";
export class VaultResolver {
#vaults: Map<string, VaultConfig>;
constructor(cfg: Config) {
this.#vaults = new Map(cfg.vaults.map((v) => [v.name, v]));
}
list(): VaultConfig[] {
return [...this.#vaults.values()];
}
resolve(name: string): VaultConfig {
const vault = this.#vaults.get(name);
if (!vault) throw new NotFoundError(`Unknown vault "${name}"`);
return vault;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InvalidError } from "../errors.js";
const execFileAsync = promisify(execFile);
export interface GitStatusEntry {
path: string;
status: string;
}
function isNotAGitRepoError(stderr: string): boolean {
return /not a git repository/i.test(stderr);
}
async function runGit(vaultRoot: string, args: string[]): Promise<string> {
try {
const { stdout } = await execFileAsync("git", args, { cwd: vaultRoot });
return stdout;
} catch (e) {
const err = e as { stderr?: string; message?: string };
const stderr = err.stderr ?? err.message ?? "";
if (isNotAGitRepoError(stderr)) {
throw new InvalidError(`Vault at ${vaultRoot} is not a git repository`);
}
throw new InvalidError(`git ${args.join(" ")} failed: ${stderr}`);
}
}
export async function status(vaultRoot: string): Promise<GitStatusEntry[]> {
const stdout = await runGit(vaultRoot, ["status", "--porcelain=v1"]);
return stdout
.split("\n")
.filter((line) => line.length > 0)
.map((line) => ({
status: line.slice(0, 2).trim(),
path: line.slice(3),
}));
}
export async function diff(vaultRoot: string, filePath?: string): Promise<{ diff: string }> {
const args = ["diff"];
if (filePath) args.push("--", filePath);
const stdout = await runGit(vaultRoot, args);
return { diff: stdout };
}
+92
View File
@@ -0,0 +1,92 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { assertInsideVault } from "./pathSafety.js";
import { VaultEscapeError } from "../errors.js";
function makeTmpVault(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-vault-"));
return dir;
}
test("normal relative path resolves inside vault", () => {
const vault = makeTmpVault();
fs.writeFileSync(path.join(vault, "note.md"), "hello");
const resolved = assertInsideVault(vault, "note.md");
assert.equal(resolved, path.join(vault, "note.md"));
});
test("nested new path resolves even if parent doesn't exist yet", () => {
const vault = makeTmpVault();
const resolved = assertInsideVault(vault, "projects/foo/bar.md");
assert.equal(resolved, path.join(vault, "projects", "foo", "bar.md"));
});
test("../escape is rejected", () => {
const vault = makeTmpVault();
assert.throws(() => assertInsideVault(vault, "../escape"), VaultEscapeError);
});
test("absolute path is rejected", () => {
const vault = makeTmpVault();
assert.throws(() => assertInsideVault(vault, "/etc/passwd"), VaultEscapeError);
});
test("embedded .. that escapes is rejected", () => {
const vault = makeTmpVault();
assert.throws(
() => assertInsideVault(vault, "projects/../../escape"),
VaultEscapeError
);
});
test("embedded .. that stays inside is allowed", () => {
const vault = makeTmpVault();
fs.mkdirSync(path.join(vault, "projects", "foo"), { recursive: true });
const resolved = assertInsideVault(vault, "projects/foo/../bar.md");
assert.equal(resolved, path.join(vault, "projects", "bar.md"));
});
test("symlink inside vault pointing outside vault is rejected", () => {
const vault = makeTmpVault();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-"));
fs.writeFileSync(path.join(outside, "secret.txt"), "nope");
fs.symlinkSync(outside, path.join(vault, "escape-link"), "dir");
assert.throws(
() => assertInsideVault(vault, "escape-link/secret.txt"),
VaultEscapeError
);
});
test("writing a NEW file through a symlink that points outside is rejected", () => {
const vault = makeTmpVault();
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-"));
fs.symlinkSync(outside, path.join(vault, "escape-link"), "dir");
// outside dir exists but the target file does not — this is the write path.
assert.throws(
() => assertInsideVault(vault, "escape-link/newfile.md"),
VaultEscapeError
);
});
test("dangling symlink pointing outside vault is rejected (escape via mkdir -p)", () => {
const vault = makeTmpVault();
const outsideBase = fs.mkdtempSync(path.join(os.tmpdir(), "braindump-outside-"));
// Symlink target does NOT exist yet — existsSync would treat the link as
// absent and wrongly allow it; lstat-based walk must still reject.
fs.symlinkSync(path.join(outsideBase, "nonexistent"), path.join(vault, "dangling"), "dir");
assert.throws(
() => assertInsideVault(vault, "dangling/pwned.md"),
VaultEscapeError
);
});
test("symlink to a directory INSIDE the vault is allowed", () => {
const vault = makeTmpVault();
fs.mkdirSync(path.join(vault, "real"), { recursive: true });
fs.symlinkSync(path.join(vault, "real"), path.join(vault, "alias"), "dir");
const resolved = assertInsideVault(vault, "alias/note.md");
assert.equal(resolved, path.join(vault, "alias", "note.md"));
});
+81
View File
@@ -0,0 +1,81 @@
import fs from "node:fs";
import path from "node:path";
import { VaultEscapeError } from "../errors.js";
/**
* Validate that `relPath` resolves to a location inside `vaultRoot`, and
* return the resolved absolute path. Throws VaultEscapeError otherwise.
*
* Defends against:
* - absolute paths passed as "relative"
* - `..` segments that walk out of the vault (even if they net back inside,
* we reject on principle — see below for the "stays inside" test case,
* which is allowed because the normalized path never leaves the root)
* - symlinks inside the vault that point outside of it
*/
export function assertInsideVault(vaultRoot: string, relPath: string): string {
if (path.isAbsolute(relPath)) {
throw new VaultEscapeError(`Path must be relative: ${relPath}`);
}
const normalized = path.normalize(relPath);
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
throw new VaultEscapeError(`Path escapes vault: ${relPath}`);
}
const resolvedRoot = path.resolve(vaultRoot);
const candidate = path.resolve(resolvedRoot, normalized);
// Belt-and-suspenders: candidate must still be within the root textually.
if (candidate !== resolvedRoot && !candidate.startsWith(resolvedRoot + path.sep)) {
throw new VaultEscapeError(`Path escapes vault: ${relPath}`);
}
// Symlink defense: realpath the deepest *existing* ancestor of the
// candidate (the candidate itself may not exist yet, e.g. a new file
// about to be written) and verify it lives inside the realpath'd vault
// root. This catches a symlink anywhere along the existing path prefix
// that would otherwise redirect us outside the vault.
let realRoot: string;
try {
realRoot = fs.realpathSync(resolvedRoot);
} catch {
// Vault root doesn't exist on disk (shouldn't normally happen) — fall
// back to the textual root.
realRoot = resolvedRoot;
}
// Walk up to the deepest ancestor that exists *as a filesystem entry of any
// kind*. Use lstatSync, not existsSync: existsSync follows symlinks, so a
// dangling symlink (e.g. vault/link -> /outside/does-not-exist) would be
// treated as "not existing" and skipped, letting a later mkdir -p + write
// follow the link and escape the vault. lstatSync treats the symlink itself
// as an existing entry so we stop on it and resolve its real target below.
let ancestor = candidate;
while (true) {
try {
fs.lstatSync(ancestor);
break;
} catch {
const parent = path.dirname(ancestor);
if (parent === ancestor) break; // reached filesystem root without finding anything
ancestor = parent;
}
}
// Fully resolve the ancestor (following any symlinks). If it's a dangling
// symlink, realpathSync throws — we reject, because we cannot prove the link
// stays inside the vault and following it on write could escape.
let realAncestor: string;
try {
realAncestor = fs.realpathSync(ancestor);
} catch {
throw new VaultEscapeError(`Path resolves through an unresolvable/broken symlink: ${relPath}`);
}
if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) {
throw new VaultEscapeError(`Path escapes vault via symlink: ${relPath}`);
}
return candidate;
}
+207
View File
@@ -0,0 +1,207 @@
import KoaRouter from "@koa/router";
import fs from "node:fs";
import type { Config } from "../config/types.js";
import type { Db, SessionRow } from "../db/Db.js";
import { VaultResolver } from "../vault/VaultResolver.js";
import { assertInsideVault } from "../vault/pathSafety.js";
import * as vaultGit from "../vault/git.js";
import { jsonBody } from "./middleware/jsonBody.js";
import { startSse } from "./sse.js";
import { buildBackend } from "../agent/registry.js";
import { ConflictError, InvalidError, NotFoundError, TurnInProgressError } from "../errors.js";
export interface ApiDeps {
db: Db;
cfg: Config;
}
function sessionToJson(session: SessionRow) {
return session;
}
export function apirouter(deps: ApiDeps): KoaRouter {
const { db, cfg } = deps;
const vaults = new VaultResolver(cfg);
const router = new KoaRouter({ prefix: "/api" });
// In-memory per-session turn lock. One entry per session id currently
// mid-turn; presence alone is the lock (value unused).
const turnLocks = new Set<string>();
router.get("/health", async (ctx) => {
ctx.body = { ok: true };
});
router.get("/vaults", async (ctx) => {
ctx.body = { ok: true, vaults: vaults.list() };
});
router.post("/sessions", jsonBody(), async (ctx) => {
const body = ctx.request.body as Record<string, unknown>;
if (typeof body.vault !== "string" || !body.vault) {
throw new InvalidError("vault is required and must be a string");
}
const backend = typeof body.backend === "string" ? body.backend : cfg.defaultBackend;
if (backend !== "claude-sdk" && backend !== "openai-compat") {
throw new InvalidError('backend must be "claude-sdk" or "openai-compat"');
}
if (body.topic !== undefined && typeof body.topic !== "string") {
throw new InvalidError("topic must be a string");
}
if (body.model !== undefined && typeof body.model !== "string") {
throw new InvalidError("model must be a string");
}
const vaultCfg = vaults.resolve(body.vault);
const session = db.createSession({
vault: vaultCfg.name,
vaultPath: vaultCfg.path,
topic: (body.topic as string | undefined) ?? null,
backend,
model: (body.model as string | undefined) ?? null,
});
ctx.status = 201;
ctx.body = { ok: true, session: sessionToJson(session) };
});
router.get("/sessions", async (ctx) => {
const status = typeof ctx.query.status === "string" ? ctx.query.status : undefined;
const sessions = db.listSessions(status);
ctx.body = { ok: true, sessions: sessions.map(sessionToJson) };
});
router.get("/sessions/:id", async (ctx) => {
const session = db.getSession(ctx.params.id!);
const messages = db.listMessages(session.id);
ctx.body = { ok: true, session: sessionToJson(session), messages };
});
router.patch("/sessions/:id", jsonBody(), async (ctx) => {
const body = ctx.request.body as Record<string, unknown>;
const patch: { topic?: string | null; status?: string } = {};
if (body.topic !== undefined) {
if (body.topic !== null && typeof body.topic !== "string") {
throw new InvalidError("topic must be a string or null");
}
patch.topic = body.topic as string | null;
}
if (body.status !== undefined) {
if (typeof body.status !== "string" || !["active", "archived"].includes(body.status)) {
throw new InvalidError('status must be "active" or "archived"');
}
patch.status = body.status;
}
if (Object.keys(patch).length === 0) {
throw new InvalidError("no updatable fields provided (topic, status)");
}
const session = db.updateSession(ctx.params.id!, patch);
ctx.body = { ok: true, session: sessionToJson(session) };
});
router.delete("/sessions/:id", async (ctx) => {
const hard = ctx.query.hard === "true";
db.getSession(ctx.params.id!); // 404 if missing
db.deleteSession(ctx.params.id!, hard);
ctx.status = 204;
});
router.post("/sessions/:id/messages", jsonBody(), async (ctx) => {
const id = ctx.params.id!;
const session = db.getSession(id);
if (session.status !== "active") {
throw new ConflictError(`Session ${id} is not active (status: ${session.status})`);
}
const body = ctx.request.body as Record<string, unknown>;
if (typeof body.text !== "string" || !body.text) {
throw new InvalidError("text is required and must be a non-empty string");
}
const userText = body.text;
if (turnLocks.has(id)) {
throw new TurnInProgressError(`Session ${id} already has a turn in progress`);
}
turnLocks.add(id);
const turn = session.turn_count + 1;
const abortController = new AbortController();
const onClose = () => abortController.abort();
ctx.req.on("close", onClose);
const sse = startSse(ctx);
try {
// NOTE: message-row persistence is owned by the backend (see registry.ts),
// not the router. The openai-compat backend writes the full user/assistant/
// tool history it needs to replay; the claude-sdk backend writes display-only
// user/assistant mirror rows. The router owns only turn_events (debug/replay
// log), turn_count, and the sdk_session_id resume token.
const backend = buildBackend(session, cfg, db);
let sdkSessionId: string | undefined;
for await (const event of backend.runTurn({ userText, signal: abortController.signal })) {
db.appendTurnEvent({ sessionId: id, turn, kind: event.type, data: event });
sse.send(event.type, event);
if (event.type === "turn_complete") {
sdkSessionId = event.sdkSessionId;
}
}
if (!abortController.signal.aborted) {
db.updateSession(id, {
turn_count: turn,
...(sdkSessionId !== undefined ? { sdk_session_id: sdkSessionId } : {}),
});
}
} catch (err) {
// Headers are already flushed (SSE), so we cannot let this propagate to
// Koa's error middleware — it would try to send a second response. Surface
// it as a terminal error event on the stream instead, and log it.
console.error(`[turn] session ${id} turn ${turn} failed:`, err);
if (!abortController.signal.aborted) {
try {
const message = err instanceof Error ? err.message : String(err);
db.appendTurnEvent({ sessionId: id, turn, kind: "error", data: { type: "error", message } });
sse.send("error", { type: "error", message });
} catch {
/* stream may be gone; nothing more we can do */
}
}
} finally {
ctx.req.off("close", onClose);
turnLocks.delete(id);
sse.end();
}
});
router.get("/sessions/:id/file", async (ctx) => {
const session = db.getSession(ctx.params.id!);
const relPath = ctx.query.path;
if (typeof relPath !== "string" || !relPath) {
throw new InvalidError("path query param is required");
}
const absPath = assertInsideVault(session.vault_path, relPath);
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
throw new NotFoundError(`No file at ${relPath}`);
}
const content = fs.readFileSync(absPath, "utf8");
ctx.body = { ok: true, path: relPath, content };
});
router.get("/sessions/:id/git/status", async (ctx) => {
const session = db.getSession(ctx.params.id!);
const entries = await vaultGit.status(session.vault_path);
ctx.body = { ok: true, status: entries };
});
router.get("/sessions/:id/git/diff", async (ctx) => {
const session = db.getSession(ctx.params.id!);
const filePath = typeof ctx.query.path === "string" ? ctx.query.path : undefined;
if (filePath) assertInsideVault(session.vault_path, filePath);
const result = await vaultGit.diff(session.vault_path, filePath);
ctx.body = { ok: true, ...result };
});
return router;
}
+35
View File
@@ -0,0 +1,35 @@
import type { Middleware } from "@koa/router";
import {
ConflictError,
InvalidError,
NotFoundError,
TurnInProgressError,
VaultEscapeError,
} from "../../errors.js";
export const convertError: Middleware = async (ctx, next) => {
try {
await next();
} catch (e) {
if (e instanceof NotFoundError) {
ctx.status = 404;
ctx.body = { ok: false, error: "not_found", message: e.message };
} else if (e instanceof TurnInProgressError) {
ctx.status = 409;
ctx.body = { ok: false, error: "turn_in_progress", message: e.message };
} else if (e instanceof ConflictError) {
ctx.status = 409;
ctx.body = { ok: false, error: "conflict", message: e.message };
} else if (e instanceof VaultEscapeError) {
ctx.status = 400;
ctx.body = { ok: false, error: "vault_escape", message: e.message };
} else if (e instanceof InvalidError) {
ctx.status = 400;
ctx.body = { ok: false, error: "invalid", message: e.message };
} else {
ctx.status = 500;
ctx.body = { ok: false, error: "unknown" };
console.error(ctx.method, ctx.path, e);
}
}
};
+19
View File
@@ -0,0 +1,19 @@
import type { Middleware } from "@koa/router";
import type { CorsConfig } from "../../config/types.js";
export function cors(cfg: CorsConfig): Middleware {
return async (ctx, next) => {
const origin = ctx.get("Origin");
if (origin && cfg.origins.includes(origin)) {
ctx.set("Access-Control-Allow-Origin", origin);
ctx.set("Vary", "Origin");
ctx.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
ctx.set("Access-Control-Allow-Headers", "Content-Type");
}
if (ctx.method === "OPTIONS") {
ctx.status = 204;
return;
}
await next();
};
}
+20
View File
@@ -0,0 +1,20 @@
import { bodyParser } from "@koa/bodyparser";
import type { Middleware } from "@koa/router";
/**
* Simplified from abode's jsonBody: parses a JSON body and 400s if absent.
* No ajv schema validation here — handlers validate shapes by hand.
*/
export function jsonBody(): Middleware {
const parseBody = bodyParser({ enableTypes: ["json"], encoding: "utf8" });
return async (ctx, next) => {
await parseBody(ctx, async () => {
if (typeof ctx.request.body === "undefined") {
ctx.status = 400;
ctx.body = { ok: false, error: "missing_body" };
return;
}
await next();
});
};
}
+6
View File
@@ -0,0 +1,6 @@
import type { Middleware } from "@koa/router";
export const logRequests: Middleware = async (ctx, next) => {
await next();
console.log(`${ctx.status} - ${ctx.method.toUpperCase()} ${ctx.path}`);
};
+53
View File
@@ -0,0 +1,53 @@
import type { Context } from "koa";
export interface SseWriter {
send(event: string, data: unknown): void;
comment(text: string): void;
end(): void;
}
const HEARTBEAT_INTERVAL_MS = 15_000;
/**
* Sets SSE response headers on `ctx` and returns a writer for frames plus a
* heartbeat comment sent every 15s (cleared automatically on end()).
* Caller is responsible for keeping the response open (koa won't finalize
* the body until `ctx.res.end()` is called, which `end()` does here).
*/
export function startSse(ctx: Context): SseWriter {
ctx.status = 200;
ctx.set("Content-Type", "text/event-stream");
ctx.set("Cache-Control", "no-cache");
ctx.set("Connection", "keep-alive");
ctx.set("X-Accel-Buffering", "no");
ctx.res.flushHeaders?.();
// Tell koa we're handling the response body ourselves.
ctx.respond = false;
let ended = false;
const heartbeat = setInterval(() => {
if (ended) return;
ctx.res.write(`: heartbeat\n\n`);
}, HEARTBEAT_INTERVAL_MS);
heartbeat.unref?.();
return {
send(event, data) {
if (ended) return;
ctx.res.write(`event: ${event}\n`);
ctx.res.write(`data: ${JSON.stringify(data)}\n\n`);
},
comment(text) {
if (ended) return;
ctx.res.write(`: ${text}\n\n`);
},
end() {
if (ended) return;
ended = true;
clearInterval(heartbeat);
ctx.res.end();
},
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"rootDir": "src",
"strict": true,
"verbatimModuleSyntax": true,
"moduleResolution": "nodenext",
"module": "nodenext",
"target": "esnext",
"allowImportingTsExtensions": false,
"noEmit": true,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}