add vault-aware tools: list_vault_tags, check_wikilinks, search_notes

Shared read-only implementations in src/vault/noteTools.ts, exposed to
both backends: as an in-process SDK MCP server (mcp__vault__*, stripped
to plain names in SSE events) for claude-sdk, and as chat-completions
tool defs for openai-compat. Wikilink resolution follows Obsidian
semantics incl. path-links resolved relative to the linking file's
folder; tags come from frontmatter (list + array forms) and inline
#tags, excluding code fences. zod added as a direct dependency
(pinned to the SDK's transitively-resolved v4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 02:14:31 +00:00
co-authored by Claude
parent a5f62512e8
commit 9d71692402
9 changed files with 820 additions and 31 deletions
+2 -2
View File
@@ -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"
}
+2 -1
View File
@@ -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",
+21 -4
View File
@@ -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 } : {}) };
}
}
}
+78
View File
@@ -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],
});
}
+4 -1
View File
@@ -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)
+69 -3
View File
@@ -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,
},
},
},
];
+29 -20
View File
@@ -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}"` };
}
+299
View File
@@ -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.");
});
+316
View File
@@ -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<string, number>();
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<string, string[]>();
const byPath = new Map<string, string>();
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;
}