Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
6dc1973
fix(tasks): keep task and heading parsing linear
aliasunder Nov 8, 2026
7f3d549
fix(links): scan link and plaintext delimiters in linear time
aliasunder Nov 8, 2026
188b1db
fix(frontmatter): check duplicate keys and bound canvas grouping
aliasunder Nov 8, 2026
7db328b
fix(review): qualify canvas rendering work guarantee
aliasunder Nov 8, 2026
a444e95
style: clarify parser invariants and field grammar
aliasunder Nov 8, 2026
e3b10bf
test: strengthen parser regression assertions
aliasunder Nov 8, 2026
265ed92
fix: qualify canvas nesting descriptions
aliasunder Nov 8, 2026
27fa7a1
fix(canvas): render each node once when group IDs repeat
aliasunder Nov 8, 2026
de7ddbb
fix(tasks): bound the metadata-start search on signifier-dense lines
aliasunder Nov 8, 2026
d75386b
test(parsers): keep differential oracles independent
aliasunder Nov 8, 2026
e40d1dd
test(tasks): clear recurrence from the line just written
aliasunder Nov 8, 2026
78ae8a4
test(frontmatter): pin duplicate pairs with nested duplicates
aliasunder Nov 8, 2026
7b3b17b
test(canvas): enforce adversarial latency and copy bounds
aliasunder Nov 8, 2026
5abf6ba
fix(search): collect heading levels without argument spread
aliasunder Nov 8, 2026
6c7f18a
fix(tasks): classify excluded lines once per lane walk
aliasunder Nov 8, 2026
b2d3a28
docs: correct the link namespace rationale
aliasunder Nov 8, 2026
b2d47e3
refactor(canvas): name renderNestedNodes' node lists and state the bu…
aliasunder Nov 8, 2026
128f970
fix(tasks): find a Dataview field glued to a hashtag in the bounded m…
aliasunder Nov 8, 2026
dd0e818
fix(tasks): keep main's lane positions next to a comment opener
aliasunder Nov 8, 2026
a5b5826
refactor(tasks): read the done status directly when completing a task
aliasunder Nov 8, 2026
43a914e
test(frontmatter): give the 10,000-document differential room on slow…
aliasunder Nov 8, 2026
368a758
fix(tasks): bound metadata boundary search across packed hashtags
aliasunder Nov 8, 2026
60724b7
fix(tasks): extract trailing description tags in one pass
aliasunder Nov 8, 2026
7b59558
test(headings): cover attached and whole-text closing hashes
aliasunder Nov 8, 2026
9d0aced
test(plaintext): exercise malformed wikilink aliases
aliasunder Nov 8, 2026
3893c74
fix(tasks): clear duplicate metadata in linear time
aliasunder Nov 8, 2026
1dedac3
fix(tasks): preserve task edit spacing compatibility
aliasunder Nov 8, 2026
1b6ba54
fix(tasks): bound description tag deduplication scans
aliasunder Nov 8, 2026
84fa304
Revert "fix(tasks): preserve task edit spacing compatibility"
aliasunder Nov 8, 2026
1264c19
test: bound scaling tests over the whole size range
aliasunder Nov 8, 2026
a6a006c
test(tasks): floor small timing medians at 25 ms in scaling bounds
aliasunder Nov 8, 2026
2c8bd8f
test(tasks): halve the field-grammar scaling sizes to fit the child d…
aliasunder Nov 8, 2026
50d756f
test(tasks): bound the status-stamp clear's time like its sibling adv…
aliasunder Nov 8, 2026
638aaf5
test(tasks): time trailing-tag deduplication across a 4x range
aliasunder Nov 8, 2026
2f2985a
test: keep the link scanner helpers in the helpers folder
aliasunder Nov 8, 2026
04c60e2
docs: keep test helpers and fixtures in their own subfolders
aliasunder Nov 8, 2026
5ed873c
docs: describe the parser bounds at the level ARCHITECTURE readers need
aliasunder Nov 8, 2026
c61e3b7
refactor: name the fenced and commented line set for what it holds
aliasunder Nov 8, 2026
5d02355
docs: scope the test helper layout rule to new files
aliasunder Nov 9, 2026
a982ab9
test: kill stalled parser children with SIGKILL and correct the canva…
aliasunder Nov 9, 2026
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
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,9 @@ exists.
while the config readers do light I/O, yet both use named exports because
neither is an operation surface.
- **`links.ts` is the deliberate edge case** β€” a pure parser that nonetheless
exports a single `links` namespace, _not_ for the service-grouping reason above
but to wall off its `/g` grammar regexes (shared `lastIndex` footgun) behind
position-safe methods.
exports a single `links` namespace to keep link recognition private behind
position-safe methods. Its scanners own their cursors per call; the remaining
`/g` inline-code regex is used through `matchAll` and `replace`.
Comment thread
aliasunder marked this conversation as resolved.
Resolved

`vault-filesystem.ts` illustrates the nuance within one module: its high-level
data API is grouped under `vaultFs`, but its low-level shared primitives
Expand Down Expand Up @@ -976,6 +976,11 @@ Two naming layers β€” MCP (JSON wire format) and TypeScript (internal):
suite (`src/__tests__/docker/`, see "Remote image boot tests β€” when to
add"); the branches inside one script stay in that script's test file.

- New test support files go in subfolders of their `__tests__/` folder:
a module that a test imports or runs (a harness, an input generator,
an oracle, a script the test spawns) in `helpers/`, and data that the
code under test reads (a sample vault, JSON vectors, a stub binary) in
`fixtures/`.
- Separate `it()` blocks over callback-pattern `it.each` when assertions
are structurally different. `it.each` is for identical assertion
shapes, with labeled case objects and `$label` in the title, never
Expand Down
9 changes: 8 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ graph TB

Embeddings persist across rebuilds to reuse unchanged content and can be regenerated from the vault.

### Note-content parsers

The `obsidian-markdown/` parsers read text straight from vault files, so one crafted note must not be able to stall the server. Their work grows linearly with input size (sorting canvas nodes adds a log factor), and adversarial tests check that bound at doubling input sizes. Two exceptions remain:

- **Large canvases:** groups nest only while `content nodes Γ— groups + groupsΒ²` stays at most 16,000,000 (2,800 groups with 2,800 nodes fit). A larger canvas renders flat, still listing every node, group label and connection.
- **Body tags:** the tag parser can still slow quadratically, so it runs in a worker with a time limit (see **Body tags column** β†’ Worker under [Property discovery + daily notes](#property-discovery--daily-notes)).

## MCP Tools

Common metadata on all discovery tools (`vault_search`, `vault_search_by_tag`, `vault_search_by_folder`, `vault_recent_notes`, `vault_search_by_property`, `vault_find_orphans` β€” introduced in the subsections below):
Expand Down Expand Up @@ -348,7 +355,7 @@ Link queries use a `links` table populated during indexing:
`vault_read_file` reads non-markdown vault files, dispatching on the file's kind to the most useful representation per type. `obsidian-markdown/file-kinds.ts` maps the lowercased extension to a kind, so `Report.PDF` is a PDF. Reads, indexing, the note mover and the link tools take every non-markdown file's kind from it:

1. **Images** (`.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`) return an MCP `image` content block plus a one-line metadata text block: the path, the delivered format, dimensions and bytes, and, when the image was recompressed, its original dimensions and bytes. A shared fit-to-byte-budget pipeline (`utils/fit-image-to-byte-budget.ts`, built on sharp) makes oversized images deliverable: EXIF auto-orient β†’ resize long edge to ≀1568px β†’ walk a fixed quality ladder (JPEG via mozjpeg for opaque images, WebP for alpha β€” PNG has no quality knob) β†’ shrink dimensions by √(budget/actual) if the ladder floor still exceeds the budget. Deterministic and terminating (bounded attempts, 64px floor); sharp's default `limitInputPixels` stays active as the decompression-bomb guard. The budget (`MAX_IMAGE_OUTPUT_BYTES`, default 48 KiB binary) is sized for the tightest mainstream client cap.
2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser ([JSON Canvas 1.0](https://jsoncanvas.org)): group membership by spatial rect containment (innermost group wins; equal rects tiebreak deterministically by id), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing β€” unknown properties ignored, malformed entries skipped. `raw: true` skips the linearizer and returns the JSON source verbatim for full structural fidelity. Canvas content is also FTS-indexed (linearized text in `file_content` + `file_content_fts` tables, gated behind `FILE_TOOLS_ENABLED`) so canvas files appear in `vault_search` results with `kind: "file"`. Canvas `file`-type node references are extracted unconditionally into the `links` table for graph queries regardless of the feature gate.
2. **Canvas** (`.canvas`) linearizes to markdown via the pure `obsidian-markdown/canvas.ts` parser ([JSON Canvas 1.0](https://jsoncanvas.org)): group membership by spatial rect containment within the [nesting limit](#note-content-parsers) (innermost group wins; equal rects tiebreak deterministically by id), nodes in reading order (y, then x), and an edge list with node ids resolved to display names. Lenient parsing β€” unknown properties ignored, malformed entries skipped. `raw: true` skips the linearizer and returns the JSON source verbatim for full structural fidelity. Canvas content is also FTS-indexed (linearized text in `file_content` + `file_content_fts` tables, gated behind `FILE_TOOLS_ENABLED`) so canvas files appear in `vault_search` results with `kind: "file"`. Canvas `file`-type node references are extracted unconditionally into the `links` table for graph queries regardless of the feature gate.
3. **Text formats** (`.svg`/`.json`/`.txt`/`.csv`/`.xml`/`.log`/`.yaml`/`.yml`/`.base`) pass through as text, without a leading byte-order mark, capped at a fixed 100 KiB output size (explicit error over silent truncation). They skip the NUL-byte check in item 5, so a NUL-padded log still reads. `start_line`/`limit` page any text result β€” text files, canvas renditions, PDF-extracted text β€” as a 1-based line window preceded by a window-metadata block stating the range, total line count, and next `start_line`; the cap applies to the window, and an unpaged read is byte-exact apart from that mark.
4. **PDFs** (`.pdf`) return structured markdown reconstructed from layout-aware extraction (`unpdf`, based on Mozilla's PDF.js):
- A document metadata header (title, page count, link count)
Expand Down
239 changes: 238 additions & 1 deletion src/vault-mcp/obsidian-markdown/__tests__/canvas.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, it, expect } from "vitest"
import { describe, it, expect, vi } from "vitest"
import { readParserComplexityReport } from "./helpers/frontmatter-canvas-adversary.js"
import {
linearizeCanvas,
extractCanvasFileLinks,
extractCanvasCardTexts,
rewriteCanvasLinks,
canNestCanvasGroups,
} from "../canvas.js"

/** Minimal node factories β€” geometry defaults keep tests focused on the
Expand Down Expand Up @@ -633,3 +635,238 @@ describe("rewriteCanvasLinks", () => {
expect(rewriteInvalidCanvas).toThrow(/^invalid \.canvas JSON: /)
})
})

const groupNode = (index: number): Record<string, unknown> => ({
id: `g${String(index).padStart(5, "0")}`,
type: "group",
label: `Group ${index}`,
x: index * 30,
y: 0,
width: 20,
height: 20,
})

const separatedCanvas = (counts: {
groups: number
contentNodes: number
}): {
json: string
nested: string
flat: string
} => {
const groups = Array.from({ length: counts.groups }, (_, index) => groupNode(index))
const contentNodes = Array.from({ length: counts.contentNodes }, (_, index) =>
textNode({
id: `n${index}`,
text: `Text ${index}`,
x: index * 30 + 1,
y: 1,
width: 1,
height: 1,
}),
)
const overview = `# Canvas: ${counts.groups + counts.contentNodes} nodes, 0 edges`
const contentSections = contentNodes.map((_, index) => `[text]\nText ${index}`)
const groupSections = groups.map((_, index) => `## Group: Group ${index}`)
const nestedGroupSections = groupSections.flatMap((heading, index) => {
const content = contentSections[index]
return content ? [heading, content] : [heading]
})
return {
json: canvasJson([...contentNodes, ...groups]),
nested: [overview, ...contentSections.slice(counts.groups), ...nestedGroupSections].join(
"\n\n",
),
flat: [overview, ...groupSections, ...contentSections].join("\n\n"),
}
}

describe("canvas ownership work and rendering depth", () => {
it("renders nested duplicate IDs once in a parent-killed child", () => {
expect(readParserComplexityReport("canvas-cycle")).toEqual({
fixture: "canvas-cycle",
node: process.version,
semantics: "exact",
})
}, 35_000)

it("renders each child only under its owning node when parent IDs repeat", () => {
const nodes = [
{ ...groupNode(0), id: "a", label: "First" },
{ ...groupNode(0), id: "a", label: "Second", x: 100 },
{ ...groupNode(0), id: "b", label: "Child", x: 1, y: 1, width: 10, height: 10 },
textNode({ id: "text", text: "Body", x: 2, y: 2, width: 1, height: 1 }),
]
expect(linearizeCanvas(canvasJson(nodes))).toBe(
[
"# Canvas: 4 nodes, 0 edges",
"## Group: First",
"### Group: Child",
"[text]\nBody",
"## Group: Second",
].join("\n\n"),
)
})

it("renders 28 duplicate-ID groups once within the parent watchdog", () => {
expect(readParserComplexityReport("canvas-duplicate-ids")).toEqual({
fixture: "canvas-duplicate-ids",
node: process.version,
inputBytes: 2590,
nodeCount: 28,
headingCount: 28,
medianMs: expect.any(Number),
semantics: "exact",
})
}, 35_000)

it.each([
{ fixture: "canvas-cross", copies: 1 },
{ fixture: "canvas-multiple", copies: 5 },
])(
"bounds $fixture at the ownership budget with complete output checks",
({ fixture, copies }) => {
expect(readParserComplexityReport(fixture)).toEqual({
fixture,
node: process.version,
checksPerCanvas: 16_000_000,
copies,
singleMedianMs: expect.any(Number),
medianMs: expect.any(Number),
semantics: "exact",
})
Comment thread
aliasunder marked this conversation as resolved.
Resolved
},
35_000,
)

it.each([
{ label: "identical-rectangle depth", fixture: "canvas-depth" },
{ label: "content-count ownership", fixture: "canvas-flat" },
])(
"bounds the $label adversary in a parent-killed child",
({ fixture }) => {
expect(readParserComplexityReport(fixture)).toEqual({
fixture,
node: process.version,
medianMs: expect.any(Number),
semantics: "exact",
})
},
35_000,
)

it.each([
{ label: "zero groups", groups: 0, contentNodes: 100_000, nested: true },
{ label: "one group at budget", groups: 1, contentNodes: 15_999_999, nested: true },
{
label: "one group one check above budget",
groups: 1,
contentNodes: 16_000_000,
nested: false,
},
{ label: "both axes at budget", groups: 2000, contentNodes: 6000, nested: true },
{ label: "both axes above budget", groups: 2000, contentNodes: 6001, nested: false },
{ label: "group axis at budget", groups: 4000, contentNodes: 0, nested: true },
{ label: "group axis above budget", groups: 4001, contentNodes: 0, nested: false },
])("uses both axes for $label without allocating nodes", ({ groups, contentNodes, nested }) => {
expect(canNestCanvasGroups({ groups, contentNodes })).toBe(nested)
})

it.each([
{ groups: 2000, contentNodes: 2000, nested: true },
{ groups: 2000, contentNodes: 6000, nested: true },
{ groups: 2000, contentNodes: 6001, nested: false },
{ groups: 4000, contentNodes: 4000, nested: false },
{ groups: 8000, contentNodes: 8000, nested: false },
{ groups: 8000, contentNodes: 8001, nested: false },
{ groups: 2000, contentNodes: 100_000, nested: false },
{ groups: 4001, contentNodes: 1, nested: false },
{ groups: 0, contentNodes: 4, nested: true },
])(
"renders every section with $groups groups and $contentNodes content nodes",
({ groups, contentNodes, nested }) => {
const fixture = separatedCanvas({ groups, contentNodes })
expect(linearizeCanvas(fixture.json)).toBe(nested ? fixture.nested : fixture.flat)
},
)

it("skips every ownership scan above budget before constructing group maps", () => {
const fixture = separatedCanvas({ groups: 2000, contentNodes: 6001 })
const nativeFilter = Array.prototype.filter
// Ownership filters scan the 2,000-group array; node parsing filters scan all 8,001 nodes.
let ownershipScans = 0
const filter = vi.spyOn(Array.prototype, "filter").mockImplementation(function (
this: unknown[],
predicate,
thisArg,
) {
if (this.length === 2000) ownershipScans++
return nativeFilter.call(this, predicate, thisArg)
})
try {
expect(linearizeCanvas(fixture.json)).toBe(fixture.flat)
expect(ownershipScans).toBe(0)
} finally {
filter.mockRestore()
}
})

it("renders a 2,000-group identical-rectangle chain once with H6 capping", () => {
const groups = Array.from({ length: 2000 }, (_, index) => ({ ...groupNode(index), x: 0 }))
const expectedGroups = groups
.toReversed()
.map((_, index) => `${"#".repeat(Math.min(2 + index, 6))} Group: Group ${1999 - index}`)
expect(linearizeCanvas(canvasJson(groups))).toBe(
["# Canvas: 2000 nodes, 0 edges", ...expectedGroups].join("\n\n"),
)
})

it("preserves flat labels, reading order and all connection names and labels", () => {
const padding = Array.from({ length: 4001 }, (_, index) => ({
...groupNode(index),
label: undefined,
y: 10,
}))
const nodes = [
{ ...groupNode(0), id: "group", label: "Named", x: 0, y: -2 },
textNode({ id: "text", x: 0, y: -1, text: "## Title\nBody" }),
{
id: "file",
type: "file",
x: 0,
y: 0,
width: 1,
height: 1,
file: "Folder/Note.md",
subpath: "#Heading",
},
{ id: "link", type: "link", x: 0, y: 1, width: 1, height: 1, url: "https://example.com" },
{ id: "unknown", type: "custom", x: 0, y: 2, width: 1, height: 1 },
{ id: "invalid", type: "text", text: "Dropped" },
...padding,
]
const edges = [
{ fromNode: "group", toNode: "text", label: "names" },
{ fromNode: "file", toNode: "link", label: "" },
{ fromNode: "unknown", toNode: "absent", label: "missing" },
{ fromNode: "g00000", toNode: "group" },
{ fromNode: "invalid" },
]
expect(linearizeCanvas(canvasJson(nodes, edges))).toBe(
[
"# Canvas: 4006 nodes, 4 edges",
"## Group: Named",
"[text]\n## Title\nBody",
"[file] β†’ Folder/Note.md#Heading",
"[link] β†’ https://example.com",
"[custom]",
...padding.map(() => "## Group: (unlabeled)"),
"## Connections",
"Named β†’ Title (names)",
"Note.md β†’ https://example.com",
'(custom node) β†’ (missing node "absent") (missing)',
"(unlabeled group) β†’ Named",
].join("\n\n"),
)
})
})
Loading
Loading