diff --git a/package-lock.json b/package-lock.json index 18283c9..20144aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.212", "@koa/bodyparser": "^6.0.0", "@koa/router": "^14.0.0", - "koa": "^3.0.1" + "koa": "^3.0.1", + "zod": "^4.4.3" }, "devDependencies": { "@types/koa": "^2.15.0", @@ -2535,7 +2536,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 6fc028d..e098673 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "@anthropic-ai/claude-agent-sdk": "^0.3.212", "@koa/bodyparser": "^6.0.0", "@koa/router": "^14.0.0", - "koa": "^3.0.1" + "koa": "^3.0.1", + "zod": "^4.4.3" }, "devDependencies": { "@types/koa": "^2.15.0", diff --git a/src/agent/claudeSdk/ClaudeSdkBackend.ts b/src/agent/claudeSdk/ClaudeSdkBackend.ts index 9a377cf..b5a26d5 100644 --- a/src/agent/claudeSdk/ClaudeSdkBackend.ts +++ b/src/agent/claudeSdk/ClaudeSdkBackend.ts @@ -5,9 +5,20 @@ import type { AgentBackend } from "../AgentBackend.js"; import type { AgentEvent } from "../events.js"; import type { Config } from "../../config/types.js"; import type { Db, SessionRow } from "../../db/Db.js"; +import { buildVaultMcpServer } from "./vaultMcpServer.js"; -const ALLOWED_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep"]; +const ALLOWED_TOOLS = [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "mcp__vault__list_vault_tags", + "mcp__vault__check_wikilinks", + "mcp__vault__search_notes", +]; const FILE_TOOLS = new Set(["Write", "Edit"]); +const MCP_VAULT_PREFIX = "mcp__vault__"; /** * Claude Agent SDK backend. Delegates the agent loop, tool execution and @@ -71,6 +82,7 @@ export class ClaudeSdkBackend implements AgentBackend { systemPrompt: { type: "preset", preset: "claude_code", append }, includePartialMessages: true, abortController, + mcpServers: { vault: buildVaultMcpServer(vaultRoot) }, ...(this.#model ? { model: this.#model } : {}), }; @@ -118,9 +130,14 @@ export class ClaudeSdkBackend implements AgentBackend { console.warn(`[claude-sdk] tool ${name} path outside vault: ${rawPath}`); } } - if (FILE_TOOLS.has(name)) { - pendingTools.set(id, { tool: name, path: rel, outside }); - yield { type: "tool_start", tool: name, ...(rel ? { path: rel } : {}) }; + const isVaultMcpTool = name.startsWith(MCP_VAULT_PREFIX); + if (FILE_TOOLS.has(name) || isVaultMcpTool) { + // Strip the "mcp__vault__" prefix so SSE consumers see the + // plain tool name (e.g. "list_vault_tags") rather than the + // MCP-qualified one. + const emittedName = isVaultMcpTool ? name.slice(MCP_VAULT_PREFIX.length) : name; + pendingTools.set(id, { tool: emittedName, path: rel, outside }); + yield { type: "tool_start", tool: emittedName, ...(rel ? { path: rel } : {}) }; } } } diff --git a/src/agent/claudeSdk/vaultMcpServer.ts b/src/agent/claudeSdk/vaultMcpServer.ts new file mode 100644 index 0000000..9b62b8b --- /dev/null +++ b/src/agent/claudeSdk/vaultMcpServer.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk"; +import { listVaultTags, checkWikilinks, searchNotes } from "../../vault/noteTools.js"; +import { BraindumpError } from "../../errors.js"; + +/** + * Read-only vault-aware MCP tools for the Claude Agent SDK backend, mirroring + * the openai-compat tool trio (list_vault_tags, check_wikilinks, + * search_notes) so both backends can inspect the vault's tag taxonomy, + * wikilink health, and note contents before writing. + */ +export function buildVaultMcpServer(vaultRoot: string) { + const listVaultTagsTool = tool( + "list_vault_tags", + "List tags used across the vault (from YAML frontmatter and inline #tags), with counts. Call before choosing frontmatter tags for a new or edited note so you reuse existing tags instead of inventing near-duplicates.", + { + prefix: z.string().optional().describe("Optional prefix filter, e.g. \"project/\" to see only tags under that namespace."), + }, + async (args) => { + try { + const tags = listVaultTags(vaultRoot, args.prefix); + const text = tags.length ? tags.map((t) => `${t.tag} (${t.count})`).join("\n") : "(no tags found)"; + return { content: [{ type: "text", text }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } + ); + + const checkWikilinksTool = tool( + "check_wikilinks", + "Check whether [[wikilinks]] resolve to existing notes. Verify wikilinks resolve after writing or renaming notes, so you catch broken links before ending the turn.", + { + path: z.string().optional().describe("Optional vault-relative path to check just one file. Omit to check the whole vault."), + }, + async (args) => { + try { + const results = checkWikilinks(vaultRoot, args.path); + if (!results.length) { + return { content: [{ type: "text", text: "(no wikilinks found)" }] }; + } + const lines = results.map((r) => + r.ok ? `OK ${r.path} -> ${r.resolvesTo}` : `BROKEN ${r.path} -> ${r.link}` + ); + const brokenCount = results.filter((r) => !r.ok).length; + lines.push(`${results.length} link(s) checked, ${brokenCount} broken.`); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } catch (e) { + return { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true }; + } + } + ); + + const searchNotesTool = tool( + "search_notes", + "Search vault notes line-by-line for a substring or regex. Use this to find existing notes or mentions of a topic before creating new files, so you extend rather than duplicate.", + { + query: z.string().describe("Text to search for (case-insensitive substring by default)."), + regex: z.boolean().optional().describe("If true, treat query as a case-insensitive regular expression."), + max_results: z.number().int().optional().describe("Maximum number of matches to return (default 50, hard cap 200)."), + }, + async (args) => { + try { + const results = searchNotes(vaultRoot, args.query, { regex: args.regex, maxResults: args.max_results }); + const text = results.length ? results.map((r) => `${r.path}:${r.line}: ${r.text}`).join("\n") : "(no matches)"; + return { content: [{ type: "text", text }] }; + } catch (e) { + const msg = e instanceof BraindumpError ? e.message : `Error: ${(e as Error).message}`; + return { content: [{ type: "text", text: msg }], isError: true }; + } + } + ); + + return createSdkMcpServer({ + name: "vault", + tools: [listVaultTagsTool, checkWikilinksTool, searchNotesTool], + }); +} diff --git a/src/agent/openaiCompat/systemPrompt.ts b/src/agent/openaiCompat/systemPrompt.ts index b504274..5ed70d7 100644 --- a/src/agent/openaiCompat/systemPrompt.ts +++ b/src/agent/openaiCompat/systemPrompt.ts @@ -60,11 +60,14 @@ ${listing} ## Tools -You have four vault-scoped tools. Use them to inspect and write notes — you cannot touch anything outside the vault. +You have vault-scoped tools. Use them to inspect and write notes — you cannot touch anything outside the vault. - list_files(subdir?) — discover existing structure. Do this before creating new files so you extend rather than duplicate. - read_file(path) — read an existing note before editing it. - write_file(path, content) — create or overwrite a file (parents auto-created). - edit_file(path, old_string, new_string) — exact-string replacement for incremental updates; old_string must be unique in the file. +- list_vault_tags(prefix?) — list existing tags with counts. Call before choosing frontmatter tags so you reuse existing tags instead of inventing near-duplicates. +- check_wikilinks(path?) — verify [[wikilinks]] resolve after writing or renaming notes. +- search_notes(query, regex?, max_results?) — find existing notes/mentions before creating new ones. ## How to work (reinforcing the skill) diff --git a/src/agent/openaiCompat/toolSchemas.ts b/src/agent/openaiCompat/toolSchemas.ts index 2e6e273..fa77d70 100644 --- a/src/agent/openaiCompat/toolSchemas.ts +++ b/src/agent/openaiCompat/toolSchemas.ts @@ -1,8 +1,9 @@ /** * Chat-completions "tools" definitions for the hand-rolled openai-compat agent - * loop. Kept deliberately small — the four vault-scoped file operations the - * brain-dump skill needs. All paths are vault-relative; the implementations in - * tools.ts fence every access through assertInsideVault. + * loop: the four vault-scoped file operations plus three read-only vault-aware + * helpers (tags, wikilinks, search) the brain-dump skill needs. All paths are + * vault-relative; the implementations in tools.ts fence every access through + * assertInsideVault. */ export interface ChatTool { @@ -103,4 +104,69 @@ export const toolSchemas: ChatTool[] = [ }, }, }, + { + type: "function", + function: { + name: "list_vault_tags", + description: + "List tags used across the vault (from YAML frontmatter and inline #tags), with counts. Call before choosing frontmatter tags for a new or edited note so you reuse existing tags instead of inventing near-duplicates.", + parameters: { + type: "object", + properties: { + prefix: { + type: "string", + description: "Optional prefix filter, e.g. \"project/\" to see only tags under that namespace.", + }, + }, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "check_wikilinks", + description: + "Check whether [[wikilinks]] resolve to existing notes. Verify wikilinks resolve after writing or renaming notes, so you catch broken links before ending the turn.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Optional vault-relative path to check just one file. Omit to check the whole vault.", + }, + }, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "search_notes", + description: + "Search vault notes line-by-line for a substring or regex. Use this to find existing notes or mentions of a topic before creating new files, so you extend rather than duplicate.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "Text to search for (case-insensitive substring by default).", + }, + regex: { + type: "boolean", + description: "If true, treat query as a case-insensitive regular expression.", + }, + max_results: { + type: "integer", + description: "Maximum number of matches to return (default 50, hard cap 200).", + }, + }, + required: ["query"], + additionalProperties: false, + }, + }, + }, ]; diff --git a/src/agent/openaiCompat/tools.ts b/src/agent/openaiCompat/tools.ts index 54af187..c8dba4e 100644 --- a/src/agent/openaiCompat/tools.ts +++ b/src/agent/openaiCompat/tools.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { assertInsideVault } from "../../vault/pathSafety.js"; import { VaultEscapeError } from "../../errors.js"; +import { walkFiles, listVaultTags, checkWikilinks, searchNotes } from "../../vault/noteTools.js"; /** * Result of executing a tool call: `content` is the string handed back to the @@ -14,25 +15,6 @@ export interface ToolExecResult { write?: { path: string; bytes: number }; } -const IGNORED_DIRS = new Set([".git", ".obsidian"]); - -function listFilesRecursive(root: string, dir: string, out: string[]): void { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue; - const abs = path.join(dir, entry.name); - if (entry.isDirectory()) { - listFilesRecursive(root, abs, out); - } else if (entry.isFile()) { - out.push(path.relative(root, abs)); - } - } -} /** * Execute a single tool call against the vault. Never throws for expected @@ -52,7 +34,7 @@ export function executeTool( const subdir = typeof args.subdir === "string" && args.subdir ? args.subdir : "."; const base = subdir === "." ? path.resolve(vaultRoot) : assertInsideVault(vaultRoot, subdir); const out: string[] = []; - listFilesRecursive(path.resolve(vaultRoot), base, out); + walkFiles(path.resolve(vaultRoot), base, out); out.sort(); return { content: out.length ? out.join("\n") : "(no files)" }; } @@ -112,6 +94,33 @@ export function executeTool( write: { path: args.path, bytes }, }; } + case "list_vault_tags": { + const prefix = typeof args.prefix === "string" && args.prefix ? args.prefix : undefined; + const tags = listVaultTags(vaultRoot, prefix); + if (tags.length === 0) return { content: "(no tags found)" }; + return { content: tags.map((t) => `${t.tag} (${t.count})`).join("\n") }; + } + case "check_wikilinks": { + const relPath = typeof args.path === "string" && args.path ? args.path : undefined; + const results = checkWikilinks(vaultRoot, relPath); + if (results.length === 0) return { content: "(no wikilinks found)" }; + const lines = results.map((r) => + r.ok ? `OK ${r.path} -> ${r.resolvesTo}` : `BROKEN ${r.path} -> ${r.link}` + ); + const brokenCount = results.filter((r) => !r.ok).length; + lines.push(`${results.length} link(s) checked, ${brokenCount} broken.`); + return { content: lines.join("\n") }; + } + case "search_notes": { + if (typeof args.query !== "string" || !args.query) { + return { content: "Error: search_notes requires a 'query' string argument." }; + } + const regex = args.regex === true; + const maxResults = typeof args.max_results === "number" ? args.max_results : undefined; + const results = searchNotes(vaultRoot, args.query, { regex, maxResults }); + if (results.length === 0) return { content: "(no matches)" }; + return { content: results.map((r) => `${r.path}:${r.line}: ${r.text}`).join("\n") }; + } default: return { content: `Error: unknown tool "${name}"` }; } diff --git a/src/vault/noteTools.test.ts b/src/vault/noteTools.test.ts new file mode 100644 index 0000000..0034054 --- /dev/null +++ b/src/vault/noteTools.test.ts @@ -0,0 +1,299 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { listVaultTags, checkWikilinks, searchNotes } from "./noteTools.js"; +import { InvalidError } from "../errors.js"; + +function makeVault(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "braindump-notetools-")); +} + +function write(vault: string, rel: string, content: string): void { + const abs = path.join(vault, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, "utf8"); +} + +function fixtureVault(): string { + const vault = makeVault(); + + write( + vault, + "frontmatter-list.md", + `--- +title: List Tags +tags: + - foo + - foo/bar +--- + +Body text, no inline tags here. +` + ); + + write( + vault, + "frontmatter-array.md", + `--- +title: Array Tags +tags: [foo, baz] +--- + +Body text. +` + ); + + write( + vault, + "inline.md", + `--- +title: Inline Tags +--- + +This note mentions #inline-tag and #foo in the body, plus a #123 number-only +tag that should NOT count, and a code fence below that must be ignored: + +\`\`\` +this is #not-a-real-tag inside a fence +\`\`\` + +Trailing #inline-tag repeat. +` + ); + + write( + vault, + "projects/x/y.md", + `--- +tags: [nested] +--- + +Nested note with a wikilink to [[frontmatter-list]] and a search needle: findme-substring. +Also a case-INSENSITIVE needle: FindMe-Regex-123. +` + ); + + write( + vault, + "wikilinks.md", + `# Wikilinks + +Resolves by basename: [[frontmatter-list]]. +Resolves by path: [[projects/x/y]]. +With alias: [[frontmatter-list|List of tags]]. +With heading: [[frontmatter-list#Some Heading]]. +Broken link: [[does-not-exist]]. + +\`\`\` +A link inside a fence that must be ignored: [[also-does-not-exist]] +\`\`\` +` + ); + + write( + vault, + "search-lines.md", + `Line one has findme-substring in it. +Line two has FINDME-SUBSTRING in different case. +Line three is plain. +Line four: findme-substring again. +Line five: findme-substring again again. +Line six: findme-substring yet again. +` + ); + + return vault; +} + +// --------------------------------------------------------------------------- +// listVaultTags +// --------------------------------------------------------------------------- + +test("listVaultTags collects frontmatter list-form tags", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault); + const byTag = new Map(tags.map((t) => [t.tag, t.count])); + assert.equal(byTag.get("foo/bar"), 1); +}); + +test("listVaultTags collects frontmatter inline-array tags", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault); + const byTag = new Map(tags.map((t) => [t.tag, t.count])); + assert.equal(byTag.get("baz"), 1); + assert.equal(byTag.get("nested"), 1); +}); + +test("listVaultTags collects inline body tags and counts repeats", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault); + const byTag = new Map(tags.map((t) => [t.tag, t.count])); + // #inline-tag appears twice in inline.md. + assert.equal(byTag.get("inline-tag"), 2); +}); + +test("listVaultTags counts 'foo' across both frontmatter files and inline mention", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault); + const byTag = new Map(tags.map((t) => [t.tag, t.count])); + // foo: list-form (1) + array-form (1) + inline #foo (1) = 3 + assert.equal(byTag.get("foo"), 3); +}); + +test("listVaultTags excludes tags found only inside code fences", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault); + const byTag = new Map(tags.map((t) => [t.tag, t.count])); + assert.equal(byTag.has("not-a-real-tag"), false); +}); + +test("listVaultTags excludes number-only inline tags", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault); + const byTag = new Map(tags.map((t) => [t.tag, t.count])); + assert.equal(byTag.has("123"), false); +}); + +test("listVaultTags filters by prefix", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault, "foo"); + for (const t of tags) { + assert.ok(t.tag.startsWith("foo")); + } + assert.ok(tags.some((t) => t.tag === "foo")); + assert.ok(tags.some((t) => t.tag === "foo/bar")); + assert.ok(!tags.some((t) => t.tag === "baz")); +}); + +test("listVaultTags sorts by count desc then name", () => { + const vault = fixtureVault(); + const tags = listVaultTags(vault); + for (let i = 1; i < tags.length; i++) { + const prev = tags[i - 1]; + const cur = tags[i]; + assert.ok(prev.count > cur.count || (prev.count === cur.count && prev.tag.localeCompare(cur.tag) <= 0)); + } +}); + +// --------------------------------------------------------------------------- +// checkWikilinks +// --------------------------------------------------------------------------- + +test("checkWikilinks resolves a link by basename", () => { + const vault = fixtureVault(); + const results = checkWikilinks(vault, "wikilinks.md"); + const byBasename = results.find((r) => r.link === "frontmatter-list"); + assert.ok(byBasename); + assert.equal(byBasename?.ok, true); + assert.equal(byBasename?.resolvesTo, "frontmatter-list.md"); +}); + +test("checkWikilinks resolves a link by vault-relative path", () => { + const vault = fixtureVault(); + const results = checkWikilinks(vault, "wikilinks.md"); + const byPath = results.find((r) => r.link === "projects/x/y"); + assert.ok(byPath); + assert.equal(byPath?.ok, true); + assert.equal(byPath?.resolvesTo, "projects/x/y.md"); +}); + +test("checkWikilinks resolves a path-link relative to the linking file's folder", () => { + const vault = fixtureVault(); + // projects/x/deep.md links to [[sub/z]] meaning projects/x/sub/z.md, + // which does not exist at vault root — Obsidian resolves it relative + // to the linking file's own folder. + write(vault, "projects/x/sub/z.md", "# Z\n"); + write(vault, "projects/x/deep.md", "Folder-relative link: [[sub/z]].\n"); + const results = checkWikilinks(vault, "projects/x/deep.md"); + const rel = results.find((r) => r.link === "sub/z"); + assert.ok(rel); + assert.equal(rel?.ok, true); + assert.equal(rel?.resolvesTo, "projects/x/sub/z.md"); +}); + +test("checkWikilinks strips alias suffix before resolving", () => { + const vault = fixtureVault(); + const results = checkWikilinks(vault, "wikilinks.md"); + const aliased = results.find((r) => r.link === "frontmatter-list|List of tags"); + assert.ok(aliased); + assert.equal(aliased?.ok, true); + assert.equal(aliased?.resolvesTo, "frontmatter-list.md"); +}); + +test("checkWikilinks strips heading suffix before resolving", () => { + const vault = fixtureVault(); + const results = checkWikilinks(vault, "wikilinks.md"); + const headinged = results.find((r) => r.link === "frontmatter-list#Some Heading"); + assert.ok(headinged); + assert.equal(headinged?.ok, true); + assert.equal(headinged?.resolvesTo, "frontmatter-list.md"); +}); + +test("checkWikilinks reports a broken link", () => { + const vault = fixtureVault(); + const results = checkWikilinks(vault, "wikilinks.md"); + const broken = results.find((r) => r.link === "does-not-exist"); + assert.ok(broken); + assert.equal(broken?.ok, false); + assert.equal(broken?.resolvesTo, undefined); +}); + +test("checkWikilinks ignores links inside code fences", () => { + const vault = fixtureVault(); + const results = checkWikilinks(vault, "wikilinks.md"); + assert.ok(!results.some((r) => r.link === "also-does-not-exist")); +}); + +test("checkWikilinks with no path scans the whole vault", () => { + const vault = fixtureVault(); + const results = checkWikilinks(vault); + assert.ok(results.some((r) => r.path === "wikilinks.md" && r.link === "does-not-exist")); +}); + +// --------------------------------------------------------------------------- +// searchNotes +// --------------------------------------------------------------------------- + +test("searchNotes finds case-insensitive substring matches", () => { + const vault = fixtureVault(); + const results = searchNotes(vault, "findme-substring"); + const paths = new Set(results.map((r) => r.path)); + assert.ok(paths.has("search-lines.md")); + assert.ok(paths.has("projects/x/y.md")); + const upperMatch = results.find((r) => r.text.includes("FINDME-SUBSTRING")); + assert.ok(upperMatch); +}); + +test("searchNotes supports regex mode", () => { + const vault = fixtureVault(); + const results = searchNotes(vault, "findme-regex-\\d+", { regex: true }); + assert.ok(results.some((r) => r.path === "projects/x/y.md")); +}); + +test("searchNotes throws InvalidError on a bad regex", () => { + const vault = fixtureVault(); + assert.throws(() => searchNotes(vault, "(unclosed", { regex: true }), InvalidError); +}); + +test("searchNotes caps results at maxResults", () => { + const vault = fixtureVault(); + const results = searchNotes(vault, "findme-substring", { maxResults: 2 }); + assert.equal(results.length, 2); +}); + +test("searchNotes hard-caps maxResults at 200 even if a larger value is requested", () => { + const vault = fixtureVault(); + const results = searchNotes(vault, "findme-substring", { maxResults: 10000 }); + assert.ok(results.length <= 200); +}); + +test("searchNotes reports 1-based line numbers and trimmed text", () => { + const vault = fixtureVault(); + const results = searchNotes(vault, "Line one has findme-substring"); + const match = results.find((r) => r.path === "search-lines.md"); + assert.ok(match); + assert.equal(match?.line, 1); + assert.equal(match?.text, "Line one has findme-substring in it."); +}); diff --git a/src/vault/noteTools.ts b/src/vault/noteTools.ts new file mode 100644 index 0000000..cd92732 --- /dev/null +++ b/src/vault/noteTools.ts @@ -0,0 +1,316 @@ +import fs from "node:fs"; +import path from "node:path"; +import { assertInsideVault } from "./pathSafety.js"; +import { InvalidError } from "../errors.js"; + +const IGNORED_DIRS = new Set([".git", ".obsidian"]); + +/** + * Recursively collect every file (any extension) under `dir` into `out` as + * paths relative to `root`. Skips `.git` and `.obsidian` directories. Shared + * by the openai-compat `list_files` tool and the note-tools helpers below. + */ +export function walkFiles(root: string, dir: string, out: string[]): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkFiles(root, abs, out); + } else if (entry.isFile()) { + out.push(path.relative(root, abs)); + } + } +} + +/** Sorted list of vault-relative `.md` file paths (forward-slash separated). */ +export function listMarkdownFiles(vaultRoot: string): string[] { + const root = path.resolve(vaultRoot); + const out: string[] = []; + walkFiles(root, root, out); + return out + .filter((f) => f.toLowerCase().endsWith(".md")) + .map((f) => f.split(path.sep).join("/")) + .sort(); +} + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +const FRONTMATTER_TAG_LIST_RE = /^\s*-\s*(.+?)\s*$/; +const INLINE_TAG_RE = /#([A-Za-z0-9_/-]+)/g; + +function unquote(s: string): string { + const t = s.trim(); + if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) { + return t.slice(1, -1); + } + return t; +} + +function normalizeTag(raw: string): string { + let t = raw.trim(); + if (t.startsWith("#")) t = t.slice(1); + return t; +} + +/** Parse the `tags:` key out of a file's YAML frontmatter block, if present. */ +function parseFrontmatterTags(content: string): string[] { + const lines = content.split(/\r?\n/); + if (lines[0]?.trim() !== "---") return []; + let end = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") { + end = i; + break; + } + } + if (end === -1) return []; + + const front = lines.slice(1, end); + const tags: string[] = []; + for (let i = 0; i < front.length; i++) { + const m = /^tags:\s*(.*)$/.exec(front[i]); + if (!m) continue; + const rest = m[1].trim(); + if (rest.startsWith("[")) { + // Inline array form: tags: [foo, bar] (collect across lines until ']'). + let arrText = rest; + let j = i; + while (!arrText.includes("]") && j + 1 < front.length) { + j++; + arrText += " " + front[j].trim(); + } + const inner = arrText.slice(arrText.indexOf("[") + 1, arrText.lastIndexOf("]")); + for (const part of inner.split(",")) { + const v = unquote(part); + if (v) tags.push(normalizeTag(v)); + } + } else if (rest === "") { + // List form: subsequent indented "- foo" lines. + let j = i + 1; + while (j < front.length) { + const item = FRONTMATTER_TAG_LIST_RE.exec(front[j]); + if (!item) break; + const v = unquote(item[1]); + if (v) tags.push(normalizeTag(v)); + j++; + } + } else { + // Single scalar value: tags: foo + const v = unquote(rest); + if (v) tags.push(normalizeTag(v)); + } + break; // only one top-level `tags:` key expected + } + return tags; +} + +/** + * Extract inline `#tag` occurrences from the file body (excluding + * frontmatter and fenced code blocks). + */ +function extractInlineTags(content: string): string[] { + const lines = content.split(/\r?\n/); + let start = 0; + if (lines[0]?.trim() === "---") { + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === "---") { + start = i + 1; + break; + } + } + } + + const tags: string[] = []; + let inFence = false; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + for (const m of line.matchAll(INLINE_TAG_RE)) { + const tag = normalizeTag(m[1]); + if (/[^0-9]/.test(tag)) tags.push(tag); + } + } + return tags; +} + +/** + * Collect tags across the vault: YAML frontmatter `tags:` (list or inline + * array form) plus inline `#tag` mentions in the body (skipping code + * fences). Counts occurrences per tag across all files, optionally filtered + * by prefix, sorted by count desc then name. + */ +export function listVaultTags(vaultRoot: string, prefix?: string): { tag: string; count: number }[] { + const root = path.resolve(vaultRoot); + const counts = new Map(); + for (const rel of listMarkdownFiles(root)) { + let content: string; + try { + content = fs.readFileSync(path.join(root, rel), "utf8"); + } catch { + continue; + } + for (const tag of [...parseFrontmatterTags(content), ...extractInlineTags(content)]) { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + } + let entries = [...counts.entries()].map(([tag, count]) => ({ tag, count })); + if (prefix) entries = entries.filter((e) => e.tag.startsWith(prefix)); + entries.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); + return entries; +} + +// --------------------------------------------------------------------------- +// Wikilinks +// --------------------------------------------------------------------------- + +const WIKILINK_RE = /\[\[([^\]]+)\]\]/g; + +/** Extract raw `[[...]]` wikilink bodies from a file, skipping fenced code blocks. */ +function extractWikilinks(content: string): string[] { + const lines = content.split(/\r?\n/); + const links: string[] = []; + let inFence = false; + for (const line of lines) { + if (/^\s*```/.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + for (const m of line.matchAll(WIKILINK_RE)) { + links.push(m[1]); + } + } + return links; +} + +/** + * Scan `[[wikilinks]]` in one file (or the whole vault) and report whether + * each resolves to a note. Obsidian resolution rules: a bare link name + * matches any note whose basename equals it (case-insensitive); a link + * containing `/` must match a vault-relative path (case-insensitive). + */ +export function checkWikilinks( + vaultRoot: string, + relPath?: string +): { path: string; link: string; ok: boolean; resolvesTo?: string }[] { + const root = path.resolve(vaultRoot); + + const allFiles = listMarkdownFiles(root); + const byBasename = new Map(); + const byPath = new Map(); + for (const f of allFiles) { + const noExt = f.slice(0, -3); + const base = path.basename(noExt).toLowerCase(); + if (!byBasename.has(base)) byBasename.set(base, []); + byBasename.get(base)!.push(f); + byPath.set(noExt.toLowerCase(), f); + } + + let targets: string[]; + if (relPath) { + const abs = assertInsideVault(root, relPath); + targets = [path.relative(root, abs).split(path.sep).join("/")]; + } else { + targets = allFiles; + } + + const results: { path: string; link: string; ok: boolean; resolvesTo?: string }[] = []; + for (const rel of targets) { + let content: string; + try { + content = fs.readFileSync(path.join(root, rel), "utf8"); + } catch { + continue; + } + for (const link of extractWikilinks(content)) { + const target = link.split("|")[0].split("#")[0].trim(); + if (!target) continue; + let resolvesTo: string | undefined; + if (target.includes("/")) { + // Obsidian resolves path-links both from the vault root and relative + // to the linking file's own folder — try root first, then relative. + resolvesTo = byPath.get(target.toLowerCase()); + if (!resolvesTo) { + const fromDir = path.posix.normalize( + path.posix.join(path.posix.dirname(rel), target) + ); + if (!fromDir.startsWith("..")) { + resolvesTo = byPath.get(fromDir.toLowerCase()); + } + } + } else { + const matches = byBasename.get(target.toLowerCase()); + if (matches && matches.length > 0) resolvesTo = matches[0]; + } + results.push({ path: rel, link, ok: !!resolvesTo, ...(resolvesTo ? { resolvesTo } : {}) }); + } + } + return results; +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_RESULTS = 50; +const HARD_MAX_RESULTS = 200; +const MAX_LINE_LEN = 200; + +/** + * Line-by-line search across all vault `.md` files. Case-insensitive + * substring match by default; `regex: true` compiles `query` as a + * case-insensitive RegExp (throws InvalidError on a bad pattern). + */ +export function searchNotes( + vaultRoot: string, + query: string, + opts?: { regex?: boolean; maxResults?: number } +): { path: string; line: number; text: string }[] { + const root = path.resolve(vaultRoot); + const maxResults = Math.max(1, Math.min(opts?.maxResults ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS)); + + let matches: (line: string) => boolean; + if (opts?.regex) { + let re: RegExp; + try { + re = new RegExp(query, "i"); + } catch (e) { + throw new InvalidError(`Invalid regex: ${(e as Error).message}`); + } + matches = (line) => re.test(line); + } else { + const needle = query.toLowerCase(); + matches = (line) => line.toLowerCase().includes(needle); + } + + const results: { path: string; line: number; text: string }[] = []; + for (const rel of listMarkdownFiles(root)) { + let content: string; + try { + content = fs.readFileSync(path.join(root, rel), "utf8"); + } catch { + continue; + } + const lines = content.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (!matches(lines[i])) continue; + let text = lines[i].trim(); + if (text.length > MAX_LINE_LEN) text = text.slice(0, MAX_LINE_LEN); + results.push({ path: rel, line: i + 1, text }); + if (results.length >= maxResults) return results; + } + } + return results; +}