Skip to content

fix(search): compare frontmatter tags ignoring letter case and store them by Obsidian's tag rule - #667

Merged
aliasunder merged 12 commits into
mainfrom
fix/tag-letter-case
Oct 10, 2026
Merged

aliasunder merged 12 commits into
mainfrom
fix/tag-letter-case

Conversation

@aliasunder

@aliasunder aliasunder commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Tag comparisons in the search index now ignore letter case, and frontmatter tags are stored by the rule Obsidian's Tags view applies, so vault_search_by_tag({ tag: "Project" }) finds notes tagged project, vault_list_tags lists Project and project as one tag, and a leading # on any tag input is accepted.

What changed

  • One fold for every tag comparison. A SQLite function fold_tag and its JavaScript mirror foldTag both apply toLowerCase(), the rule Obsidian groups tag spellings by. vault_search_by_tag (both modes), vault_search's tags filter (SQL and the vector-only mirror), vault_list_tasks's tag filter, and vault_list_tags all compare folded values.
  • Nested matching in one predicate. The nested-tag arms share NESTED_TAG_PREDICATE (fold_tag(value) || '/' LIKE ? ESCAPE '\' bound to <tag>/%), so a tag matches itself or any tag nested under it with one fold per stored value. vault_search's tags filter now nests like vault_search_by_tag.
  • Leading #. Every tag input drops one leading #. An input that is empty after it ("#") is rejected with tag must not be empty after its leading "#", listed in the three tools' Errors and asserted over HTTP.
  • Tags stored by Obsidian's rule. New pure parser obsidian-markdown/tags.ts reproduces Obsidian 1.14.4's parseFrontMatterTags + getTags name test: the first key spelled tags in any letter case, a text value as one tag, a list's text entries, each trimmed, without a leading # or a trailing /, kept only when Obsidian would list it as a tag (no whitespace, none of Obsidian's rejected punctuation, not digits only). coerceToArray stays for related.
  • vault_list_tags merges spellings. Spellings that differ only in letter case are one entry, counted as distinct notes and shown as the spelling with the most occurrences across notes (ties in binary order).
  • Tags: reported once. formatNoteMetadata strips the tags key the index read, in any letter case, from additional_properties; a later key spelled tags with its own values stays there.
  • Tool descriptions and parameter texts for vault_search, vault_search_by_tag, vault_list_tags, and vault_list_tasks state the case rule, the nested match, and the # error; ARCHITECTURE.md, AGENTS.md, and the wiki inventory updated; tool-surface snapshot regenerated.

Tool-surface size: default 136,295 → 136,588 (+293), embedding-off 135,079 → 135,372 (+293); both under the cap.

Tool-definition review: the tool-definition-reviewer agent compared main's captured tool list with this branch's. Only the five intended tools changed and no fact was dropped. Its four defects in the new text are applied (an unnamed referent in vault_search_by_property, vault_search_by_tag's Returns claiming tags come back "as written", vault_list_tags not saying only accepted names are counted, and two optional remedies: the secondary sort in vault_list_tags and the .md remedy in vault_list_tasks). Dismissed: re-quoting the empty-tag error message, which wraps a message containing quotes in a second pair and read as confusing on review; and listing the index-corruption error of the row mappers, which a client cannot act on.

Not a breaking change: the tags array was documented as "every frontmatter tag" and vault_list_tags as omitting #, which the stored values now satisfy; nested matches in vault_search's filter and the accepted # are additive; the values that no longer index (2024, alpha, beta, true) were never documented as tags and Obsidian does not show them.

Tests

  • tags.test.ts: the name rule (accepted and rejected characters, digits only, the Punctuation blocks), key lookup in any letter case, first-key-wins, text and list values, trailing-slash and leading-# removal.
  • search-helpers.test.ts: foldTag (ẞ/ß merge, ß/ss apart), normalizeTagQuery, tagMatchesQuery, nestedTagLikePattern, and the vector-only mirror with decoys.
  • search-index.test.ts: stored tags per key form; searchByTag case folding in both modes with Projects/my-project decoys, non-ASCII folding, # and / inputs, foo//; fullTextSearch nested tags filter; listAllTags merge, occurrence ranking, tie-break; the chunk metadata prefix.
  • task-queries.test.ts, hybrid-search.test.ts: case-insensitive task tag filter; vector-only tag filter with an FTS miss.
  • tool-helpers.test.ts: formatNoteMetadata with a Tags: key and a second tags-spelled key.
  • server-error-contracts.test.ts: the "#" error over HTTP for all three tools.

Full suite: 5,288 tests passing; eslint, tsc, knip, markdownlint clean.

Live Test Results

Head: e71536d
Image: ghcr.io/aliasunder/vault-cortex:test built from that head (org.opencontainers.image.revision matches); the container was re-created, started, and reported healthy.

This run re-verifies the head after a rename in tool-helpers.ts. The first run, on bd39ecc16eb9d98c30d5b3425cfcbe51fd7f0a86, gave the same results.

Four probe notes, each with the body token prprobetoken667: probe-upper (tag PrProbe667), probe-lower (tag prprobe667), prprobe667-child (tag prprobe667/child), and the decoy prprobe667s (tag prprobe667s). Before = the server before the test deploy; after = the test build.

  • vault_search_by_tag({ tag: "prprobe667" }): before lower, child; after lower, upper, child.
  • vault_search_by_tag({ tag: "PrProbe667" }): before upper, child; after lower, upper, child.
  • vault_search_by_tag({ tag: "#prprobe667" }): before empty; after lower, upper, child.
  • vault_search_by_tag({ tag: "PRPROBE667", exact: true }): before empty; after lower, upper.
  • vault_search({ query: "prprobetoken667", filters: { tags: ["prprobe667"] } }): before lower; after child, lower, upper.
  • vault_search_by_tag({ tag: "#" }): after returns the error tag must not be empty after its leading "#".
  • vault_list_tags: before one entry per spelling; after PrProbe667 with count 2 (the two spellings merged, the tie in binary order), prprobe667/child 1, prprobe667s 1.
  • The decoy never matched any tag probe, before or after.

The probe notes were removed after the test.

🤖 Generated with Claude Code

aliasunder and others added 6 commits October 9, 2026 14:10
…them by Obsidian's tag rule

Tag comparisons across the index now fold both sides with toLowerCase(),
the rule Obsidian's Tags view groups spellings by: vault_search_by_tag in
both modes, vault_search's tags filter, vault_list_tasks's tag filter, and
vault_list_tags, which merges spellings under the most-used one. A leading
"#" on any tag input is accepted; an input left empty after it is rejected.
vault_search's tags filter matches nested tags like vault_search_by_tag.

Frontmatter tags are stored by Obsidian 1.14.4's own rule, in a new pure
parser (obsidian-markdown/tags.ts): the first key spelled tags in any
letter case, a text value as one tag, a list's text entries, each without
a leading "#" or a trailing "/", and only names Obsidian accepts as tags.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
List obsidian-markdown/tags in the AGENTS.md structure tree, say in ARCHITECTURE.md that a leading # on a tag input is dropped and a lone # rejected, describe how vault_list_tags merges letter-case spellings, and name the tag comparison helpers in the search-helpers row.

Ship-Check: pr-review · claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments state why the tags parser keeps repeats, what an empty tag input means, and how the tag listing picks a spelling. searchByTag picks its predicate and bound value in one branch. The tag tool texts say a leading # is optional, show a nested match, and say tags come back in the note's letter case; the tool-surface snapshots are regenerated.

Ship-Check: code-quality · claude-opus-5-5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the response formatter

tags.ts exports isTagsKey, the one rule for a property key spelled tags in
any letter case, and tool-helpers.ts uses it for the promoted-key strip
instead of restating the rule with toLowerCase().

Ship-Check: triage · claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- listAllTags: the merge fixture now separates ranking by occurrences from
  ranking by notes and from the binary tie-break, and both merge tests assert
  the whole tag list
- searchByTag: the Projects and my-project decoys sit in their own note, so a
  match on either one shows up
- new tests: a stored non-ASCII tag in prefix mode, a leading # in exact mode,
  the enriched chunk prefix's tags, a trimmed text tags value, and a
  Supplemental Punctuation name
- exact error and fold assertions; it.each labels within vitest's 40-character
  title limit

Ship-Check: test-audit · claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
formatNoteMetadata strips only the tags key the index read (the first key
spelled tags in any letter case), so a later key such as TAGS with its own
values stays in additional_properties instead of vanishing from the response.

Ship-Check: triage · claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/vault-mcp/mcp-core/tools/search-tools.ts Outdated
@umm-actually

umm-actually Bot commented Oct 9, 2026

Copy link
Copy Markdown

Add tags.ts to the Obsidian Markdown Parsing module list
Low severity · conventions · medium confidence

.devin/wiki.json:41 — beyond the diff's line ranges, in code the changes touch or depend on.

The page's purpose enumerates the obsidian-markdown/ parser modules but omits the new tags.ts this PR adds to that layer, so the page now describes an incomplete parser inventory. AGENTS.md's parallel tree comment was updated in this PR, leaving the two inventories inconsistent.

Failure scenario: A contributor or a DeepWiki index reads the Obsidian Markdown Parsing page to learn which parsers the layer holds; tags.ts — now the home of frontmatter tag parsing consumed by the search index and formatNoteMetadata — is absent from the list, so tag-parsing work gets attributed to frontmatter.ts or overlooked.

Suggested fix
Extend the enumeration in the Obsidian Markdown Parsing purpose with tags.ts (frontmatter tag parsing per Obsidian's Tags view rules).

umm-actually · z-ai/glm-5.3-flash

@umm-actually

umm-actually Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

umm-actually re-reviewed at e71536d

No new findings (6 tracked finding(s) across all runs).

Context notes
  • Priority docs already in context: ARCHITECTURE.md
  • Priority docs not included: deploy/railway/README.md, deploy/render/README.md, deploy/remote/README.md (missing, unreadable, or over budget)
  • 18 changed file(s) excluded from review: src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/disabled-tools.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/file-tools-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off+file-tools-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/obsidian-sync.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+file-tools-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off+embedding-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off+file-tools-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly+memory-off.json (diff_exclude_paths input), src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json (diff_exclude_paths input)

umm-actually · z-ai/glm-5.3-flash

Ship-Check: pr-monitor · claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@aliasunder

Copy link
Copy Markdown
Owner Author

Fixed (issue comment 6087399066, .devin/wiki.json parser inventory) — tags.ts is now listed in the Obsidian Markdown Parsing page's purpose and in the Architecture page's parser summary. The same pass completes the inventory with pdf.ts and pdf-engine.ts, which the AGENTS.md tree already lists, so the two inventories agree.


🔍 ship-check · pr-monitor · claude-fable-5-1

Comment thread ARCHITECTURE.md Outdated
Comment thread src/vault-mcp/mcp-core/tools/search-tools.ts Outdated
@umm-actually

umm-actually Bot commented Oct 9, 2026

Copy link
Copy Markdown

Update the additional_properties claim for the per-note tags key
Low severity · conventions · high confidence

src/vault-mcp/mcp-core/tools/search-tools.ts:484 — beyond the diff's line ranges, in code the changes touch or depend on.

The PR makes formatNoteMetadata promote the first tags-spelled key in any letter case and keep a later one under additional_properties, but vault_search_by_property's Returns bullet still claims additional_properties holds only keys outside title, tags, type, created, and related. A capitalized Tags key no longer lands there and a second tags-spelled key now does, so the documented contract and the response shape disagree.

Failure scenario: A note whose frontmatter is Tags: [project] followed by tags: [draft] (with the other promoted keys present): vault_search_by_property returns additional_properties containing the key named tags, which the documented claim says cannot appear there, so an agent trusting the description mis-reads the response shape.

Suggested fix
Reword the bullet to the new rule, e.g. "- additional_properties holds every frontmatter key whose value was not promoted — title, type, created, related, and the first key spelled tags in any letter case; a later key spelled tags stays here too."

umm-actually · z-ai/glm-5.3-flash

… per-note tags key

ARCHITECTURE.md scopes the nested-tag sentence to non-exact searches;
vault_search_by_tag's Returns says the tags array holds the frontmatter tags
Obsidian accepts, as written; vault_search_by_property's Returns names the
tags key the index read among the keys kept out of additional_properties.

Ship-Check: pr-monitor · claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@aliasunder

Copy link
Copy Markdown
Owner Author

Fixed (issue comment 6087801512, vault_search_by_property Returns) — the additional_properties bullet now names the keys kept out of it as title, type, created, related, and the tags key the index read, so a later key spelled tags is documented as staying there. Swept the other descriptions for the same five-key enumeration: this was the only one; vault_search_by_tag and vault_search_by_folder say "other frontmatter keys" without naming them.


🔍 ship-check · pr-monitor · claude-fable-5-1

aliasunder and others added 2 commits October 9, 2026 17:09
…once

The vault_search tags bullet read "all required"; it now says every listed
frontmatter tag must match. The empty-tag Errors bullets in the three tools
no longer wrap a message that contains quotes in a second pair of quotes.

Ship-Check: pr-monitor · claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vault_search_by_tag's Returns names the tags array as every frontmatter tag
Obsidian accepts, in the note's letter case; vault_list_tags says only tags
Obsidian accepts are counted and names the secondary sort; the
vault_search_by_property bullet names the first key spelled tags in any
letter case instead of "the tags key read"; vault_list_tasks's path error
keeps its remedy.

Ship-Check: pr-monitor · claude-fable-5-1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread src/vault-mcp/obsidian-markdown/tags.ts
aliasunder and others added 2 commits October 9, 2026 20:15
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
FIXED_PROMOTED_KEYS holds the four keys with one spelling; promotedKeysOfNote
adds the tags key the note's frontmatter spells, and the comment says why
that key varies per note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@aliasunder
aliasunder merged commit b9e14e0 into main Oct 10, 2026
19 checks passed
@aliasunder
aliasunder deleted the fix/tag-letter-case branch October 10, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant