diff --git a/AGENTS.md b/AGENTS.md index a691448..58caa19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,12 +31,14 @@ Dendrite receives raw captures (Telegram, webhook, CLI), classifies them with an ```bash npm run build && npm test # 31 checks — run before/after changes -dendrite doctor [--stats] +dendrite doctor [--stats] [--json] # + embedding coverage, queue health, dangling links dendrite ingest "text" [--dry-run] +dendrite ask "question" # RAG answer over the vault, with [[wikilink]] citations dendrite sort [--dry-run] # inbox + unfiled imports → brain/ dendrite repair [--dry-run] # split junk-drawer notes dendrite migrate [--dry-run] # upgrade frontmatter schema dendrite embed [--force] # build semantic vectors (hybrid search) +dendrite eval # classifier accuracy on golden dataset (dry-run) dendrite remove --last dendrite reindex dendrite mcp @@ -45,7 +47,7 @@ dendrite serve ### Telegram (`dendrite serve`) -`/start` `/help` `/inbox` `/recent` `/compartments` `/sort` `/undo` +`/start` `/help` `/inbox` `/recent` `/compartments` `/ask` `/sort` `/undo` - **`/sort`** — dry-run preview with ✅ Confirm / ❌ Cancel buttons (10 min expiry). @@ -55,6 +57,7 @@ dendrite serve |------|----------| | `describe_schema` | **Call first** — compartments + frontmatter contract | | `search_vault` | Keyword + hybrid semantic search (if embeddings enabled) | +| `answer_question` | RAG answer from the vault with `[[wikilink]]` citations | | `read_note` | Read note by vault-relative path | | `vault_catalog` | Full index snapshot | | `list_compartments` | Compartment list + counts | @@ -101,6 +104,7 @@ Archives: `brain/_dendrite/imported/` (sort), `brain/_dendrite/repaired/` (repai 3. **`create_new` honored** on splits — no FTS junk-drawer appends. 4. **Near-dup guard:** title keywords must appear in new text to append. 5. **Hybrid search:** when `index.embeddings.enabled` + vectors exist, crosslink and MCP search blend FTS + cosine similarity (`hybrid_weight`). +6. **Per-compartment templates:** optional `templates/.md` files customize frontmatter + body of newly created notes (dynamic core frontmatter still wins). ## Config knobs @@ -136,7 +140,7 @@ Covers: classification, laundry-list, multi-split, idempotency, sort/migrate/rep ## Not yet built (v0.3+) -Per-compartment templates, email input, MCP `capture_note`, merge-back correction. See [ROADMAP.md](ROADMAP.md). +Email input, MCP `capture_note`, merge-back correction. See [ROADMAP.md](ROADMAP.md) and [SPEC.md](SPEC.md). ## Cursor Cloud specific instructions diff --git a/CHANGELOG.md b/CHANGELOG.md index 1efcc1b..3a3d44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.3.0 — Unreleased + +### Features + +- RAG question-answering: `dendrite ask "..."` answers from vault notes only via hybrid FTS + embeddings retrieval, cites sources as `[[wikilinks]]`, is read-only, and refuses (without calling the LLM) when nothing relevant is found. Also `/ask` (Telegram) and `answer_question` (MCP). Flags: `--compartment`, `-k`, `--json`. +- Per-compartment templates: optional `templates/.md` customize frontmatter + body of newly created notes with `{{variable}}` placeholders; existing notes are never rewritten. Config: `templates.enabled`, `templates.dir`. +- `dendrite doctor` upgrades: reports embedding coverage, ingest-queue health (pending/processing/dead), and dangling `[[wikilink]]` count; new `--json` flag exits non-zero on critical issues. +- Classification eval harness: `dendrite eval` runs a golden `eval/dataset.jsonl` through the classifier in dry-run and reports routing accuracy + per-compartment breakdown. Flags: `--limit`, `--min`, `--json`, `--dataset`. + ## 0.1.0 — 2026-07-07 First public beta. diff --git a/DOCS.md b/DOCS.md index 0a7afdf..c92fb52 100644 --- a/DOCS.md +++ b/DOCS.md @@ -100,6 +100,39 @@ dendrite reindex --- +## Asking your vault (RAG) + +`dendrite ask` answers a natural-language question using **only** the notes already +in your vault. It retrieves relevant notes with the existing hybrid FTS + embeddings +search, feeds them to the LLM as context, and cites the notes it used inline as +`[[wikilinks]]`. + +```bash +dendrite ask "what did I learn about agent orchestration?" +``` + +It is **read-only** — it never writes to the vault. When nothing relevant is +retrieved, it **refuses without calling the LLM** and replies that there is no +matching note. + +### Flags + +| Flag | Description | +|------|-------------| +| `-c, --config ` | Config file path | +| `--compartment ` | Restrict retrieval to one compartment | +| `-k ` | Number of notes to retrieve (default from config) | +| `--json` | Machine-readable output: `{ question, answer, sources[], usedNotes, refused }` | + +Like classification, `ask` reuses the LLM provider config and needs a reachable LLM. + +### Telegram and MCP + +- **Telegram:** `/ask ` +- **MCP:** `answer_question({ question, compartment?, k? })` + +--- + ## Configuration Main file: `dendrite.config.yaml` (copy from `dendrite.config.example.yaml`). @@ -184,6 +217,28 @@ Then build vectors: dendrite embed ``` +### Retrieval (RAG) + +Controls how `dendrite ask` retrieves context from the vault: + +```yaml +retrieval: + k: 8 # notes retrieved for `dendrite ask` + max_context_chars: 6000 + min_score: 0 +``` + +### Templates + +```yaml +templates: + enabled: true + dir: templates +``` + +No templates ship by default, so behavior is unchanged until you add a +`templates/.md` file. See [Per-compartment templates](#per-compartment-templates). + --- ## Brain compartments @@ -311,10 +366,12 @@ curl http://localhost:8787/health | Command | Description | |---------|-------------| | `dendrite init` | Interactive setup wizard | -| `dendrite doctor [--stats]` | Health check + metrics | +| `dendrite doctor [--stats] [--json]` | Health check + metrics (embedding coverage, queue health, dangling links) | | `dendrite ingest "text"` | Classify and write one capture | | `dendrite ingest --dry-run "text"` | Preview without writing | | `dendrite ingest --file audio.ogg` | Transcribe + ingest audio | +| `dendrite ask "question"` | RAG answer from your vault, with citations | +| `dendrite eval` | Classification accuracy on a golden dataset | | `dendrite serve` | Run daemon | | `dendrite mcp` | MCP read-server (stdio) | | `dendrite reindex` | Rebuild search index from vault | @@ -383,6 +440,64 @@ TIL agent orchestration uses a DAG not a chain. Related: [[related-note]]. --- +## Per-compartment templates + +Drop a `templates/.md` file to customize the layout and frontmatter of +**newly created** notes in that compartment (e.g. `templates/reads.md`, +`templates/tasks.md`). Existing notes are never rewritten — templates only affect the +first write that creates a note. + +A template may contain optional YAML frontmatter (extra static fields) plus a Markdown +body with `{{variable}}` placeholders. + +### Available variables + +| Variable | Value | +|----------|-------| +| `{{title}}` | Note title | +| `{{summary}}` | One-line summary | +| `{{date}}` | Capture date | +| `{{source}}` | Capture source (e.g. `telegram-text`) | +| `{{compartment}}` | Target compartment | +| `{{entities}}` | Extracted entities (comma-joined) | +| `{{tags}}` | Tags (comma-joined) | +| `{{links}}` | Cross-links (comma-joined) | +| `{{capture}}` | The timestamped capture section | + +If a template omits `{{capture}}`, the capture section is appended after the template +body. Dynamic core frontmatter (`compartment`, `title`, `created`, `updated`, `source`, +`confidence`, `entities`, `tags`, `links`, `dendrite_version`, `summary`) always wins +over template static fields. + +### Example — `templates/reads.md` + +```markdown +--- +rating: +status: to-read +--- + +# {{title}} + +> {{summary}} + +**Source:** {{source}} + +{{capture}} +``` + +Enable/locate templates in config: + +```yaml +templates: + enabled: true + dir: templates +``` + +No templates ship by default, so behavior is unchanged until you add a template file. + +--- + ## Vault maintenance ### Sort unfiled notes @@ -454,6 +569,7 @@ Register in Cursor / Claude Code / Hermes: |------|---------| | `describe_schema` | Compartments + frontmatter contract | | `search_vault` | Keyword + hybrid semantic search | +| `answer_question` | RAG answer from the vault with `[[wikilink]]` citations | | `read_note` | Read note by path | | `vault_catalog` | Full index grouped by compartment | | `list_compartments` | Compartment list + counts | diff --git a/README.md b/README.md index dcc23d4..dad7104 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ flowchart LR | **Correction loop** | Telegram inline keyboard corrections feed few-shot examples into future classifications. | | **Idempotent ingest** | Same `dump.id` twice → no-op. Safe for webhook retries. | | **Soft undo** | `dendrite remove --last` or Telegram `/undo` — section remove or move to inbox. | +| **Per-compartment templates** | Optional `templates/.md` customize frontmatter + body of newly created notes. | ### Inputs @@ -195,6 +196,8 @@ flowchart LR | `dendrite migrate` | Upgrade note frontmatter to current `dendrite_version`. | | `dendrite embed` | Build embedding vectors for hybrid semantic search. | | `dendrite backfill` | Classify vault-root / scratch notes into brain folders. | +| `dendrite ask` | RAG question-answering over the vault, with `[[wikilink]]` citations. | +| `dendrite eval` | Run a golden labeled dataset through the classifier to measure routing accuracy. | ### Agent interface (MCP) @@ -202,6 +205,7 @@ flowchart LR |------|-------------| | `describe_schema` | Compartments + frontmatter contract — call this first. | | `search_vault` | Keyword + hybrid semantic search over the index. | +| `answer_question` | RAG answer from your vault with `[[wikilink]]` citations. | | `read_note` | Read any note by vault-relative path. | | `vault_catalog` | Full index snapshot grouped by compartment. | | `get_capture_siblings` | Reconstruct a multi-segment capture by `split_group`. | @@ -216,6 +220,8 @@ dendrite init # interactive setup wizard dendrite doctor [--stats] # health check + local metrics dendrite ingest "text" # classify + write dendrite ingest --dry-run # preview without writing +dendrite ask "question" # RAG answer from your vault, with citations +dendrite eval # classification accuracy on a golden dataset dendrite serve # daemon: telegram + webhook + crons dendrite mcp # MCP read-server (stdio) dendrite reindex # rebuild SQLite index from vault @@ -229,7 +235,7 @@ dendrite backfill # classify vault-root imports only dendrite pattern-scan # weekly digest now ``` -Telegram: `/help` `/inbox` `/recent` `/compartments` `/sort` `/undo` +Telegram: `/help` `/inbox` `/recent` `/compartments` `/ask` `/sort` `/undo` ## Configuration diff --git a/eval/dataset.jsonl b/eval/dataset.jsonl new file mode 100644 index 0000000..36e7f0c --- /dev/null +++ b/eval/dataset.jsonl @@ -0,0 +1,22 @@ +# Dendrite classification golden set. Lines starting with '#' and blank lines are ignored. +# Routing case: {"text": "...", "expected": ""} -> pass if any segment matches. +# Split case: {"text": "...", "expected_min_segments": } -> pass if results.length >= n. +{"text": "My sister Anna works as a nurse at a hospital in Munich.", "expected": "memories", "note": "durable personal/biographical fact about a relative"} +{"text": "I'm allergic to penicillin and my doctor's office is on Oak Street.", "expected": "memories", "note": "durable personal health fact"} +{"text": "TIL that in Rust the borrow checker prevents data races at compile time.", "expected": "learnings", "note": "TIL / new concept learned"} +{"text": "Learned a neat technique today: you can debounce input handlers with a trailing setTimeout to avoid extra renders.", "expected": "learnings", "note": "technique learned"} +{"text": "Need to book a dentist appointment before Friday.", "expected": "tasks", "note": "clear to-do with deadline"} +{"text": "Remember to renew my passport, it expires next month.", "expected": "tasks", "note": "follow-up action item"} +{"text": "Had coffee, walked the dog, and felt kind of tired this afternoon.", "expected": "journal", "note": "ephemeral mundane daily log"} +{"text": "What if we built an AI code reviewer that leaves inline comments on diffs automatically?", "expected": "ideas", "note": "unformed product idea"} +{"text": "Read a great article about the MCP protocol; key takeaway is that tools are exposed as JSON schemas.", "expected": "reads", "note": "article consumed with takeaway"} +{"text": "Finished the book Thinking Fast and Slow this weekend, loved the section on cognitive biases.", "expected": "reads", "note": "book consumed"} +{"text": "I realised I communicate way better in writing than in live meetings, and I should lean into async updates.", "expected": "reflections", "note": "personal growth insight"} +{"text": "Hit a nasty bug in the auth refactor today, the refresh token rotation breaks when the clock skews.", "expected": "projects", "note": "per-project progress/blocker"} +{"text": "Honestly the new deploy process is infuriating, every single release feels like pulling teeth and nobody documents anything.", "expected": "rants", "note": "raw emotional thought stream"} +{"text": "qqqxxzz asdf lorem zzz ttttt gibber", "expected": "inbox", "note": "gibberish, low confidence -> inbox"} +{"text": "asdkfj 9282 ;;; ---- wut", "expected": "inbox", "note": "unparseable noise -> inbox"} +{"text": "My parents live in Germany. Need to book a dentist before Friday. TIL Rust prevents data races.", "expected_min_segments": 3, "note": "memory + task + learning -> 3 segments"} +{"text": "I read a fascinating paper on transformers today. Also, remind me to email the landlord about the leak.", "expected_min_segments": 2, "note": "reads + task -> 2 segments"} +{"text": "My grandmother turns 90 next year. I want to prototype a birthday reminder app. Also I felt anxious all morning.", "expected_min_segments": 3, "note": "memory + idea + journal -> 3 segments"} +{"text": "Learned that Postgres uses MVCC for isolation. Need to add an index on the orders table. Feeling great about the sprint.", "expected_min_segments": 2, "note": "learning + task + journal -> at least 2 segments"} diff --git a/scripts/ci-smoke.mjs b/scripts/ci-smoke.mjs index 21f00e5..f0139fc 100644 --- a/scripts/ci-smoke.mjs +++ b/scripts/ci-smoke.mjs @@ -4,7 +4,7 @@ * Full LLM integration: npm test (local or with GitHub secrets). */ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -106,6 +106,94 @@ async function main() { else fail(label, `exit ${code}`); } + console.log("\n=== CONFIG DEFAULTS ==="); + try { + const { loadConfig } = await import(join(ROOT, "dist", "config.js")); + const { config } = loadConfig(); + const k = config.retrieval?.k; + const templatesEnabled = config.templates?.enabled; + const maxCtx = config.retrieval?.max_context_chars; + if (k === 8 && templatesEnabled === true && maxCtx === 6000) + pass("config: retrieval+templates defaults"); + else + fail( + "config: retrieval+templates defaults", + `retrieval.k=${k} templates.enabled=${templatesEnabled} retrieval.max_context_chars=${maxCtx}`, + ); + } catch (e) { + fail("config defaults", e.message); + } + + console.log("\n=== TEMPLATE RENDER (unit) ==="); + try { + const { renderVars, renderTemplateBody } = await import( + join(ROOT, "dist", "pipeline", "template.js") + ); + const vars = { + title: "T", + summary: "S", + source: "cli", + date: "2026-01-01 00:00", + compartment: "reads", + entities: "a, b", + tags: "x", + links: "", + capture: "## SECTION\nbody", + }; + + const a = renderVars("# {{title}} — {{summary}}", vars); + if (a === "# T — S") pass("template render A"); + else fail("template render A", `got ${JSON.stringify(a)}`); + + const b = renderTemplateBody({ frontmatter: {}, body: "# {{title}}", hasCapture: false }, vars); + if (b.includes("# T") && b.includes("## SECTION")) pass("template render B"); + else fail("template render B", `got ${JSON.stringify(b)}`); + + const c = renderTemplateBody({ frontmatter: {}, body: "{{capture}}", hasCapture: true }, vars); + if (c === vars.capture) pass("template render C"); + else fail("template render C", `got ${JSON.stringify(c)}`); + } catch (e) { + fail("template render", e.message); + } + + console.log("\n=== EVAL DATASET ==="); + try { + const datasetPath = join(ROOT, "eval", "dataset.jsonl"); + if (!existsSync(datasetPath)) { + fail("eval dataset present", "eval/dataset.jsonl missing"); + } else { + const raw = readFileSync(datasetPath, "utf8"); + const lines = raw + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")); + let badLine = null; + let n = 0; + for (const line of lines) { + let obj; + try { + obj = JSON.parse(line); + } catch (err) { + badLine = `invalid JSON: ${line.slice(0, 80)}`; + break; + } + const hasExpected = + typeof obj.expected === "string" || typeof obj.expected_min_segments === "number"; + const hasText = typeof obj.text === "string" && obj.text.length > 0; + if (!hasExpected || !hasText) { + badLine = `missing fields: ${line.slice(0, 80)}`; + break; + } + n++; + } + if (badLine) fail("eval dataset valid", badLine); + else if (n >= 10) pass("eval dataset valid", `${n} cases`); + else fail("eval dataset valid", `only ${n} cases`); + } + } catch (e) { + fail("eval dataset", e.message); + } + return summary(); } diff --git a/src/cli.ts b/src/cli.ts index 81baf2e..50a7990 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,8 @@ import { runRemove } from "./commands/remove.js"; import { runMigrate } from "./commands/migrate.js"; import { runRepair } from "./commands/repair.js"; import { runEmbed } from "./commands/embed.js"; +import { runAsk } from "./commands/ask.js"; +import { runEval } from "./commands/eval.js"; import { startMcpServer } from "./mcp/server.js"; const program = new Command(); @@ -30,6 +32,7 @@ program program .command("doctor") .option("--stats", "Show local metrics") + .option("--json", "Output machine-readable health JSON") .option("-c, --config ", "Config file path") .action(runDoctor); @@ -47,6 +50,25 @@ program .option("-c, --config ", "Config file path") .action(runServe); +program + .command("ask [question]") + .description("Answer a question using only your vault notes (read-only RAG)") + .option("-c, --config ", "Config file path") + .option("--compartment ", "Restrict retrieval to one compartment") + .option("-k, --k ", "Number of notes to retrieve") + .option("--json", "Output machine-readable JSON") + .action(runAsk); + +program + .command("eval") + .description("Run the golden classification dataset and report routing accuracy") + .option("-c, --config ", "Config file path") + .option("--limit ", "Only run the first N cases") + .option("--min ", "Exit non-zero if accuracy is below this ratio (e.g. 0.7)") + .option("--dataset ", "Path to a JSONL dataset (default: eval/dataset.jsonl)") + .option("--json", "Output machine-readable JSON") + .action(runEval); + program .command("mcp") .description("Run the MCP read-server (stdio)") diff --git a/src/commands/ask.ts b/src/commands/ask.ts new file mode 100644 index 0000000..e638d43 --- /dev/null +++ b/src/commands/ask.ts @@ -0,0 +1,40 @@ +import { loadConfig } from "../config.js"; +import { DendriteIndex } from "../pipeline/index.js"; +import { answerQuestion } from "../pipeline/answer.js"; + +export async function runAsk( + question: string | undefined, + opts: { config?: string; compartment?: string; k?: string; json?: boolean }, +): Promise { + const q = (question ?? "").trim(); + if (!q) { + console.error('Usage: dendrite ask "your question"'); + process.exit(1); + } + + const { config, llm } = loadConfig(opts.config); + const index = new DendriteIndex(config.index.db_path); + + try { + const result = await answerQuestion(index, config.vault.path, q, config, llm, { + compartment: opts.compartment, + k: opts.k ? Number(opts.k) : undefined, + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`\n${result.answer}\n`); + if (result.sources.length > 0) { + console.log("Sources:"); + for (const s of result.sources) { + console.log(` - [[${s.slug}]] — ${s.title} (${s.path}, score ${s.score.toFixed(3)})`); + } + console.log(`\n(${result.usedNotes} note${result.usedNotes === 1 ? "" : "s"} used as context)`); + } + } finally { + index.close(); + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bb4b930..fcca1b3 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { join } from "node:path"; @@ -9,89 +9,180 @@ const execFileAsync = promisify(execFile); import { DendriteIndex } from "../pipeline/index.js"; import { testChatEndpoint } from "../providers/llm.js"; -export async function runDoctor(opts: { config?: string; stats?: boolean }): Promise { - let ok = true; +function countDanglingLinks(vaultPath: string, index: DendriteIndex): number { + const notes = index.listAllNotes(); + const knownSlugs = new Set(); + for (const note of notes) { + const slug = note.path.replace(/\.md$/i, "").split("/").pop(); + if (slug) knownSlugs.add(slug.toLowerCase()); + } + + const wikilinkRe = /\[\[([^\]]+)\]\]/g; + const journalRe = /^\d{2}-\d{2}-\d{4}$/; + let dangling = 0; + + for (const note of notes) { + const fullPath = join(vaultPath, note.path); + if (!existsSync(fullPath)) continue; + let text: string; + try { + text = readFileSync(fullPath, "utf8"); + } catch { + continue; + } + let match: RegExpExecArray | null; + while ((match = wikilinkRe.exec(text)) !== null) { + const raw = match[1]; + const target = raw.split(/[|#]/)[0].trim().toLowerCase(); + if (!target) continue; + if (journalRe.test(target)) continue; + if (!knownSlugs.has(target)) dangling += 1; + } + } + + return dangling; +} + +export async function runDoctor(opts: { + config?: string; + stats?: boolean; + json?: boolean; +}): Promise { + const json = opts.json === true; + const line = (s: string): void => { + if (!json) console.log(s); + }; + + const health: { + ok: boolean; + config: boolean; + vault_path: string | null; + index_db_path: string | null; + llm: { + primary: { + baseURL: string | null; + model: string | null; + apiKeyPresent: boolean; + reachable: boolean; + }; + fallback_present: boolean; + }; + stt_provider: string | null; + indexed_notes: number; + processed_dumps: number; + embedding_coverage: { embedded: number; total: number; pct: number }; + queue: { pending: number; processing: number; done: number; dead: number }; + dangling_links: number; + } = { + ok: true, + config: false, + vault_path: null, + index_db_path: null, + llm: { + primary: { baseURL: null, model: null, apiKeyPresent: false, reachable: false }, + fallback_present: false, + }, + stt_provider: null, + indexed_notes: 0, + processed_dumps: 0, + embedding_coverage: { embedded: 0, total: 0, pct: 0 }, + queue: { pending: 0, processing: 0, done: 0, dead: 0 }, + dangling_links: 0, + }; try { const { config, llm } = loadConfig(opts.config); - console.log("Config: OK"); - console.log(` Vault: ${config.vault.path}`); - console.log(` Index: ${config.index.db_path}`); + health.config = true; + health.vault_path = config.vault.path; + health.index_db_path = config.index.db_path; + line("Config: OK"); + line(` Vault: ${config.vault.path}`); + line(` Index: ${config.index.db_path}`); if (!existsSync(config.vault.path)) { - console.log(" Vault path missing — creating…"); + line(" Vault path missing — creating…"); mkdirSync(config.vault.path, { recursive: true }); } // LLM primary - console.log(`\n LLM primary: ${llm.primary.baseURL} / ${llm.primary.model}`); + health.llm.primary.baseURL = llm.primary.baseURL; + health.llm.primary.model = llm.primary.model; + line(`\n LLM primary: ${llm.primary.baseURL} / ${llm.primary.model}`); if (llm.primary.apiKeyEnv && llm.primary.apiKeyEnv !== "NONE") { try { resolveApiKey(llm.primary.apiKeyEnv); - console.log(` API key (${llm.primary.apiKeyEnv}): set`); + health.llm.primary.apiKeyPresent = true; + line(` API key (${llm.primary.apiKeyEnv}): set`); } catch { - console.log(` API key (${llm.primary.apiKeyEnv}): MISSING`); - ok = false; + health.llm.primary.apiKeyPresent = false; + line(` API key (${llm.primary.apiKeyEnv}): MISSING`); + health.ok = false; } } else { - console.log(" API key: not required"); + health.llm.primary.apiKeyPresent = true; + line(" API key: not required"); } try { const reachable = await testChatEndpoint(llm.primary); - console.log(` Reachable: ${reachable ? "yes" : "no"}`); + health.llm.primary.reachable = reachable; + line(` Reachable: ${reachable ? "yes" : "no"}`); + if (!reachable) health.ok = false; } catch (err) { - console.log(` Reachable: no (${err instanceof Error ? err.message : err})`); - ok = false; + health.llm.primary.reachable = false; + line(` Reachable: no (${err instanceof Error ? err.message : err})`); + health.ok = false; } // LLM fallback if (llm.fallback) { - console.log(`\n LLM fallback: ${llm.fallback.baseURL} / ${llm.fallback.model}`); + health.llm.fallback_present = true; + line(`\n LLM fallback: ${llm.fallback.baseURL} / ${llm.fallback.model}`); if (llm.fallback.apiKeyEnv && llm.fallback.apiKeyEnv !== "NONE") { try { resolveApiKey(llm.fallback.apiKeyEnv); - console.log(` API key (${llm.fallback.apiKeyEnv}): set`); + line(` API key (${llm.fallback.apiKeyEnv}): set`); } catch { - console.log(` API key (${llm.fallback.apiKeyEnv}): MISSING (fallback won't work)`); + line(` API key (${llm.fallback.apiKeyEnv}): MISSING (fallback won't work)`); } } } // STT const stt = config.providers.stt as SttConfig; - console.log(`\n STT provider: ${stt.provider}`); - if (stt.baseURL) console.log(` baseURL: ${stt.baseURL}`); - if (stt.model) console.log(` model: ${stt.model}`); - if (stt.language) console.log(` language: ${stt.language}`); + health.stt_provider = stt.provider; + line(`\n STT provider: ${stt.provider}`); + if (stt.baseURL) line(` baseURL: ${stt.baseURL}`); + if (stt.model) line(` model: ${stt.model}`); + if (stt.language) line(` language: ${stt.language}`); if (stt.apiKeyEnv && stt.apiKeyEnv !== "NONE") { try { resolveApiKey(stt.apiKeyEnv, false); - console.log(` API key (${stt.apiKeyEnv}): set`); + line(` API key (${stt.apiKeyEnv}): set`); } catch { - console.log(` API key (${stt.apiKeyEnv}): optional / not set`); + line(` API key (${stt.apiKeyEnv}): optional / not set`); } } if (stt.provider === "nvidia-riva-grpc") { if (!stt.function_id) { - console.log(" function_id: MISSING"); - ok = false; + line(" function_id: MISSING"); + health.ok = false; } else { - console.log(` function_id: ${stt.function_id}`); - console.log(` server: ${stt.server ?? "grpc.nvcf.nvidia.com:443"}`); + line(` function_id: ${stt.function_id}`); + line(` server: ${stt.server ?? "grpc.nvcf.nvidia.com:443"}`); } const venvPython = join(process.cwd(), ".venv-stt", "bin", "python3"); const checkPython = existsSync(venvPython) ? venvPython : "python3"; try { await execFileAsync(checkPython, ["-c", "import riva.client"]); - console.log(` nvidia-riva-client: installed (${checkPython})`); + line(` nvidia-riva-client: installed (${checkPython})`); } catch { - console.log( + line( " nvidia-riva-client: MISSING — run: python3 -m venv .venv-stt && .venv-stt/bin/pip install -r requirements-stt.txt", ); - ok = false; + health.ok = false; } } else if (stt.provider === "nvidia-nim") { - console.log(" API key: optional (required for remote NIM; omit for local container)"); + line(" API key: optional (required for remote NIM; omit for local container)"); } const index = new DendriteIndex(config.index.db_path); @@ -101,10 +192,28 @@ export async function runDoctor(opts: { config?: string; stats?: boolean }): Pro const dumpCount = ( index.db.prepare(`SELECT COUNT(*) as c FROM dumps`).get() as { c: number } ).c; - console.log(`\n Indexed notes: ${noteCount}`); - console.log(` Processed dumps: ${dumpCount}`); + health.indexed_notes = noteCount; + health.processed_dumps = dumpCount; + line(`\n Indexed notes: ${noteCount}`); + line(` Processed dumps: ${dumpCount}`); + + // NEW health metrics + const total = index.listAllNotes().length; + const embedded = index.countEmbeddings(); + const pct = total ? Math.round((embedded / total) * 100) : 0; + health.embedding_coverage = { embedded, total, pct }; + + health.queue = index.queueStatusCounts(); + health.dangling_links = countDanglingLinks(config.vault.path, index); + index.close(); + line(` Embedding coverage: ${embedded}/${total} (${pct}%)`); + line( + ` Queue: pending=${health.queue.pending} processing=${health.queue.processing} dead=${health.queue.dead}`, + ); + line(` Dangling links: ${health.dangling_links}`); + if (opts.stats) { const idx = new DendriteIndex(config.index.db_path); const corrections = ( @@ -115,16 +224,20 @@ export async function runDoctor(opts: { config?: string; stats?: boolean }): Pro c: number; } ).c; - console.log("\nStats:"); - console.log(` Corrections: ${corrections}`); - console.log(` Queue pending: ${pending}`); + line("\nStats:"); + line(` Corrections: ${corrections}`); + line(` Queue pending: ${pending}`); idx.close(); } } catch (err) { - console.error(`Config error: ${err instanceof Error ? err.message : err}`); - ok = false; + if (!json) console.error(`Config error: ${err instanceof Error ? err.message : err}`); + health.ok = false; } - console.log(ok ? "\nDoctor: all checks passed" : "\nDoctor: issues found"); - process.exit(ok ? 0 : 1); + if (json) { + console.log(JSON.stringify(health, null, 2)); + } else { + console.log(health.ok ? "\nDoctor: all checks passed" : "\nDoctor: issues found"); + } + process.exit(health.ok ? 0 : 1); } diff --git a/src/commands/eval.ts b/src/commands/eval.ts new file mode 100644 index 0000000..989234f --- /dev/null +++ b/src/commands/eval.ts @@ -0,0 +1,190 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadConfig } from "../config.js"; +import { createPipelineContext, processDump } from "../pipeline/pipeline.js"; +import type { Dump } from "../types.js"; + +interface EvalCase { + text: string; + expected?: string; + expected_min_segments?: number; + note?: string; +} + +interface CaseResult { + text: string; + expected?: string; + expected_min_segments?: number; + got?: string; + segments: number; + pass: boolean; + error?: string; +} + +export interface EvalOptions { + config?: string; + limit?: string; + min?: string; + json?: boolean; + dataset?: string; +} + +export async function runEval(opts: EvalOptions): Promise { + const datasetPath = opts.dataset + ? resolve(opts.dataset) + : resolve(process.cwd(), "eval/dataset.jsonl"); + + if (!existsSync(datasetPath)) { + console.error(`Dataset not found: ${datasetPath}`); + process.exit(1); + } + + const raw = readFileSync(datasetPath, "utf8"); + const cases: EvalCase[] = []; + const lines = raw.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + if (!line || line.startsWith("#")) continue; + try { + cases.push(JSON.parse(line) as EvalCase); + } catch { + console.warn(`Skipping unparseable line ${i + 1}: ${line.slice(0, 60)}`); + } + } + + const limit = opts.limit !== undefined ? parseInt(opts.limit, 10) : undefined; + const selected = + limit !== undefined && Number.isFinite(limit) && limit >= 0 + ? cases.slice(0, limit) + : cases; + + const min = opts.min !== undefined ? parseFloat(opts.min) : undefined; + + const { config, configDir, llm } = loadConfig(opts.config); + const ctx = createPipelineContext(config, configDir, llm, true); + + const results: CaseResult[] = []; + const breakdown = new Map(); + + try { + for (let i = 0; i < selected.length; i++) { + const c = selected[i]!; + const dump: Dump = { + id: `eval-${i}-${Date.now()}`, + source: "cli", + receivedAt: new Date().toISOString(), + text: c.text, + }; + + let entry: CaseResult; + try { + const pipelineResults = await processDump(ctx, dump); + const segments = pipelineResults.length; + if (c.expected !== undefined) { + const got = pipelineResults[0]?.compartment; + const pass = pipelineResults.some((r) => r.compartment === c.expected); + entry = { text: c.text, expected: c.expected, got, segments, pass }; + } else if (c.expected_min_segments !== undefined) { + const pass = segments >= c.expected_min_segments; + entry = { + text: c.text, + expected_min_segments: c.expected_min_segments, + segments, + pass, + }; + } else { + entry = { text: c.text, segments, pass: false, error: "case has no expectation" }; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + entry = { + text: c.text, + expected: c.expected, + expected_min_segments: c.expected_min_segments, + segments: 0, + pass: false, + error: message, + }; + } + + if (entry.expected !== undefined) { + const stat = breakdown.get(entry.expected) ?? { correct: 0, total: 0 }; + stat.total += 1; + if (entry.pass) stat.correct += 1; + breakdown.set(entry.expected, stat); + } + + results.push(entry); + } + } finally { + ctx.index.close(); + } + + const total = results.length; + const passed = results.filter((r) => r.pass).length; + const failed = total - passed; + const accuracy = total > 0 ? passed / total : 0; + + if (opts.json) { + console.log( + JSON.stringify( + { + total, + passed, + failed, + accuracy, + min: min ?? null, + cases: results.map((r) => { + const out: CaseResult = { + text: r.text, + segments: r.segments, + pass: r.pass, + }; + if (r.expected !== undefined) out.expected = r.expected; + if (r.expected_min_segments !== undefined) + out.expected_min_segments = r.expected_min_segments; + if (r.got !== undefined) out.got = r.got; + if (r.error !== undefined) out.error = r.error; + return out; + }), + }, + null, + 2, + ), + ); + } else { + for (const r of results) { + const snippet = r.text.length > 60 ? `${r.text.slice(0, 57)}…` : r.text; + const label = r.expected ?? `>=${r.expected_min_segments} segments`; + if (r.pass) { + console.log(`✓ ${label} ← "${snippet}"`); + } else if (r.error) { + console.log(`✗ ${label} (error: ${r.error}) ← "${snippet}"`); + } else if (r.expected !== undefined) { + console.log(`✗ ${label} (got ${r.got ?? "none"}) ← "${snippet}"`); + } else { + console.log(`✗ ${label} (got ${r.segments} segments) ← "${snippet}"`); + } + } + + console.log(""); + console.log(`Total: ${total} Passed: ${passed} Failed: ${failed}`); + + if (breakdown.size > 0) { + console.log("\nPer-compartment accuracy:"); + for (const [compartment, stat] of [...breakdown.entries()].sort()) { + const pct = stat.total > 0 ? ((stat.correct / stat.total) * 100).toFixed(0) : "0"; + console.log(` ${compartment}: ${stat.correct}/${stat.total} (${pct}%)`); + } + } + + console.log(`\nAccuracy: ${(accuracy * 100).toFixed(1)}%`); + if (min !== undefined) { + console.log(`Threshold: ${(min * 100).toFixed(1)}% → ${accuracy >= min ? "PASS" : "FAIL"}`); + } + } + + if (min !== undefined && Number.isFinite(min)) { + process.exit(accuracy >= min ? 0 : 1); + } +} diff --git a/src/config.ts b/src/config.ts index 2cdce46..ace4c81 100644 --- a/src/config.ts +++ b/src/config.ts @@ -86,6 +86,19 @@ const ConfigSchema = z.object({ max_title_relevance: z.number().default(0.34), }) .default({}), + retrieval: z + .object({ + k: z.number().int().positive().default(8), + max_context_chars: z.number().int().positive().default(6000), + min_score: z.number().min(0).default(0), + }) + .default({}), + templates: z + .object({ + enabled: z.boolean().default(true), + dir: z.string().default("templates"), + }) + .default({}), organization: z.enum(["folders", "flat"]).default("folders"), tasks: z.object({ render: z.enum(["frontmatter"]).default("frontmatter") }).default({}), dashboard: z diff --git a/src/inputs/telegram.ts b/src/inputs/telegram.ts index 0d7bd4f..2550667 100644 --- a/src/inputs/telegram.ts +++ b/src/inputs/telegram.ts @@ -13,6 +13,7 @@ import { import { loadCompartments, type DendriteConfig } from "../config.js"; import { undoCapture, resolveUndoTarget } from "../pipeline/remove.js"; import { previewSort, runSort, formatSortPreviewTelegram } from "../commands/sort.js"; +import { answerQuestion } from "../pipeline/answer.js"; import type { Context } from "grammy"; const pendingSorts = new Map(); @@ -25,6 +26,7 @@ const TELEGRAM_COMMANDS = [ { command: "compartments", description: "List brain compartments" }, { command: "sort", description: "Preview LLM vault sort (inbox + imports)" }, { command: "undo", description: "Undo your last capture" }, + { command: "ask", description: "Ask a question answered from your vault" }, ] as const; export async function startTelegramBot( @@ -121,6 +123,22 @@ export async function startTelegramBot( } }); + bot.command("ask", async (c) => { + if (!isAllowed(c.from?.id, allowed)) return; + const question = (c.match ?? "").toString().trim(); + if (!question) return c.reply("Usage: /ask "); + try { + const result = await answerQuestion(ctx.index, config.vault.path, question, config, ctx.llm); + const sources = result.sources.length + ? "\n\nSources:\n" + result.sources.slice(0, 5).map((s) => `• [[${s.slug}]]`).join("\n") + : ""; + await safeReply(c, { text: `${result.answer}${sources}` }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await c.reply(`Ask failed: ${msg}`); + } + }); + bot.on("message:text", async (c) => { if (!isAllowed(c.from?.id, allowed)) return; const text = c.message.text; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8e940e3..adaff5b 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { loadConfig, loadCompartments } from "../config.js"; import { DendriteIndex } from "../pipeline/index.js"; import { smartSearch } from "../pipeline/search.js"; +import { answerQuestion } from "../pipeline/answer.js"; import { FRONTMATTER_CONTRACT } from "../types.js"; import matter from "gray-matter"; @@ -38,6 +39,16 @@ export async function startMcpServer(configPath?: string): Promise { }, ); + server.tool( + "answer_question", + "Answer a natural-language question using ONLY vault notes, with [[wikilink]] citations. Read-only RAG; refuses when nothing relevant is found.", + { question: z.string(), compartment: z.string().optional(), k: z.number().optional() }, + async ({ question, compartment, k }) => { + const result = await answerQuestion(index, config.vault.path, question, config, llm, { compartment, k }); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + }, + ); + server.tool( "read_note", "Read a note from the vault by relative path", diff --git a/src/pipeline/answer.ts b/src/pipeline/answer.ts new file mode 100644 index 0000000..4958874 --- /dev/null +++ b/src/pipeline/answer.ts @@ -0,0 +1,128 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import matter from "gray-matter"; +import type { DendriteConfig, LlmEndpoints } from "../config.js"; +import type { DendriteIndex } from "./index.js"; +import { createChatProvider } from "../providers/llm.js"; +import { smartSearch } from "./search.js"; +import { wikilink } from "../util/slug.js"; + +export interface AnswerSource { + path: string; + title: string; + slug: string; + score: number; +} + +export interface AnswerResult { + question: string; + answer: string; + sources: AnswerSource[]; + /** Number of notes whose content was placed in the LLM context window. */ + usedNotes: number; + /** True when no note cleared the retrieval floor, so no LLM call was made. */ + refused: boolean; +} + +const REFUSAL = + "I don't have a note about that in the vault. Capture it first with `dendrite ingest`."; + +const ANSWER_SYSTEM = `You are Dendrite's librarian. Answer the user's question using ONLY the notes provided as context. + +Rules: +- Ground every claim in the provided notes. Do not use outside knowledge or guess. +- Cite the notes you used inline with their wikilink, e.g. [[note-slug]]. +- If the notes do not contain the answer, reply exactly: "The vault does not contain an answer to that." Do not invent facts. +- Be concise. Prefer a direct answer over a summary of the notes.`; + +function slugOf(path: string): string { + return path.replace(/\.md$/i, "").split("/").pop() ?? path; +} + +/** Read a note body (frontmatter stripped) from disk, best-effort. */ +function readNoteBody(vaultPath: string, relPath: string): string { + const abs = join(vaultPath, relPath); + if (!existsSync(abs)) return ""; + try { + const { content } = matter(readFileSync(abs, "utf8")); + return content.trim(); + } catch { + return ""; + } +} + +/** + * Retrieval-augmented question answering over the vault. + * + * Hybrid-search the index, pull the matching note bodies into a bounded context + * window, and ask the LLM to answer with inline `[[wikilink]]` citations. This is + * strictly read-only and refuses (without an LLM call) when nothing is retrieved. + */ +export async function answerQuestion( + index: DendriteIndex, + vaultPath: string, + question: string, + config: DendriteConfig, + llm: LlmEndpoints, + opts?: { compartment?: string; k?: number }, +): Promise { + const q = question.trim(); + if (!q) throw new Error("Empty question"); + + const k = opts?.k ?? config.retrieval.k; + const hits = ( + await smartSearch(index, q, config, llm, { + compartment: opts?.compartment, + limit: k, + excludeEphemeral: false, + }) + ).filter( + // Answer from captured knowledge under brain/, not vault scaffolding + // (e.g. a starter README) which pollutes context and derails small models. + (h) => h.path.startsWith("brain/") && h.score >= config.retrieval.min_score, + ); + + const sources: AnswerSource[] = hits.map((h) => ({ + path: h.path, + title: h.title, + slug: slugOf(h.path), + score: h.score, + })); + + if (hits.length === 0) { + return { question: q, answer: REFUSAL, sources: [], usedNotes: 0, refused: true }; + } + + // Build a bounded context window from note bodies. + const budget = config.retrieval.max_context_chars; + const blocks: string[] = []; + let used = 0; + let usedNotes = 0; + for (const hit of hits) { + if (used >= budget) break; + const body = readNoteBody(vaultPath, hit.path) || hit.snippet; + const remaining = budget - used; + const excerpt = body.length > remaining ? body.slice(0, remaining) + "…" : body; + blocks.push( + `### ${wikilink(slugOf(hit.path))} — ${hit.title}\n(path: ${hit.path})\n${excerpt}`, + ); + used += excerpt.length; + usedNotes++; + } + + const context = blocks.join("\n\n---\n\n"); + const userContent = `Question: ${q}\n\nNotes:\n${context}`; + + const chat = createChatProvider(llm); + const answer = ( + await chat.complete({ + messages: [ + { role: "system", content: ANSWER_SYSTEM }, + { role: "user", content: userContent }, + ], + temperature: 0, + }) + ).trim(); + + return { question: q, answer, sources, usedNotes, refused: false }; +} diff --git a/src/pipeline/index.ts b/src/pipeline/index.ts index ce98e38..19364e0 100644 --- a/src/pipeline/index.ts +++ b/src/pipeline/index.ts @@ -342,6 +342,19 @@ export class DendriteIndex { return row.c; } + queueStatusCounts(): { pending: number; processing: number; done: number; dead: number } { + const rows = this.db + .prepare(`SELECT status, COUNT(*) as c FROM ingest_queue GROUP BY status`) + .all() as Array<{ status: string; c: number }>; + const out = { pending: 0, processing: 0, done: 0, dead: 0 }; + for (const r of rows) { + if (r.status === "pending" || r.status === "processing" || r.status === "done" || r.status === "dead") { + out[r.status] = r.c; + } + } + return out; + } + listEmbeddingPaths(): string[] { const rows = this.db.prepare(`SELECT note_path FROM embeddings`).all() as Array<{ note_path: string; diff --git a/src/pipeline/pipeline.ts b/src/pipeline/pipeline.ts index 580b6a1..1c61ee3 100644 --- a/src/pipeline/pipeline.ts +++ b/src/pipeline/pipeline.ts @@ -217,6 +217,7 @@ export async function processDump( uniqueLinks, config, segments.length > 1 ? parentId : undefined, + configDir, ); index.upsertNote({ diff --git a/src/pipeline/template.ts b/src/pipeline/template.ts new file mode 100644 index 0000000..0dd201f --- /dev/null +++ b/src/pipeline/template.ts @@ -0,0 +1,93 @@ +import { readFileSync, existsSync } from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; +import matter from "gray-matter"; +import type { DendriteConfig } from "../config.js"; + +/** Variables exposed to compartment templates. */ +export interface TemplateVars { + title: string; + summary: string; + source: string; + date: string; + compartment: string; + entities: string; + tags: string; + links: string; + /** The rendered timestamped capture section for this first write. */ + capture: string; +} + +export interface LoadedTemplate { + /** Extra static frontmatter fields declared in the template (may contain vars). */ + frontmatter: Record; + /** Template body with `{{var}}` placeholders. */ + body: string; + /** Whether the body references `{{capture}}`. */ + hasCapture: boolean; +} + +const CAPTURE_RE = /\{\{\s*capture\s*\}\}/; +const VAR_RE = /\{\{\s*([\w.]+)\s*\}\}/g; + +export function resolveTemplateDir(config: DendriteConfig, configDir: string): string { + const dir = config.templates.dir; + return isAbsolute(dir) ? dir : resolve(configDir, dir); +} + +/** + * Load a compartment template if templates are enabled and a + * `/.md` file exists. Returns null otherwise so callers fall + * back to the built-in default note layout. + */ +export function loadCompartmentTemplate( + config: DendriteConfig, + configDir: string, + compartment: string, +): LoadedTemplate | null { + if (!config.templates.enabled) return null; + const file = join(resolveTemplateDir(config, configDir), `${compartment}.md`); + if (!existsSync(file)) return null; + try { + const parsed = matter(readFileSync(file, "utf8")); + return { + frontmatter: (parsed.data ?? {}) as Record, + body: parsed.content, + hasCapture: CAPTURE_RE.test(parsed.content), + }; + } catch { + return null; + } +} + +/** Replace `{{var}}` placeholders in a string. Unknown vars render as empty. */ +export function renderVars(input: string, vars: TemplateVars): string { + return input.replace(VAR_RE, (_m, key: string) => { + const value = (vars as unknown as Record)[key]; + return value === undefined || value === null ? "" : String(value); + }); +} + +/** Deep-render string values inside template frontmatter. */ +export function renderFrontmatter( + fm: Record, + vars: TemplateVars, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(fm)) { + if (typeof value === "string") out[key] = renderVars(value, vars); + else if (Array.isArray(value)) { + out[key] = value.map((v) => (typeof v === "string" ? renderVars(v, vars) : v)); + } else out[key] = value; + } + return out; +} + +/** + * Render a template into a note body. If the template references `{{capture}}` + * the capture section is inlined there; otherwise it is appended after the body. + */ +export function renderTemplateBody(tpl: LoadedTemplate, vars: TemplateVars): string { + const rendered = renderVars(tpl.body, vars); + if (tpl.hasCapture) return rendered; + return `${rendered.trimEnd()}\n\n${vars.capture}`; +} diff --git a/src/pipeline/write.ts b/src/pipeline/write.ts index 8105f8b..d5cf24b 100644 --- a/src/pipeline/write.ts +++ b/src/pipeline/write.ts @@ -4,6 +4,12 @@ import matter from "gray-matter"; import type { Classification, Dump, ResolvedTarget } from "../types.js"; import type { DendriteConfig } from "../config.js"; import { formatTimestamp, nowIso } from "../util/datetime.js"; +import { + loadCompartmentTemplate, + renderFrontmatter, + renderTemplateBody, + type TemplateVars, +} from "./template.js"; export interface WriteResult { notePath: string; @@ -19,6 +25,7 @@ export function writeNote( links: string[], config: DendriteConfig, splitGroup?: string, + configDir?: string, ): WriteResult { const absPath = join(vaultPath, target.notePath); mkdirSync(dirname(absPath), { recursive: true }); @@ -35,6 +42,27 @@ export function writeNote( if (created) { frontmatter = buildFrontmatter(dump, classification, target, links, splitGroup); body = `# ${classification.title}\n\n${section}`; + + // Per-compartment template (optional). Dynamic core frontmatter always wins; + // templates may add extra static fields and control body layout. + const template = configDir + ? loadCompartmentTemplate(config, configDir, target.compartment) + : null; + if (template) { + const vars: TemplateVars = { + title: classification.title, + summary: classification.summary, + source: dump.source, + date: timestamp, + compartment: target.compartment, + entities: classification.entities.join(", "), + tags: classification.tags.join(", "), + links: links.join(", "), + capture: section, + }; + frontmatter = { ...renderFrontmatter(template.frontmatter, vars), ...frontmatter }; + body = renderTemplateBody(template, vars); + } } else { const existing = matter(readFileSync(absPath, "utf8")); frontmatter = { ...existing.data }; diff --git a/templates/reads.md b/templates/reads.md new file mode 100644 index 0000000..66fc589 --- /dev/null +++ b/templates/reads.md @@ -0,0 +1,11 @@ +--- +status: to-read +rating: +--- +# {{title}} + +> {{summary}} + +**Source:** {{source}} · **Tags:** {{tags}} + +{{capture}} diff --git a/templates/tasks.md b/templates/tasks.md new file mode 100644 index 0000000..372bdee --- /dev/null +++ b/templates/tasks.md @@ -0,0 +1,8 @@ +--- +status: open +--- +# {{title}} + +> {{summary}} + +{{capture}}