Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ log would produce N lines during a vault rebuild (one per note), it's
This applies everywhere: function params, callback params (`row`
not `r`, `entry` not `e`, `orphan` not `o`), SQL aliases
(`element` not `je`), destructured bindings, and loop variables.
- Function and helper names state what they _do_, specifically — a reader
should know what a function does without reading its body
(`collectWikilinksFrom` not `collect`,
`convertFrontmatterDatesToIsoStrings` not `normalizeDates`). A docstring
complements a self-documenting name; it never excuses a vague one.
- Regex constants get doc comments explaining what they match.
Inline regexes used more than once should be extracted to a named
`const` with a doc comment.
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ The vault `.md` files are canonical. SQLite FTS5 is derived — rebuildable from
| `vault_get_outgoing_links` | `path` | readOnlyHint |
| `vault_find_orphans` | `exclude_folders?, limit?` | readOnlyHint |

Link queries use a `links` table populated from `[[wikilink]]` and `[text](path.md)` regex during indexing, with fence-aware parsing (skips code blocks). Links are resolved against all known note paths (shortest-path-first for ambiguous basenames). `vault_find_orphans` excludes folders listed in `ORPHAN_EXCLUDE_FOLDERS` (default: `Daily Notes`, `Templates`, and the memory dir).
Link queries use a `links` table populated during indexing from `[[wikilink]]` and `[text](path.md)` links in the note body (fence-aware parsing skips code blocks) plus `[[wikilink]]`s in frontmatter property values (e.g. `related:`), matching Obsidian's graph. Links are resolved against all known note paths (shortest-path-first for ambiguous basenames). `vault_find_orphans` excludes folders listed in `ORPHAN_EXCLUDE_FOLDERS` (default: `Daily Notes`, `Templates`, and the memory dir).

### Phase 2: Knowledge Base (R8)

Expand Down
216 changes: 216 additions & 0 deletions src/vault-mcp/search/__tests__/search-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createSearchIndex,
sanitizeFtsQuery,
extractLinks,
extractFrontmatterLinks,
resolveLink,
} from "../search-index.js"
import type { SearchIndex } from "../search-index.js"
Expand Down Expand Up @@ -1367,6 +1368,26 @@ describe("rebuildFromVault", () => {
expect(results).toHaveLength(1)
expect(results[0].path).toBe("About Me/Principles.md")
})

it("resolves a frontmatter wikilink whose target is indexed later (forward reference)", async () => {
// related: points to a note that sorts after the source, so the source is
// indexed first; the two-pass rebuild must still resolve it. The body has
// no link to z-target, so only frontmatter extraction can produce the edge.
await writeFile(
join(vaultDir, "a-source.md"),
'---\ntitle: Source\nrelated: ["[[z-target]]"]\n---\n\n# Source\n\nProse only.\n',
"utf8",
)
await writeFile(
join(vaultDir, "z-target.md"),
"# Z Target\n\nBody.\n",
"utf8",
)
await index.rebuildFromVault(vaultDir)
const backlinks = index.getBacklinks({ path: "z-target.md" }, logger)
expect(backlinks).toHaveLength(1)
expect(backlinks[0].path).toBe("a-source.md")
})
})

// ── extractLinks ─────────────────────────────────────────────────
Expand Down Expand Up @@ -1502,6 +1523,58 @@ describe("extractLinks", () => {
})
})

// ── extractFrontmatterLinks ──────────────────────────────────────

describe("extractFrontmatterLinks", () => {
it("extracts a wikilink from a string property value", () => {
expect(extractFrontmatterLinks({ up: "[[Parent Note]]" })).toEqual([
"Parent Note",
])
})

it("extracts wikilinks from an array property (e.g. related)", () => {
expect(
extractFrontmatterLinks({ related: ["[[Note A]]", "[[Note B]]"] }),
).toEqual(["Note A", "Note B"])
})

it("strips alias and heading from a frontmatter wikilink", () => {
expect(
extractFrontmatterLinks({ related: ["[[Note A#Section|display]]"] }),
).toEqual(["Note A"])
})

it("extracts a wikilink embedded in surrounding text", () => {
expect(
extractFrontmatterLinks({ note: "see [[Note A]] for context" }),
).toEqual(["Note A"])
})

it("walks nested object property values", () => {
expect(extractFrontmatterLinks({ meta: { parent: "[[Note A]]" } })).toEqual(
["Note A"],
)
})

it("returns empty for plain-string values with no wikilinks", () => {
expect(
extractFrontmatterLinks({ related: ["Routines", "Career"] }),
).toEqual([])
})

it("ignores non-string scalar values", () => {
expect(
extractFrontmatterLinks({ count: 3, draft: true, missing: null }),
).toEqual([])
})

it("deduplicates a target repeated across properties", () => {
expect(
extractFrontmatterLinks({ up: "[[Note A]]", related: ["[[Note A]]"] }),
).toEqual(["Note A"])
})
})

// ── resolveLink ──────────────────────────────────────────────────

describe("resolveLink", () => {
Expand Down Expand Up @@ -1788,4 +1861,147 @@ describe("forward reference resolution", () => {
expect(backlinks).toHaveLength(1)
expect(backlinks[0].path).toBe("source.md")
})

it("re-resolves a full-path forward reference when the target is created later", () => {
// A full-path link is stored without .md ("folder/target") while the target
// doesn't exist yet. Frontmatter related: links are usually full-path, so
// this is the common incremental case. Body has no link → only the
// frontmatter edge is under test.
index.upsertNote(
{
filePath: "source.md",
rawContent:
'---\ntitle: Source\nrelated: ["[[folder/target]]"]\n---\n\n# Source\n\nProse only.\n',
fileStat: testStat(1000),
},
logger,
)
// Target absent → link stored unresolved → no backlink yet.
expect(
index.getBacklinks({ path: "folder/target.md" }, logger),
).toHaveLength(0)

index.upsertNote(
{
filePath: "folder/target.md",
rawContent: "# Target\n\nBody.\n",
fileStat: testStat(2000),
},
logger,
)
const backlinks = index.getBacklinks({ path: "folder/target.md" }, logger)
expect(backlinks).toHaveLength(1)
expect(backlinks[0].path).toBe("source.md")
})
})

describe("frontmatter links in the graph", () => {
// Every fixture below gives the source a body with NO link to the target, so
// the asserted edge can only come from the frontmatter wikilink — never from a
// body link that happened to cover it.

it("surfaces a frontmatter-only target in getOutgoingLinks", () => {
index.upsertNote(
{
filePath: "session.md",
rawContent:
'---\ntitle: Session\nrelated: ["[[task-board]]"]\n---\n\n# Session\n\nProse with no links.\n',
fileStat: testStat(1000),
},
logger,
)
index.upsertNote(
{
filePath: "task-board.md",
rawContent: "# Task Board\n\nBody.\n",
fileStat: testStat(2000),
},
logger,
)
const links = index.getOutgoingLinks({ path: "session.md" }, logger)
expect(links).toHaveLength(1)
expect(links[0].path).toBe("task-board.md")
expect(links[0].exists).toBe(true)
})

it("surfaces a frontmatter-only source in getBacklinks", () => {
index.upsertNote(
{
filePath: "session.md",
rawContent:
'---\ntitle: Session\nrelated: ["[[task-board]]"]\n---\n\n# Session\n\nProse with no links.\n',
fileStat: testStat(1000),
},
logger,
)
index.upsertNote(
{
filePath: "task-board.md",
rawContent: "# Task Board\n\nBody.\n",
fileStat: testStat(2000),
},
logger,
)
const backlinks = index.getBacklinks({ path: "task-board.md" }, logger)
expect(backlinks).toHaveLength(1)
expect(backlinks[0].path).toBe("session.md")
})

it("does not flag a frontmatter-referenced note as an orphan, but still flags a truly unreferenced one", () => {
index.upsertNote(
{
filePath: "referencer.md",
rawContent:
'---\ntitle: Referencer\nrelated: ["[[referenced]]"]\n---\n\n# Referencer\n\nNo body links.\n',
fileStat: testStat(1000),
},
logger,
)
index.upsertNote(
{
filePath: "referenced.md",
rawContent: "# Referenced\n\nBody.\n",
fileStat: testStat(2000),
},
logger,
)
index.upsertNote(
{
filePath: "true-orphan.md",
rawContent: "# True Orphan\n\nNobody links here.\n",
fileStat: testStat(3000),
},
logger,
)
const orphanPaths = index
.findOrphans({}, logger)
.map((orphan) => orphan.path)
// referenced only via frontmatter → connected, not an orphan
expect(orphanPaths).not.toContain("referenced.md")
// genuinely unreferenced → still an orphan (proves exclusion is selective)
expect(orphanPaths).toContain("true-orphan.md")
})

it("counts a target linked from both body and frontmatter as a single edge", () => {
index.upsertNote(
{
filePath: "double.md",
rawContent:
'---\ntitle: Double\nrelated: ["[[shared]]"]\n---\n\n# Double\n\nAlso links [[shared]] in the body.\n',
fileStat: testStat(1000),
},
logger,
)
index.upsertNote(
{
filePath: "shared.md",
rawContent: "# Shared\n\nBody.\n",
fileStat: testStat(2000),
},
logger,
)
const backlinks = index.getBacklinks({ path: "shared.md" }, logger)
expect(backlinks).toHaveLength(1)
expect(backlinks[0].path).toBe("double.md")
})
})
70 changes: 64 additions & 6 deletions src/vault-mcp/search/search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,9 @@ type OutgoingLinkEntry = {
//
// Indexing flow:
// 1. extractLinks() parses wikilinks ([[target]]) and markdown links
// ([text](path.md)) from note body, skipping fenced code blocks
// and inline code spans.
// ([text](path.md)) from the note body (skipping fenced code blocks
// and inline code spans); extractFrontmatterLinks() adds [[wikilinks]]
// from frontmatter property values (e.g. related:).
// 2. resolveLink() maps each raw target to a vault-relative path by
// trying exact match → basename match → shortest-path heuristic.
// 3. upsertNote stores resolved links in the `links` table. Unresolved
Expand Down Expand Up @@ -269,6 +270,59 @@ export const extractLinks = (content: string): string[] => {
return [...targets]
}

/** Extracts [[wikilink]] targets from frontmatter property values. Obsidian
* resolves wikilinks in any frontmatter property (e.g. `related:`), so they
* are real graph edges — body-only extraction silently drops them. Recursively
* walks strings, arrays, and nested objects, applying WIKILINK_RE to every
* string. Frontmatter is YAML (no code fences or inline-code spans), so the
* body extractor's fence handling doesn't apply. Markdown [text](path.md)
* links are a body convention and are intentionally not scanned here. Returns
* deduplicated raw targets (pre-resolution), matching extractLinks' contract. */
export const extractFrontmatterLinks = (
data: Record<string, unknown>,
): string[] => {
const targets = new Set<string>()

// Walks one frontmatter value, adding every [[wikilink]] target it finds to
// `targets`. A value is one of three shapes, so it recurses through the two
// container shapes down to the string leaves.
const collectWikilinksFrom = (frontmatterValue: unknown): void => {
// Leaf: a string may hold one or more wikilinks — pull out each target.
if (typeof frontmatterValue === "string") {
for (const match of frontmatterValue.matchAll(WIKILINK_RE)) {
const target = match[1]!.trim()
if (target.length > 0) targets.add(target)
}
return
}
// Array (e.g. a multi-value `related:`): recurse into every element.
if (Array.isArray(frontmatterValue)) {
for (const item of frontmatterValue) collectWikilinksFrom(item)
return
}
// Nested object: recurse into every value. The null guard is required —
// `typeof null === "object"`, and Object.values(null) would throw.
if (frontmatterValue !== null && typeof frontmatterValue === "object") {
for (const nestedValue of Object.values(frontmatterValue)) {
collectWikilinksFrom(nestedValue)
}
}
}

collectWikilinksFrom(data)
return [...targets]
}

/** A note's complete link set — body links unioned with frontmatter wikilinks,
* deduplicated. Single source of truth for "what does this note link to",
* shared by incremental upsert and full rebuild so the two can't diverge. */
const extractAllLinks = (
content: string,
data: Record<string, unknown>,
): string[] => [
...new Set([...extractLinks(content), ...extractFrontmatterLinks(data)]),
]

/** Resolves a wikilink target to a vault-relative path using all known paths.
* Returns null if unresolvable. */
export const resolveLink = (
Expand Down Expand Up @@ -441,16 +495,20 @@ export const createSearchIndex = (dbPath: string) => {
const pathList = allPaths.map((row) => row.path)

deleteLinksStmt.run(note.path)
for (const rawTarget of extractLinks(parsed.content)) {
for (const rawTarget of extractAllLinks(parsed.content, frontmatter)) {
const resolved = resolveLink(rawTarget, pathList)
insertLinkStmt.run(note.path, resolved ?? rawTarget)
}
Comment thread
aliasunder marked this conversation as resolved.

// Re-resolve stale unresolved targets that match the newly added note.
// Two forms: wikilinks store just the basename ("Note"), markdown links
// may store the full path ("folder/Note.md").
// Three stored forms: wikilinks store the basename ("Note"); folder
// wikilinks ([[folder/Note]]) and markdown links ([text](folder/Note.md))
// store the full path without the extension ("folder/Note"); explicit-ext
// wikilinks ([[folder/Note.md]]) store the full path with it.
const fileBasename = basename(note.path, ".md")
const pathWithoutExtension = note.path.replace(/\.md$/, "")
reResolveStmt.run({ resolved: note.path, raw: fileBasename })
reResolveStmt.run({ resolved: note.path, raw: pathWithoutExtension })
reResolveStmt.run({ resolved: note.path, raw: note.path })
}

Expand Down Expand Up @@ -528,7 +586,7 @@ export const createSearchIndex = (dbPath: string) => {
db.exec("DELETE FROM links")
for (const note of noteContents) {
const parsed = parseNote(note.content)
for (const rawTarget of extractLinks(parsed.content)) {
for (const rawTarget of extractAllLinks(parsed.content, parsed.data)) {
const resolved = resolveLink(rawTarget, pathList)
insertLinkStmt.run(note.relativePath, resolved ?? rawTarget)
}
Expand Down