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:
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}"` };
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user