From 45e7a099aa4e67d3c133b32f07f6130bf14eba83 Mon Sep 17 00:00:00 2001 From: Joe G Date: Tue, 24 Feb 2026 16:11:40 -0800 Subject: [PATCH 1/3] feat: add Deno launcher, root tasks, and README Adds the orchestration layer and user-facing docs: - launcher.ts: starts the Python backend and Fresh frontend, streams their output, handles graceful shutdown - deno.json: root tasks (start, dev:web, build:web) - README.md: quick-start guide with vLLM requirements, architecture diagram, and feature list Co-Authored-By: Claude Sonnet 4.6 --- README.md | 100 ++++++++++++++++++++ deno.json | 7 ++ deno.lock | 17 ++++ launcher.ts | 257 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 README.md create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 launcher.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca5e114 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Librarian + +A local AI research assistant. Ask it questions; it searches the web and academic papers, reasons over the results, and gives you a cited answer. + +## Features + +- **Agentic research** — routes queries to a research agent that searches, reads, and synthesizes sources before answering +- **Web + paper search** — searches the web via [Serper](https://serper.dev) and academic papers via Semantic Scholar +- **Cited answers** — every answer includes references to the sources used +- **Bookmark groups** — pin specific sites so the agent always searches them +- **Conversation memory** — follow-up questions build on previous answers within a session +- **Configurable thinking effort** — tune how deeply the model reasons per query +- **Local LLM** — powered by [vLLM](https://github.com/vllm-project/vllm); your data stays on your machine + +## Architecture + +```mermaid +graph LR + Browser -->|chat UI| Frontend["Frontend\n(Fresh / Deno)"] + Frontend -->|SSE stream| Backend["Backend\n(Python / FastAPI)"] + Backend --> Router["Router"] + Router --> Research["Research Agent"] + Router --> Coder["Coder Agent"] + Research -->|web search| Serper + Research -->|paper search| SemanticScholar["Semantic Scholar"] + Research -->|fetch URLs| Web + Backend --> vLLM["vLLM\n(local LLM)"] +``` + +## Prerequisites + +| Tool | Purpose | +|------|---------| +| [Deno](https://deno.com) v2+ | Launcher and frontend | +| [Python](https://python.org) 3.12+ + [uv](https://docs.astral.sh/uv/) | Backend | +| [vLLM](https://github.com/vllm-project/vllm) >= 0.13 | LLM inference — **you must start this yourself** | +| [Serper](https://serper.dev) API key | Web search | + +## Quick Start + +**1. Start vLLM** + +Librarian requires vLLM >= 0.13 started **without a reasoning parser**: + +```sh +vllm serve $VLLM_MODEL_NAME \ + --api-key $VLLM_API_KEY \ + --enable-auto-tool-choice \ + --tool-call-parser hermes \ + --gpu-memory-utilization 0.92 \ + --max-model-len auto +``` + +> Hermes-format tool calling is required. The `--tool-call-parser` flag must be set, but `--reasoning-parser` must **not** be set. + +**2. Install backend dependencies** + +```sh +cd api && uv sync && cd .. +``` + +**3. Set environment variables** + +```sh +export VLLM_BASE_URL=http://localhost:8000/v1 +export VLLM_API_KEY=your-vllm-key +export VLLM_MODEL_NAME=your-model-name +export SERPER_API_TOKEN=your-serper-key +``` + +> Put these in a `.env` file and `source` it before launching. + +**4. Launch** + +```sh +deno task start +``` + +Librarian starts the backend and frontend, then opens your browser. + +## Configuration + +Port numbers are saved to `librarian.config.json` on first run. To change them, delete the file and re-run `deno task start`. + +| Service | Default port | +|---------|-------------| +| Frontend | 8080 | +| Backend | 8001 | + +## Development + +See [`api/`](api/) and [`web/`](web/) for backend and frontend developer docs. + +```sh +deno task dev:web # Frontend dev server with hot reload +``` + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..b852efd --- /dev/null +++ b/deno.json @@ -0,0 +1,7 @@ +{ + "tasks": { + "start": "deno run -A launcher.ts", + "dev:web": "cd web && deno task dev", + "build:web": "cd web && deno task build" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..9b2906d --- /dev/null +++ b/deno.lock @@ -0,0 +1,17 @@ +{ + "version": "5", + "specifiers": { + "npm:@types/node@*": "24.2.0" + }, + "npm": { + "@types/node@24.2.0": { + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dependencies": [ + "undici-types" + ] + }, + "undici-types@7.10.0": { + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + } + } +} diff --git a/launcher.ts b/launcher.ts new file mode 100644 index 0000000..97b9020 --- /dev/null +++ b/launcher.ts @@ -0,0 +1,257 @@ +/** + * Librarian Launcher + * Orchestrates: Python backend, Fresh frontend + */ + +const ROOT_DIR = new URL(".", import.meta.url).pathname; +const API_DIR = `${ROOT_DIR}api`; +const WEB_DIR = `${ROOT_DIR}web`; + +// Env vars, shared with the front/backend services +const ENV_SCHEMA_PATH = `${ROOT_DIR}env.schema.json`; + +interface EnvVarDef { + default?: string; + description: string; + required: boolean; + link?: string; +} + +interface EnvSchema { + env: Record; +} + +async function loadEnvSchema(): Promise { + return JSON.parse(await Deno.readTextFile(ENV_SCHEMA_PATH)); +} + +function validateEnv(schema: EnvSchema): void { + const missing = Object.entries(schema.env) + .filter(([name, def]) => def.required && !Deno.env.get(name)) + .map(([name]) => name); + + if (missing.length > 0) { + console.error( + `Missing required environment variables:\n ${missing.join("\n ")}`, + ); + console.error("\nSet them in your shell or a .env file and retry."); + Deno.exit(1); + } +} + +// Services config +const CONFIG_PATH = `${ROOT_DIR}librarian.config.json`; + +interface Config { + ports: { + backend: number; + frontend: number; + }; +} + +const DEFAULT_CONFIG: Config = { + ports: { + backend: 8001, + frontend: 8080, + }, +}; + +async function loadConfig(): Promise { + let text: string; + try { + text = await Deno.readTextFile(CONFIG_PATH); + } catch { + return null; // file not found — use defaults + } + const parsed = JSON.parse(text); // parse errors bubble up + return { ports: { ...DEFAULT_CONFIG.ports, ...(parsed.ports ?? {}) } }; +} + +// Service utilities +interface ProcessHandle { + name: string; + process: Deno.ChildProcess; +} + +const processes: ProcessHandle[] = []; + +function startBackend(port: number): Deno.ChildProcess { + console.log(`\nStarting backend on port ${port}...`); + const cmd = new Deno.Command("uv", { + args: ["run", "python", "-m", "librarian.server", "--port", String(port)], + cwd: API_DIR, + stdout: "piped", + stderr: "piped", + }); + return cmd.spawn(); +} + +function startFrontend( + port: number, + backendPort: number, +): Deno.ChildProcess { + console.log(`Starting frontend on port ${port}...`); + const cmd = new Deno.Command("deno", { + args: ["run", "-A", "npm:vite", "--port", String(port)], + cwd: WEB_DIR, + stdout: "piped", + stderr: "piped", + env: { + ...Deno.env.toObject(), + BACKEND_URL: `http://localhost:${backendPort}`, + }, + }); + return cmd.spawn(); +} + +async function isPortInUse(port: number): Promise { + try { + const conn = await Deno.connect({ port, hostname: "127.0.0.1" }); + conn.close(); + return true; + } catch { + return false; + } +} + +async function waitForPort(port: number, timeoutMs = 15000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isPortInUse(port)) return true; + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +async function waitForBackend( + port: number, + timeoutMs = 60000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://localhost:${port}/openapi.json`); + if (res.ok) return true; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +function streamOutput(name: string, stream: ReadableStream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + + (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + for (const line of text.split("\n").filter((l) => l.trim())) { + console.log(`[${name}] ${line}`); + } + } + })(); +} + +async function shutdown() { + console.log("\nShutting down..."); + + for (const { name, process } of processes) { + console.log(`Stopping ${name}...`); + try { + process.kill("SIGTERM"); + } catch { + // process may already be dead + } + } + + // Wait up to 5 seconds for graceful shutdown, then force kill + const timeout = setTimeout(() => { + console.log("Force killing remaining processes..."); + for (const { process } of processes) { + try { + process.kill("SIGKILL"); + } catch { + // ignore + } + } + }, 5000); + + await Promise.all( + processes.map(({ process }) => process.status.catch(() => {})), + ); + clearTimeout(timeout); +} + +async function main() { + console.log("=== Librarian ===\n"); + console.log("Press Ctrl+C to stop.\n"); + + const schema = await loadEnvSchema(); + validateEnv(schema); + + const handleSignal = async () => { + await shutdown(); + Deno.exit(0); + }; + Deno.addSignalListener("SIGINT", handleSignal); + Deno.addSignalListener("SIGTERM", handleSignal); + + const config = await loadConfig() ?? DEFAULT_CONFIG; + const frontendPort = config.ports.frontend; + const backendPort = config.ports.backend; + + if (await isPortInUse(frontendPort)) { + console.error( + `Port ${frontendPort} is already in use. Stop the existing process and retry.`, + ); + Deno.exit(1); + } + const frontend = startFrontend(frontendPort, backendPort); + processes.push({ name: "frontend", process: frontend }); + streamOutput("frontend", frontend.stdout); + streamOutput("frontend", frontend.stderr); + + if (!await waitForPort(frontendPort)) { + console.error("Frontend failed to start within 15 seconds."); + await shutdown(); + Deno.exit(1); + } + + if (await isPortInUse(backendPort)) { + console.error( + `Port ${backendPort} is already in use. Stop the existing process and retry.`, + ); + await shutdown(); + Deno.exit(1); + } + const backend = startBackend(backendPort); + processes.push({ name: "backend", process: backend }); + streamOutput("backend", backend.stdout); + streamOutput("backend", backend.stderr); + + if (!await waitForBackend(backendPort)) { + console.error( + "Backend failed to start within 60 seconds. Check [backend] logs above.", + ); + await shutdown(); + Deno.exit(1); + } + + // Wait for any process to exit, then shut down all others + const { handle, status } = await Promise.race( + processes.map(async (p) => ({ handle: p, status: await p.process.status })), + ); + if (!status.success) { + console.error( + `\n[${handle.name}] exited unexpectedly (code ${status.code}) — check logs above`, + ); + } + await shutdown(); + Deno.exit(status.success ? 0 : 1); +} + +main(); From 5cb67a80fbb3f2beac35090284c16caefad2265b Mon Sep 17 00:00:00 2001 From: Joe G Date: Tue, 24 Feb 2026 16:17:42 -0800 Subject: [PATCH 2/3] fix: address Greptile review comments - README: fix incorrect claim that launcher opens the browser - launcher: wrap streamOutput reader loop in try/catch to handle non-fatal reader errors when a process exits Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- launcher.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ca5e114..fa35d57 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ export SERPER_API_TOKEN=your-serper-key deno task start ``` -Librarian starts the backend and frontend, then opens your browser. +Librarian starts the backend and frontend. Open http://localhost:8080 in your browser. ## Configuration diff --git a/launcher.ts b/launcher.ts index 97b9020..ddff27d 100644 --- a/launcher.ts +++ b/launcher.ts @@ -145,13 +145,17 @@ function streamOutput(name: string, stream: ReadableStream) { const decoder = new TextDecoder(); (async () => { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value); - for (const line of text.split("\n").filter((l) => l.trim())) { - console.log(`[${name}] ${line}`); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const text = decoder.decode(value); + for (const line of text.split("\n").filter((l) => l.trim())) { + console.log(`[${name}] ${line}`); + } } + } catch { + // Reader errors are non-fatal (process may have exited) } })(); } From c202edcc99132a58be2ec67c1d0dfb188eae090f Mon Sep 17 00:00:00 2001 From: Joe G Date: Tue, 24 Feb 2026 16:21:57 -0800 Subject: [PATCH 3/3] docs: simplify quick start to clone + launch Remove manual uv sync step (uv run handles it automatically) and consolidate to a two-line clone-and-run. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fa35d57..895dcf2 100644 --- a/README.md +++ b/README.md @@ -53,30 +53,25 @@ vllm serve $VLLM_MODEL_NAME \ > Hermes-format tool calling is required. The `--tool-call-parser` flag must be set, but `--reasoning-parser` must **not** be set. -**2. Install backend dependencies** +**2. Set environment variables** -```sh -cd api && uv sync && cd .. -``` - -**3. Set environment variables** +Create a `.env` file: ```sh -export VLLM_BASE_URL=http://localhost:8000/v1 -export VLLM_API_KEY=your-vllm-key -export VLLM_MODEL_NAME=your-model-name -export SERPER_API_TOKEN=your-serper-key +VLLM_BASE_URL=http://localhost:8000/v1 +VLLM_API_KEY=your-vllm-key +VLLM_MODEL_NAME=your-model-name +SERPER_API_TOKEN=your-serper-key ``` -> Put these in a `.env` file and `source` it before launching. - -**4. Launch** +**3. Clone and launch** ```sh -deno task start +git clone https://github.com/g-eoj/librarian.git && cd librarian +source .env && deno task start ``` -Librarian starts the backend and frontend. Open http://localhost:8080 in your browser. +Open http://localhost:8080 in your browser. ## Configuration