diff --git a/README.md b/README.md index 57084bb..4872ee9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # CrickNote -CrickNote is a local research assistant for an Obsidian vault. It indexes your markdown notes, lets an AI search them, and can propose safe edits that you approve before anything is written. +CrickNote is a local research assistant for an Obsidian vault. It indexes your markdown notes, lets an AI search them, and can propose safe edits that you approve before anything is written. CrickNote runs as a set of command-line tools that an AI agent drives directly — there is no long-running service. ## What You Need - Node.js 22 or newer -- An Obsidian vault +- An Obsidian vault (or any folder of markdown notes) - An API key for the language model provider you choose during setup ## First-Time Setup @@ -16,7 +16,7 @@ Install the project dependencies: npm install ``` -Build the TypeScript code and the Obsidian plugin: +Build the TypeScript code: ```bash npm run build @@ -28,15 +28,26 @@ Run setup: npm run setup ``` -Setup asks where your Obsidian vault is and which AI provider to use. It saves the app config under your home folder in `.cricknote`. +Setup asks where your vault is and which AI provider to use. It saves the app config under your home folder in `.cricknote`. -## Start CrickNote +## Index Your Vault ```bash -npm run start +npm run reindex ``` -Leave this running while you use the Obsidian plugin. The service starts a local WebSocket server and indexes your vault in the background. +This performs a full BM25 + metadata index of every markdown file in the vault. Re-run it whenever notes change; no background process is needed. + +## Driving CrickNote from an Agent + +An AI agent interacts with the vault through the CLI: + +```bash +cricknote tools # list the available tools and their parameters +cricknote tool '' # execute a tool with JSON arguments +``` + +Most write tools return a pending edit by default; pass `--no-apply` to inspect the diff without writing. ## Run Checks @@ -52,32 +63,23 @@ Run the build check: npm run build ``` -Run the optional socket end-to-end tests: - -```bash -CRICKNOTE_RUN_SOCKET_TESTS=1 npm test -``` - -Those optional tests start a real local WebSocket server, so they are useful before a release. - ## Project Map -- `src/ingestion`: reads markdown files, parses metadata, chunks note text, and indexes notes -- `src/retrieval`: searches indexed notes and assembles context for the AI -- `src/agent`: connects the AI provider to CrickNote tools +- `src/ingestion`: reads markdown files, parses metadata, chunks note text, and indexes notes (BM25 + metadata) +- `src/retrieval`: parses queries and builds structured filters over the index +- `src/agent`: connects the AI provider to CrickNote tools and routes tool calls +- `src/cli`: setup, reindex, and the `tool`/`tools` dispatch entrypoints - `src/editing`: creates safe edit proposals, diffs, conflict checks, and audit logs - `src/storage`: owns the SQLite database and migrations -- `src/server`: runs the local WebSocket server used by the Obsidian plugin -- `obsidian-plugin`: plugin code that connects Obsidian to the local service -- `tests`: unit, integration, and end-to-end tests +- `tests`: unit and integration tests ## Common Commands ```bash -npm test # run tests -npm run build # compile TypeScript and build the plugin -npm run start # start the local CrickNote service -npm run setup # configure CrickNote after building +npm test # run tests +npm run build # compile TypeScript +npm run reindex # full vault re-index +npm run setup # configure CrickNote after building ``` ## Safety Notes diff --git a/docs/superpowers/plans/2026-06-12-prune-retired-runtime.md b/docs/superpowers/plans/2026-06-12-prune-retired-runtime.md new file mode 100644 index 0000000..4835de9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-prune-retired-runtime.md @@ -0,0 +1,142 @@ +# Prune Retired Runtime (embedding + websocket service) + +**Date:** 2026-06-12 +**Branch:** `claude/general-session-Gs88j` +**Status:** EXECUTED — committed directly to the session branch (468a329, d481918, +39cfc01, 334fcc6, + docs). Decisions: delete the Obsidian plugin; leave the +`chunk_embeddings` table. Note: compile-ordering forced service/start removal into +L2 and the config trim into L3 (each commit stays tsc-clean and test-green). + +## Why + +CrickNote is pivoting to an **agent-native CLI bridge** (cf. merged PR #3 +`feat/agent-native-bridge`, plus `feat(cli): expose 'tool' and 'tools' commands +for agent access` and `perf(search): drop query-time semantic rank so CLI search +needs no model`). In that model an external AI agent drives the vault by shelling +out to `cricknote tool ` / `cricknote tools`. The legacy runtime — local +embeddings, a background file watcher/worker, and a long-running WebSocket server +that the Obsidian chat plugin connected to — is now dead weight: search is already +BM25 + metadata only (no query-time embeddings), and nothing in the agent path +loads a model or opens a socket. + +This plan removes that retired runtime, the heavy dependencies it dragged in, and +the now-orphaned tests, leaving a lean embedding-free CLI. + +> **Note on lost work:** a prior session performed this prune inside an isolated +> git worktree that was removed; none of those commits (`72d38d4`…`1ed0481`) +> survived. This plan re-derives the work from the *current* `main`/session tree +> (`0f9d075`) and will commit **directly to the session branch** so it cannot +> vanish again. + +## Baseline (verify before starting) + +- `npx tsc --noEmit` clean +- `npx vitest run` green — record file/test counts as the regression gate. + +## Inventory (current codebase, verified) + +### Delete (retired runtime) +| File | Role | Real importers | +|---|---|---| +| `src/ingestion/embedder.ts` | `@xenova/transformers` embeddings | `worker.ts`, `indexer.ts`* | +| `src/ingestion/watcher.ts` | `chokidar` file watcher | `worker.ts` | +| `src/ingestion/worker.ts` | background ingestion loop | `service.ts` | +| `src/retrieval/semantic-ranker.ts` | vector rerank (reads `chunk_embeddings`) | **none (orphaned)** | +| `src/retrieval/context-assembler.ts` | LLM context builder | **none (orphaned)** | +| `src/server/websocket.ts` | `ws` server for Obsidian plugin | `service.ts`, `cli.ts` | +| `src/server/auth.ts` | token gen/rotate for the socket | `cli.ts`, `setup.ts` | +| `src/server/rate-limiter.ts` | per-connection rate limiting | `websocket.ts` | +| `src/service.ts` | wires server + worker for `start` | `cli/start.ts` | +| `src/cli/start.ts` | `cricknote start` long-running service | `cli.ts` | + +\* `indexer.ts` is **kept** but surgically edited (see below). + +### Keep — confirmed still in use (do NOT delete) +- `src/retrieval/structured-filter.ts`, `src/retrieval/query-parser.ts` — both + imported by `src/agent/tools/search.ts` (the live BM25 search). The + "feed the semantic ranker" comment in `structured-filter.ts` is stale prose + only; reword, don't delete. +- `src/ingestion/indexer.ts`, `index-file.ts`, `parser.ts`, `chunker.ts`, + `ignore.ts` — the embedding-free index path used by `reindex` and the tools. +- `src/cli/reindex.ts` — already standalone, embedding-free (the `watcher` grep + hit was a `// no watcher` comment, not an import). + +### Surgically edit (kept files that reference deleted code) +1. **`src/ingestion/indexer.ts`** — remove `import { embeddingToBuffer } from './embedder.js'`, + drop the `embeddings: Float32Array[]` input field and the + `INSERT INTO chunk_embeddings` loop. `index-file.ts` already passes + `embeddings: []`, so this is dead at runtime; update its call site to stop + passing the field. +2. **`src/ingestion/index-file.ts`** — drop `embeddings: []` from the `indexNote` call. +3. **`src/cli.ts`** — remove the `start` command + import, the `rotate-token` + command + `rotateToken` import, and the now-unused `crypto` only if it + becomes unused (it's still used by the `tool` command — keep). Keep `setup`, + `reindex`, `tool`, `tools`. +4. **`src/cli/setup.ts`** — remove `generateToken/getTokenPath` import + the + "Generate auth token" block (lines ~187-189) and the `server:` field in the + saved config (line ~171). Re-inspect the obsidian-plugin install block + (~222+) under the plugin decision below. +5. **`src/config/config.ts`** — remove the unused `embeddingModelPath?` and + `server: { host; port }` fields from `CrickNoteConfig`, the `server` default + in `DEFAULT_CONFIG` (leaving `{}` or removing the spread), and any + `config.server`/`config.llm` start-banner usage in deleted `start.ts` + (already covered by deletion). +6. **`README.md`** — drop the "Start CrickNote" / `npm run start` / WebSocket + sections and the `src/server` + plugin transport bullets; describe the CLI + tool entrypoint instead. +7. **`package.json`** — remove the `start` script (its command is gone); keep + `setup`/`reindex`/`test`. Remove deps `@xenova/transformers`, `chokidar`, + `ws` (each used only by a deleted file; `openai` + `@anthropic-ai/sdk` stay). + +### Delete orphaned tests (only those whose subject is deleted) +`worker.test.ts`, `watcher.test.ts`, `websocket-client.test.ts`, +`websocket-mapper.test.ts`, `tests/e2e/server.e2e.test.ts`, +`auth-validation.test.ts`, `rate-limiter.test.ts`, `context-assembler.test.ts`. +(No `embedder`/`semantic-ranker` test exists.) Every other test stays; if any +kept test transitively imports deleted code it must be **rewritten to cover the +retained behavior**, not dropped. + +## Open decisions (need your call) + +1. **`obsidian-plugin/` + `scripts/build-plugin.sh` + `build:plugin` script.** + The plugin (`websocket-client.ts`, `chat-view.ts`, `main.ts`) connects *only* + via the WebSocket server we're deleting, so it becomes non-functional. + - **(Recommended) Delete it** in a final layer — it's the whole point of the + agent-native pivot, and leaving dead plugin code + build step is misleading. + - Keep it untouched (document as deprecated) if you still want the chat UI. +2. **`chunk_embeddings` table.** Created by migration `001-initial.ts`. After the + prune nothing reads or writes it. + - **(Recommended) Leave the table** — never rewrite historical migrations; + an empty unused table is harmless and zero-risk. + - Add a new `004` migration to `DROP TABLE chunk_embeddings` if you want a + fully clean schema (slightly more risk, more test churn). + +## Execution — layered, each layer a commit behind a green gate + +Each layer: delete/edit → fix fallout in kept code → remove only genuinely +orphaned tests → `tsc --noEmit` clean → `vitest run` green → commit. Stop and +report if a layer can't go green. + +- **L0** Baseline: record tsc + test counts. +- **L1 — Retrieval orphans.** Delete `semantic-ranker.ts`, `context-assembler.ts` + + `context-assembler.test.ts`. Reword stale comment in `structured-filter.ts`. + (Zero importers → smallest, safest first.) +- **L2 — Ingestion runtime.** Delete `embedder.ts`, `watcher.ts`, `worker.ts` + + their tests. Surgically strip embeddings from `indexer.ts` + `index-file.ts`. +- **L3 — Server.** Delete `src/server/` (websocket, auth, rate-limiter) + their + tests (`websocket-*`, `auth-validation`, `rate-limiter`, `server.e2e`). +- **L4 — Service + CLI surface.** Delete `service.ts`, `cli/start.ts`; edit + `cli.ts` (drop `start` + `rotate-token`) and `setup.ts` (drop token/server). +- **L5 — Config + docs.** Trim `config.ts` fields; update `README.md`. +- **L6 — Deps.** `npm rm @xenova/transformers chokidar ws`; drop `start` script; + verify lockfile + tsc + tests; confirm no dangling references via grep. +- **L7 — (pending decision 1)** Delete `obsidian-plugin/`, `build-plugin.sh`, + `build:plugin`, and the plugin step in `setup.ts` + `build` script. + +## Risks / guards +- Hidden transitive imports (like the prior session's `registry.ts` → + `providers/base.ts` coupling): each layer greps for dangling references before + committing. +- Don't over-delete `structured-filter`/`query-parser` — they're live. +- Keep historical migrations intact. +- Commit per layer on the session branch; never use an isolated worktree. diff --git a/obsidian-plugin/chat-view.ts b/obsidian-plugin/chat-view.ts deleted file mode 100644 index 4960751..0000000 --- a/obsidian-plugin/chat-view.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { ItemView, MarkdownRenderer, WorkspaceLeaf } from 'obsidian'; -import type CrickNotePlugin from './main'; -import type { CrickNotePluginData } from './main'; - -export const CHAT_VIEW_TYPE = 'cricknote-chat'; - -interface ChatMessage { - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp: number; - pendingEdits?: Array<{ editId: string; batchId?: string; path: string; diff: string; hasConflict: boolean; warnings: string[] }>; -} - -export class ChatView extends ItemView { - private plugin: CrickNotePlugin; - private messages: ChatMessage[] = []; - private inputEl: HTMLTextAreaElement | null = null; - private messagesEl: HTMLElement | null = null; - - // WebSocket event handlers — kept so they can be detached cleanly. - private chatChunkHandler: ((msg: Record) => void) | null = null; - private chatResponseHandler: ((msg: Record) => void) | null = null; - private errorHandler: ((msg: Record) => void) | null = null; - private editResultHandler: ((msg: Record) => void) | null = null; - - // In-progress streaming bubble. Created on first chunk, finalized on chat_response. - private streamingMessageEl: HTMLElement | null = null; - private streamingContentEl: HTMLElement | null = null; - private streamingText = ''; - - /** Map from editId to the action buttons container, for updating on server response. */ - private pendingEditButtons: Map = new Map(); - - constructor(leaf: WorkspaceLeaf, plugin: CrickNotePlugin) { - super(leaf); - this.plugin = plugin; - } - - getViewType(): string { - return CHAT_VIEW_TYPE; - } - - getDisplayText(): string { - return 'CrickNote Chat'; - } - - getIcon(): string { - return 'message-square'; - } - - async onOpen(): Promise { - const container = this.containerEl.children[1]; - container.empty(); - container.addClass('cricknote-chat-container'); - - // Messages area - this.messagesEl = container.createDiv({ cls: 'cricknote-messages' }); - this.messages = []; - - // Replay persisted history, or show welcome for new sessions. - const savedData = ((await this.plugin.loadData()) as CrickNotePluginData | null) ?? {}; - const history = savedData.chatHistory ?? []; - if (history.length > 0) { - for (const msg of history) { - this.addMessage({ ...msg, pendingEdits: undefined }); - } - } else { - this.addMessage({ - role: 'system', - content: 'Welcome to CrickNote! Ask me about your experiments, or tell me what to record.', - timestamp: Date.now(), - }); - } - - // Input area - const inputContainer = container.createDiv({ cls: 'cricknote-input-container' }); - this.inputEl = inputContainer.createEl('textarea', { - cls: 'cricknote-input', - attr: { placeholder: 'Ask about your research...' }, - }); - - const sendBtn = inputContainer.createEl('button', { cls: 'cricknote-send-btn', text: 'Send' }); - - // Event handlers - this.inputEl.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - this.sendMessage(); - } - }); - - sendBtn.addEventListener('click', () => this.sendMessage()); - - // Remove any leftover listeners from a previous open before registering new ones. - this.detachListeners(); - - // --- chat_chunk: append text to the in-progress streaming bubble --- - this.chatChunkHandler = (msg: Record) => { - const text = msg.text as string; - if (!text || !this.messagesEl) return; - - if (!this.streamingMessageEl) { - // Create the bubble on the first chunk. - this.streamingMessageEl = this.messagesEl.createDiv({ - cls: 'cricknote-message cricknote-assistant', - }); - const roleEl = this.streamingMessageEl.createDiv({ cls: 'cricknote-role' }); - roleEl.setText('CrickNote'); - this.streamingContentEl = this.streamingMessageEl.createDiv({ cls: 'cricknote-content' }); - this.streamingText = ''; - } - - this.streamingText += text; - // Show plain text during streaming; markdown is rendered on finalization. - this.streamingContentEl!.setText(this.streamingText); - this.messagesEl.scrollTop = this.messagesEl.scrollHeight; - }; - - // --- chat_response: finalize the streaming bubble or create one if no chunks arrived --- - this.chatResponseHandler = (msg: Record) => { - const content = msg.content as string; - const pendingEdits = (msg.pendingEdits && Array.isArray(msg.pendingEdits) && (msg.pendingEdits as unknown[]).length > 0) - ? msg.pendingEdits as ChatMessage['pendingEdits'] - : undefined; - - const assistantMessage: ChatMessage = { role: 'assistant', content, timestamp: Date.now(), pendingEdits }; - - if (this.streamingMessageEl && this.streamingContentEl) { - this.messages.push(assistantMessage); - void this.saveHistory(); - // Finalize the streaming bubble: replace plain text with rendered markdown. - this.streamingContentEl.empty(); - MarkdownRenderer.render(this.plugin.app, content, this.streamingContentEl, '', this); - if (pendingEdits) { - this.appendPendingEdits(this.streamingMessageEl, pendingEdits); - } - this.streamingMessageEl = null; - this.streamingContentEl = null; - this.streamingText = ''; - } else { - // No chunks arrived (e.g. streaming disabled or very fast empty reply). - this.addMessage(assistantMessage); - void this.saveHistory(); - } - - if (this.messagesEl) { - this.messagesEl.scrollTop = this.messagesEl.scrollHeight; - } - }; - - this.errorHandler = (msg: Record) => { - // Discard any half-rendered streaming bubble before showing the error. - this.streamingMessageEl?.remove(); - this.streamingMessageEl = null; - this.streamingContentEl = null; - this.streamingText = ''; - - this.addMessage({ - role: 'system', - content: `Error: ${msg.message}`, - timestamp: Date.now(), - }); - }; - - this.editResultHandler = (msg: Record) => { - const editId = msg.editId as string; - const allPendingEdits = this.messages.flatMap(m => m.pendingEdits ?? []); - const currentEdit = allPendingEdits.find(e => e.editId === editId); - const batchId = currentEdit?.batchId; - const affectedEditIds = batchId - ? allPendingEdits.filter(e => e.batchId === batchId).map(e => e.editId) - : [editId]; - const affectedButtons = affectedEditIds - .map(id => [id, this.pendingEditButtons.get(id)] as const) - .filter((entry): entry is readonly [string, NonNullable>] => Boolean(entry[1])); - - if (affectedButtons.length === 0) return; - - if (msg.success) { - const clickedButtons = this.pendingEditButtons.get(editId); - const wasCancel = clickedButtons?.cancelBtn.textContent === 'Cancelling\u2026'; - - for (const [, btns] of affectedButtons) { - btns.applyBtn.disabled = true; - btns.cancelBtn.disabled = true; - if (btns.forceBtn) btns.forceBtn.disabled = true; - if (wasCancel) { - btns.cancelBtn.setText('Cancelled'); - } else { - btns.applyBtn.setText('Applied'); - } - } - - const continueBtn = affectedButtons[0][1].actionsEl.createEl('button', { - cls: 'cricknote-continue-btn', - text: 'Continue', - }); - continueBtn.addEventListener('click', () => { - continueBtn.remove(); - this.sendMessageText('continue'); - }); - } else if (batchId) { - for (const [, btns] of affectedButtons) { - btns.applyBtn.disabled = true; - btns.applyBtn.setText('Batch cancelled'); - btns.cancelBtn.disabled = true; - if (btns.forceBtn) btns.forceBtn.disabled = true; - } - this.addMessage({ - role: 'system', - content: `Edit failed: ${msg.message ?? 'Unknown error'}`, - timestamp: Date.now(), - }); - } else { - const btns = affectedButtons[0][1]; - btns.applyBtn.disabled = false; - btns.applyBtn.setText('Apply'); - btns.cancelBtn.disabled = false; - btns.cancelBtn.setText('Cancel'); - if (btns.forceBtn) { - btns.forceBtn.disabled = false; - btns.forceBtn.setText('Force Apply'); - } - this.addMessage({ - role: 'system', - content: `Edit failed: ${msg.message ?? 'Unknown error'}`, - timestamp: Date.now(), - }); - } - - for (const [affectedEditId] of affectedButtons) { - this.pendingEditButtons.delete(affectedEditId); - } - }; - - this.plugin.ws?.on('chat_chunk', this.chatChunkHandler); - this.plugin.ws?.on('chat_response', this.chatResponseHandler); - this.plugin.ws?.on('server_error', this.errorHandler); - this.plugin.ws?.on('edit_result', this.editResultHandler); - } - - async onClose(): Promise { - this.detachListeners(); - } - - private detachListeners(): void { - if (this.chatChunkHandler) { - this.plugin.ws?.off('chat_chunk', this.chatChunkHandler); - this.chatChunkHandler = null; - } - if (this.chatResponseHandler) { - this.plugin.ws?.off('chat_response', this.chatResponseHandler); - this.chatResponseHandler = null; - } - if (this.errorHandler) { - this.plugin.ws?.off('server_error', this.errorHandler); - this.errorHandler = null; - } - if (this.editResultHandler) { - this.plugin.ws?.off('edit_result', this.editResultHandler); - this.editResultHandler = null; - } - this.streamingMessageEl = null; - this.streamingContentEl = null; - this.streamingText = ''; - this.pendingEditButtons.clear(); - } - - private sendMessage(): void { - if (!this.inputEl) return; - const content = this.inputEl.value.trim(); - if (!content) return; - - this.addMessage({ role: 'user', content, timestamp: Date.now() }); - this.plugin.ws?.sendChat(content); - this.inputEl.value = ''; - void this.saveHistory(); - } - - private sendMessageText(text: string): void { - this.addMessage({ role: 'user', content: text, timestamp: Date.now() }); - this.plugin.ws?.sendChat(text); - void this.saveHistory(); - } - - private async saveHistory(): Promise { - const data = ((await this.plugin.loadData()) as CrickNotePluginData | null) ?? {}; - const chatHistory = this.messages - .filter(m => m.role === 'user' || m.role === 'assistant') - .map(({ role, content, timestamp }) => ({ role: role as 'user' | 'assistant', content, timestamp })); - await this.plugin.saveData({ ...data, chatHistory }); - } - - private addMessage(msg: ChatMessage): void { - this.messages.push(msg); - if (!this.messagesEl) return; - - const msgEl = this.messagesEl.createDiv({ cls: `cricknote-message cricknote-${msg.role}` }); - - const roleEl = msgEl.createDiv({ cls: 'cricknote-role' }); - roleEl.setText(msg.role === 'user' ? 'You' : msg.role === 'assistant' ? 'CrickNote' : 'System'); - - const contentEl = msgEl.createDiv({ cls: 'cricknote-content' }); - if (msg.role === 'assistant') { - MarkdownRenderer.render(this.plugin.app, msg.content, contentEl, '', this); - } else { - contentEl.setText(msg.content); - } - - if (msg.pendingEdits) { - this.appendPendingEdits(msgEl, msg.pendingEdits); - } - - this.messagesEl.scrollTop = this.messagesEl.scrollHeight; - } - - /** Render pending-edit blocks with confirmation buttons into an existing message element. */ - private appendPendingEdits( - msgEl: HTMLElement, - pendingEdits: NonNullable, - ): void { - for (const edit of pendingEdits) { - const editEl = msgEl.createDiv({ cls: 'cricknote-pending-edit' }); - editEl.createDiv({ cls: 'cricknote-edit-path', text: edit.path }); - - if (edit.warnings && edit.warnings.length > 0) { - const warningsEl = editEl.createDiv({ cls: 'cricknote-template-warnings' }); - for (const warning of edit.warnings) { - warningsEl.createDiv({ cls: 'cricknote-template-warning', text: `Warning: ${warning}` }); - } - } - - if (edit.hasConflict) { - editEl.createDiv({ cls: 'cricknote-conflict-warning', text: 'Conflict detected — file was modified since last read' }); - } - - const diffEl = editEl.createEl('pre', { cls: 'cricknote-diff' }); - diffEl.setText(edit.diff); - - const actionsEl = editEl.createDiv({ cls: 'cricknote-edit-actions' }); - - const applyBtn = actionsEl.createEl('button', { text: 'Apply', cls: 'mod-cta' }); - const cancelBtn = actionsEl.createEl('button', { text: 'Cancel' }); - let forceBtn: HTMLButtonElement | undefined; - - const btnEntry: { applyBtn: HTMLButtonElement; cancelBtn: HTMLButtonElement; forceBtn?: HTMLButtonElement; actionsEl: HTMLElement } = { applyBtn, cancelBtn, actionsEl }; - - const disableAll = () => { - applyBtn.disabled = true; - cancelBtn.disabled = true; - if (forceBtn) forceBtn.disabled = true; - }; - - applyBtn.addEventListener('click', () => { - this.plugin.ws?.confirmEdit(edit.editId, 'apply'); - disableAll(); - applyBtn.setText('Applying\u2026'); - }); - - if (edit.hasConflict) { - forceBtn = actionsEl.createEl('button', { text: 'Force Apply' }); - btnEntry.forceBtn = forceBtn; - forceBtn.addEventListener('click', () => { - this.plugin.ws?.confirmEdit(edit.editId, 'force'); - disableAll(); - forceBtn!.setText('Applying\u2026'); - }); - } - - cancelBtn.addEventListener('click', () => { - this.plugin.ws?.confirmEdit(edit.editId, 'cancel'); - disableAll(); - cancelBtn.setText('Cancelling\u2026'); - }); - - this.pendingEditButtons.set(edit.editId, btnEntry); - } - } -} diff --git a/obsidian-plugin/main.ts b/obsidian-plugin/main.ts deleted file mode 100644 index 750b4e3..0000000 --- a/obsidian-plugin/main.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Plugin, WorkspaceLeaf } from 'obsidian'; -import { ChatView, CHAT_VIEW_TYPE } from './chat-view'; -import { CrickNoteWebSocket } from './websocket-client'; - -export interface CrickNotePluginData { - chatSessionId?: string; - chatHistory?: Array<{ role: 'user' | 'assistant'; content: string; timestamp: number }>; -} - -function isValidSessionId(value: unknown): value is string { - return typeof value === 'string' && /^[A-Za-z0-9._:-]{8,128}$/.test(value); -} - -function createChatSessionId(): string { - const randomId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`; - return `obsidian-${randomId}`; -} - -export default class CrickNotePlugin extends Plugin { - ws: CrickNoteWebSocket | null = null; - - async onload() { - this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)); - - this.addRibbonIcon('message-square', 'CrickNote Chat', () => { - this.activateChatView(); - }); - - this.addCommand({ - id: 'open-chat', - name: 'Open CrickNote Chat', - callback: () => this.activateChatView(), - }); - - const data = (await this.loadData()) as CrickNotePluginData | null; - let chatSessionId = data?.chatSessionId; - if (!isValidSessionId(chatSessionId)) { - chatSessionId = createChatSessionId(); - await this.saveData({ ...(data ?? {}), chatSessionId }); - } - - // Connect to agent service using built-in defaults (127.0.0.1:18790). - // Configurable host/port/token will require a proper settings model and UI. - this.ws = new CrickNoteWebSocket(this, { sessionId: chatSessionId }); - await this.ws.connect(); - - // Status bar - const statusBar = this.addStatusBarItem(); - statusBar.setText('CrickNote: connecting...'); - - // Safety net: Node's EventEmitter throws if 'error' is emitted with no listener - this.ws.on('error', (err) => { console.error('CrickNote websocket error:', err); }); - - this.ws.on('connected', () => { - statusBar.setText('CrickNote: connected'); - }); - - this.ws.on('disconnected', () => { - statusBar.setText('CrickNote: disconnected'); - }); - - this.ws.on('indexing', (data: { state: string; total: number; indexed: number }) => { - if (data.state === 'indexing') { - const pct = data.total > 0 ? Math.round((data.indexed / data.total) * 100) : 0; - statusBar.setText(`CrickNote: indexing ${data.indexed}/${data.total} (${pct}%)`); - } else { - statusBar.setText('CrickNote: connected'); - } - }); - } - - async onunload() { - this.ws?.disconnect(); - } - - async activateChatView() { - const { workspace } = this.app; - - let leaf: WorkspaceLeaf | null = null; - const leaves = workspace.getLeavesOfType(CHAT_VIEW_TYPE); - - if (leaves.length > 0) { - leaf = leaves[0]; - } else { - const rightLeaf = workspace.getRightLeaf(false); - if (rightLeaf) { - leaf = rightLeaf; - await leaf.setViewState({ type: CHAT_VIEW_TYPE, active: true }); - } - } - - if (leaf) { - workspace.revealLeaf(leaf); - } - } -} diff --git a/obsidian-plugin/manifest.json b/obsidian-plugin/manifest.json deleted file mode 100644 index ac9b34f..0000000 --- a/obsidian-plugin/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "cricknote", - "name": "CrickNote", - "version": "0.1.0", - "minAppVersion": "1.0.0", - "description": "Scientific research assistant — chat with your vault, record experiments, search notes.", - "author": "CrickNote", - "isDesktopOnly": true -} diff --git a/obsidian-plugin/styles.css b/obsidian-plugin/styles.css deleted file mode 100644 index 812bd33..0000000 --- a/obsidian-plugin/styles.css +++ /dev/null @@ -1,124 +0,0 @@ -.cricknote-chat-container { - display: flex; - flex-direction: column; - height: 100%; - padding: 0; -} - -.cricknote-messages { - flex: 1; - overflow-y: auto; - padding: 12px; -} - -.cricknote-message { - margin-bottom: 16px; - padding: 8px 12px; - border-radius: 6px; -} - -.cricknote-user { - background: var(--background-modifier-form-field); -} - -.cricknote-assistant { - background: var(--background-secondary); -} - -.cricknote-system { - background: var(--background-secondary-alt); - font-style: italic; - font-size: 0.9em; -} - -.cricknote-role { - font-weight: 600; - font-size: 0.8em; - text-transform: uppercase; - margin-bottom: 4px; - color: var(--text-muted); -} - -/* Obsidian sets `* { user-select: none }` globally. Override for the entire - message content tree — the rule must cover descendants too because `*` - explicitly overrides inherited values on every child element. */ -.cricknote-content, -.cricknote-content * { - user-select: text; - -webkit-user-select: text; -} - -.cricknote-content { - white-space: pre-wrap; - line-height: 1.5; -} - -.cricknote-input-container { - display: flex; - gap: 8px; - padding: 12px; - border-top: 1px solid var(--background-modifier-border); -} - -.cricknote-input { - flex: 1; - resize: none; - min-height: 40px; - max-height: 120px; - padding: 8px; - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - background: var(--background-primary); - color: var(--text-normal); - font-family: inherit; - font-size: inherit; -} - -.cricknote-send-btn { - align-self: flex-end; - padding: 8px 16px; -} - -.cricknote-pending-edit { - margin-top: 12px; - padding: 8px; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; -} - -.cricknote-edit-path { - font-weight: 600; - font-size: 0.9em; - margin-bottom: 4px; -} - -.cricknote-conflict-warning { - color: var(--text-error); - font-size: 0.85em; - margin-bottom: 4px; -} - -/* Same descendant override as .cricknote-content: `* { user-select: none }` - would otherwise block selection of text inside the
. */
-.cricknote-diff,
-.cricknote-diff * {
-  user-select: text;
-  -webkit-user-select: text;
-}
-
-.cricknote-diff {
-  font-family: var(--font-monospace);
-  font-size: 0.85em;
-  overflow-x: auto;
-  background: var(--background-primary);
-  padding: 8px;
-  border-radius: 4px;
-  max-height: 200px;
-  overflow-y: auto;
-}
-
-.cricknote-edit-actions {
-  display: flex;
-  gap: 8px;
-  margin-top: 8px;
-}
diff --git a/obsidian-plugin/websocket-client.ts b/obsidian-plugin/websocket-client.ts
deleted file mode 100644
index 26b740d..0000000
--- a/obsidian-plugin/websocket-client.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import { EventEmitter } from 'events';
-import type CrickNotePlugin from './main';
-import { readFileSync, existsSync } from 'fs';
-import { join } from 'path';
-
-const PROTOCOL_VERSION = 1;
-
-export interface WebSocketOptions {
-  host?: string;
-  port?: number;
-  tokenPath?: string;
-  sessionId?: string;
-}
-
-export class CrickNoteWebSocket extends EventEmitter {
-  private ws: WebSocket | null = null;
-  private plugin: CrickNotePlugin;
-  private authenticated = false;
-  private reconnectTimer: ReturnType | null = null;
-  private reconnectEnabled = true;
-  private reconnectDelay = 1000;
-  private host: string;
-  private port: number;
-  private tokenPath: string;
-  private sessionId?: string;
-
-  constructor(plugin: CrickNotePlugin, options: WebSocketOptions = {}) {
-    super();
-    this.plugin = plugin;
-    this.host = options.host ?? '127.0.0.1';
-    this.port = options.port ?? 18790;
-    const homeDir = process.env.HOME ?? '~';
-    this.tokenPath = options.tokenPath ?? join(homeDir, '.cricknote', 'auth-token');
-    this.sessionId = options.sessionId;
-  }
-
-  async connect(): Promise {
-    this.reconnectEnabled = true;
-    const host = this.host;
-    const port = this.port;
-    const url = `ws://${host}:${port}`;
-
-    try {
-      this.ws = new WebSocket(url);
-
-      this.ws.onopen = () => {
-        const token = this.readToken();
-        if (!token) {
-          console.error('CrickNote: No auth token found');
-          return;
-        }
-        this.ws?.send(JSON.stringify({
-          type: 'auth',
-          token,
-          protocolVersion: PROTOCOL_VERSION,
-          pluginVersion: '0.1.0',
-          ...(this.sessionId ? { sessionId: this.sessionId } : {}),
-        }));
-      };
-
-      this.ws.onmessage = (event: MessageEvent) => {
-        let msg: Record;
-        try {
-          msg = JSON.parse(event.data as string);
-        } catch {
-          console.error('CrickNote: received malformed WebSocket frame, ignoring');
-          return;
-        }
-        this.handleMessage(msg);
-      };
-
-      this.ws.onclose = () => {
-        this.authenticated = false;
-        this.emit('disconnected');
-        if (this.reconnectEnabled) {
-          this.scheduleReconnect();
-        }
-      };
-
-      this.ws.onerror = () => {
-        // onclose will fire after this
-      };
-    } catch {
-      this.scheduleReconnect();
-    }
-  }
-
-  disconnect(): void {
-    this.reconnectEnabled = false;
-    if (this.reconnectTimer) {
-      clearTimeout(this.reconnectTimer);
-      this.reconnectTimer = null;
-    }
-    const ws = this.ws;
-    this.ws = null;
-    ws?.close();
-  }
-
-  send(message: Record): void {
-    if (!this.ws || !this.authenticated) return;
-    this.ws.send(JSON.stringify(message));
-  }
-
-  sendChat(content: string): void {
-    this.send({ type: 'chat', content });
-  }
-
-  confirmEdit(editId: string, action: 'apply' | 'force' | 'cancel'): void {
-    this.send({ type: 'edit_confirm', editId, action });
-  }
-
-  requestStatus(): void {
-    this.send({ type: 'status' });
-  }
-
-  private handleMessage(msg: Record): void {
-    switch (msg.type) {
-      case 'auth_ok':
-        this.authenticated = true;
-        if (typeof msg.sessionId === 'string') {
-          this.sessionId = msg.sessionId;
-        }
-        this.reconnectDelay = 1000; // reset backoff on successful connection
-        this.emit('connected');
-        this.requestStatus();
-        break;
-
-      case 'auth_error':
-        console.error(`CrickNote auth error: ${msg.reason}`);
-        this.emit('auth_error', msg);
-        break;
-
-      case 'chat_chunk':
-        this.emit('chat_chunk', msg);
-        break;
-
-      case 'chat_response':
-        this.emit('chat_response', msg);
-        break;
-
-      case 'edit_result':
-        this.emit('edit_result', msg);
-        break;
-
-      case 'status_response':
-        if (msg.indexing) {
-          this.emit('indexing', msg.indexing);
-        }
-        break;
-
-      case 'error':
-        this.emit('server_error', msg);
-        break;
-    }
-  }
-
-  private readToken(): string | null {
-    if (existsSync(this.tokenPath)) {
-      return readFileSync(this.tokenPath, 'utf-8').trim();
-    }
-    return null;
-  }
-
-  private scheduleReconnect(): void {
-    if (!this.reconnectEnabled || this.reconnectTimer) return;
-    const delay = this.reconnectDelay;
-    this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
-    this.reconnectTimer = setTimeout(() => {
-      this.reconnectTimer = null;
-      if (this.reconnectEnabled) {
-        this.connect();
-      }
-    }, delay);
-  }
-}
diff --git a/package-lock.json b/package-lock.json
index 5af1316..a9f64ec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,17 +9,14 @@
       "version": "0.1.0",
       "dependencies": {
         "@anthropic-ai/sdk": "^0.39.0",
-        "@xenova/transformers": "^2.17.0",
         "better-sqlite3": "^11.7.0",
-        "chokidar": "^4.0.0",
         "chrono-node": "^2.7.0",
         "commander": "^13.0.0",
         "diff": "^7.0.0",
         "gray-matter": "^4.0.3",
         "inquirer": "^12.0.0",
         "openai": "^4.77.0",
-        "pdf-parse": "^1.1.4",
-        "ws": "^8.18.0"
+        "pdf-parse": "^1.1.4"
       },
       "bin": {
         "cricknote": "dist/cli.js"
@@ -29,8 +26,6 @@
         "@types/diff": "^6.0.0",
         "@types/node": "^22.0.0",
         "@types/pdf-parse": "^1.1.5",
-        "@types/ws": "^8.5.13",
-        "esbuild": "^0.24.0",
         "typescript": "^5.7.0",
         "vitest": "^3.0.0"
       },
@@ -68,401 +63,10 @@
       "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
       "license": "MIT"
     },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
-      "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
-      "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
-      "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
-      "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
-      "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
-      "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
-      "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
-      "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
-      "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
-      "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
-      "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
-      "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
-      "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
-      "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
-      "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
-      "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
-      "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
-      "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
-      "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
-      "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
-      "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
     "node_modules/@esbuild/openharmony-arm64": {
       "version": "0.27.4",
       "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
-      "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
-      "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
-      "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
+      "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
       "cpu": [
         "arm64"
       ],
@@ -470,55 +74,12 @@
       "license": "MIT",
       "optional": true,
       "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
-      "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
-      "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32"
+        "openharmony"
       ],
       "engines": {
         "node": ">=18"
       }
     },
-    "node_modules/@huggingface/jinja": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz",
-      "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      }
-    },
     "node_modules/@inquirer/ansi": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@@ -860,70 +421,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@protobufjs/aspromise": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
-      "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/base64": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
-      "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/codegen": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
-      "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/eventemitter": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
-      "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/fetch": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
-      "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@protobufjs/aspromise": "^1.1.1",
-        "@protobufjs/inquire": "^1.1.0"
-      }
-    },
-    "node_modules/@protobufjs/float": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
-      "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/inquire": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
-      "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/path": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
-      "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/pool": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
-      "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/utf8": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
-      "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
-      "license": "BSD-3-Clause"
-    },
     "node_modules/@rollup/rollup-android-arm-eabi": {
       "version": "4.60.0",
       "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
@@ -1316,12 +813,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@types/long": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
-      "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
-      "license": "MIT"
-    },
     "node_modules/@types/node": {
       "version": "22.19.15",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -1351,16 +842,6 @@
         "@types/node": "*"
       }
     },
-    "node_modules/@types/ws": {
-      "version": "8.18.1",
-      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
-      "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*"
-      }
-    },
     "node_modules/@vitest/expect": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -1476,20 +957,6 @@
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@xenova/transformers": {
-      "version": "2.17.2",
-      "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz",
-      "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "@huggingface/jinja": "^0.2.2",
-        "onnxruntime-web": "1.14.0",
-        "sharp": "^0.32.0"
-      },
-      "optionalDependencies": {
-        "onnxruntime-node": "1.14.0"
-      }
-    },
     "node_modules/abort-controller": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -1563,111 +1030,6 @@
       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
       "license": "MIT"
     },
-    "node_modules/b4a": {
-      "version": "1.8.0",
-      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
-      "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
-      "license": "Apache-2.0",
-      "peerDependencies": {
-        "react-native-b4a": "*"
-      },
-      "peerDependenciesMeta": {
-        "react-native-b4a": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/bare-events": {
-      "version": "2.8.2",
-      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
-      "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
-      "license": "Apache-2.0",
-      "peerDependencies": {
-        "bare-abort-controller": "*"
-      },
-      "peerDependenciesMeta": {
-        "bare-abort-controller": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/bare-fs": {
-      "version": "4.5.6",
-      "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.6.tgz",
-      "integrity": "sha512-1QovqDrR80Pmt5HPAsMsXTCFcDYr+NSUKW6nd6WO5v0JBmnItc/irNRzm2KOQ5oZ69P37y+AMujNyNtG+1Rggw==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "bare-events": "^2.5.4",
-        "bare-path": "^3.0.0",
-        "bare-stream": "^2.6.4",
-        "bare-url": "^2.2.2",
-        "fast-fifo": "^1.3.2"
-      },
-      "engines": {
-        "bare": ">=1.16.0"
-      },
-      "peerDependencies": {
-        "bare-buffer": "*"
-      },
-      "peerDependenciesMeta": {
-        "bare-buffer": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/bare-os": {
-      "version": "3.8.4",
-      "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.4.tgz",
-      "integrity": "sha512-4JboWUl7/2LhgU536tjUszzaVC8/WEWKtyX5crayvlN71ih8+O2SdvBhotQeDsuhhmPZmLCrPBJEcwVPhI/kkQ==",
-      "license": "Apache-2.0",
-      "engines": {
-        "bare": ">=1.14.0"
-      }
-    },
-    "node_modules/bare-path": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
-      "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "bare-os": "^3.0.1"
-      }
-    },
-    "node_modules/bare-stream": {
-      "version": "2.11.0",
-      "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.11.0.tgz",
-      "integrity": "sha512-Y/+iQ49fL3rIn6w/AVxI/2+BRrpmzJvdWt5Jv8Za6Ngqc6V227c+pYjYYgLdpR3MwQ9ObVXD0ZrqoBztakM0rw==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "streamx": "^2.25.0",
-        "teex": "^1.0.1"
-      },
-      "peerDependencies": {
-        "bare-abort-controller": "*",
-        "bare-buffer": "*",
-        "bare-events": "*"
-      },
-      "peerDependenciesMeta": {
-        "bare-abort-controller": {
-          "optional": true
-        },
-        "bare-buffer": {
-          "optional": true
-        },
-        "bare-events": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/bare-url": {
-      "version": "2.4.0",
-      "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz",
-      "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "bare-path": "^3.0.0"
-      }
-    },
     "node_modules/base64-js": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1799,21 +1161,6 @@
         "node": ">= 16"
       }
     },
-    "node_modules/chokidar": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
-      "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
-      "license": "MIT",
-      "dependencies": {
-        "readdirp": "^4.0.1"
-      },
-      "engines": {
-        "node": ">= 14.16.0"
-      },
-      "funding": {
-        "url": "https://paulmillr.com/funding/"
-      }
-    },
     "node_modules/chownr": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -1838,19 +1185,6 @@
         "node": ">= 12"
       }
     },
-    "node_modules/color": {
-      "version": "4.2.3",
-      "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
-      "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
-      "license": "MIT",
-      "dependencies": {
-        "color-convert": "^2.0.1",
-        "color-string": "^1.9.0"
-      },
-      "engines": {
-        "node": ">=12.5.0"
-      }
-    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1869,16 +1203,6 @@
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
       "license": "MIT"
     },
-    "node_modules/color-string": {
-      "version": "1.9.1",
-      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
-      "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
-      "license": "MIT",
-      "dependencies": {
-        "color-name": "^1.0.0",
-        "simple-swizzle": "^0.2.2"
-      }
-    },
     "node_modules/combined-stream": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2060,47 +1384,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/esbuild": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
-      "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.24.2",
-        "@esbuild/android-arm": "0.24.2",
-        "@esbuild/android-arm64": "0.24.2",
-        "@esbuild/android-x64": "0.24.2",
-        "@esbuild/darwin-arm64": "0.24.2",
-        "@esbuild/darwin-x64": "0.24.2",
-        "@esbuild/freebsd-arm64": "0.24.2",
-        "@esbuild/freebsd-x64": "0.24.2",
-        "@esbuild/linux-arm": "0.24.2",
-        "@esbuild/linux-arm64": "0.24.2",
-        "@esbuild/linux-ia32": "0.24.2",
-        "@esbuild/linux-loong64": "0.24.2",
-        "@esbuild/linux-mips64el": "0.24.2",
-        "@esbuild/linux-ppc64": "0.24.2",
-        "@esbuild/linux-riscv64": "0.24.2",
-        "@esbuild/linux-s390x": "0.24.2",
-        "@esbuild/linux-x64": "0.24.2",
-        "@esbuild/netbsd-arm64": "0.24.2",
-        "@esbuild/netbsd-x64": "0.24.2",
-        "@esbuild/openbsd-arm64": "0.24.2",
-        "@esbuild/openbsd-x64": "0.24.2",
-        "@esbuild/sunos-x64": "0.24.2",
-        "@esbuild/win32-arm64": "0.24.2",
-        "@esbuild/win32-ia32": "0.24.2",
-        "@esbuild/win32-x64": "0.24.2"
-      }
-    },
     "node_modules/esprima": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -2133,15 +1416,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/events-universal": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
-      "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "bare-events": "^2.7.0"
-      }
-    },
     "node_modules/expand-template": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -2173,12 +1447,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/fast-fifo": {
-      "version": "1.3.2",
-      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
-      "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
-      "license": "MIT"
-    },
     "node_modules/fdir": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2203,12 +1471,6 @@
       "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
       "license": "MIT"
     },
-    "node_modules/flatbuffers": {
-      "version": "1.12.0",
-      "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz",
-      "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==",
-      "license": "SEE LICENSE IN LICENSE.txt"
-    },
     "node_modules/form-data": {
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -2344,12 +1606,6 @@
         "node": ">=6.0"
       }
     },
-    "node_modules/guid-typescript": {
-      "version": "1.0.9",
-      "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
-      "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
-      "license": "ISC"
-    },
     "node_modules/has-symbols": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -2472,12 +1728,6 @@
         }
       }
     },
-    "node_modules/is-arrayish": {
-      "version": "0.3.4",
-      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
-      "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
-      "license": "MIT"
-    },
     "node_modules/is-extendable": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
@@ -2525,12 +1775,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/long": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
-      "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
-      "license": "Apache-2.0"
-    },
     "node_modules/loupe": {
       "version": "3.2.1",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2657,12 +1901,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/node-addon-api": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
-      "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
-      "license": "MIT"
-    },
     "node_modules/node-domexception": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -2718,50 +1956,6 @@
         "wrappy": "1"
       }
     },
-    "node_modules/onnx-proto": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz",
-      "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==",
-      "license": "MIT",
-      "dependencies": {
-        "protobufjs": "^6.8.8"
-      }
-    },
-    "node_modules/onnxruntime-common": {
-      "version": "1.14.0",
-      "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz",
-      "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==",
-      "license": "MIT"
-    },
-    "node_modules/onnxruntime-node": {
-      "version": "1.14.0",
-      "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz",
-      "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==",
-      "license": "MIT",
-      "optional": true,
-      "os": [
-        "win32",
-        "darwin",
-        "linux"
-      ],
-      "dependencies": {
-        "onnxruntime-common": "~1.14.0"
-      }
-    },
-    "node_modules/onnxruntime-web": {
-      "version": "1.14.0",
-      "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz",
-      "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==",
-      "license": "MIT",
-      "dependencies": {
-        "flatbuffers": "^1.12.0",
-        "guid-typescript": "^1.0.9",
-        "long": "^4.0.0",
-        "onnx-proto": "^4.0.4",
-        "onnxruntime-common": "~1.14.0",
-        "platform": "^1.3.6"
-      }
-    },
     "node_modules/openai": {
       "version": "4.104.0",
       "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
@@ -2860,12 +2054,6 @@
         "url": "https://github.com/sponsors/jonschlinkert"
       }
     },
-    "node_modules/platform": {
-      "version": "1.3.6",
-      "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
-      "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
-      "license": "MIT"
-    },
     "node_modules/postcss": {
       "version": "8.5.8",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -2922,32 +2110,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/protobufjs": {
-      "version": "6.11.4",
-      "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz",
-      "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==",
-      "hasInstallScript": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@protobufjs/aspromise": "^1.1.2",
-        "@protobufjs/base64": "^1.1.2",
-        "@protobufjs/codegen": "^2.0.4",
-        "@protobufjs/eventemitter": "^1.1.0",
-        "@protobufjs/fetch": "^1.1.0",
-        "@protobufjs/float": "^1.0.2",
-        "@protobufjs/inquire": "^1.1.0",
-        "@protobufjs/path": "^1.1.2",
-        "@protobufjs/pool": "^1.1.0",
-        "@protobufjs/utf8": "^1.1.0",
-        "@types/long": "^4.0.1",
-        "@types/node": ">=13.7.0",
-        "long": "^4.0.0"
-      },
-      "bin": {
-        "pbjs": "bin/pbjs",
-        "pbts": "bin/pbts"
-      }
-    },
     "node_modules/pump": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -2987,19 +2149,6 @@
         "node": ">= 6"
       }
     },
-    "node_modules/readdirp": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
-      "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 14.18.0"
-      },
-      "funding": {
-        "type": "individual",
-        "url": "https://paulmillr.com/funding/"
-      }
-    },
     "node_modules/rollup": {
       "version": "4.60.0",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
@@ -3114,55 +2263,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/sharp": {
-      "version": "0.32.6",
-      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
-      "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==",
-      "hasInstallScript": true,
-      "license": "Apache-2.0",
-      "dependencies": {
-        "color": "^4.2.3",
-        "detect-libc": "^2.0.2",
-        "node-addon-api": "^6.1.0",
-        "prebuild-install": "^7.1.1",
-        "semver": "^7.5.4",
-        "simple-get": "^4.0.1",
-        "tar-fs": "^3.0.4",
-        "tunnel-agent": "^0.6.0"
-      },
-      "engines": {
-        "node": ">=14.15.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/sharp/node_modules/tar-fs": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
-      "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
-      "license": "MIT",
-      "dependencies": {
-        "pump": "^3.0.0",
-        "tar-stream": "^3.1.5"
-      },
-      "optionalDependencies": {
-        "bare-fs": "^4.0.1",
-        "bare-path": "^3.0.0"
-      }
-    },
-    "node_modules/sharp/node_modules/tar-stream": {
-      "version": "3.1.8",
-      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
-      "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
-      "license": "MIT",
-      "dependencies": {
-        "b4a": "^1.6.4",
-        "bare-fs": "^4.5.5",
-        "fast-fifo": "^1.2.0",
-        "streamx": "^2.15.0"
-      }
-    },
     "node_modules/siginfo": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3227,15 +2327,6 @@
         "simple-concat": "^1.0.0"
       }
     },
-    "node_modules/simple-swizzle": {
-      "version": "0.2.4",
-      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
-      "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
-      "license": "MIT",
-      "dependencies": {
-        "is-arrayish": "^0.3.1"
-      }
-    },
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3266,17 +2357,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/streamx": {
-      "version": "2.25.0",
-      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
-      "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
-      "license": "MIT",
-      "dependencies": {
-        "events-universal": "^1.0.0",
-        "fast-fifo": "^1.3.2",
-        "text-decoder": "^1.1.0"
-      }
-    },
     "node_modules/string_decoder": {
       "version": "1.3.0",
       "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -3371,24 +2451,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/teex": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
-      "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
-      "license": "MIT",
-      "dependencies": {
-        "streamx": "^2.12.5"
-      }
-    },
-    "node_modules/text-decoder": {
-      "version": "1.2.7",
-      "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
-      "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "b4a": "^1.6.4"
-      }
-    },
     "node_modules/tinybench": {
       "version": "2.9.0",
       "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -4205,6 +3267,8 @@
       "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
       "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
       "license": "MIT",
+      "optional": true,
+      "peer": true,
       "engines": {
         "node": ">=10.0.0"
       },
diff --git a/package.json b/package.json
index 6ffb00c..34d9ff9 100644
--- a/package.json
+++ b/package.json
@@ -7,10 +7,8 @@
     "cricknote": "./dist/cli.js"
   },
   "scripts": {
-    "build": "tsc && bash scripts/build-plugin.sh",
-    "build:plugin": "bash scripts/build-plugin.sh",
+    "build": "tsc",
     "dev": "tsc --watch",
-    "start": "node dist/cli.js start",
     "setup": "node dist/cli.js setup",
     "reindex": "node dist/cli.js reindex",
     "test": "vitest run",
@@ -21,25 +19,20 @@
   },
   "dependencies": {
     "@anthropic-ai/sdk": "^0.39.0",
-    "@xenova/transformers": "^2.17.0",
     "better-sqlite3": "^11.7.0",
-    "chokidar": "^4.0.0",
     "chrono-node": "^2.7.0",
     "commander": "^13.0.0",
     "diff": "^7.0.0",
     "gray-matter": "^4.0.3",
     "inquirer": "^12.0.0",
     "openai": "^4.77.0",
-    "pdf-parse": "^1.1.4",
-    "ws": "^8.18.0"
+    "pdf-parse": "^1.1.4"
   },
   "devDependencies": {
     "@types/better-sqlite3": "^7.6.12",
     "@types/diff": "^6.0.0",
     "@types/node": "^22.0.0",
     "@types/pdf-parse": "^1.1.5",
-    "@types/ws": "^8.5.13",
-    "esbuild": "^0.24.0",
     "typescript": "^5.7.0",
     "vitest": "^3.0.0"
   }
diff --git a/scripts/build-plugin.sh b/scripts/build-plugin.sh
deleted file mode 100755
index 377a11f..0000000
--- a/scripts/build-plugin.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/bin/bash
-set -e
-
-cd "$(dirname "$0")/.."
-
-echo "Building CrickNote Obsidian plugin..."
-
-npx esbuild obsidian-plugin/main.ts \
-  --bundle \
-  --external:obsidian \
-  --external:electron \
-  --external:node:* \
-  --external:events \
-  --external:fs \
-  --external:path \
-  --external:@codemirror/autocomplete \
-  --external:@codemirror/collab \
-  --external:@codemirror/commands \
-  --external:@codemirror/language \
-  --external:@codemirror/lint \
-  --external:@codemirror/search \
-  --external:@codemirror/state \
-  --external:@codemirror/view \
-  --format=cjs \
-  --target=es2022 \
-  --outfile=obsidian-plugin/main.js
-
-echo "Plugin built: obsidian-plugin/main.js"
diff --git a/skills/cricknote-daily-review/SKILL.md b/skills/cricknote-daily-review/SKILL.md
index 9800a3a..5c50027 100644
--- a/skills/cricknote-daily-review/SKILL.md
+++ b/skills/cricknote-daily-review/SKILL.md
@@ -10,7 +10,9 @@ Run `cricknote reindex` to absorb manual Obsidian edits.
 
 ## Gather state
 - `cricknote tool get_today_diary '{}'` and `cricknote tool get_week_plan '{}'`.
-- `cricknote tool task_list '{"status":"pending","days":90}'` — open tasks.
+- `cricknote tool task_agenda '{}'` — pending tasks bucketed into overdue / due
+  today / due in the next 7 days / no deadline. This is the primary task view;
+  use `task_list` only when you need the full unfiltered history.
 - `cricknote tool vault_list '{"folder":"Projects","status":"in-progress"}'` —
   experiments still open.
 - `cricknote tool reading_pipeline_status '{}'` for stuck reading bundles.
@@ -18,7 +20,11 @@ Run `cricknote reindex` to absorb manual Obsidian edits.
 
 ## Present
 Summarize: open experiments, stuck reading notes, pending KB targets, due tasks.
-Lead with what is overdue or blocking.
+Lead with the agenda's `overdue` and `today` buckets — those are what is blocking.
+
+To leave the user a persistent agenda they can open in Obsidian, call
+`cricknote tool task_agenda '{"write":true}'`; it returns a pending edit that
+writes `Memory/Agenda.md` (a "Today's Agenda" dashboard). Apply it as usual.
 
 ## Reminder reconciliation
 If the user uses Apple Reminders (skill: cricknote-reminders), ask whether any
diff --git a/src/agent/tools/tasks.ts b/src/agent/tools/tasks.ts
index d028f5a..9520068 100644
--- a/src/agent/tools/tasks.ts
+++ b/src/agent/tools/tasks.ts
@@ -156,5 +156,110 @@ export function createTaskTools(vaultPath: string, conflictDetector?: ConflictDe
         return JSON.stringify({ error: `Task not found matching: "${args.task_description}"` });
       },
     },
+    {
+      definition: {
+        name: 'task_agenda',
+        description:
+          "Build a daily agenda of pending tasks bucketed by deadline (overdue, due today, due soon, no deadline). " +
+          "By default returns the agenda as data for the agent to read out. Pass write=true to also persist it as a " +
+          "'Today's Agenda' note (Memory/Agenda.md) so it is visible in Obsidian; that path triggers the safe edit flow.",
+        parameters: {
+          type: 'object',
+          properties: {
+            horizon_days: { type: 'number', description: 'Lookahead window for "due soon" tasks (default 7)' },
+            days: { type: 'number', description: 'How many days of diary history to scan (default 90)' },
+            write: { type: 'boolean', description: "Also write the agenda to Memory/Agenda.md (default false)" },
+          },
+        },
+      },
+      execute: async (args) => {
+        const today = localDateString();
+        const horizonDays = typeof args.horizon_days === 'number' && args.horizon_days > 0 ? Math.floor(args.horizon_days) : 7;
+        const horizonDate = new Date();
+        horizonDate.setDate(horizonDate.getDate() + horizonDays);
+        const horizon = localDateString(horizonDate);
+
+        type AgendaItem = { text: string; due: string | null; source: string };
+        const agenda = { overdue: [] as AgendaItem[], today: [] as AgendaItem[], soon: [] as AgendaItem[], no_deadline: [] as AgendaItem[] };
+
+        const diaryDir = path.join(vaultPath, 'Memory', 'Daily');
+        if (fs.existsSync(diaryDir)) {
+          const windowDays = typeof args.days === 'number' && args.days > 0 ? Math.floor(args.days) : 90;
+          const files = fs.readdirSync(diaryDir).filter(f => f.endsWith('.md')).sort().reverse().slice(0, windowDays);
+
+          for (const file of files) {
+            const content = fs.readFileSync(path.join(diaryDir, file), 'utf-8');
+            const source = `Memory/Daily/${file}`;
+            // Only unchecked (pending) tasks belong on an agenda.
+            const taskRegex = /^- \[ \] (.+)$/gm;
+            let match;
+            while ((match = taskRegex.exec(content)) !== null) {
+              const text = match[1];
+              const due = parseDueDate(text);
+              const item: AgendaItem = { text, due, source };
+              if (due === null) agenda.no_deadline.push(item);
+              else if (due < today) agenda.overdue.push(item);
+              else if (due === today) agenda.today.push(item);
+              else if (due <= horizon) agenda.soon.push(item);
+              // Tasks dated beyond the horizon are not yet actionable; omit them.
+            }
+          }
+        }
+
+        if (!args.write) {
+          return JSON.stringify({ date: today, horizon_days: horizonDays, ...agenda });
+        }
+
+        const agendaPath = 'Memory/Agenda.md';
+        const fullPath = resolveVaultPath(vaultPath, agendaPath);
+        const exists = fs.existsSync(fullPath);
+        if (exists) {
+          conflictDetector?.recordFileRead(fullPath, fs.readFileSync(fullPath, 'utf-8'));
+        }
+
+        const newContent = renderAgendaNote(today, horizonDays, agenda);
+        return JSON.stringify({
+          type: 'pending_edit',
+          path: fullPath,
+          newContent,
+          operation: exists ? 'update' : 'create',
+        });
+      },
+    },
   ];
 }
+
+/** Extract a YYYY-MM-DD deadline from a task line's "(due: ...)" annotation, or null. */
+function parseDueDate(text: string): string | null {
+  const match = text.match(/\(due:\s*([^)]+)\)/);
+  if (!match) return null;
+  const raw = match[1].trim();
+  if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
+  const parsed = chrono.parseDate(raw, new Date(), { forwardDate: true });
+  return parsed ? localDateString(parsed) : null;
+}
+
+/** Render the agenda as a Markdown dashboard note (plain bullets, never task checkboxes). */
+function renderAgendaNote(
+  today: string,
+  horizonDays: number,
+  agenda: { overdue: Array<{ text: string; source: string }>; today: Array<{ text: string; source: string }>; soon: Array<{ text: string; source: string }>; no_deadline: Array<{ text: string; source: string }> },
+): string {
+  const section = (heading: string, items: Array<{ text: string; source: string }>): string => {
+    const lines = items.length > 0 ? items.map(i => `- ${i.text} — ${i.source}`).join('\n') : '- (none)';
+    return `## ${heading}\n${lines}\n`;
+  };
+  return [
+    '---',
+    'type: agenda',
+    `generated: ${today}`,
+    '---',
+    '',
+    `# Today's Agenda — ${today}`,
+    '',
+    section('Overdue', agenda.overdue),
+    section('Due today', agenda.today),
+    section(`Due in next ${horizonDays} days`, agenda.soon),
+    section('No deadline', agenda.no_deadline),
+  ].join('\n');
+}
diff --git a/src/cli.ts b/src/cli.ts
index eb3be67..0453033 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -3,9 +3,7 @@
 import { Command } from 'commander';
 import crypto from 'node:crypto';
 import { setup } from './cli/setup.js';
-import { start } from './cli/start.js';
 import { reindex } from './cli/reindex.js';
-import { rotateToken } from './server/auth.js';
 import { loadConfig } from './config/config.js';
 import { runTool, listToolCatalog } from './cli/tool-dispatch.js';
 
@@ -18,18 +16,11 @@ program
 
 program
   .command('setup')
-  .description('First-time setup: configure vault, LLM, and install plugin')
+  .description('First-time setup: configure vault and LLM')
   .action(async () => {
     await setup();
   });
 
-program
-  .command('start')
-  .description('Start the CrickNote agent service')
-  .action(async () => {
-    await start();
-  });
-
 program
   .command('reindex')
   .description('Force a full vault re-index')
@@ -37,15 +28,6 @@ program
     await reindex();
   });
 
-program
-  .command('rotate-token')
-  .description('Generate a new auth token')
-  .action(() => {
-    const token = rotateToken();
-    console.log('New auth token generated.');
-    console.log('Restart the agent service and Obsidian plugin to use the new token.');
-  });
-
 program
   .command('tool  [argsJson]')
   .description('Execute a CrickNote tool with JSON arguments (for AI agents)')
diff --git a/src/cli/install-agent-assets.ts b/src/cli/install-agent-assets.ts
index 8884e4d..f7d978b 100644
--- a/src/cli/install-agent-assets.ts
+++ b/src/cli/install-agent-assets.ts
@@ -1,13 +1,34 @@
 import fs from 'node:fs';
 import path from 'node:path';
 
+/**
+ * Heading that every CrickNote-authored agent guide starts with. Used to tell a
+ * CrickNote-managed guide (safe to refresh) apart from a user's own CLAUDE.md /
+ * AGENTS.md (must never be clobbered).
+ */
+const MANAGED_GUIDE_HEADING = '# CrickNote Vault — Agent Guide';
+
+export interface InstalledAgentAssets {
+  /** Guides written fresh at the vault root (no file was there before). */
+  guidesWritten: string[];
+  /** CrickNote-managed guides that were refreshed in place. */
+  guidesRefreshed: string[];
+  /** A user guide already existed; CrickNote's guidance went to this sidecar instead. */
+  sidecarsWritten: string[];
+}
+
 /**
  * Copy CrickNote skills and agent guide docs from the repo into the vault.
  * Skills go to both `.claude/skills/` (Claude Code) and `.agents/skills/`
  * (Codex). Copies, not symlinks — robust to vault sync tools; re-running
  * refreshes. Idempotent.
+ *
+ * The root guides (CLAUDE.md / AGENTS.md) are never clobbered: a fresh vault
+ * gets them written, a CrickNote-managed guide is refreshed, but a guide the
+ * user wrote themselves is left untouched and CrickNote's guidance is written
+ * alongside it as `CrickNote-` for them to @-import or merge.
  */
-export function installAgentAssets(vaultPath: string, repoRoot: string): void {
+export function installAgentAssets(vaultPath: string, repoRoot: string): InstalledAgentAssets {
   const skillsSrc = path.join(repoRoot, 'skills');
   if (fs.existsSync(skillsSrc)) {
     for (const dest of ['.claude', '.agents']) {
@@ -17,11 +38,30 @@ export function installAgentAssets(vaultPath: string, repoRoot: string): void {
     }
   }
 
+  const result: InstalledAgentAssets = { guidesWritten: [], guidesRefreshed: [], sidecarsWritten: [] };
   const docsSrc = path.join(repoRoot, 'templates', 'agent-docs');
   for (const doc of ['CLAUDE.md', 'AGENTS.md']) {
     const src = path.join(docsSrc, doc);
-    if (fs.existsSync(src)) {
-      fs.copyFileSync(src, path.join(vaultPath, doc));
+    if (!fs.existsSync(src)) continue;
+    const dest = path.join(vaultPath, doc);
+
+    if (!fs.existsSync(dest)) {
+      fs.copyFileSync(src, dest);
+      result.guidesWritten.push(doc);
+      continue;
+    }
+
+    const existing = fs.readFileSync(dest, 'utf-8');
+    if (existing.trimStart().startsWith(MANAGED_GUIDE_HEADING)) {
+      // Our own guide from a previous setup — safe to refresh.
+      fs.copyFileSync(src, dest);
+      result.guidesRefreshed.push(doc);
+    } else {
+      // The user's own guide — leave it; drop our guidance beside it.
+      const sidecar = `CrickNote-${doc}`;
+      fs.copyFileSync(src, path.join(vaultPath, sidecar));
+      result.sidecarsWritten.push(sidecar);
     }
   }
+  return result;
 }
diff --git a/src/cli/setup.ts b/src/cli/setup.ts
index e36668f..e43cdc7 100644
--- a/src/cli/setup.ts
+++ b/src/cli/setup.ts
@@ -2,7 +2,6 @@ import { input, password, select } from '@inquirer/prompts';
 import fs from 'node:fs';
 import path from 'node:path';
 import { saveConfig, PROVIDER_PRESETS, type CrickNoteConfig } from '../config/config.js';
-import { generateToken, getTokenPath } from '../server/auth.js';
 import { getDatabase, getDataDir, closeDatabase } from '../storage/database.js';
 import { rebuildKnowledgeIndex } from '../knowledge/index-builder.js';
 import { DEFAULT_TEMPLATE_FILES, renderFolderReadmeSync } from '../templates/template-loader.js';
@@ -168,7 +167,6 @@ export async function setup(): Promise {
       ...(model !== modelDefault ? { model } : { model: modelDefault }),
       ...(baseUrl ? { baseUrl } : {}),
     },
-    server: { host: '127.0.0.1', port: 18790 },
   };
   saveConfig(config);
   console.log(`\u2713 Config saved to ${path.join(getDataDir(), 'config.json')}`);
@@ -178,16 +176,20 @@ export async function setup(): Promise {
   // src/cli/setup.ts  -> src/cli/../../  = repo root (same relative path works in both modes).
   const repoRoot = path.resolve(import.meta.dirname, '..', '..');
   try {
-    installAgentAssets(resolvedVaultPath, repoRoot);
-    console.log('Installed CrickNote skills and agent guides into the vault.');
+    const assets = installAgentAssets(resolvedVaultPath, repoRoot);
+    console.log('Installed CrickNote skills into the vault (.claude/skills, .agents/skills).');
+    for (const doc of [...assets.guidesWritten, ...assets.guidesRefreshed]) {
+      console.log(`✓ Agent guide ${doc} ready at the vault root.`);
+    }
+    for (const sidecar of assets.sidecarsWritten) {
+      const original = sidecar.replace('CrickNote-', '');
+      console.log(`⚠  You already have a ${original}; left it untouched and wrote CrickNote's guidance to ${sidecar}.`);
+      console.log(`   Add "@${sidecar}" to your ${original} (or merge it) to enable the CrickNote agent guide.`);
+    }
   } catch (err) {
     console.warn(`Could not install agent assets: ${(err as Error).message}`);
   }
 
-  // Generate auth token
-  generateToken();
-  console.log(`\u2713 Auth token generated (${getTokenPath()})`);
-
   // Initialize database
   const dbPath = path.join(getDataDir(), 'db.sqlite');
   getDatabase(dbPath);
@@ -214,30 +216,6 @@ export async function setup(): Promise {
   ensureVaultScaffold(resolvedVaultPath);
   console.log('\u2713 Vault directories verified');
 
-  // Install Obsidian plugin
-  const pluginDir = path.join(resolvedVaultPath, '.obsidian', 'plugins', 'cricknote');
-  fs.mkdirSync(pluginDir, { recursive: true });
-
-  // dist/cli/setup.js → dist/ → project root → obsidian-plugin/
-  const pluginSourceDir = path.join(import.meta.dirname, '..', '..', 'obsidian-plugin');
-  const mainJs = path.join(pluginSourceDir, 'main.js');
-  const manifest = path.join(pluginSourceDir, 'manifest.json');
-  const styles = path.join(pluginSourceDir, 'styles.css');
-
-  if (!fs.existsSync(mainJs)) {
-    console.warn('\u26a0  Plugin bundle not found. Run "npm run build:plugin" first, then re-run setup.');
-    console.warn(`  Expected: ${mainJs}`);
-  } else {
-    fs.copyFileSync(mainJs, path.join(pluginDir, 'main.js'));
-    if (fs.existsSync(manifest)) {
-      fs.copyFileSync(manifest, path.join(pluginDir, 'manifest.json'));
-    }
-    if (fs.existsSync(styles)) {
-      fs.copyFileSync(styles, path.join(pluginDir, 'styles.css'));
-    }
-    console.log(`\u2713 Obsidian plugin installed to ${pluginDir}`);
-  }
-
-  console.log('\nSetup complete! Start the agent: cricknote start');
-  console.log('Then enable CrickNote in Obsidian → Settings → Community Plugins.\n');
+  console.log('\nSetup complete! Index your vault: cricknote reindex');
+  console.log('Then drive CrickNote from your AI agent via: cricknote tool \n');
 }
diff --git a/src/cli/start.ts b/src/cli/start.ts
deleted file mode 100644
index 9fea06d..0000000
--- a/src/cli/start.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { loadConfig } from '../config/config.js';
-import { startService } from '../service.js';
-
-export async function start(): Promise {
-  const config = loadConfig();
-
-  console.log(`\nCrickNote agent starting...`);
-  console.log(`Vault: ${config.vaultPath}`);
-  console.log(`LLM: ${config.llm.provider}${config.llm.baseUrl ? ` → ${config.llm.baseUrl}` : ''} (model: ${config.llm.model ?? 'default'})`);
-  console.log(`Server: ${config.server.host}:${config.server.port}`);
-
-  await startService(config);
-}
diff --git a/src/config/config.ts b/src/config/config.ts
index 9d41f1b..c9528e2 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -86,11 +86,6 @@ export interface CrickNoteConfig {
     /** Custom base URL for API-compatible providers (e.g. Z.AI, DeepSeek, Ollama). */
     baseUrl?: string;
   };
-  embeddingModelPath?: string;
-  server: {
-    host: string;
-    port: number;
-  };
   zotero?: ZoteroConfig;
 }
 
@@ -113,13 +108,6 @@ export const PROVIDER_PRESETS: Record = {
-  server: {
-    host: '127.0.0.1',
-    port: 18790,
-  },
-};
-
 let cachedConfig: CrickNoteConfig | null = null;
 
 export function getConfigPath(): string {
@@ -137,7 +125,7 @@ export function loadConfig(): CrickNoteConfig {
   }
 
   const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
-  const config = { ...DEFAULT_CONFIG, ...raw } as CrickNoteConfig;
+  const config = { ...raw } as CrickNoteConfig;
 
   // Runtime validation of critical fields
   const errors: string[] = [];
diff --git a/src/ingestion/embedder.ts b/src/ingestion/embedder.ts
deleted file mode 100644
index 3994edd..0000000
--- a/src/ingestion/embedder.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * Embedding generator using @xenova/transformers with all-MiniLM-L6-v2.
- * Lazy-loads the model on first use. Uses an async queue to avoid blocking.
- */
-
-// Use dynamic import for @xenova/transformers to handle ESM/CJS interop
-type Pipeline = (texts: string | string[], options?: Record) =>
-  Promise<{ data: Float32Array }>;
-
-let pipeline: Pipeline | null = null;
-let modelLoadPromise: Promise | null = null;
-
-/** Default model for semantic embeddings */
-const DEFAULT_MODEL = 'Xenova/all-MiniLM-L6-v2';
-
-/** Embedding dimension for all-MiniLM-L6-v2 */
-export const EMBEDDING_DIM = 384;
-
-/**
- * Lazy-load the embedding model. Only loads once, subsequent calls return
- * the cached pipeline. Safe to call concurrently — all callers await
- * the same promise.
- */
-async function getEmbeddingPipeline(): Promise {
-  if (pipeline) return pipeline;
-
-  if (!modelLoadPromise) {
-    modelLoadPromise = (async () => {
-      // Dynamic import of the transformers library
-      const { pipeline: createPipeline } = await import('@xenova/transformers');
-
-      const modelPath = process.env.CRICKNOTE_EMBEDDING_MODEL_PATH ?? DEFAULT_MODEL;
-      const pipe = await createPipeline('feature-extraction', modelPath, {
-        quantized: true,
-      });
-
-      pipeline = pipe as unknown as Pipeline;
-      return pipeline;
-    })();
-  }
-
-  return modelLoadPromise;
-}
-
-/**
- * Generate an embedding vector for a single text string.
- * Returns a Float32Array of dimension 384.
- */
-export async function embedText(text: string): Promise {
-  const pipe = await getEmbeddingPipeline();
-  const result = await pipe(text, { pooling: 'mean', normalize: true });
-
-  // The result.data is a flat Float32Array; extract the embedding
-  return new Float32Array(result.data);
-}
-
-/**
- * Generate embeddings for multiple text chunks.
- * Processes sequentially via an internal queue to avoid overwhelming
- * memory or blocking the event loop for too long.
- *
- * @param texts Array of text strings to embed
- * @returns Array of Float32Array embeddings, one per input text
- */
-export async function embedTexts(texts: string[]): Promise {
-  if (texts.length === 0) return [];
-
-  // Ensure the model is loaded before processing
-  await getEmbeddingPipeline();
-
-  const results: Float32Array[] = [];
-
-  for (const text of texts) {
-    const embedding = await embedText(text);
-    results.push(embedding);
-
-    // Yield to the event loop between embeddings to avoid blocking
-    await new Promise(resolve => setImmediate(resolve));
-  }
-
-  return results;
-}
-
-/**
- * Convert a Float32Array embedding to a Buffer for SQLite BLOB storage.
- */
-export function embeddingToBuffer(embedding: Float32Array): Buffer {
-  return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
-}
-
-/**
- * Convert a SQLite BLOB Buffer back to a Float32Array embedding.
- */
-export function bufferToEmbedding(buffer: Buffer): Float32Array {
-  const arrayBuffer = buffer.buffer.slice(
-    buffer.byteOffset,
-    buffer.byteOffset + buffer.byteLength
-  );
-  return new Float32Array(arrayBuffer);
-}
-
-/**
- * Pre-load the embedding model. Call during startup to avoid
- * latency on the first embedding request.
- */
-export async function preloadModel(): Promise {
-  await getEmbeddingPipeline();
-}
diff --git a/src/ingestion/index-file.ts b/src/ingestion/index-file.ts
index 65051ec..d9946ca 100644
--- a/src/ingestion/index-file.ts
+++ b/src/ingestion/index-file.ts
@@ -11,10 +11,8 @@ import { resolveVaultPath } from '../utils/paths.js';
 export type IndexOutcome = 'indexed' | 'skipped' | 'unchanged' | 'gone';
 
 /**
- * Index a single note by its vault-relative path, writing BM25 + metadata only
- * (no embeddings — empty embeddings array means indexNote skips the embedding
- * insert while still populating chunks and BM25). Safe to call in a short-lived
- * CLI process: no model load, no watcher.
+ * Index a single note by its vault-relative path, writing BM25 + metadata only.
+ * Safe to call in a short-lived CLI process: no model load, no watcher.
  */
 export function indexFileSync(relativePath: string, vaultRoot: string, db?: Database.Database): IndexOutcome {
   if (shouldIgnoreIngestionPath(relativePath)) return 'skipped';
@@ -44,7 +42,7 @@ export function indexFileSync(relativePath: string, vaultRoot: string, db?: Data
 
   const parsed = parseNote(relativePath, content);
   const chunks = chunkText(parsed.body);
-  indexNote({ note: parsed, contentHash, mtime: stat.mtimeMs, chunks, embeddings: [] }, db);
+  indexNote({ note: parsed, contentHash, mtime: stat.mtimeMs, chunks }, db);
   return 'indexed';
 }
 
diff --git a/src/ingestion/indexer.ts b/src/ingestion/indexer.ts
index 00fb269..f1370e9 100644
--- a/src/ingestion/indexer.ts
+++ b/src/ingestion/indexer.ts
@@ -2,7 +2,6 @@ import type Database from 'better-sqlite3';
 import { getDatabase } from '../storage/database.js';
 import type { ParsedNote } from './parser.js';
 import type { TextChunk } from './chunker.js';
-import { embeddingToBuffer } from './embedder.js';
 
 export interface IndexNoteInput {
   /** Parsed note metadata */
@@ -13,8 +12,6 @@ export interface IndexNoteInput {
   mtime: number;
   /** Chunked text segments */
   chunks: TextChunk[];
-  /** Embedding vectors, one per chunk (same order as chunks) */
-  embeddings: Float32Array[];
 }
 
 interface ExistingNoteTypeRow {
@@ -45,12 +42,12 @@ function decrementExperimentTypeCount(database: Database.Database, experimentTyp
 }
 
 /**
- * Upsert a note and its chunks/embeddings into the database.
+ * Upsert a note and its chunks into the database (BM25 + metadata only).
  * Uses a transaction for atomicity.
  */
 export function indexNote(input: IndexNoteInput, db?: Database.Database): void {
   const database = db ?? getDatabase();
-  const { note, contentHash, mtime, chunks, embeddings } = input;
+  const { note, contentHash, mtime, chunks } = input;
   const now = Date.now();
 
   database.transaction(() => {
@@ -132,17 +129,12 @@ export function indexNote(input: IndexNoteInput, db?: Database.Database): void {
     // Delete old chunks (cascades to chunk_embeddings)
     database.prepare('DELETE FROM note_chunks WHERE path = ?').run(note.filePath);
 
-    // 3. Insert new chunks, embeddings, and BM25 entries
+    // 3. Insert new chunks and BM25 entries
     const insertChunk = database.prepare(`
       INSERT INTO note_chunks (path, chunk_index, start_offset, end_offset, content)
       VALUES (?, ?, ?, ?, ?)
     `);
 
-    const insertEmbedding = database.prepare(`
-      INSERT INTO chunk_embeddings (chunk_id, embedding)
-      VALUES (?, ?)
-    `);
-
     const insertBm25 = database.prepare(`
       INSERT INTO bm25_index (chunk_id, content)
       VALUES (?, ?)
@@ -150,7 +142,6 @@ export function indexNote(input: IndexNoteInput, db?: Database.Database): void {
 
     for (let i = 0; i < chunks.length; i++) {
       const chunk = chunks[i];
-      const embedding = embeddings[i];
 
       const result = insertChunk.run(
         note.filePath,
@@ -162,11 +153,6 @@ export function indexNote(input: IndexNoteInput, db?: Database.Database): void {
 
       const chunkId = result.lastInsertRowid as number;
 
-      // Insert embedding
-      if (embedding) {
-        insertEmbedding.run(chunkId, embeddingToBuffer(embedding));
-      }
-
       // Insert BM25 full-text index entry
       insertBm25.run(String(chunkId), chunk.content);
     }
diff --git a/src/ingestion/watcher.ts b/src/ingestion/watcher.ts
deleted file mode 100644
index 517e0d8..0000000
--- a/src/ingestion/watcher.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import chokidar, { type FSWatcher } from 'chokidar';
-import path from 'node:path';
-
-export type FileChangeEvent = 'add' | 'change' | 'unlink';
-
-export interface FileChange {
-  event: FileChangeEvent;
-  filePath: string;
-  /** Absolute path to the file */
-  absolutePath: string;
-}
-
-export type FileChangeCallback = (change: FileChange) => void;
-
-/** Directories to ignore when watching the vault. */
-const IGNORED_DIRS = [
-  '.obsidian',
-  '.trash',
-  '.git',
-  'node_modules',
-  'Agent',
-];
-
-const IGNORED_PATH_PATTERNS = [
-  /(^|[/\\])attachments([/\\]|$)/,
-  /[/\\][^/\\]+-mapping(?:-\d{8}T\d{6})?\.md$/,
-  /(^|[/\\])_changelog\.md$/,
-] as const;
-
-/** Debounce interval in milliseconds for file change events. */
-const DEBOUNCE_MS = 1500;
-
-export class VaultWatcher {
-  private watcher: FSWatcher | null = null;
-  private readonly vaultPath: string;
-  private readonly callback: FileChangeCallback;
-  private debounceTimers: Map> = new Map();
-
-  constructor(vaultPath: string, callback: FileChangeCallback) {
-    this.vaultPath = vaultPath;
-    this.callback = callback;
-  }
-
-  /**
-   * Start watching the vault for .md file changes.
-   * Returns the chokidar watcher instance for external control.
-   */
-  start(): FSWatcher {
-    const ignoredPatterns = IGNORED_DIRS.map(dir =>
-      path.join(this.vaultPath, dir, '**')
-    );
-
-    this.watcher = chokidar.watch(this.vaultPath, {
-      ignored: [
-        ...ignoredPatterns,
-        ...IGNORED_PATH_PATTERNS,
-        // Also ignore dotfiles/dotdirs at any level
-        /(^|[/\\])\../,
-      ],
-      persistent: true,
-      ignoreInitial: true,
-      awaitWriteFinish: {
-        stabilityThreshold: 500,
-        pollInterval: 100,
-      },
-    });
-
-    const handleEvent = (event: FileChangeEvent, filePath: string): void => {
-      // Only process .md files
-      if (!filePath.endsWith('.md')) return;
-
-      const relativePath = path.relative(this.vaultPath, filePath);
-
-      // Clear existing debounce timer for this file
-      const existing = this.debounceTimers.get(filePath);
-      if (existing) {
-        clearTimeout(existing);
-      }
-
-      // Set new debounce timer
-      const timer = setTimeout(() => {
-        this.debounceTimers.delete(filePath);
-        this.callback({
-          event,
-          filePath: relativePath,
-          absolutePath: filePath,
-        });
-      }, DEBOUNCE_MS);
-
-      this.debounceTimers.set(filePath, timer);
-    };
-
-    this.watcher.on('add', (filePath: string) => handleEvent('add', filePath));
-    this.watcher.on('change', (filePath: string) => handleEvent('change', filePath));
-    this.watcher.on('unlink', (filePath: string) => handleEvent('unlink', filePath));
-
-    return this.watcher;
-  }
-
-  /**
-   * Stop watching the vault.
-   */
-  async stop(): Promise {
-    // Clear all pending debounce timers
-    for (const timer of this.debounceTimers.values()) {
-      clearTimeout(timer);
-    }
-    this.debounceTimers.clear();
-
-    if (this.watcher) {
-      await this.watcher.close();
-      this.watcher = null;
-    }
-  }
-
-  /**
-   * Get all .md files in the vault (for initial full index).
-   * Walks the vault directory, respecting the same ignore rules.
-   */
-  static async getAllMarkdownFiles(vaultPath: string): Promise {
-    const files: string[] = [];
-
-    const walk = async (dir: string): Promise => {
-      const { readdir, lstat } = await import('node:fs/promises');
-      const entries = await readdir(dir);
-
-      for (const entry of entries) {
-        // Skip ignored directories
-        if (IGNORED_DIRS.includes(entry)) continue;
-        // Skip hidden files/dirs
-        if (entry.startsWith('.')) continue;
-
-        const fullPath = path.join(dir, entry);
-        if (IGNORED_PATH_PATTERNS.some(pattern => pattern.test(fullPath))) continue;
-        const entryStat = await lstat(fullPath);
-
-        // Never follow symlinks – they could escape the vault boundary
-        if (entryStat.isSymbolicLink()) continue;
-
-        if (entryStat.isDirectory()) {
-          await walk(fullPath);
-        } else if (entry.endsWith('.md')) {
-          files.push(path.relative(vaultPath, fullPath));
-        }
-      }
-    };
-
-    await walk(vaultPath);
-    return files;
-  }
-}
diff --git a/src/ingestion/worker.ts b/src/ingestion/worker.ts
deleted file mode 100644
index d80d74b..0000000
--- a/src/ingestion/worker.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-import { EventEmitter } from 'node:events';
-import fs from 'node:fs';
-import path from 'node:path';
-import crypto from 'node:crypto';
-import { VaultWatcher, type FileChange } from './watcher.js';
-import { parseNote } from './parser.js';
-import { chunkText } from './chunker.js';
-import { embedTexts, preloadModel } from './embedder.js';
-import { shouldIgnoreIngestionPath } from './ignore.js';
-import {
-  indexNote,
-  deleteNote,
-  needsReindex,
-  updateIndexingStatus,
-  markFullIndexComplete,
-  deleteStaleNotes,
-  getIndexingStatus,
-} from './indexer.js';
-import { logger } from '../utils/logger.js';
-import { resolveVaultPath } from '../utils/paths.js';
-
-const log = logger.child('ingestion');
-
-export interface WorkerEvents {
-  /** Emitted when indexing state changes */
-  status: [state: 'idle' | 'indexing' | 'error', message: string];
-  /** Emitted when progress updates during full index */
-  progress: [indexed: number, total: number];
-  /** Emitted when a single note is indexed */
-  indexed: [filePath: string];
-  /** Emitted when a note is removed from index */
-  removed: [filePath: string];
-  /** Emitted on errors */
-  error: [error: Error, filePath?: string];
-}
-
-export interface IngestionWorkerOptions {
-  watchForChanges?: boolean;
-}
-
-export class IngestionWorker extends EventEmitter {
-  private readonly vaultPath: string;
-  private readonly watchForChanges: boolean;
-  private watcher: VaultWatcher | null = null;
-  private running = false;
-  private processingQueue: FileChange[] = [];
-  private processing = false;
-
-  constructor(vaultPath: string, options: IngestionWorkerOptions = {}) {
-    super();
-    this.vaultPath = vaultPath;
-    this.watchForChanges = options.watchForChanges ?? true;
-  }
-
-  /**
-   * Start the ingestion worker.
-   * 1. Pre-loads the embedding model
-   * 2. Performs an initial full index of the vault
-   * 3. Starts watching for incremental changes
-   */
-  async start(): Promise {
-    if (this.running) return;
-    this.running = true;
-
-    this.emit('status', 'indexing', 'Starting ingestion worker...');
-
-    let fullIndexStarted = false;
-    try {
-      // Check for interrupted previous run
-      const currentStatus = getIndexingStatus();
-      if (currentStatus?.state === 'indexing') {
-        log.warn('Previous index run did not complete — restarting full index.');
-      }
-
-      // Pre-load embedding model
-      log.info('Loading embedding model');
-      this.emit('status', 'indexing', 'Loading embedding model...');
-      await preloadModel();
-
-      // Perform initial full index
-      fullIndexStarted = true;
-      await this.fullIndex();
-      fullIndexStarted = false;
-
-      if (this.watchForChanges) {
-        // Start file watcher for incremental updates
-        this.watcher = new VaultWatcher(this.vaultPath, (change) => {
-          this.enqueueChange(change);
-        });
-        this.watcher.start();
-        this.emit('status', 'idle', 'Ingestion worker ready. Watching for changes.');
-      } else {
-        this.emit('status', 'idle', 'Ingestion worker ready.');
-      }
-    } catch (error) {
-      const err = error instanceof Error ? error : new Error(String(error));
-      this.running = false;
-      // fullIndex already emitted status/error events for its own failures;
-      // only re-emit here for errors that occurred before fullIndex was called.
-      if (!fullIndexStarted) {
-        this.emit('status', 'error', `Failed to start: ${err.message}`);
-        this.emit('error', err);
-      }
-      throw err;
-    }
-  }
-
-  /**
-   * Stop the ingestion worker and file watcher.
-   */
-  async stop(): Promise {
-    this.running = false;
-
-    if (this.watcher) {
-      await this.watcher.stop();
-      this.watcher = null;
-    }
-
-    this.processingQueue = [];
-    this.emit('status', 'idle', 'Ingestion worker stopped.');
-  }
-
-  /**
-   * Perform a full index of all markdown files in the vault.
-   * Skips files whose content hash hasn't changed.
-   */
-  private async fullIndex(): Promise {
-    this.emit('status', 'indexing', 'Starting full vault index...');
-
-    let totalFiles = 0;
-    let indexedCount = 0;
-
-    try {
-      const allFiles = await VaultWatcher.getAllMarkdownFiles(this.vaultPath);
-      const indexableFiles = allFiles.filter(f => !shouldIgnoreIngestionPath(f));
-
-      totalFiles = indexableFiles.length;
-      updateIndexingStatus('indexing', totalFiles, 0);
-      this.emit('progress', 0, totalFiles);
-
-      const fileErrors: Array<{ path: string; error: Error }> = [];
-
-      for (const relativePath of indexableFiles) {
-        if (!this.running) break;
-
-        try {
-          await this.processFile(relativePath);
-          indexedCount++;
-        } catch (error) {
-          const err = error instanceof Error ? error : new Error(String(error));
-          this.emit('error', err, relativePath);
-          fileErrors.push({ path: relativePath, error: err });
-        }
-
-        updateIndexingStatus('indexing', totalFiles, indexedCount);
-        this.emit('progress', indexedCount, totalFiles);
-      }
-
-      if (fileErrors.length > 0) {
-        const first = fileErrors[0]!;
-        throw new Error(
-          `Full index completed with ${fileErrors.length} file error(s); first failure at ${first.path}: ${first.error.message}`
-        );
-      }
-
-      deleteStaleNotes(indexableFiles);
-      markFullIndexComplete();
-      log.info('Full index complete', { indexed: indexedCount, total: totalFiles });
-      this.emit('status', 'idle', `Full index complete. ${indexedCount}/${totalFiles} files indexed.`);
-    } catch (error) {
-      const err = error instanceof Error ? error : new Error(String(error));
-      updateIndexingStatus('error', totalFiles, indexedCount, err.message);
-      this.emit('status', 'error', `Full index failed: ${err.message}`);
-      this.emit('error', err);
-      throw err;
-    }
-  }
-
-  /**
-   * Process a single file: read → parse → chunk → embed → index.
-   * Skips if content hash hasn't changed.
-   */
-  private async processFile(relativePath: string): Promise {
-    if (shouldIgnoreIngestionPath(relativePath)) {
-      return;
-    }
-
-    let absolutePath: string;
-    try {
-      absolutePath = resolveVaultPath(this.vaultPath, relativePath);
-    } catch {
-      return;
-    }
-
-    // Read file
-    let content: string;
-    let stat: fs.Stats;
-    try {
-      stat = fs.lstatSync(absolutePath);
-      // Never process symlinks – they could escape the vault boundary
-      if (stat.isSymbolicLink()) return;
-      content = fs.readFileSync(absolutePath, 'utf-8');
-    } catch {
-      // File may have been deleted between detection and processing
-      return;
-    }
-
-    // Compute content hash
-    const contentHash = crypto.createHash('sha256').update(content).digest('hex');
-
-    // Check if re-indexing is needed
-    if (!needsReindex(relativePath, contentHash)) {
-      return;
-    }
-
-    // Parse frontmatter and classify
-    const parsed = parseNote(relativePath, content);
-
-    // Log validation warnings
-    if (parsed.warnings.length > 0) {
-      for (const warning of parsed.warnings) {
-        this.emit('error', new Error(`[${relativePath}] ${warning.message}`), relativePath);
-      }
-    }
-
-    // Chunk the body content
-    const chunks = chunkText(parsed.body);
-
-    // Generate embeddings for all chunks
-    const texts = chunks.map(c => c.content);
-    const embeddings = await embedTexts(texts);
-
-    // Write to database
-    indexNote({
-      note: parsed,
-      contentHash,
-      mtime: stat.mtimeMs,
-      chunks,
-      embeddings,
-    });
-
-    log.debug('Indexed file', { path: relativePath, chunks: chunks.length });
-    this.emit('indexed', relativePath);
-  }
-
-  /**
-   * Enqueue a file change event for processing.
-   */
-  private enqueueChange(change: FileChange): void {
-    this.processingQueue.push(change);
-    this.drainQueue();
-  }
-
-  /**
-   * Process queued file changes sequentially.
-   */
-  private async drainQueue(): Promise {
-    if (this.processing) return;
-    this.processing = true;
-
-    try {
-      while (this.processingQueue.length > 0 && this.running) {
-        const change = this.processingQueue.shift()!;
-
-        try {
-          if (change.event === 'unlink') {
-            log.debug('File removed', { path: change.filePath });
-            deleteNote(change.filePath);
-            this.emit('removed', change.filePath);
-          } else {
-            // 'add' or 'change'
-            log.debug('File changed', { path: change.filePath, event: change.event });
-            await this.processFile(change.filePath);
-          }
-        } catch (error) {
-          const err = error instanceof Error ? error : new Error(String(error));
-          this.emit('error', err, change.filePath);
-        }
-      }
-    } finally {
-      this.processing = false;
-    }
-  }
-}
-
-export { shouldIgnoreIngestionPath };
diff --git a/src/retrieval/context-assembler.ts b/src/retrieval/context-assembler.ts
deleted file mode 100644
index 3732754..0000000
--- a/src/retrieval/context-assembler.ts
+++ /dev/null
@@ -1,417 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import type Database from 'better-sqlite3';
-import { resolveVaultPath } from '../utils/paths.js';
-
-// --- Types ---
-
-export interface AssembledContext {
-  /** The primary note's path */
-  notePath: string;
-  /** Full markdown body of the note */
-  body: string;
-  /** Resolved linked protocol contents (from [[wikilinks]]) */
-  linkedProtocols: LinkedProtocol[];
-  /** Attachment file paths referenced in frontmatter */
-  attachments: string[];
-  /** Related notes from the same project within +/-7 days */
-  relatedNotes: RelatedNote[];
-}
-
-export interface LinkedProtocol {
-  /** The wikilink reference (e.g. "western-blot-protocol") */
-  ref: string;
-  /** Resolved file path */
-  path: string;
-  /** Content of the protocol file, or null if not found */
-  content: string | null;
-}
-
-export interface RelatedNote {
-  path: string;
-  date: string;
-  experimentType: string | null;
-  resultSummary: string | null;
-}
-
-export interface ContextAssemblerOptions {
-  /** The vault root path on disk */
-  vaultPath: string;
-  /** Maximum number of related notes to include */
-  maxRelatedNotes?: number;
-}
-
-function resolveVaultRoot(vaultPath: string): string {
-  try {
-    return fs.realpathSync(vaultPath);
-  } catch {
-    return path.resolve(vaultPath);
-  }
-}
-
-function resolveContextNotePath(
-  vaultPath: string,
-  notePath: string,
-): { fullPath: string; normalizedNotePath: string } | null {
-  const realVault = resolveVaultRoot(vaultPath);
-
-  if (path.isAbsolute(notePath)) {
-    let realTarget: string;
-    try {
-      realTarget = fs.realpathSync(notePath);
-    } catch {
-      realTarget = path.resolve(notePath);
-    }
-    if (realTarget !== realVault && !realTarget.startsWith(realVault + path.sep)) {
-      return null;
-    }
-    return {
-      fullPath: realTarget,
-      normalizedNotePath: path.relative(realVault, realTarget).replace(/\\/g, '/'),
-    };
-  }
-
-  try {
-    const fullPath = resolveVaultPath(vaultPath, notePath);
-    return {
-      fullPath,
-      normalizedNotePath: path.relative(realVault, fullPath).replace(/\\/g, '/'),
-    };
-  } catch {
-    return null;
-  }
-}
-
-// --- Wikilink extraction ---
-
-const WIKILINK_REGEX = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
-
-/**
- * Extract all [[wikilink]] references from markdown content.
- * Handles display aliases like [[target|display text]].
- */
-export function extractWikilinks(content: string): string[] {
-  const links: string[] = [];
-  let match: RegExpExecArray | null;
-
-  // Reset regex state
-  WIKILINK_REGEX.lastIndex = 0;
-  while ((match = WIKILINK_REGEX.exec(content)) !== null) {
-    links.push(match[1].trim());
-  }
-
-  return [...new Set(links)];
-}
-
-/**
- * Extract attachment paths from frontmatter content.
- * Looks for an "attachments:" YAML key and extracts the list items.
- */
-export function extractAttachments(content: string): string[] {
-  const attachments: string[] = [];
-  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
-  if (!frontmatterMatch) return attachments;
-
-  const frontmatter = frontmatterMatch[1];
-  const lines = frontmatter.split('\n');
-  let inAttachments = false;
-
-  for (const line of lines) {
-    if (/^attachments:\s*$/.test(line) || /^attachments:/.test(line)) {
-      inAttachments = true;
-      // Check for inline list: attachments: [a, b]
-      const inlineMatch = line.match(/^attachments:\s*\[(.+)\]/);
-      if (inlineMatch) {
-        const items = inlineMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
-        attachments.push(...items);
-        inAttachments = false;
-      }
-      continue;
-    }
-
-    if (inAttachments) {
-      const itemMatch = line.match(/^\s+-\s+(.+)/);
-      if (itemMatch) {
-        attachments.push(itemMatch[1].trim().replace(/^["']|["']$/g, ''));
-      } else if (/^\S/.test(line)) {
-        // New top-level key, stop collecting
-        inAttachments = false;
-      }
-    }
-  }
-
-  return attachments;
-}
-
-// --- Protocol resolution ---
-
-/**
- * Resolve a wikilink to a file path within the vault.
- * Searches the Protocols/ folder first, then the entire vault.
- */
-function resolveWikilinkPath(
-  ref: string,
-  vaultPath: string,
-): string | null {
-  // Path-safety: reject refs containing traversal or separator characters
-  if (ref.includes('/') || ref.includes('\\') || ref.includes('..') || ref.includes('\0')) {
-    console.warn(`[context-assembler] Wikilink [[${ref}]] contains unsafe path characters — skipping`);
-    return null;
-  }
-  const baseName = ref.replace(/\.md$/, '');
-
-  // Try Protocols/ folder first
-  const protocolPath = path.join(vaultPath, 'Protocols', `${baseName}.md`);
-  if (fs.existsSync(protocolPath)) return protocolPath;
-
-  // Try Knowledge/ subfolders
-  const kbCandidates: string[] = [];
-  for (const sub of ['Concepts', 'Entities', 'Methods']) {
-    const kbPath = path.join(vaultPath, 'Knowledge', sub, `${baseName}.md`);
-    if (fs.existsSync(kbPath)) kbCandidates.push(kbPath);
-  }
-  if (kbCandidates.length === 1) return kbCandidates[0];
-  if (kbCandidates.length > 1) {
-    // Ambiguous: log and return null — never silently pick
-    console.warn(`[context-assembler] Ambiguous wikilink [[${ref}]] matches ${kbCandidates.length} Knowledge notes — skipping`);
-    return null;
-  }
-
-  // Try vault root
-  const rootPath = path.join(vaultPath, `${baseName}.md`);
-  if (fs.existsSync(rootPath)) return rootPath;
-
-  // Try Reading/ subfolders
-  const readingCandidates: string[] = [];
-  for (const sub of ['Papers', 'Threads']) {
-    const rPath = path.join(vaultPath, 'Reading', sub, `${baseName}.md`);
-    if (fs.existsSync(rPath)) readingCandidates.push(rPath);
-  }
-  if (readingCandidates.length === 1) return readingCandidates[0];
-  if (readingCandidates.length > 1) {
-    console.warn(`[context-assembler] Ambiguous wikilink [[${ref}]] matches ${readingCandidates.length} Reading notes — skipping`);
-    return null;
-  }
-
-  // Try other common subfolders (one level deep, excluding Knowledge which is already handled)
-  for (const folder of ['Projects', 'Memory', 'Agent']) {
-    const folderPath = path.join(vaultPath, folder);
-    if (!fs.existsSync(folderPath)) continue;
-    const directPath = path.join(folderPath, `${baseName}.md`);
-    if (fs.existsSync(directPath)) return directPath;
-    try {
-      for (const sub of fs.readdirSync(folderPath, { withFileTypes: true }).filter(d => d.isDirectory())) {
-        const subPath = path.join(folderPath, sub.name, `${baseName}.md`);
-        if (fs.existsSync(subPath)) return subPath;
-      }
-    } catch { /* ignore */ }
-  }
-  return null;
-}
-
-/**
- * Resolve linked protocols from wikilinks found in the note content.
- * Only resolves links that point to files in the Protocols/ folder
- * or contain "protocol" in their name.
- */
-function resolveLinkedProtocols(
-  content: string,
-  vaultPath: string,
-): LinkedProtocol[] {
-  const wikilinks = extractWikilinks(content);
-  const protocols: LinkedProtocol[] = [];
-
-  for (const ref of wikilinks) {
-    const resolvedPath = resolveWikilinkPath(ref, vaultPath);
-    if (resolvedPath === null) {
-      protocols.push({ ref, path: '', content: null });
-      continue;
-    }
-
-    // Only include as linked protocol if it's in Protocols/ or has "protocol" in name
-    const isProtocol =
-      resolvedPath.includes(`${path.sep}Protocols${path.sep}`) ||
-      ref.toLowerCase().includes('protocol');
-
-    if (!isProtocol) continue;
-
-    try {
-      const protocolContent = fs.readFileSync(resolvedPath, 'utf-8');
-      protocols.push({ ref, path: resolvedPath, content: protocolContent });
-    } catch {
-      protocols.push({ ref, path: resolvedPath, content: null });
-    }
-  }
-
-  return protocols;
-}
-
-// --- Related notes ---
-
-/**
- * Find related notes: same project, within +/-7 days of the given date.
- */
-function findRelatedNotes(
-  db: Database.Database,
-  notePath: string,
-  project: string | null,
-  date: string | null,
-  maxResults: number,
-): RelatedNote[] {
-  if (project === null || date === null) return [];
-
-  const stmt = db.prepare(`
-    SELECT path, date, experiment_type, result_summary
-    FROM note_metadata
-    WHERE project = ?
-      AND date BETWEEN date(?, '-7 days') AND date(?, '+7 days')
-      AND path != ?
-    ORDER BY ABS(julianday(date) - julianday(?))
-    LIMIT ?
-  `);
-
-  const rows = stmt.all(project, date, date, notePath, date, maxResults) as Array<{
-    path: string;
-    date: string;
-    experiment_type: string | null;
-    result_summary: string | null;
-  }>;
-
-  return rows.map(row => ({
-    path: row.path,
-    date: row.date,
-    experimentType: row.experiment_type,
-    resultSummary: row.result_summary,
-  }));
-}
-
-// --- Main assembler ---
-
-/**
- * Assemble context for a single note path.
- * Loads the full markdown body, resolves linked protocols,
- * extracts attachment references, and finds related notes.
- */
-export function assembleNoteContext(
-  db: Database.Database,
-  notePath: string,
-  options: ContextAssemblerOptions,
-): AssembledContext | null {
-  const { vaultPath, maxRelatedNotes = 5 } = options;
-
-  // Read the note from disk (vault is source of truth)
-  const resolved = resolveContextNotePath(vaultPath, notePath);
-  if (!resolved) {
-    return null;
-  }
-  const { fullPath, normalizedNotePath } = resolved;
-
-  if (!fs.existsSync(fullPath)) {
-    return null;
-  }
-
-  let body: string;
-  try {
-    body = fs.readFileSync(fullPath, 'utf-8');
-  } catch {
-    return null;
-  }
-
-  // Get metadata from DB for project/date info
-  const meta = db.prepare(
-    'SELECT project, date FROM note_metadata WHERE path = ?',
-  ).get(normalizedNotePath) as { project: string | null; date: string | null } | undefined;
-
-  // Resolve linked protocols
-  const linkedProtocols = resolveLinkedProtocols(body, vaultPath);
-
-  // Extract attachments from frontmatter
-  const attachments = extractAttachments(body);
-
-  // Find related notes
-  const relatedNotes = findRelatedNotes(
-    db,
-    normalizedNotePath,
-    meta?.project ?? null,
-    meta?.date ?? null,
-    maxRelatedNotes,
-  );
-
-  return {
-    notePath: normalizedNotePath,
-    body,
-    linkedProtocols,
-    attachments,
-    relatedNotes,
-  };
-}
-
-/**
- * Assemble context for multiple note paths and format for LLM consumption.
- *
- * Returns a single string with all assembled context, structured with
- * clear section headers for the LLM to parse.
- */
-export function assembleContext(
-  db: Database.Database,
-  notePaths: string[],
-  options: ContextAssemblerOptions,
-): string {
-  const sections: string[] = [];
-
-  for (const notePath of notePaths) {
-    const ctx = assembleNoteContext(db, notePath, options);
-    if (ctx === null) continue;
-
-    const noteSection: string[] = [];
-
-    // --- Primary note ---
-    noteSection.push(`## Note: ${ctx.notePath}`);
-    noteSection.push('');
-    noteSection.push(ctx.body);
-
-    // --- Linked protocols ---
-    if (ctx.linkedProtocols.length > 0) {
-      noteSection.push('');
-      noteSection.push('### Linked Protocols');
-      for (const protocol of ctx.linkedProtocols) {
-        if (protocol.content !== null) {
-          noteSection.push('');
-          noteSection.push(`#### Protocol: ${protocol.ref}`);
-          noteSection.push(protocol.content);
-        } else {
-          noteSection.push(`- [[${protocol.ref}]] (not found)`);
-        }
-      }
-    }
-
-    // --- Attachments ---
-    if (ctx.attachments.length > 0) {
-      noteSection.push('');
-      noteSection.push('### Attachments');
-      for (const att of ctx.attachments) {
-        noteSection.push(`- ${att}`);
-      }
-    }
-
-    // --- Related notes ---
-    if (ctx.relatedNotes.length > 0) {
-      noteSection.push('');
-      noteSection.push('### Related Notes (same project, nearby dates)');
-      for (const related of ctx.relatedNotes) {
-        const parts = [related.path, related.date];
-        if (related.experimentType) parts.push(related.experimentType);
-        if (related.resultSummary) parts.push(related.resultSummary);
-        noteSection.push(`- ${parts.join(' | ')}`);
-      }
-    }
-
-    sections.push(noteSection.join('\n'));
-  }
-
-  if (sections.length === 0) {
-    return 'No matching notes found.';
-  }
-
-  return sections.join('\n\n---\n\n');
-}
diff --git a/src/retrieval/semantic-ranker.ts b/src/retrieval/semantic-ranker.ts
deleted file mode 100644
index 62e05bc..0000000
--- a/src/retrieval/semantic-ranker.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import type Database from 'better-sqlite3';
-
-// --- Types ---
-
-export interface RankedResult {
-  chunkId: number;
-  score: number;
-}
-
-export interface ChunkEmbeddingRow {
-  chunk_id: number;
-  embedding: Buffer;
-}
-
-// --- Vector math ---
-
-/**
- * Compute cosine similarity between two Float32Array vectors.
- * Returns a value in [-1, 1]. Higher is more similar.
- */
-export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
-  if (a.length !== b.length) {
-    throw new Error(
-      `Vector dimension mismatch: ${a.length} vs ${b.length}`,
-    );
-  }
-
-  let dot = 0;
-  let normA = 0;
-  let normB = 0;
-
-  for (let i = 0; i < a.length; i++) {
-    dot += a[i] * b[i];
-    normA += a[i] * a[i];
-    normB += b[i] * b[i];
-  }
-
-  const denom = Math.sqrt(normA) * Math.sqrt(normB);
-  if (denom === 0) return 0;
-
-  return dot / denom;
-}
-
-/**
- * Deserialize a BLOB (Buffer) into a Float32Array.
- * The embedding is stored as raw float32 bytes in the chunk_embeddings table.
- */
-export function blobToFloat32Array(blob: Buffer): Float32Array {
-  const arrayBuffer = blob.buffer.slice(
-    blob.byteOffset,
-    blob.byteOffset + blob.byteLength,
-  );
-  return new Float32Array(arrayBuffer);
-}
-
-// --- Semantic ranker ---
-
-/**
- * Rank candidate chunks by cosine similarity to a query embedding.
- *
- * Only used when candidateChunkIds.length > 5 (per spec).
- * If candidates <= 5, the caller should skip ranking and use all candidates.
- *
- * @param db - better-sqlite3 database instance
- * @param queryEmbedding - the embedded query vector (Float32Array)
- * @param candidateChunkIds - chunk IDs from the structured filter step
- * @param topK - maximum number of results to return (default: 10)
- * @returns ranked results sorted by descending cosine similarity score
- */
-export function rankChunks(
-  db: Database.Database,
-  queryEmbedding: Float32Array,
-  candidateChunkIds: number[],
-  topK: number = 10,
-): RankedResult[] {
-  if (candidateChunkIds.length === 0) {
-    return [];
-  }
-
-  // Load embeddings for candidate chunks.
-  // Use batched queries to avoid SQLite variable limit issues.
-  const batchSize = 500;
-  const allRows: ChunkEmbeddingRow[] = [];
-
-  for (let i = 0; i < candidateChunkIds.length; i += batchSize) {
-    const batch = candidateChunkIds.slice(i, i + batchSize);
-    const placeholders = batch.map(() => '?').join(',');
-    const stmt = db.prepare(
-      `SELECT chunk_id, embedding FROM chunk_embeddings WHERE chunk_id IN (${placeholders})`,
-    );
-    const rows = stmt.all(...batch) as ChunkEmbeddingRow[];
-    allRows.push(...rows);
-  }
-
-  // Compute cosine similarity for each chunk
-  const scored: RankedResult[] = [];
-
-  for (const row of allRows) {
-    const chunkEmbedding = blobToFloat32Array(row.embedding);
-    const score = cosineSimilarity(queryEmbedding, chunkEmbedding);
-    scored.push({ chunkId: row.chunk_id, score });
-  }
-
-  // Sort by score descending
-  scored.sort((a, b) => b.score - a.score);
-
-  // Return top-k
-  return scored.slice(0, topK);
-}
-
-/**
- * Full semantic ranking pipeline: loads embeddings, computes similarity,
- * returns ranked results. Skips ranking if candidate count <= 5.
- *
- * @returns If candidates <= 5, returns all candidate IDs with score 1.0.
- *          If candidates > 5, returns top-k ranked by cosine similarity.
- */
-export function semanticRank(
-  db: Database.Database,
-  queryEmbedding: Float32Array,
-  candidateChunkIds: number[],
-  topK: number = 10,
-): RankedResult[] {
-  // Per spec: only use semantic ranking when candidate count > 5
-  if (candidateChunkIds.length <= 5) {
-    return candidateChunkIds.map(chunkId => ({ chunkId, score: 1.0 }));
-  }
-
-  return rankChunks(db, queryEmbedding, candidateChunkIds, topK);
-}
diff --git a/src/retrieval/structured-filter.ts b/src/retrieval/structured-filter.ts
index ceca0f3..eeebd2e 100644
--- a/src/retrieval/structured-filter.ts
+++ b/src/retrieval/structured-filter.ts
@@ -105,26 +105,6 @@ export function buildNoteQuery(filters: StructuredFilterInput): FilterResult {
   return { sql, params };
 }
 
-/**
- * Build a query that returns chunk IDs for notes matching the filters.
- * Used to feed candidate chunks into the semantic ranker.
- */
-export function buildChunkCandidateQuery(filters: StructuredFilterInput): FilterResult {
-  const { sql: whereClause, params } = buildFilter(filters);
-
-  const sql = [
-    'SELECT nc.id AS chunk_id, nc.path, nc.chunk_index, nc.content',
-    'FROM note_chunks nc',
-    'JOIN note_metadata nm ON nc.path = nm.path',
-    whereClause,
-    'ORDER BY nm.date DESC',
-  ]
-    .filter(line => line.length > 0)
-    .join('\n');
-
-  return { sql, params };
-}
-
 /**
  * Convenience: convert a ParsedQuery from the query parser into a StructuredFilterInput.
  */
diff --git a/src/server/auth.ts b/src/server/auth.ts
deleted file mode 100644
index a8b4e51..0000000
--- a/src/server/auth.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import crypto from 'node:crypto';
-import fs from 'node:fs';
-import path from 'node:path';
-import { getDataDir } from '../storage/database.js';
-
-const PROTOCOL_VERSION = 1;
-
-export function getTokenPath(): string {
-  return path.join(getDataDir(), 'auth-token');
-}
-
-export function generateToken(): string {
-  const token = crypto.randomBytes(32).toString('hex');
-  const tokenPath = getTokenPath();
-  fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
-  fs.writeFileSync(tokenPath, token, { mode: 0o600 });
-  return token;
-}
-
-export function readToken(): string {
-  const tokenPath = getTokenPath();
-  if (!fs.existsSync(tokenPath)) {
-    throw new Error(`Auth token not found at ${tokenPath}. Run "cricknote setup" first.`);
-  }
-  return fs.readFileSync(tokenPath, 'utf-8').trim();
-}
-
-export function validateToken(provided: string): boolean {
-  const stored = readToken();
-  const a = Buffer.from(provided);
-  const b = Buffer.from(stored);
-  // timingSafeEqual requires equal-length buffers; length mismatch is itself
-  // not secret, so we can short-circuit false without a timing leak concern.
-  if (a.length !== b.length) return false;
-  return crypto.timingSafeEqual(a, b);
-}
-
-export function rotateToken(): string {
-  return generateToken();
-}
-
-export function getProtocolVersion(): number {
-  return PROTOCOL_VERSION;
-}
-
-export interface AuthMessage {
-  type: 'auth';
-  token: string;
-  protocolVersion: number;
-  pluginVersion: string;
-  sessionId?: string;
-}
-
-export interface AuthOkMessage {
-  type: 'auth_ok';
-  protocolVersion: number;
-  serviceVersion: string;
-  sessionId?: string;
-}
-
-export interface AuthErrorMessage {
-  type: 'auth_error';
-  reason: 'invalid_token' | 'version_mismatch' | 'timeout';
-  required?: number;
-}
-
-export function validateAuthMessage(
-  msg: AuthMessage,
-  serviceVersion: string
-): AuthOkMessage | AuthErrorMessage {
-  // Validate required fields are present and the right types before using them.
-  // These checks guard against malformed payloads that were cast rather than validated.
-  if (typeof msg.protocolVersion !== 'number') {
-    return { type: 'auth_error', reason: 'version_mismatch', required: PROTOCOL_VERSION };
-  }
-  if (msg.protocolVersion !== PROTOCOL_VERSION) {
-    return {
-      type: 'auth_error',
-      reason: 'version_mismatch',
-      required: PROTOCOL_VERSION,
-    };
-  }
-
-  if (typeof msg.token !== 'string' || msg.token.length === 0) {
-    return { type: 'auth_error', reason: 'invalid_token' };
-  }
-
-  try {
-    if (!validateToken(msg.token)) {
-      return { type: 'auth_error', reason: 'invalid_token' };
-    }
-  } catch {
-    // Token file missing/unreadable — return structured error instead of crashing.
-    return { type: 'auth_error', reason: 'invalid_token' };
-  }
-
-  return {
-    type: 'auth_ok',
-    protocolVersion: PROTOCOL_VERSION,
-    serviceVersion,
-  };
-}
diff --git a/src/server/rate-limiter.ts b/src/server/rate-limiter.ts
deleted file mode 100644
index bd7752d..0000000
--- a/src/server/rate-limiter.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * Simple sliding-window rate limiter for WebSocket connections.
- *
- * Tracks message timestamps per connection and rejects messages that
- * exceed the configured rate. Designed for local-only connections
- * (Obsidian plugin → CrickNote server) so the limits are generous.
- *
- * Usage:
- *   const limiter = new RateLimiter({ maxMessages: 20, windowMs: 60_000 });
- *   if (!limiter.allow(connectionId)) {
- *     // reject the message
- *   }
- *   limiter.remove(connectionId); // on disconnect
- */
-
-export interface RateLimiterOptions {
-  /** Maximum messages allowed within the window. Default: 30. */
-  maxMessages?: number;
-  /** Sliding window duration in milliseconds. Default: 60_000 (1 minute). */
-  windowMs?: number;
-}
-
-export class RateLimiter {
-  private readonly maxMessages: number;
-  private readonly windowMs: number;
-  private readonly windows = new Map();
-
-  constructor(options: RateLimiterOptions = {}) {
-    this.maxMessages = options.maxMessages ?? 30;
-    this.windowMs = options.windowMs ?? 60_000;
-  }
-
-  /**
-   * Check whether a message from this connection is allowed.
-   * Returns true if within rate limit, false if exceeded.
-   */
-  allow(connectionId: string): boolean {
-    const now = Date.now();
-    const cutoff = now - this.windowMs;
-
-    let timestamps = this.windows.get(connectionId);
-    if (!timestamps) {
-      timestamps = [];
-      this.windows.set(connectionId, timestamps);
-    }
-
-    // Prune timestamps outside the window
-    while (timestamps.length > 0 && timestamps[0] <= cutoff) {
-      timestamps.shift();
-    }
-
-    if (timestamps.length >= this.maxMessages) {
-      return false;
-    }
-
-    timestamps.push(now);
-    return true;
-  }
-
-  /** Remove tracking for a disconnected client. */
-  remove(connectionId: string): void {
-    this.windows.delete(connectionId);
-  }
-}
diff --git a/src/server/websocket.ts b/src/server/websocket.ts
deleted file mode 100644
index 139fec7..0000000
--- a/src/server/websocket.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { WebSocketServer, WebSocket } from 'ws';
-import type { CrickNoteConfig } from '../config/config.js';
-import { validateAuthMessage, type AuthMessage } from './auth.js';
-import { AgentRuntime, type RuntimeResponse } from '../agent/runtime.js';
-import { RateLimiter } from './rate-limiter.js';
-import { logger } from '../utils/logger.js';
-
-const log = logger.child('websocket');
-
-export function mapPendingEditForPlugin(
-  pe: RuntimeResponse['pendingEdits'][number],
-  vaultPath: string,
-): { editId: string; batchId: string | undefined; path: string; diff: string; hasConflict: boolean; warnings: string[] } {
-  return {
-    editId: pe.editId,
-    batchId: pe.batchId,
-    path: path.relative(vaultPath, pe.proposal.filePath),
-    diff: pe.proposal.diff,
-    hasConflict: pe.proposal.hasConflict,
-    warnings: pe.warnings,
-  };
-}
-
-interface AuthenticatedClient {
-  ws: WebSocket;
-  pluginVersion: string;
-  sessionId: string;
-  connectionId: string;
-}
-
-export function normalizeClientSessionId(value: unknown): string | null {
-  if (typeof value !== 'string') return null;
-  const trimmed = value.trim();
-  if (!/^[A-Za-z0-9._:-]{8,128}$/.test(trimmed)) return null;
-  return trimmed;
-}
-
-export function createWebSocketServer(config: CrickNoteConfig): Promise {
-  return new Promise((resolve, reject) => {
-    const wss = new WebSocketServer({
-      host: config.server.host,
-      port: config.server.port,
-    });
-
-    // Handle fatal startup errors (e.g. EADDRINUSE)
-    wss.once('error', (error) => {
-      log.error('WebSocket server error', { error: error.message });
-      reject(error);
-    });
-
-    wss.once('listening', () => {
-      // Replace the one-shot error handler with a persistent one for runtime errors
-      wss.on('error', (error) => {
-        log.error('WebSocket server error', { error: error.message });
-      });
-      resolve(wss);
-    });
-
-  const runtime = new AgentRuntime(config);
-  const clients = new Map();
-  const rateLimiter = new RateLimiter({ maxMessages: 30, windowMs: 60_000 });
-  let connectionCounter = 0;
-  // Resolve the vault root through symlinks so path.relative() works correctly
-  // when config.vaultPath is itself a symlink.
-  let realVaultPath: string;
-  try {
-    realVaultPath = fs.realpathSync(config.vaultPath);
-  } catch {
-    realVaultPath = path.resolve(config.vaultPath);
-  }
-
-  wss.on('connection', (ws, req) => {
-    // Reject non-loopback connections
-    const remoteAddress = req.socket.remoteAddress;
-    if (remoteAddress !== '127.0.0.1' && remoteAddress !== '::1' && remoteAddress !== '::ffff:127.0.0.1') {
-      log.warn('Non-loopback connection rejected', { remoteAddress });
-      ws.close(4003, 'Non-loopback connections rejected');
-      return;
-    }
-
-    const connectionId = `conn-${++connectionCounter}`;
-
-    // Auth timeout: 5 seconds
-    const authTimeout = setTimeout(() => {
-      if (!clients.has(ws)) {
-        log.warn('Auth timeout — closing connection');
-        ws.close(4001, 'Auth timeout');
-      }
-    }, 5000);
-
-    ws.on('message', async (data) => {
-      // Rate limiting — check before any processing
-      if (!rateLimiter.allow(connectionId)) {
-        log.warn('Rate limit exceeded', { connectionId });
-        ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded. Try again shortly.' }));
-        return;
-      }
-
-      let msg: Record;
-      try {
-        msg = JSON.parse(data.toString());
-      } catch {
-        ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
-        return;
-      }
-      if (typeof msg !== 'object' || msg === null) {
-        ws.send(JSON.stringify({ type: 'error', message: 'Message must be a JSON object' }));
-        return;
-      }
-
-      // Handle auth
-      if (msg.type === 'auth' && !clients.has(ws)) {
-        clearTimeout(authTimeout);
-        const result = validateAuthMessage(msg as unknown as AuthMessage, '0.1.0');
-
-        if (result.type === 'auth_ok') {
-          const sessionId = normalizeClientSessionId(msg.sessionId) ?? `obsidian-${Date.now()}-${connectionCounter}`;
-          clients.set(ws, {
-            ws,
-            pluginVersion: (msg as unknown as AuthMessage).pluginVersion,
-            sessionId,
-            connectionId,
-          });
-          log.info('Client authenticated', { sessionId, pluginVersion: (msg as unknown as AuthMessage).pluginVersion });
-          ws.send(JSON.stringify({ ...result, sessionId }));
-        } else {
-          log.warn('Auth rejected', { reason: result.reason });
-          ws.send(JSON.stringify(result));
-          ws.close(4002, result.reason);
-        }
-        return;
-      }
-
-      // Reject unauthenticated messages
-      const client = clients.get(ws);
-      if (!client) {
-        ws.close(4002, 'Not authenticated');
-        return;
-      }
-
-      // Handle chat messages
-      if (msg.type === 'chat') {
-        if (typeof msg.content !== 'string' || msg.content.length === 0) {
-          ws.send(JSON.stringify({ type: 'error', message: 'content must be a non-empty string' }));
-          return;
-        }
-        try {
-          log.info('Chat message received', { sessionId: client.sessionId, length: (msg.content as string).length });
-          const response = await runtime.processMessage(
-            msg.content,
-            client.sessionId,
-            (text) => {
-              if (ws.readyState === WebSocket.OPEN) {
-                ws.send(JSON.stringify({ type: 'chat_chunk', text }));
-              }
-            },
-          );
-          const pendingEdits = response.pendingEdits.map(pe => mapPendingEditForPlugin(pe, realVaultPath));
-          log.info('Chat response sent', {
-            sessionId: client.sessionId,
-            toolCalls: response.toolCalls.length,
-            pendingEdits: pendingEdits.length,
-          });
-          ws.send(JSON.stringify({
-            type: 'chat_response',
-            content: response.content,
-            toolCalls: response.toolCalls,
-            pendingEdits,
-          }));
-        } catch (err) {
-          log.error('Chat processing failed', {
-            sessionId: client.sessionId,
-            error: err instanceof Error ? err.message : 'Unknown error',
-          });
-          ws.send(JSON.stringify({
-            type: 'error',
-            message: err instanceof Error ? err.message : 'Unknown error',
-          }));
-        }
-        return;
-      }
-
-      // Handle edit confirmations
-      if (msg.type === 'edit_confirm') {
-        const action = msg.action;
-        if (action !== 'apply' && action !== 'force' && action !== 'cancel') {
-          log.warn('Invalid edit_confirm action', { action });
-          ws.send(JSON.stringify({ type: 'error', message: `Invalid action: "${action}". Must be apply, force, or cancel.` }));
-          return;
-        }
-        const editId = typeof msg.editId === 'string' ? msg.editId : '';
-        try {
-          const result = await runtime.confirmEdit(editId, action, client.sessionId);
-          log.info('Edit confirmed', { editId, action, success: result.success });
-          ws.send(JSON.stringify({
-            type: 'edit_result',
-            editId,
-            success: result.success,
-            message: result.message,
-          }));
-        } catch (err) {
-          log.error('Edit confirmation failed', { editId, action, error: err instanceof Error ? err.message : 'Unknown error' });
-          ws.send(JSON.stringify({
-            type: 'error',
-            message: err instanceof Error ? err.message : 'Unknown error',
-          }));
-        }
-        return;
-      }
-
-      // Handle status requests
-      if (msg.type === 'status') {
-        const status = runtime.getStatus();
-        ws.send(JSON.stringify({ type: 'status_response', ...status }));
-      }
-    });
-
-    ws.on('close', () => {
-      clearTimeout(authTimeout);
-      const client = clients.get(ws);
-      if (client) {
-        log.info('Client disconnected', { sessionId: client.sessionId });
-        // Clean up any pending edits associated with this session
-        runtime.cleanupSession(client.sessionId);
-      }
-      rateLimiter.remove(connectionId);
-      clients.delete(ws);
-    });
-  });
-
-  }); // end Promise
-}
diff --git a/src/service.ts b/src/service.ts
deleted file mode 100644
index e9fb6f8..0000000
--- a/src/service.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { CrickNoteConfig } from './config/config.js';
-import { getDatabase } from './storage/database.js';
-import { createWebSocketServer } from './server/websocket.js';
-import { IngestionWorker } from './ingestion/worker.js';
-import { logger } from './utils/logger.js';
-
-const log = logger.child('service');
-
-export async function startService(config: CrickNoteConfig): Promise {
-  // Initialize database
-  getDatabase();
-
-  // Start WebSocket server first so clients can connect immediately
-  const wss = await createWebSocketServer(config);
-
-  log.info('CrickNote agent running', { host: config.server.host, port: config.server.port });
-  log.info('Open Obsidian to connect');
-
-  // Start ingestion worker in the background (model preload + indexing)
-  const ingestion = new IngestionWorker(config.vaultPath);
-  // Must register 'error' listener before start() — Node.js throws on unhandled error events.
-  ingestion.on('error', (err, filePath) => {
-    log.warn('Ingestion warning', { error: err.message, file: filePath });
-  });
-  ingestion.start().catch((err) => {
-    log.error('Ingestion worker failed to start', { error: (err as Error).message });
-  });
-
-  // Graceful shutdown
-  const shutdown = () => {
-    log.info('Shutting down');
-    ingestion.stop();
-    wss.close();
-    log.close();
-    process.exit(0);
-  };
-
-  process.on('SIGINT', shutdown);
-  process.on('SIGTERM', shutdown);
-}
diff --git a/tests/e2e/server.e2e.test.ts b/tests/e2e/server.e2e.test.ts
deleted file mode 100644
index 7776f94..0000000
--- a/tests/e2e/server.e2e.test.ts
+++ /dev/null
@@ -1,577 +0,0 @@
-/**
- * End-to-End tests for CrickNote.
- *
- * These tests start the REAL server (WebSocket + ingestion) against the
- * sample-vault fixture, then exercise the full flows that the Obsidian
- * plugin would perform:
- *
- *   connect → auth → status → chat → tool execution → edit proposal → confirm/cancel
- *
- * Unlike unit tests that mock dependencies, E2E tests use every real component
- * wired together, so they catch integration bugs that unit tests miss.
- */
-import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import os from 'node:os';
-import { WebSocket } from 'ws';
-import { createWebSocketServer } from '../../src/server/websocket.js';
-import { generateToken, readToken } from '../../src/server/auth.js';
-import { getDatabase, closeDatabase } from '../../src/storage/database.js';
-import { IngestionWorker } from '../../src/ingestion/worker.js';
-import type { CrickNoteConfig } from '../../src/config/config.js';
-import type { WebSocketServer } from 'ws';
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-const SAMPLE_VAULT = path.resolve('tests/fixtures/sample-vault');
-const RUN_SOCKET_TESTS = process.env.CRICKNOTE_RUN_SOCKET_TESTS === '1';
-const describeSockets = RUN_SOCKET_TESTS ? describe : describe.skip;
-
-/** Copy the sample vault into a temp directory so tests can write without polluting fixtures. */
-function copyVault(): string {
-  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cricknote-e2e-'));
-  copyRecursive(SAMPLE_VAULT, tmp);
-  return tmp;
-}
-
-function copyRecursive(src: string, dest: string): void {
-  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
-    const srcPath = path.join(src, entry.name);
-    const destPath = path.join(dest, entry.name);
-    if (entry.isDirectory()) {
-      fs.mkdirSync(destPath, { recursive: true });
-      copyRecursive(srcPath, destPath);
-    } else {
-      fs.copyFileSync(srcPath, destPath);
-    }
-  }
-}
-
-/** Open a WebSocket, authenticate, and return a helper object. */
-function createClient(port: number, token: string): Promise {
-  return new Promise((resolve, reject) => {
-    const ws = new WebSocket(`ws://127.0.0.1:${port}`);
-    const timeout = setTimeout(() => reject(new Error('Connection timeout')), 5000);
-
-    ws.on('open', () => {
-      ws.send(JSON.stringify({
-        type: 'auth',
-        token,
-        protocolVersion: 1,
-        pluginVersion: '0.1.0',
-      }));
-    });
-
-    ws.on('message', (data) => {
-      const msg = JSON.parse(data.toString());
-      if (msg.type === 'auth_ok') {
-        clearTimeout(timeout);
-        resolve(new TestClient(ws));
-      } else if (msg.type === 'auth_error') {
-        clearTimeout(timeout);
-        reject(new Error(`Auth failed: ${msg.reason}`));
-      }
-    });
-
-    ws.on('error', (err) => {
-      clearTimeout(timeout);
-      reject(err);
-    });
-  });
-}
-
-class TestClient {
-  private ws: WebSocket;
-  private pendingMessages: Record[] = [];
-  private waiters: Array<(msg: Record) => void> = [];
-
-  constructor(ws: WebSocket) {
-    this.ws = ws;
-    ws.on('message', (data) => {
-      const msg = JSON.parse(data.toString());
-      if (this.waiters.length > 0) {
-        this.waiters.shift()!(msg);
-      } else {
-        this.pendingMessages.push(msg);
-      }
-    });
-  }
-
-  send(msg: Record): void {
-    this.ws.send(JSON.stringify(msg));
-  }
-
-  /** Wait for the next server message, with a timeout. */
-  receive(timeoutMs = 10000): Promise> {
-    if (this.pendingMessages.length > 0) {
-      return Promise.resolve(this.pendingMessages.shift()!);
-    }
-    return new Promise((resolve, reject) => {
-      const timer = setTimeout(() => reject(new Error('Receive timeout')), timeoutMs);
-      this.waiters.push((msg) => {
-        clearTimeout(timer);
-        resolve(msg);
-      });
-    });
-  }
-
-  close(): void {
-    this.ws.close();
-  }
-}
-
-// ---------------------------------------------------------------------------
-// Test suite
-// ---------------------------------------------------------------------------
-
-describe('E2E: CrickNote server', () => {
-  let vaultPath: string;
-  let dataDir: string;
-  let config: CrickNoteConfig;
-  let wss: WebSocketServer;
-  let ingestion: IngestionWorker;
-  let token: string;
-  let port: number;
-  const originalDataDir = process.env.CRICKNOTE_DATA_DIR;
-
-  beforeAll(async () => {
-    // Use a temp copy of the vault so write tests don't touch fixtures
-    vaultPath = copyVault();
-    dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cricknote-e2e-data-'));
-    process.env.CRICKNOTE_DATA_DIR = dataDir;
-
-    // Generate an auth token
-    generateToken();
-    token = readToken();
-
-    // Pick a random high port to avoid collisions
-    port = 19000 + Math.floor(Math.random() * 1000);
-
-    config = {
-      vaultPath,
-      llm: { provider: 'anthropic', apiKey: 'sk-ant-test-placeholder' },
-      server: { host: '127.0.0.1', port },
-    };
-
-    // Initialize database and ingest the sample vault
-    const dbPath = path.join(os.tmpdir(), `cricknote-e2e-${Date.now()}.sqlite`);
-    getDatabase(dbPath);
-
-    ingestion = new IngestionWorker(vaultPath, { watchForChanges: false });
-    await ingestion.start();
-
-    // Wait for indexing to settle
-    await new Promise(r => setTimeout(r, 2000));
-
-    if (RUN_SOCKET_TESTS) {
-      wss = await createWebSocketServer(config);
-    }
-  }, 30000);
-
-  afterAll(() => {
-    wss?.close();
-    ingestion?.stop();
-    closeDatabase();
-    fs.rmSync(vaultPath, { recursive: true, force: true });
-    fs.rmSync(dataDir, { recursive: true, force: true });
-    process.env.CRICKNOTE_DATA_DIR = originalDataDir;
-  });
-
-  // -------------------------------------------------------------------------
-  // 1. Connection & Authentication
-  // -------------------------------------------------------------------------
-
-  describeSockets('authentication', () => {
-    it('accepts a valid token and returns auth_ok', async () => {
-      const client = await createClient(port, token);
-      client.close();
-      // If we got here, auth succeeded (createClient rejects on auth_error)
-    });
-
-    it('rejects an invalid token with auth_error', async () => {
-      const ws = new WebSocket(`ws://127.0.0.1:${port}`);
-      const msg = await new Promise>((resolve, reject) => {
-        ws.on('open', () => {
-          ws.send(JSON.stringify({
-            type: 'auth',
-            token: 'wrong-token',
-            protocolVersion: 1,
-            pluginVersion: '0.1.0',
-          }));
-        });
-        ws.on('message', (data) => resolve(JSON.parse(data.toString())));
-        ws.on('error', reject);
-        setTimeout(() => reject(new Error('Timeout')), 5000);
-      });
-
-      expect(msg.type).toBe('auth_error');
-      expect(msg.reason).toBe('invalid_token');
-      ws.close();
-    });
-
-    it('rejects a wrong protocol version', async () => {
-      const ws = new WebSocket(`ws://127.0.0.1:${port}`);
-      const msg = await new Promise>((resolve, reject) => {
-        ws.on('open', () => {
-          ws.send(JSON.stringify({
-            type: 'auth',
-            token,
-            protocolVersion: 99,
-            pluginVersion: '0.1.0',
-          }));
-        });
-        ws.on('message', (data) => resolve(JSON.parse(data.toString())));
-        ws.on('error', reject);
-        setTimeout(() => reject(new Error('Timeout')), 5000);
-      });
-
-      expect(msg.type).toBe('auth_error');
-      expect(msg.reason).toBe('version_mismatch');
-      expect(msg.required).toBe(1);
-      ws.close();
-    });
-
-    it('closes connection for unauthenticated messages', async () => {
-      const ws = new WebSocket(`ws://127.0.0.1:${port}`);
-      const code = await new Promise((resolve, reject) => {
-        ws.on('open', () => {
-          ws.send(JSON.stringify({ type: 'chat', content: 'hello' }));
-        });
-        ws.on('close', (c) => resolve(c));
-        ws.on('error', reject);
-        setTimeout(() => reject(new Error('Timeout')), 5000);
-      });
-
-      expect(code).toBe(4002);
-    });
-  });
-
-  // -------------------------------------------------------------------------
-  // 2. Status
-  // -------------------------------------------------------------------------
-
-  describeSockets('status', () => {
-    it('returns indexing state with file counts from the sample vault', async () => {
-      const client = await createClient(port, token);
-      client.send({ type: 'status' });
-      const msg = await client.receive();
-
-      expect(msg.type).toBe('status_response');
-      expect(msg.indexing).toBeDefined();
-      const indexing = msg.indexing as { state: string; total: number; indexed: number };
-      expect(indexing.indexed).toBeGreaterThan(0);
-      expect(indexing.total).toBeGreaterThan(0);
-
-      client.close();
-    });
-  });
-
-  // -------------------------------------------------------------------------
-  // 3. Error handling
-  // -------------------------------------------------------------------------
-
-  describeSockets('error handling', () => {
-    it('rejects malformed JSON', async () => {
-      const ws = new WebSocket(`ws://127.0.0.1:${port}`);
-      // Need to auth first, then send bad JSON — but raw ws doesn't let us send non-JSON after auth easily.
-      // Actually, the server handles bad JSON before auth too:
-      const msg = await new Promise>((resolve, reject) => {
-        ws.on('open', () => {
-          ws.send('this is not json{{{');
-        });
-        ws.on('message', (data) => resolve(JSON.parse(data.toString())));
-        ws.on('error', reject);
-        setTimeout(() => reject(new Error('Timeout')), 5000);
-      });
-
-      expect(msg.type).toBe('error');
-      expect(msg.message).toBe('Invalid JSON');
-      ws.close();
-    });
-
-    it('rejects empty chat content', async () => {
-      const client = await createClient(port, token);
-      client.send({ type: 'chat', content: '' });
-      const msg = await client.receive();
-
-      expect(msg.type).toBe('error');
-      expect(msg.message).toBe('content must be a non-empty string');
-      client.close();
-    });
-
-    it('rejects invalid edit_confirm actions', async () => {
-      const client = await createClient(port, token);
-      client.send({ type: 'edit_confirm', action: 'delete', editId: 'fake' });
-      const msg = await client.receive();
-
-      expect(msg.type).toBe('error');
-      expect(msg.message as string).toContain('Invalid action');
-      expect(msg.message as string).toContain('delete');
-      client.close();
-    });
-
-    it('handles edit_confirm for unknown editId gracefully', async () => {
-      const client = await createClient(port, token);
-      client.send({ type: 'edit_confirm', action: 'cancel', editId: 'nonexistent-id' });
-      const msg = await client.receive();
-
-      expect(msg.type).toBe('edit_result');
-      expect(msg.success).toBe(false);
-      client.close();
-    });
-  });
-
-  // -------------------------------------------------------------------------
-  // 4. Vault tools (direct execution without LLM)
-  // -------------------------------------------------------------------------
-
-  describe('vault tools — direct execution', () => {
-    // These tests exercise the tool layer directly rather than going through
-    // the LLM (which would need a real API key). This still validates the full
-    // server-side pipeline: tool registry → tool execution → response.
-
-    it('vault_read returns file content from the sample vault', async () => {
-      const { createVaultTools } = await import('../../src/agent/tools/vault.js');
-      const tools = createVaultTools(vaultPath);
-      const readTool = tools.find(t => t.definition.name === 'vault_read')!;
-
-      const result = JSON.parse(await readTool.execute({
-        path: 'Projects/ProjectA-CellMigration/2026-03-24-western-blot.md',
-      }));
-
-      expect(result.frontmatter.experiment_type).toBe('western-blot');
-      expect(result.frontmatter.project).toBe('ProjectA-CellMigration');
-      expect(result.content).toContain('p53 protein levels');
-    });
-
-    it('vault_list returns indexed notes from the database', async () => {
-      const { createVaultTools } = await import('../../src/agent/tools/vault.js');
-      const tools = createVaultTools(vaultPath);
-      const listTool = tools.find(t => t.definition.name === 'vault_list')!;
-
-      const result = JSON.parse(await listTool.execute({ folder: 'Projects' }));
-
-      expect(Array.isArray(result)).toBe(true);
-      expect(result.length).toBeGreaterThan(0);
-      const paths = result.map((r: { path: string }) => r.path);
-      expect(paths.some((p: string) => p.includes('western-blot'))).toBe(true);
-    });
-
-    it('vault_read rejects path traversal', async () => {
-      const { createVaultTools } = await import('../../src/agent/tools/vault.js');
-      const tools = createVaultTools(vaultPath);
-      const readTool = tools.find(t => t.definition.name === 'vault_read')!;
-
-      const result = JSON.parse(await readTool.execute({ path: '../../../etc/passwd' }));
-      expect(result.error).toBeDefined();
-    });
-
-    it('vault_write returns a pending_edit for a new file', async () => {
-      const { createVaultTools } = await import('../../src/agent/tools/vault.js');
-      const tools = createVaultTools(vaultPath);
-      const writeTool = tools.find(t => t.definition.name === 'vault_write')!;
-
-      const result = JSON.parse(await writeTool.execute({
-        path: 'Projects/test-note.md',
-        content: '---\ndate: 2026-03-31\n---\n# Test\n',
-      }));
-
-      expect(result.type).toBe('pending_edit');
-      expect(result.operation).toBe('create');
-    });
-
-    it('vault_append returns a pending_edit with merged content', async () => {
-      const { createVaultTools } = await import('../../src/agent/tools/vault.js');
-      const tools = createVaultTools(vaultPath);
-      const appendTool = tools.find(t => t.definition.name === 'vault_append')!;
-
-      const result = JSON.parse(await appendTool.execute({
-        path: 'Projects/ProjectA-CellMigration/2026-03-24-western-blot.md',
-        content: '\n## Follow-up\nNeed to repeat with 72h timepoint.\n',
-      }));
-
-      expect(result.type).toBe('pending_edit');
-      expect(result.operation).toBe('append');
-      expect(result.newContent).toContain('Need to repeat with 72h timepoint');
-      expect(result.newContent).toContain('p53 protein levels'); // original content preserved
-    });
-  });
-
-  // -------------------------------------------------------------------------
-  // 5. Search tools
-  // -------------------------------------------------------------------------
-
-  describe('search tools — direct execution', () => {
-    it('vault_search finds notes matching a query', async () => {
-      const { createSearchTools } = await import('../../src/agent/tools/search.js');
-      const tools = createSearchTools();
-      const searchTool = tools.find(t => t.definition.name === 'vault_search')!;
-
-      const result = JSON.parse(await searchTool.execute({ query: 'western blot p53' }));
-
-      expect(result.results).toBeDefined();
-      expect(result.results.length).toBeGreaterThan(0);
-      const paths = result.results.map((r: { path: string }) => r.path);
-      expect(paths.some((p: string) => p.includes('western-blot'))).toBe(true);
-    });
-
-    it('vault_search returns empty results for nonsense query', async () => {
-      const { createSearchTools } = await import('../../src/agent/tools/search.js');
-      const tools = createSearchTools();
-      const searchTool = tools.find(t => t.definition.name === 'vault_search')!;
-
-      const result = JSON.parse(await searchTool.execute({ query: 'zzzyyyxxx_no_match_12345' }));
-
-      // Should return gracefully — either empty results or a message
-      expect(result.results).toBeDefined();
-      expect(result.results.length).toBe(0);
-    });
-  });
-
-  // -------------------------------------------------------------------------
-  // 6. Task tools
-  // -------------------------------------------------------------------------
-
-  describe('task tools — direct execution', () => {
-    it('task_list returns tasks from the diary', async () => {
-      const { createTaskTools } = await import('../../src/agent/tools/tasks.js');
-      const tools = createTaskTools(vaultPath);
-      const listTool = tools.find(t => t.definition.name === 'task_list')!;
-
-      const result = JSON.parse(await listTool.execute({ status: 'all' }));
-
-      expect(Array.isArray(result)).toBe(true);
-      expect(result.length).toBeGreaterThan(0);
-
-      // Should find the tasks from 2026-03-29.md
-      const taskTexts = result.map((t: { text: string }) => t.text);
-      expect(taskTexts.some((t: string) => t.includes('PCR'))).toBe(true);
-    });
-
-    it('task_list filters pending tasks only', async () => {
-      const { createTaskTools } = await import('../../src/agent/tools/tasks.js');
-      const tools = createTaskTools(vaultPath);
-      const listTool = tools.find(t => t.definition.name === 'task_list')!;
-
-      const result = JSON.parse(await listTool.execute({ status: 'pending' }));
-
-      // All returned tasks should be uncompleted
-      for (const task of result) {
-        expect(task.completed).toBe(false);
-      }
-    });
-
-    it('task_add returns a pending_edit to create/update a diary note', async () => {
-      const { createTaskTools } = await import('../../src/agent/tools/tasks.js');
-      const tools = createTaskTools(vaultPath);
-      const addTool = tools.find(t => t.definition.name === 'task_add')!;
-
-      const result = JSON.parse(await addTool.execute({
-        description: 'Run gel electrophoresis',
-        project: 'ProjectA',
-      }));
-
-      expect(result.type).toBe('pending_edit');
-      expect(result.newContent).toContain('Run gel electrophoresis');
-      expect(result.newContent).toContain('[ProjectA]');
-    });
-
-    it('task_complete returns a pending_edit that checks off a matching task', async () => {
-      const { createTaskTools } = await import('../../src/agent/tools/tasks.js');
-      const tools = createTaskTools(vaultPath);
-      const completeTool = tools.find(t => t.definition.name === 'task_complete')!;
-
-      const result = JSON.parse(await completeTool.execute({
-        task_description: 'PCR',
-      }));
-
-      expect(result.type).toBe('pending_edit');
-      expect(result.newContent).toContain('- [x] Run PCR for ProjectB samples');
-    });
-  });
-
-  // -------------------------------------------------------------------------
-  // 7. Safe editing flow (propose → confirm/cancel)
-  // -------------------------------------------------------------------------
-
-  describe('safe editing — propose and confirm', () => {
-    it('proposeEdit + confirmEdit("apply") writes the file to disk', async () => {
-      const { SafeWriter } = await import('../../src/editing/safe-writer.js');
-      const writer = new SafeWriter();
-
-      const targetPath = path.join(vaultPath, 'Projects', 'e2e-test-note.md');
-      const content = '---\ndate: 2026-03-31\n---\n# E2E Test Note\n';
-
-      const proposal = writer.proposeEdit(targetPath, content, 'e2e test', 'e2e-session');
-
-      expect(proposal.editId).toBeDefined();
-      expect(proposal.diff).toContain('E2E Test Note');
-      expect(proposal.hasConflict).toBe(false);
-
-      // Confirm the edit
-      const result = writer.confirmEdit(proposal.editId, 'apply');
-      expect(result.success).toBe(true);
-
-      // Verify the file was actually written
-      expect(fs.existsSync(targetPath)).toBe(true);
-      const written = fs.readFileSync(targetPath, 'utf-8');
-      expect(written).toBe(content);
-
-      // Cleanup
-      fs.unlinkSync(targetPath);
-    });
-
-    it('proposeEdit + confirmEdit("cancel") does NOT write the file', async () => {
-      const { SafeWriter } = await import('../../src/editing/safe-writer.js');
-      const writer = new SafeWriter();
-
-      const targetPath = path.join(vaultPath, 'Projects', 'e2e-cancelled-note.md');
-      const content = '# Should not exist\n';
-
-      const proposal = writer.proposeEdit(targetPath, content, 'e2e cancel test', 'e2e-session');
-
-      const result = writer.confirmEdit(proposal.editId, 'cancel');
-      expect(result.success).toBe(true);
-
-      // File should NOT exist
-      expect(fs.existsSync(targetPath)).toBe(false);
-    });
-  });
-
-  // -------------------------------------------------------------------------
-  // 8. Ingestion pipeline
-  // -------------------------------------------------------------------------
-
-  describe('ingestion pipeline', () => {
-    it('indexes all markdown files from the sample vault into the database', () => {
-      const db = getDatabase();
-
-      const notes = db.prepare('SELECT path, note_type, experiment_type, project FROM note_metadata').all() as Array<{
-        path: string; note_type: string; experiment_type: string; project: string;
-      }>;
-
-      expect(notes.length).toBeGreaterThanOrEqual(4); // at least the 4 .md files in fixture
-
-      // Verify the western blot experiment was indexed correctly
-      const wb = notes.find(n => n.path.includes('western-blot'));
-      expect(wb).toBeDefined();
-      expect(wb!.experiment_type).toBe('western-blot');
-      expect(wb!.project).toBe('ProjectA-CellMigration');
-    });
-
-    it('creates text chunks for indexed notes', () => {
-      const db = getDatabase();
-      const chunks = db.prepare('SELECT COUNT(*) as count FROM note_chunks').get() as { count: number };
-      expect(chunks.count).toBeGreaterThan(0);
-    });
-
-    it('populates the BM25 full-text index', () => {
-      const db = getDatabase();
-      const bm25 = db.prepare('SELECT COUNT(*) as count FROM bm25_index').get() as { count: number };
-      expect(bm25.count).toBeGreaterThan(0);
-    });
-  });
-});
diff --git a/tests/integration/indexer-experiment-types.test.ts b/tests/integration/indexer-experiment-types.test.ts
index 542a333..cd8de96 100644
--- a/tests/integration/indexer-experiment-types.test.ts
+++ b/tests/integration/indexer-experiment-types.test.ts
@@ -32,7 +32,6 @@ describe('indexer — experiment_types counts', () => {
       contentHash,
       mtime: Date.now(),
       chunks: [],
-      embeddings: [],
     }, db);
   }
 
diff --git a/tests/integration/indexer-kb.test.ts b/tests/integration/indexer-kb.test.ts
index 9ebb553..25ac788 100644
--- a/tests/integration/indexer-kb.test.ts
+++ b/tests/integration/indexer-kb.test.ts
@@ -16,7 +16,7 @@ describe('indexer — KB fields', () => {
         kbStatus: 'pending', knowledgeKind: 'concept', needsReview: false,
         reviewFlaggedAt: undefined, aliases: ['test alias'], rqSource: undefined, rqTarget: undefined,
       },
-      contentHash: 'abc', mtime: Date.now(), chunks: [], embeddings: [],
+      contentHash: 'abc', mtime: Date.now(), chunks: [],
     }, db);
     const row = db.prepare('SELECT kb_status, knowledge_kind, needs_review, review_flagged_at, aliases, rq_source, rq_target FROM note_metadata WHERE path = ?')
       .get('Knowledge/Concepts/test.md') as Record;
diff --git a/tests/integration/indexer-stale.test.ts b/tests/integration/indexer-stale.test.ts
index 05a8d5d..750ae20 100644
--- a/tests/integration/indexer-stale.test.ts
+++ b/tests/integration/indexer-stale.test.ts
@@ -15,7 +15,6 @@ function minNote(filePath: string) {
     contentHash: 'abc',
     mtime: Date.now(),
     chunks: [],
-    embeddings: [],
   };
 }
 
diff --git a/tests/unit/auth-validation.test.ts b/tests/unit/auth-validation.test.ts
deleted file mode 100644
index 087233a..0000000
--- a/tests/unit/auth-validation.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { validateAuthMessage, generateToken } from '../../src/server/auth.js';
-
-const tmpDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cricknote-auth-test-'));
-const originalDataDir = process.env.CRICKNOTE_DATA_DIR;
-
-beforeEach(() => {
-  process.env.CRICKNOTE_DATA_DIR = tmpDataDir;
-  fs.mkdirSync(tmpDataDir, { recursive: true });
-});
-
-afterEach(() => {
-  process.env.CRICKNOTE_DATA_DIR = originalDataDir;
-  // Clean token file between tests.
-  const tokenPath = path.join(tmpDataDir, 'auth-token');
-  if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath);
-});
-
-describe('validateAuthMessage — valid payload', () => {
-  it('returns auth_ok for a valid token and correct protocol version', () => {
-    const token = generateToken();
-    const result = validateAuthMessage(
-      { type: 'auth', token, protocolVersion: 1, pluginVersion: '0.1.0' },
-      '0.1.0',
-    );
-    expect(result.type).toBe('auth_ok');
-  });
-});
-
-describe('validateAuthMessage — field validation (no throws)', () => {
-  it('returns version_mismatch for wrong protocol version number', () => {
-    generateToken();
-    const result = validateAuthMessage(
-      { type: 'auth', token: 'x', protocolVersion: 99, pluginVersion: '0.1.0' },
-      '0.1.0',
-    );
-    expect(result.type).toBe('auth_error');
-    expect((result as { reason: string }).reason).toBe('version_mismatch');
-  });
-
-  it('returns version_mismatch instead of throwing when protocolVersion is missing', () => {
-    generateToken();
-    const malformed = { type: 'auth', token: 'x', pluginVersion: '0.1.0' } as never;
-    expect(() => validateAuthMessage(malformed, '0.1.0')).not.toThrow();
-    const result = validateAuthMessage(malformed, '0.1.0');
-    expect((result as { reason: string }).reason).toBe('version_mismatch');
-  });
-
-  it('returns invalid_token instead of throwing when token field is missing', () => {
-    generateToken();
-    const malformed = { type: 'auth', protocolVersion: 1, pluginVersion: '0.1.0' } as never;
-    expect(() => validateAuthMessage(malformed, '0.1.0')).not.toThrow();
-    const result = validateAuthMessage(malformed, '0.1.0');
-    expect(result.type).toBe('auth_error');
-    expect((result as { reason: string }).reason).toBe('invalid_token');
-  });
-
-  it('returns invalid_token instead of throwing when token is a non-string', () => {
-    generateToken();
-    const malformed = { type: 'auth', token: 12345, protocolVersion: 1, pluginVersion: '0.1.0' } as never;
-    expect(() => validateAuthMessage(malformed, '0.1.0')).not.toThrow();
-    const result = validateAuthMessage(malformed, '0.1.0');
-    expect(result.type).toBe('auth_error');
-    expect((result as { reason: string }).reason).toBe('invalid_token');
-  });
-
-  it('returns invalid_token for an empty string token', () => {
-    generateToken();
-    const result = validateAuthMessage(
-      { type: 'auth', token: '', protocolVersion: 1, pluginVersion: '0.1.0' },
-      '0.1.0',
-    );
-    expect(result.type).toBe('auth_error');
-    expect((result as { reason: string }).reason).toBe('invalid_token');
-  });
-});
diff --git a/tests/unit/context-assembler.test.ts b/tests/unit/context-assembler.test.ts
deleted file mode 100644
index 83d3748..0000000
--- a/tests/unit/context-assembler.test.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import Database from 'better-sqlite3';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { assembleNoteContext } from '../../src/retrieval/context-assembler.js';
-import { runMigrations } from '../../src/storage/migrations/001-initial.js';
-
-describe('assembleNoteContext path safety', () => {
-  let db: Database.Database;
-  let tmpDir: string;
-  let vaultPath: string;
-
-  beforeEach(() => {
-    db = new Database(':memory:');
-    runMigrations(db);
-    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cricknote-context-'));
-    vaultPath = path.join(tmpDir, 'vault');
-    fs.mkdirSync(path.join(vaultPath, 'Projects'), { recursive: true });
-    fs.writeFileSync(path.join(vaultPath, 'Projects', 'note.md'), '# Vault note\n', 'utf-8');
-
-    db.prepare(`
-      INSERT INTO note_metadata (
-        path, folder, note_type, date, project, content_hash, mtime, last_indexed
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-    `).run('Projects/note.md', 'Projects', 'experiment', '2026-05-06', 'P001', 'hash', 1000, 1000);
-  });
-
-  afterEach(() => {
-    db.close();
-    fs.rmSync(tmpDir, { recursive: true, force: true });
-  });
-
-  it('reads vault-relative note paths', () => {
-    const ctx = assembleNoteContext(db, 'Projects/note.md', { vaultPath });
-
-    expect(ctx?.notePath).toBe('Projects/note.md');
-    expect(ctx?.body).toContain('Vault note');
-  });
-
-  it('normalizes inside-vault absolute paths back to vault-relative paths', () => {
-    const ctx = assembleNoteContext(db, path.join(vaultPath, 'Projects', 'note.md'), { vaultPath });
-
-    expect(ctx?.notePath).toBe('Projects/note.md');
-    expect(ctx?.body).toContain('Vault note');
-  });
-
-  it('rejects absolute paths outside the vault', () => {
-    const outsidePath = path.join(tmpDir, 'outside.md');
-    fs.writeFileSync(outsidePath, '# Outside\n', 'utf-8');
-
-    expect(assembleNoteContext(db, outsidePath, { vaultPath })).toBeNull();
-  });
-});
diff --git a/tests/unit/install-agent-assets.test.ts b/tests/unit/install-agent-assets.test.ts
index b99b035..03e2669 100644
--- a/tests/unit/install-agent-assets.test.ts
+++ b/tests/unit/install-agent-assets.test.ts
@@ -13,8 +13,8 @@ describe('installAgentAssets', () => {
     fs.mkdirSync(path.join(repo, 'skills', 'cricknote-record-experiment'), { recursive: true });
     fs.writeFileSync(path.join(repo, 'skills', 'cricknote-record-experiment', 'SKILL.md'), '# skill');
     fs.mkdirSync(path.join(repo, 'templates', 'agent-docs'), { recursive: true });
-    fs.writeFileSync(path.join(repo, 'templates', 'agent-docs', 'CLAUDE.md'), '# claude');
-    fs.writeFileSync(path.join(repo, 'templates', 'agent-docs', 'AGENTS.md'), '# agents');
+    fs.writeFileSync(path.join(repo, 'templates', 'agent-docs', 'CLAUDE.md'), '# CrickNote Vault — Agent Guide\nclaude');
+    fs.writeFileSync(path.join(repo, 'templates', 'agent-docs', 'AGENTS.md'), '# CrickNote Vault — Agent Guide\nagents');
   });
   afterEach(() => {
     fs.rmSync(vault, { recursive: true, force: true });
@@ -22,14 +22,35 @@ describe('installAgentAssets', () => {
   });
 
   it('copies skills into both .claude and .agents skill dirs and writes the doc files', () => {
-    installAgentAssets(vault, repo);
+    const result = installAgentAssets(vault, repo);
     expect(fs.existsSync(path.join(vault, '.claude', 'skills', 'cricknote-record-experiment', 'SKILL.md'))).toBe(true);
     expect(fs.existsSync(path.join(vault, '.agents', 'skills', 'cricknote-record-experiment', 'SKILL.md'))).toBe(true);
-    expect(fs.readFileSync(path.join(vault, 'CLAUDE.md'), 'utf-8')).toBe('# claude');
-    expect(fs.readFileSync(path.join(vault, 'AGENTS.md'), 'utf-8')).toBe('# agents');
+    expect(fs.readFileSync(path.join(vault, 'CLAUDE.md'), 'utf-8')).toContain('claude');
+    expect(fs.readFileSync(path.join(vault, 'AGENTS.md'), 'utf-8')).toContain('agents');
+    expect(result.guidesWritten).toEqual(['CLAUDE.md', 'AGENTS.md']);
+    expect(result.sidecarsWritten).toEqual([]);
+  });
+
+  it('refreshes a CrickNote-managed guide in place', () => {
+    installAgentAssets(vault, repo);
+    fs.writeFileSync(path.join(repo, 'templates', 'agent-docs', 'CLAUDE.md'), '# CrickNote Vault — Agent Guide\nclaude v2');
+    const result = installAgentAssets(vault, repo);
+    expect(fs.readFileSync(path.join(vault, 'CLAUDE.md'), 'utf-8')).toContain('claude v2');
+    expect(result.guidesRefreshed).toContain('CLAUDE.md');
+    expect(fs.existsSync(path.join(vault, 'CrickNote-CLAUDE.md'))).toBe(false);
+  });
+
+  it("never clobbers a user's own CLAUDE.md — writes a sidecar instead", () => {
+    fs.writeFileSync(path.join(vault, 'CLAUDE.md'), '# My own project guide\nkeep me');
+    const result = installAgentAssets(vault, repo);
+    // User's file is untouched.
+    expect(fs.readFileSync(path.join(vault, 'CLAUDE.md'), 'utf-8')).toBe('# My own project guide\nkeep me');
+    // CrickNote guidance lands in the sidecar.
+    expect(result.sidecarsWritten).toContain('CrickNote-CLAUDE.md');
+    expect(fs.readFileSync(path.join(vault, 'CrickNote-CLAUDE.md'), 'utf-8')).toContain('claude');
   });
 
-  it('is idempotent — re-running refreshes without error', () => {
+  it('is idempotent — re-running refreshes skills without error', () => {
     installAgentAssets(vault, repo);
     fs.writeFileSync(path.join(repo, 'skills', 'cricknote-record-experiment', 'SKILL.md'), '# skill v2');
     installAgentAssets(vault, repo);
diff --git a/tests/unit/rate-limiter.test.ts b/tests/unit/rate-limiter.test.ts
deleted file mode 100644
index d04ee36..0000000
--- a/tests/unit/rate-limiter.test.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-import { RateLimiter } from '../../src/server/rate-limiter.js';
-
-describe('RateLimiter', () => {
-  it('allows messages under the limit', () => {
-    const limiter = new RateLimiter({ maxMessages: 5, windowMs: 1000 });
-
-    for (let i = 0; i < 5; i++) {
-      expect(limiter.allow('client-1')).toBe(true);
-    }
-  });
-
-  it('rejects messages over the limit', () => {
-    const limiter = new RateLimiter({ maxMessages: 3, windowMs: 1000 });
-
-    expect(limiter.allow('client-1')).toBe(true);
-    expect(limiter.allow('client-1')).toBe(true);
-    expect(limiter.allow('client-1')).toBe(true);
-    expect(limiter.allow('client-1')).toBe(false); // 4th message rejected
-    expect(limiter.allow('client-1')).toBe(false); // still rejected
-  });
-
-  it('tracks clients independently', () => {
-    const limiter = new RateLimiter({ maxMessages: 2, windowMs: 1000 });
-
-    expect(limiter.allow('client-1')).toBe(true);
-    expect(limiter.allow('client-1')).toBe(true);
-    expect(limiter.allow('client-1')).toBe(false); // client-1 exhausted
-
-    // client-2 should still be allowed
-    expect(limiter.allow('client-2')).toBe(true);
-    expect(limiter.allow('client-2')).toBe(true);
-  });
-
-  it('allows messages again after the window expires', () => {
-    vi.useFakeTimers();
-    try {
-      const limiter = new RateLimiter({ maxMessages: 2, windowMs: 1000 });
-
-      expect(limiter.allow('c')).toBe(true);
-      expect(limiter.allow('c')).toBe(true);
-      expect(limiter.allow('c')).toBe(false);
-
-      // Advance time past the window
-      vi.advanceTimersByTime(1001);
-
-      // Should be allowed again
-      expect(limiter.allow('c')).toBe(true);
-    } finally {
-      vi.useRealTimers();
-    }
-  });
-
-  it('remove cleans up client tracking', () => {
-    const limiter = new RateLimiter({ maxMessages: 2, windowMs: 1000 });
-
-    expect(limiter.allow('c')).toBe(true);
-    expect(limiter.allow('c')).toBe(true);
-    expect(limiter.allow('c')).toBe(false);
-
-    // Remove and re-add — should reset
-    limiter.remove('c');
-    expect(limiter.allow('c')).toBe(true);
-  });
-
-  it('uses default values when no options provided', () => {
-    const limiter = new RateLimiter();
-
-    // Default is 30 messages per 60s — should allow 30
-    for (let i = 0; i < 30; i++) {
-      expect(limiter.allow('c')).toBe(true);
-    }
-    expect(limiter.allow('c')).toBe(false);
-  });
-});
diff --git a/tests/unit/search-no-embed.test.ts b/tests/unit/search-no-embed.test.ts
index affc0fc..737be73 100644
--- a/tests/unit/search-no-embed.test.ts
+++ b/tests/unit/search-no-embed.test.ts
@@ -1,13 +1,12 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
 import Database from 'better-sqlite3';
 import fs from 'node:fs';
 import os from 'node:os';
 import path from 'node:path';
 import { runMigrations } from '../../src/storage/migrations/001-initial.js';
-import * as embedder from '../../src/ingestion/embedder.js';
 import { createSearchTools } from '../../src/agent/tools/search.js';
 
-describe('vault_search does not load the embedding model', () => {
+describe('vault_search runs on BM25 + metadata only (no embedding model)', () => {
   let db: Database.Database;
   let vault: string;
   beforeEach(() => {
@@ -17,24 +16,19 @@ describe('vault_search does not load the embedding model', () => {
     // Register experiment type so parseQuery can extract it from the query.
     db.prepare('INSERT INTO experiment_types (name, aliases) VALUES (?, ?)')
       .run('western-blot', JSON.stringify(['western blot', 'wb']));
-    // 6+ candidates would have triggered semantic ranking in the old code.
     for (let i = 1; i <= 8; i++) {
       db.prepare('INSERT INTO note_metadata (path, folder, note_type, experiment_type, content_hash, mtime, last_indexed) VALUES (?, ?, ?, ?, ?, ?, ?)')
         .run(`Projects/wb${i}.md`, 'Projects', 'experiment', 'western-blot', `h${i}`, 1, 1);
-      // Add a chunk so the old semantic-rank code's inner guard (chunks.length > 0) was satisfied.
       db.prepare('INSERT INTO note_chunks (path, chunk_index, start_offset, end_offset, content) VALUES (?, ?, ?, ?, ?)')
         .run(`Projects/wb${i}.md`, 0, 0, 20, `western blot data ${i}`);
     }
   });
   afterEach(() => { db.close(); fs.rmSync(vault, { recursive: true, force: true }); });
 
-  it('never calls embedText', async () => {
-    const spy = vi.spyOn(embedder, 'embedText');
+  it('returns matches without any embedding step', async () => {
     const tools = createSearchTools(db);
     const tool = tools.find(t => t.definition.name === 'vault_search')!;
     const res = JSON.parse(await tool.execute({ query: 'western blot' }));
     expect(res.results.length).toBeGreaterThan(0);
-    expect(spy).not.toHaveBeenCalled();
-    spy.mockRestore();
   });
 });
diff --git a/tests/unit/task-tools.test.ts b/tests/unit/task-tools.test.ts
index 639b78d..3a2fc50 100644
--- a/tests/unit/task-tools.test.ts
+++ b/tests/unit/task-tools.test.ts
@@ -51,4 +51,70 @@ describe('task tools', () => {
     expect(result.newContent).toContain('- [x] Run PCR for ProjectB samples');
     expect(detector.getSnapshot(diaryPath)?.content).toContain('Run PCR for ProjectB samples');
   });
+
+  function writeDiary(date: string, body: string): void {
+    fs.writeFileSync(path.join(vaultPath, 'Memory', 'Daily', `${date}.md`), body, 'utf-8');
+  }
+
+  function isoOffset(days: number): string {
+    const d = new Date();
+    d.setDate(d.getDate() + days);
+    return localDateString(d);
+  }
+
+  it('task_agenda buckets pending tasks by deadline and ignores completed/beyond-horizon ones', async () => {
+    const today = localDateString();
+    writeDiary('2026-01-01', [
+      '## Tasks',
+      `- [ ] Overdue thing (due: ${isoOffset(-3)})`,
+      `- [ ] Due today thing (due: ${today})`,
+      `- [ ] Soon thing (due: ${isoOffset(3)})`,
+      `- [ ] Far future thing (due: ${isoOffset(30)})`,
+      '- [ ] Someday thing',
+      `- [x] Done thing (due: ${isoOffset(-1)})`,
+      '',
+    ].join('\n'));
+
+    const agendaTool = createTaskTools(vaultPath, detector).find(t => t.definition.name === 'task_agenda');
+    const res = JSON.parse(await agendaTool!.execute({}));
+
+    expect(res.date).toBe(today);
+    expect(res.overdue.map((i: { text: string }) => i.text)).toEqual([expect.stringContaining('Overdue thing')]);
+    expect(res.today.map((i: { text: string }) => i.text)).toEqual([expect.stringContaining('Due today thing')]);
+    expect(res.soon.map((i: { text: string }) => i.text)).toEqual([expect.stringContaining('Soon thing')]);
+    expect(res.no_deadline.map((i: { text: string }) => i.text)).toEqual([expect.stringContaining('Someday thing')]);
+    // Far-future (beyond 7d) and completed tasks appear in no bucket.
+    const allTexts = [...res.overdue, ...res.today, ...res.soon, ...res.no_deadline].map((i: { text: string }) => i.text).join('|');
+    expect(allTexts).not.toContain('Far future thing');
+    expect(allTexts).not.toContain('Done thing');
+  });
+
+  it('task_agenda write=true returns a pending edit for Memory/Agenda.md', async () => {
+    const today = localDateString();
+    const realVaultPath = fs.realpathSync(vaultPath);
+    writeDiary('2026-01-01', `## Tasks\n- [ ] Ship the report (due: ${today})\n`);
+
+    const agendaTool = createTaskTools(vaultPath, detector).find(t => t.definition.name === 'task_agenda');
+    const res = JSON.parse(await agendaTool!.execute({ write: true }));
+
+    expect(res.type).toBe('pending_edit');
+    expect(res.path).toBe(path.join(realVaultPath, 'Memory', 'Agenda.md'));
+    expect(res.operation).toBe('create');
+    expect(res.newContent).toContain("# Today's Agenda");
+    expect(res.newContent).toContain('## Due today');
+    expect(res.newContent).toContain('Ship the report');
+    // Agenda bullets must never be task checkboxes (would pollute task_list).
+    expect(res.newContent).not.toContain('- [ ]');
+  });
+
+  it('task_agenda honors a custom horizon_days window', async () => {
+    writeDiary('2026-01-01', `## Tasks\n- [ ] Two weeks out (due: ${isoOffset(10)})\n`);
+    const agendaTool = createTaskTools(vaultPath, detector).find(t => t.definition.name === 'task_agenda');
+
+    const narrow = JSON.parse(await agendaTool!.execute({ horizon_days: 7 }));
+    expect(narrow.soon).toHaveLength(0);
+
+    const wide = JSON.parse(await agendaTool!.execute({ horizon_days: 14 }));
+    expect(wide.soon.map((i: { text: string }) => i.text)).toEqual([expect.stringContaining('Two weeks out')]);
+  });
 });
diff --git a/tests/unit/watcher.test.ts b/tests/unit/watcher.test.ts
deleted file mode 100644
index 9e127a9..0000000
--- a/tests/unit/watcher.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { VaultWatcher } from '../../src/ingestion/watcher.js';
-
-describe('VaultWatcher.getAllMarkdownFiles', () => {
-  let vaultPath: string;
-
-  beforeEach(() => {
-    vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'watcher-test-'));
-    fs.mkdirSync(path.join(vaultPath, 'Reading', 'Papers'), { recursive: true });
-    fs.mkdirSync(path.join(vaultPath, 'Reading', 'attachments', 'smith-2026'), { recursive: true });
-    fs.mkdirSync(path.join(vaultPath, 'Projects', 'P001-CM', 'attachments', 'CM001'), { recursive: true });
-
-    fs.writeFileSync(path.join(vaultPath, 'Reading', 'Papers', 'smith-2026.md'), '# reading note');
-    fs.writeFileSync(path.join(vaultPath, 'Reading', 'Papers', 'smith-2026-mapping.md'), '# mapping artifact');
-    fs.writeFileSync(path.join(vaultPath, 'Reading', 'attachments', 'smith-2026', 'notes.md'), '# source notes');
-    fs.writeFileSync(path.join(vaultPath, 'Projects', 'P001-CM', 'attachments', 'CM001', 'notes.md'), '# attachment notes');
-  });
-
-  afterEach(() => {
-    fs.rmSync(vaultPath, { recursive: true, force: true });
-  });
-
-  it('ignores markdown files inside attachments directories', async () => {
-    const files = await VaultWatcher.getAllMarkdownFiles(vaultPath);
-
-    expect(files).toContain('Reading/Papers/smith-2026.md');
-    expect(files).not.toContain('Reading/Papers/smith-2026-mapping.md');
-    expect(files).not.toContain('Reading/attachments/smith-2026/notes.md');
-    expect(files).not.toContain('Projects/P001-CM/attachments/CM001/notes.md');
-  });
-
-  it('excludes _changelog.md files from the full markdown scan', async () => {
-    fs.writeFileSync(
-      path.join(vaultPath, 'Reading', 'Papers', '_changelog.md'),
-      '2026-05-03T12:00:00Z | op | desc\n'
-    );
-    fs.writeFileSync(
-      path.join(vaultPath, 'Projects', 'P001-CM', '_changelog.md'),
-      '2026-05-03T12:00:00Z | op | desc\n'
-    );
-
-    const files = await VaultWatcher.getAllMarkdownFiles(vaultPath);
-
-    expect(files).not.toContain('Reading/Papers/_changelog.md');
-    expect(files).not.toContain('Projects/P001-CM/_changelog.md');
-    expect(files).toContain('Reading/Papers/smith-2026.md');
-  });
-});
diff --git a/tests/unit/websocket-client.test.ts b/tests/unit/websocket-client.test.ts
deleted file mode 100644
index 622ed48..0000000
--- a/tests/unit/websocket-client.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { CrickNoteWebSocket } from '../../obsidian-plugin/websocket-client';
-
-class FakeWebSocket {
-  static instances: FakeWebSocket[] = [];
-
-  onopen: (() => void) | null = null;
-  onmessage: ((event: { data: string }) => void) | null = null;
-  onclose: (() => void) | null = null;
-  onerror: (() => void) | null = null;
-  send = vi.fn();
-
-  constructor(public readonly url: string) {
-    FakeWebSocket.instances.push(this);
-  }
-
-  close(): void {
-    this.onclose?.();
-  }
-}
-
-describe('CrickNoteWebSocket reconnect behavior', () => {
-  const OriginalWebSocket = globalThis.WebSocket;
-  let tmpDir: string;
-  let tokenPath: string;
-
-  beforeEach(() => {
-    vi.useFakeTimers();
-    FakeWebSocket.instances = [];
-    vi.stubGlobal('WebSocket', FakeWebSocket as unknown as typeof WebSocket);
-    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cricknote-ws-client-'));
-    tokenPath = path.join(tmpDir, 'auth-token');
-    fs.writeFileSync(tokenPath, 'test-token');
-  });
-
-  afterEach(() => {
-    vi.useRealTimers();
-    vi.unstubAllGlobals();
-    fs.rmSync(tmpDir, { recursive: true, force: true });
-    if (OriginalWebSocket) {
-      globalThis.WebSocket = OriginalWebSocket;
-    }
-  });
-
-  it('does not reconnect after an intentional disconnect', async () => {
-    const client = new CrickNoteWebSocket({} as never);
-    await client.connect();
-
-    expect(FakeWebSocket.instances).toHaveLength(1);
-
-    client.disconnect();
-    vi.advanceTimersByTime(5000);
-
-    expect(FakeWebSocket.instances).toHaveLength(1);
-  });
-
-  it('still reconnects after an unintentional close', async () => {
-    const client = new CrickNoteWebSocket({} as never);
-    await client.connect();
-
-    expect(FakeWebSocket.instances).toHaveLength(1);
-
-    FakeWebSocket.instances[0].onclose?.();
-    vi.advanceTimersByTime(5000);
-
-    expect(FakeWebSocket.instances).toHaveLength(2);
-    client.disconnect();
-  });
-
-  it('sends the persisted session id during authentication', async () => {
-    const client = new CrickNoteWebSocket({} as never, {
-      tokenPath,
-      sessionId: 'obsidian-session-1',
-    });
-    await client.connect();
-
-    FakeWebSocket.instances[0].onopen?.();
-
-    expect(FakeWebSocket.instances[0].send).toHaveBeenCalledWith(JSON.stringify({
-      type: 'auth',
-      token: 'test-token',
-      protocolVersion: 1,
-      pluginVersion: '0.1.0',
-      sessionId: 'obsidian-session-1',
-    }));
-  });
-});
diff --git a/tests/unit/websocket-mapper.test.ts b/tests/unit/websocket-mapper.test.ts
deleted file mode 100644
index 21b163b..0000000
--- a/tests/unit/websocket-mapper.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import path from 'node:path';
-import { mapPendingEditForPlugin, normalizeClientSessionId } from '../../src/server/websocket.js';
-
-const vaultPath = '/vault';
-
-function makePe(overrides: Partial<{ editId: string; batchId: string | undefined; filePath: string; diff: string; hasConflict: boolean; warnings: string[] }> = {}) {
-  const filePath = overrides.filePath ?? path.join(vaultPath, 'Projects/P001-CM/CM001.md');
-  return {
-    editId: overrides.editId ?? 'edit-1',
-    batchId: overrides.batchId,
-    proposal: {
-      filePath,
-      diff: overrides.diff ?? '--- a\n+++ b\n',
-      hasConflict: overrides.hasConflict ?? false,
-      newContent: '# content',
-    },
-    warnings: overrides.warnings ?? [],
-  };
-}
-
-describe('mapPendingEditForPlugin', () => {
-  it('forwards batchId when present', () => {
-    const pe = makePe({ batchId: 'batch-abc' });
-    const result = mapPendingEditForPlugin(pe, vaultPath);
-    expect(result.batchId).toBe('batch-abc');
-  });
-
-  it('forwards batchId as undefined when absent', () => {
-    const pe = makePe({ batchId: undefined });
-    const result = mapPendingEditForPlugin(pe, vaultPath);
-    expect(result.batchId).toBeUndefined();
-  });
-
-  it('normalises filePath to vault-relative path', () => {
-    const pe = makePe({ filePath: path.join(vaultPath, 'Reading/Papers/smith-2026.md') });
-    const result = mapPendingEditForPlugin(pe, vaultPath);
-    expect(result.path).toBe('Reading/Papers/smith-2026.md');
-  });
-
-  it('preserves editId, diff, hasConflict, and warnings', () => {
-    const pe = makePe({ editId: 'e-42', diff: '--- x\n', hasConflict: true, warnings: ['w1'] });
-    const result = mapPendingEditForPlugin(pe, vaultPath);
-    expect(result.editId).toBe('e-42');
-    expect(result.diff).toBe('--- x\n');
-    expect(result.hasConflict).toBe(true);
-    expect(result.warnings).toEqual(['w1']);
-  });
-
-  it('does not include newContent in the output', () => {
-    const pe = makePe();
-    const result = mapPendingEditForPlugin(pe, vaultPath);
-    expect(Object.keys(result)).not.toContain('newContent');
-  });
-});
-
-describe('normalizeClientSessionId', () => {
-  it('accepts normal plugin session ids', () => {
-    expect(normalizeClientSessionId('obsidian-123e4567-e89b-12d3-a456-426614174000')).toBe('obsidian-123e4567-e89b-12d3-a456-426614174000');
-  });
-
-  it('rejects unsafe or malformed values', () => {
-    expect(normalizeClientSessionId('../bad-session')).toBeNull();
-    expect(normalizeClientSessionId('short')).toBeNull();
-    expect(normalizeClientSessionId(42)).toBeNull();
-  });
-});
diff --git a/tests/unit/worker.test.ts b/tests/unit/worker.test.ts
deleted file mode 100644
index 50d9f92..0000000
--- a/tests/unit/worker.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { shouldIgnoreIngestionPath, IngestionWorker } from '../../src/ingestion/worker.js';
-import * as embedderModule from '../../src/ingestion/embedder.js';
-import * as watcherModule from '../../src/ingestion/watcher.js';
-import * as indexerModule from '../../src/ingestion/indexer.js';
-
-describe('shouldIgnoreIngestionPath', () => {
-  it('ignores markdown files under Reading attachments', () => {
-    expect(shouldIgnoreIngestionPath('Reading/attachments/smith-2026/notes.md')).toBe(true);
-  });
-
-  it('ignores markdown files under project attachments', () => {
-    expect(shouldIgnoreIngestionPath('Projects/P001-CM/attachments/CM001/notes.md')).toBe(true);
-  });
-
-  it('ignores mapping artifacts stored alongside reading notes', () => {
-    expect(shouldIgnoreIngestionPath('Reading/Papers/smith-2026-mapping.md')).toBe(true);
-    expect(shouldIgnoreIngestionPath('Projects/P001-CM/CM001-western-blot-mapping-20260412T104938.md')).toBe(true);
-  });
-
-  it('ignores KB housekeeping artifacts', () => {
-    expect(shouldIgnoreIngestionPath('Knowledge/_Ops/Lint-Reports/2026-04-12.md')).toBe(true);
-    expect(shouldIgnoreIngestionPath('Knowledge/Concepts/_index.md')).toBe(true);
-  });
-
-  it('does not ignore real vault notes', () => {
-    expect(shouldIgnoreIngestionPath('Reading/Papers/smith-2026.md')).toBe(false);
-    expect(shouldIgnoreIngestionPath('Knowledge/Concepts/il-42.md')).toBe(false);
-  });
-
-  it('ignores _changelog.md files in any content folder', () => {
-    expect(shouldIgnoreIngestionPath('Projects/P001-CM/_changelog.md')).toBe(true);
-    expect(shouldIgnoreIngestionPath('Reading/Papers/_changelog.md')).toBe(true);
-    expect(shouldIgnoreIngestionPath('Knowledge/Concepts/_changelog.md')).toBe(true);
-  });
-});
-
-describe('IngestionWorker.fullIndex error state', () => {
-  it('writes state=error when getAllMarkdownFiles throws', async () => {
-    vi.spyOn(embedderModule, 'preloadModel').mockResolvedValue(undefined);
-    vi.spyOn(watcherModule.VaultWatcher, 'getAllMarkdownFiles').mockRejectedValue(new Error('disk failure'));
-    const updateStatus = vi.spyOn(indexerModule, 'updateIndexingStatus').mockReturnValue(undefined);
-
-    vi.spyOn(indexerModule, 'getIndexingStatus').mockReturnValue({ state: 'idle', totalFiles: 0, indexedFiles: 0, lastError: null });
-
-    const worker = new IngestionWorker('/tmp/test-vault', { watchForChanges: false });
-
-    await expect(worker.start()).rejects.toThrow('disk failure');
-    expect(updateStatus).toHaveBeenCalledWith('error', 0, 0, 'disk failure');
-
-    vi.restoreAllMocks();
-  });
-});
-
-describe('IngestionWorker.processFile path safety', () => {
-  it('skips symlinked markdown files before reading file contents', async () => {
-    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cricknote-worker-symlink-'));
-    const vaultPath = path.join(tmpDir, 'vault');
-    const outsidePath = path.join(tmpDir, 'outside.md');
-    const linkedPath = path.join(vaultPath, 'linked.md');
-    fs.mkdirSync(vaultPath);
-    fs.writeFileSync(outsidePath, '# Outside vault\n', 'utf-8');
-    fs.symlinkSync(outsidePath, linkedPath);
-
-    const readSpy = vi.spyOn(fs, 'readFileSync');
-    const worker = new IngestionWorker(vaultPath, { watchForChanges: false });
-
-    try {
-      await (worker as unknown as { processFile(relativePath: string): Promise })
-        .processFile('linked.md');
-
-      expect(readSpy).not.toHaveBeenCalled();
-    } finally {
-      vi.restoreAllMocks();
-      fs.rmSync(tmpDir, { recursive: true, force: true });
-    }
-  });
-});
-
-describe('IngestionWorker startup recovery', () => {
-  it('completes start() and runs full index when indexing_status.state is indexing on startup', async () => {
-    vi.spyOn(embedderModule, 'preloadModel').mockResolvedValue(undefined);
-    vi.spyOn(watcherModule.VaultWatcher, 'getAllMarkdownFiles').mockResolvedValue([]);
-    vi.spyOn(indexerModule, 'updateIndexingStatus').mockReturnValue(undefined);
-    vi.spyOn(indexerModule, 'markFullIndexComplete').mockReturnValue(undefined);
-    vi.spyOn(indexerModule, 'deleteStaleNotes').mockReturnValue(undefined);
-    vi.spyOn(indexerModule, 'getIndexingStatus').mockReturnValue({
-      state: 'indexing', totalFiles: 20, indexedFiles: 13, lastError: null,
-    });
-
-    const worker = new IngestionWorker('/tmp/test-vault', { watchForChanges: false });
-    await worker.start();
-
-    // Verify start() completed and triggered a full index (updateIndexingStatus called with 'indexing')
-    expect(indexerModule.updateIndexingStatus).toHaveBeenCalledWith('indexing', 0, 0);
-
-    vi.restoreAllMocks();
-  });
-});
diff --git a/tsconfig.json b/tsconfig.json
index d687c76..87fcd6d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,5 +15,5 @@
     "sourceMap": true
   },
   "include": ["src/**/*"],
-  "exclude": ["node_modules", "dist", "tests", "obsidian-plugin"]
+  "exclude": ["node_modules", "dist", "tests"]
 }