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:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
config.jsonc
|
||||||
|
dist
|
||||||
@@ -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, 1–3 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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2554
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 1–3 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.`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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[];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
`,
|
||||||
|
];
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user