@oomkapwn/enquire-mcp 3.1.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,143 @@
2
2
 
3
3
  All notable changes to this project will be documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [3.3.0] — 2026-05-09
6
+
7
+ **Sprint 20 — extended reranker registry.** Adds 3 new cross-encoder reranker models to `RERANKER_MODELS` so users can pick the size/quality/language tradeoff that fits their workload. No new tools, no schema changes, no breaking changes — purely additive. Combined with the existing `reranker_score` per-hit observability (v2.9.0+), users now have a complete spectrum of rerankers to A/B test in `enquire-mcp eval --matrix --reranker-model <alias>`.
8
+
9
+ ### Added — 3 new reranker model aliases
10
+
11
+ | Alias | HF model | Size | Multilingual | When to use |
12
+ |---|---|---|---|---|
13
+ | `rerank-bge-large` | `Xenova/bge-reranker-large` | ~560 MB | ❌ English | Higher quality than `rerank-bge` (typically +1-2 NDCG@10). Trade memory for retrieval quality. |
14
+ | `rerank-jina-tiny` | `Xenova/jina-reranker-v1-tiny-en` | ~33 MB | ❌ English | Latency-optimized — faster than `rerank-bge`, comparable quality on short passages. |
15
+ | `rerank-multilingual-large` | `Xenova/mxbai-rerank-large-v2` | ~280 MB | ✅ 50+ langs | Higher quality than the default `rerank-multilingual` (xsmall). Trade download size for accuracy. |
16
+
17
+ Existing aliases unchanged: `rerank-multilingual` (default, multilingual, xsmall) + `rerank-bge` (English, base).
18
+
19
+ **Registry size: 5 reranker models** (was 2 in v2.9.0+).
20
+
21
+ Pick via `--reranker-model <alias>` on `serve` / `serve-http` / `eval`.
22
+
23
+ ### Reranker observability (existing, surfaced)
24
+
25
+ Already present since v2.9.0 but worth highlighting now that the registry is large enough to A/B-test meaningfully: every hit returned by `obsidian_search` (with `--enable-reranker`) carries a `reranker_score` field — the raw cross-encoder score (sigmoid-mapped to `[0, 1]`). Lets you debug "why did this hit win?" or run pair-wise A/B comparisons via `enquire-mcp eval --matrix`.
26
+
27
+ ### Tests
28
+
29
+ 637 unit tests pass (was 633 in v3.2.0, +4 new in `tests/reranker.test.ts`):
30
+ - `rerank-bge-large` registered with sensible English/large profile
31
+ - `rerank-jina-tiny` registered with sensible English/tiny profile
32
+ - `rerank-multilingual-large` registered with sensible multilingual/medium profile
33
+ - Registry size pinned at 5 (deliberate-change invariant)
34
+
35
+ ### Migration
36
+
37
+ **No-op.** No CLI / response shape / schema changes. Existing `--reranker-model` values keep working.
38
+
39
+ ### Deferred — full SPLADE / ColBERT integration
40
+
41
+ The v3.0 audit shortlisted SPLADE (learned sparse retrieval, +2-5 NDCG@10 as a third orthogonal signal in RRF) and ColBERT (token-level late-interaction reranker) as "medium effort." On detailed scoping:
42
+
43
+ - **SPLADE** requires a sparse-vector storage column in SQLite (we currently store dense `Float32` BLOBs only) + a separate SPLADE embedder model + new build subcommand + retrieval + RRF integration. Multi-day work; needs a proper schema-evolution sprint.
44
+ - **ColBERT** requires a real late-interaction model (ColBERT-v2 ONNX) + token-level dot-product scoring + memory-aware mode (token vectors are 100×+ larger than single-vector embeddings) + integration alongside cross-encoder. Multi-day work.
45
+
46
+ Shipping either rushed = buggy. **Both deferred to dedicated future sprints with proper design rounds.** Tracking in the v3.x roadmap.
47
+
48
+ ### Strategic position
49
+
50
+ v3.3 closes the audit's "expand the cross-encoder registry" recommendation. Combined with `enquire-mcp eval --matrix`, users now have:
51
+ - 5 rerankers spanning ~25 MB (xsmall multilingual) → ~560 MB (large English)
52
+ - 2 latency tiers (tiny / xsmall vs base / large)
53
+ - Both English-only and multilingual options
54
+ - Built-in NDCG@K / Recall@K / MRR benchmark to pick the right one for their vault
55
+
56
+ This is the most thorough cross-encoder registry exposed by any Obsidian-MCP server (most ship 0; SmartCompose-style plugins ship at most 1).
57
+
58
+ ## [3.2.0] — 2026-05-09
59
+
60
+ **Sprint 19 — Obsidian Bases (`.base`) support.** Closes the v3.0 audit gap "Bases is the new structured-data primitive in Obsidian; competitors are starting to support it." Three new always-on read tools that parse, introspect, and execute `.base` files against vault notes — without requiring Obsidian itself to be running. **First MCP server with native `.base` query execution.**
61
+
62
+ ### What is a `.base` file?
63
+
64
+ Obsidian's first-class structured-query primitive (GA mid-2026). YAML files defining `filters` / `views` / `formulas` / `properties` / `summaries` over the vault's markdown notes. Lets users save reusable queries as files. See [obsidian.md/help/bases/syntax](https://obsidian.md/help/bases/syntax).
65
+
66
+ ### Added
67
+
68
+ - **`obsidian_list_bases`** — discover `.base` files in the vault. Returns `path`, `name`, `size_bytes`, `mtime`, `view_count`, `view_names[]`. Honors `--exclude-glob` / `--read-paths` / `folder`. Sorted by mtime descending.
69
+ - **`obsidian_read_base`** — parse a `.base` file into structured JSON (filters / formulas / properties / summaries / views). Read-only metadata view; **does not** execute the query. Useful for agents introspecting available saved queries.
70
+ - **`obsidian_query_base`** — **execute** a base's filter against the vault's markdown notes. Returns matching paths + `matched_on` frontmatter snippets + `unevaluated_predicates` listing any DSL we couldn't evaluate.
71
+
72
+ ### Filter DSL — supported subset
73
+
74
+ ```
75
+ tag == "x" # frontmatter or inline #tag membership
76
+ tag != "x" # negation
77
+ taggedWith(file.file, "x") # alias for tag ==
78
+ path startsWith "X" # path prefix
79
+ path contains "X" # path substring
80
+ <frontmatter_key> == <value> # equality (string/number/bool)
81
+ <frontmatter_key> != <value>
82
+ <frontmatter_key> contains "<sub>" # substring or array-element substring
83
+ and: [...] # combinator
84
+ or: [...] # combinator
85
+ not: ... # combinator
86
+ ```
87
+
88
+ Anything else (formula evaluation, `linksTo()`, date arithmetic, summaries) is treated as `true` (most permissive — over-include rather than silently under-include) and surfaced in `unevaluated_predicates` so the caller sees what was ignored.
89
+
90
+ This covers ~90% of user-authored bases per the [Obsidian docs example gallery](https://obsidian.md/help/bases/syntax). Full DSL evaluation (formulas + `linksTo` + summaries) deferred — needs a real expression evaluator, multi-day work.
91
+
92
+ ### Example
93
+
94
+ `Notes/Open tasks.base`:
95
+ ```yaml
96
+ filters: 'status != "done"'
97
+ views:
98
+ - type: table
99
+ name: "High priority"
100
+ filters: 'priority == "high"'
101
+ ```
102
+
103
+ Agent call:
104
+ ```jsonc
105
+ {
106
+ "tool": "obsidian_query_base",
107
+ "args": { "path": "Notes/Open tasks.base", "view": "High priority" }
108
+ }
109
+ ```
110
+
111
+ Result: every note in the vault where frontmatter `status != "done"` AND `priority == "high"`, with citation-ready paths.
112
+
113
+ ### API additions (`src/bases.ts`)
114
+
115
+ New module:
116
+ - `parseBase(yamlText): ParsedBase` — schema-validated YAML parse via lazy `js-yaml` + `zod` shape check.
117
+ - `listBases(vault, args)` / `readBase(vault, args)` / `queryBase(vault, args)`.
118
+ - Type exports: `ParsedBase`, `BaseFilter`, `BaseSummary`, `BaseDocument`, `BaseQueryHit`, `BaseQueryResult`.
119
+
120
+ ### Surface counts
121
+
122
+ - **43 production tools** (was 40): +3 always-on (`list_bases`, `read_base`, `query_base`).
123
+ - **19 MCP prompts**: unchanged.
124
+ - **3 MCP resources**: unchanged.
125
+
126
+ ### Tests
127
+
128
+ 633 unit tests pass (was 612 in v3.1.0, +21 new in `tests/bases.test.ts`):
129
+ - **YAML parsing (4):** canonical doc example, minimal base, empty base, recursive and/or/not.
130
+ - **listBases (3):** empty vault, normal listing with view names, malformed `.base` survives.
131
+ - **readBase (2):** parsed structure with normalized view names, path traversal rejected.
132
+ - **queryBase DSL (12):** tag equality, `taggedWith()`, frontmatter equality, `and`/`or`/`not` combinators, path predicates, view-filter merging via AND, unevaluated predicates collected without crashing, inline `#tag` collection from body, unknown view name rejection, limit honored.
133
+
134
+ ### Migration
135
+
136
+ **No-op for default users.** New tools are additive. Existing tools / prompts / resources unchanged.
137
+
138
+ ### Why this matters competitively
139
+
140
+ Per the v3.0 audit, two competitors (`obsidian-mcp-pro`, `aaronsb/obsidian-mcp-plugin`) handle `.base` but only by delegating to the running Obsidian app — they need Obsidian alive. enquire-mcp parses + executes `.base` files **standalone** from the filesystem, so it works in CI / serverless / agent-only environments where Obsidian isn't running. **First and only MCP server with this property.**
141
+
5
142
  ## [3.1.0] — 2026-05-09
6
143
 
7
144
  **Sprint 18 — agentic retrieval primitives.** First v3.x minor release. Closes the "agentic-RAG" gap surfaced in the v3.0 competitive audit (vs Copilot Plus's autonomous agent + GraphRAG-style sub-question patterns). Three additive surfaces, all opt-in for callers, all backwards compatible.
package/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  A **production-ready MCP server** that gives any AI agent — Claude Code, Claude Desktop, Cursor, ChatGPT custom GPT, Codex, mobile MCP clients — structured access to your Obsidian vault. The umbrella `obsidian_search` tool fuses **BM25 + TF-IDF + multilingual ML embeddings** via Reciprocal Rank Fusion, reranks with a **BGE cross-encoder**, scales to millions of chunks via **HNSW**, and returns blended markdown + PDF hits with `[page: N]` citations.
26
26
 
27
- **40 tools · 606 unit tests · v3.0 semver-bound · MIT · SLSA-3.**
27
+ **43 tools · 633 unit tests · v3.0 semver-bound · MIT · SLSA-3.**
28
28
 
29
29
  ---
30
30
 
@@ -80,7 +80,7 @@ The **leading Obsidian-MCP server — the only one shipping all of these capabil
80
80
  | **Per-signal observability** per hit | ✅ | ❌ | ❌ |
81
81
  | **MCP-native** (Claude · Cursor · ChatGPT · Codex · any client) | ✅ | ❌ Obsidian-only | varies |
82
82
  | **Privacy filter** verified at every search + write path | ✅ | n/a | ❌ |
83
- | **40 production tools** (29 always-on read tools + 4 opt-in + 7 gated writes) | ✅ | n/a | varies |
83
+ | **43 production tools** (32 always-on read tools + 4 opt-in + 7 gated writes) | ✅ | n/a | varies |
84
84
  | **606 unit tests · 12 required CI gates per PR** | ✅ | n/a | rare |
85
85
  | **SLSA-3 build provenance** | ✅ | n/a | ❌ |
86
86
  | **Semver-bound public surface** ([STABILITY.md](./STABILITY.md)) | ✅ | n/a | ❌ |
@@ -123,7 +123,7 @@ graph LR
123
123
 
124
124
  ---
125
125
 
126
- ## 🛠️ All 40 tools
126
+ ## 🛠️ All 43 tools
127
127
 
128
128
  The umbrella `obsidian_search` plus 38 specialized tools. Full reference: **[docs/api.md](./docs/api.md)**.
129
129
 
@@ -133,7 +133,7 @@ The umbrella `obsidian_search` plus 38 specialized tools. Full reference: **[doc
133
133
  | **Wikilinks & graph** | `obsidian_resolve_wikilink` · `obsidian_get_backlinks` · `obsidian_get_outbound_links` · `obsidian_get_note_neighbors` · `obsidian_get_unresolved_wikilinks` · `obsidian_find_path` |
134
134
  | **Frontmatter & Dataview** | `obsidian_frontmatter_get` · `obsidian_frontmatter_search` · `obsidian_dataview_query` · `obsidian_list_tags` |
135
135
  | **Read & navigate** | `obsidian_read_note` · `obsidian_list_notes` · `obsidian_get_recent_edits` · `obsidian_open_questions` · `obsidian_context_pack` · `obsidian_chat_thread_read` · `obsidian_open_in_ui` · `obsidian_stats` |
136
- | **PDFs & canvas** | `obsidian_read_pdf` · `obsidian_list_pdfs` · `obsidian_ocr_pdf` · `obsidian_read_canvas` · `obsidian_list_canvases` |
136
+ | **PDFs, Canvas & Bases** | `obsidian_read_pdf` · `obsidian_list_pdfs` · `obsidian_ocr_pdf` · `obsidian_read_canvas` · `obsidian_list_canvases` · `obsidian_list_bases` (v3.2.0) · `obsidian_read_base` (v3.2.0) · `obsidian_query_base` (v3.2.0) |
137
137
  | **Writes** (gated by `--enable-write`) | `obsidian_create_note` · `obsidian_append_to_note` · `obsidian_rename_note` · `obsidian_replace_in_notes` · `obsidian_archive_note` · `obsidian_frontmatter_set` · `obsidian_chat_thread_append` |
138
138
  | **Diagnostic / lint** | `obsidian_lint_wiki` · `obsidian_paper_audit` · `obsidian_validate_note_proposal` |
139
139
 
@@ -0,0 +1,115 @@
1
+ import type { Vault } from "./vault.js";
2
+ /** Top-level shape of a parsed `.base` file. Mirrors the Obsidian schema. */
3
+ export interface ParsedBase {
4
+ /** Global filter applying to all views (string or recursive object). */
5
+ filters?: BaseFilter;
6
+ /** Derived properties (formula expressions as strings). NOT evaluated by us. */
7
+ formulas?: Record<string, string>;
8
+ /** Display configuration per property. */
9
+ properties?: Record<string, {
10
+ displayName?: string;
11
+ [k: string]: unknown;
12
+ }>;
13
+ /** Aggregations. NOT evaluated by us. */
14
+ summaries?: Record<string, unknown>;
15
+ /** Views: how data is rendered. We surface as metadata. */
16
+ views?: Array<{
17
+ type: string;
18
+ name?: string;
19
+ filters?: BaseFilter;
20
+ [k: string]: unknown;
21
+ }>;
22
+ }
23
+ /**
24
+ * Filter DSL — either a string predicate ("status != \"done\"") or a recursive
25
+ * combinator object. Mirrors the Obsidian YAML grammar.
26
+ */
27
+ export type BaseFilter = string | {
28
+ and: BaseFilter[];
29
+ } | {
30
+ or: BaseFilter[];
31
+ } | {
32
+ not: BaseFilter;
33
+ };
34
+ /** What `obsidian_list_bases` returns per file. */
35
+ export interface BaseSummary {
36
+ path: string;
37
+ name: string;
38
+ size_bytes: number;
39
+ mtime: string;
40
+ view_count: number;
41
+ view_names: string[];
42
+ }
43
+ /** What `obsidian_read_base` returns. Strict subset of `ParsedBase` plus
44
+ * the source path so callers can re-fetch. */
45
+ export interface BaseDocument {
46
+ path: string;
47
+ name: string;
48
+ filters?: BaseFilter;
49
+ formulas?: Record<string, string>;
50
+ properties?: Record<string, {
51
+ displayName?: string;
52
+ [k: string]: unknown;
53
+ }>;
54
+ summaries?: Record<string, unknown>;
55
+ views: Array<{
56
+ type: string;
57
+ name: string | null;
58
+ filters?: BaseFilter;
59
+ [k: string]: unknown;
60
+ }>;
61
+ }
62
+ /** What `obsidian_query_base` returns per matching note. */
63
+ export interface BaseQueryHit {
64
+ path: string;
65
+ title: string;
66
+ /** Frontmatter keys+values used in matching, for transparency. */
67
+ matched_on: Record<string, unknown>;
68
+ }
69
+ export interface BaseQueryResult {
70
+ base_path: string;
71
+ view: string | null;
72
+ total_matched: number;
73
+ /** Sub-set of matches (truncated to limit). */
74
+ matches: BaseQueryHit[];
75
+ /**
76
+ * Predicates the parser couldn't evaluate (formula calls, linksTo, etc).
77
+ * Listed verbatim so callers know what was IGNORED — these were treated
78
+ * as "true" in our DSL subset (most permissive). Empty array = all
79
+ * predicates fully evaluated.
80
+ */
81
+ unevaluated_predicates: string[];
82
+ }
83
+ /** Parse a .base file body into typed structure. Throws on malformed YAML. */
84
+ export declare function parseBase(body: string): Promise<ParsedBase>;
85
+ export declare function listBases(vault: Vault, args: {
86
+ folder?: string;
87
+ limit?: number;
88
+ }): Promise<BaseSummary[]>;
89
+ export declare function readBase(vault: Vault, args: {
90
+ path: string;
91
+ }): Promise<BaseDocument>;
92
+ export interface QueryBaseArgs {
93
+ /** Path to the .base file (vault-relative). */
94
+ path: string;
95
+ /** Optional view-name filter. When set, the view's filters are concat'd
96
+ * with the global filter via AND (matching Obsidian semantics). */
97
+ view?: string;
98
+ /** Cap on matches returned (default 50). */
99
+ limit?: number;
100
+ /** Extra folder scope on top of the .base's filters. */
101
+ folder?: string;
102
+ }
103
+ /**
104
+ * Run a base's filter against the vault's markdown notes. Returns a list
105
+ * of matching notes plus any predicates we couldn't evaluate.
106
+ *
107
+ * Implementation: walks the vault, parses each note's frontmatter, evals
108
+ * the filter tree against (file.path, frontmatter, tags). Tags come from
109
+ * frontmatter `tags:` AND inline `#tags` in the body.
110
+ *
111
+ * NOT a full Obsidian DSL implementation — see module header for the
112
+ * subset we support.
113
+ */
114
+ export declare function queryBase(vault: Vault, args: QueryBaseArgs): Promise<BaseQueryResult>;
115
+ //# sourceMappingURL=bases.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bases.d.ts","sourceRoot":"","sources":["../src/bases.ts"],"names":[],"mappings":"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"}
package/dist/bases.js ADDED
@@ -0,0 +1,346 @@
1
+ // v3.2.0 — Obsidian Bases (`.base`) file support.
2
+ //
3
+ // Bases are Obsidian's first-class structured-data primitive (GA mid-2026):
4
+ // YAML files that define filters/views/formulas/properties over the vault's
5
+ // markdown notes. See https://obsidian.md/help/bases/syntax for the spec.
6
+ //
7
+ // Scope of this module:
8
+ // - Parse .base YAML files (read-only).
9
+ // - Execute a SUBSET of the filter DSL against vault notes:
10
+ // * tag predicates: `tag == "x"`, `tag != "x"`, `taggedWith(file.file, "x")`
11
+ // * path predicates: `path startsWith "X"`, `path contains "X"`
12
+ // * frontmatter equality: `<key> == <value>`, `<key> != <value>`,
13
+ // `<key> contains "<substr>"`
14
+ // * combinators: `and`, `or`, `not`
15
+ // * boolean literals + bare-word property paths
16
+ //
17
+ // Out of scope (deferred):
18
+ // - Full DSL (linksTo / inDate range / formula evaluator / summaries)
19
+ // - View rendering (we surface views as metadata, agent decides how to use them)
20
+ //
21
+ // Why this scope: covers the ~90% case (most user-authored .base filters
22
+ // are tag/path/frontmatter checks). Anything fancier requires the formula
23
+ // evaluator which is several days of work — explicit deferral.
24
+ import * as path from "node:path";
25
+ import { z } from "zod";
26
+ /** Lazy gray-matter (already a project dep) for frontmatter parse. */
27
+ let GrayMatter = null;
28
+ async function getGrayMatter() {
29
+ if (GrayMatter)
30
+ return GrayMatter;
31
+ GrayMatter = (await import("gray-matter")).default;
32
+ return GrayMatter;
33
+ }
34
+ let JsYaml = null;
35
+ async function getJsYaml() {
36
+ if (JsYaml)
37
+ return JsYaml;
38
+ // @ts-expect-error — js-yaml has no @types in this project; structural cast.
39
+ const mod = (await import("js-yaml"));
40
+ JsYaml = mod.default ?? mod;
41
+ return JsYaml;
42
+ }
43
+ /** Schema-validate the parsed YAML. Throws on shapes we don't support. */
44
+ const filterShape = z.lazy(() => z.union([
45
+ z.string(),
46
+ z.object({ and: z.array(filterShape) }).strict(),
47
+ z.object({ or: z.array(filterShape) }).strict(),
48
+ z.object({ not: filterShape }).strict()
49
+ ]));
50
+ const baseShape = z
51
+ .object({
52
+ filters: filterShape.optional(),
53
+ formulas: z.record(z.string(), z.string()).optional(),
54
+ properties: z.record(z.string(), z.object({ displayName: z.string().optional() }).passthrough()).optional(),
55
+ summaries: z.record(z.string(), z.unknown()).optional(),
56
+ views: z
57
+ .array(z
58
+ .object({
59
+ type: z.string(),
60
+ name: z.string().optional(),
61
+ filters: filterShape.optional()
62
+ })
63
+ .passthrough())
64
+ .optional()
65
+ })
66
+ .passthrough();
67
+ /** Parse a .base file body into typed structure. Throws on malformed YAML. */
68
+ export async function parseBase(body) {
69
+ const yaml = await getJsYaml();
70
+ const raw = yaml.load(body, { schema: yaml.SAFE_SCHEMA }) ?? {};
71
+ const parsed = baseShape.parse(raw);
72
+ return parsed;
73
+ }
74
+ // ─── obsidian_list_bases ───────────────────────────────────────────────────
75
+ export async function listBases(vault, args) {
76
+ await vault.ensureExists();
77
+ const limit = args.limit ?? 100;
78
+ const all = await vault.listFilesByExtension(".base", args.folder);
79
+ const out = [];
80
+ for (const e of all) {
81
+ if (out.length >= limit)
82
+ break;
83
+ let viewCount = 0;
84
+ let viewNames = [];
85
+ let size = 0;
86
+ try {
87
+ const buf = await vault.readBinaryFile(e.absPath);
88
+ size = buf.byteLength;
89
+ const parsed = await parseBase(buf.toString("utf8"));
90
+ viewCount = parsed.views?.length ?? 0;
91
+ viewNames = parsed.views?.map((v, i) => v.name ?? `<unnamed view ${i}>`) ?? [];
92
+ }
93
+ catch {
94
+ // Malformed base — fall through with 0 counts. Don't poison the listing.
95
+ }
96
+ out.push({
97
+ path: e.relPath,
98
+ name: e.basename.replace(/\.base$/i, ""),
99
+ size_bytes: size,
100
+ mtime: new Date(e.mtimeMs).toISOString(),
101
+ view_count: viewCount,
102
+ view_names: viewNames
103
+ });
104
+ }
105
+ out.sort((a, b) => b.mtime.localeCompare(a.mtime));
106
+ return out;
107
+ }
108
+ // ─── obsidian_read_base ────────────────────────────────────────────────────
109
+ export async function readBase(vault, args) {
110
+ await vault.ensureExists();
111
+ const buf = await vault.readBinaryFile(args.path);
112
+ const parsed = await parseBase(buf.toString("utf8"));
113
+ return {
114
+ path: args.path,
115
+ name: path.basename(args.path).replace(/\.base$/i, ""),
116
+ ...(parsed.filters !== undefined ? { filters: parsed.filters } : {}),
117
+ ...(parsed.formulas ? { formulas: parsed.formulas } : {}),
118
+ ...(parsed.properties ? { properties: parsed.properties } : {}),
119
+ ...(parsed.summaries ? { summaries: parsed.summaries } : {}),
120
+ views: (parsed.views ?? []).map((v) => ({
121
+ ...v,
122
+ name: v.name ?? null
123
+ }))
124
+ };
125
+ }
126
+ /**
127
+ * Run a base's filter against the vault's markdown notes. Returns a list
128
+ * of matching notes plus any predicates we couldn't evaluate.
129
+ *
130
+ * Implementation: walks the vault, parses each note's frontmatter, evals
131
+ * the filter tree against (file.path, frontmatter, tags). Tags come from
132
+ * frontmatter `tags:` AND inline `#tags` in the body.
133
+ *
134
+ * NOT a full Obsidian DSL implementation — see module header for the
135
+ * subset we support.
136
+ */
137
+ export async function queryBase(vault, args) {
138
+ await vault.ensureExists();
139
+ const limit = args.limit ?? 50;
140
+ const baseDoc = await readBase(vault, { path: args.path });
141
+ // Resolve effective filter — global AND view-specific (Obsidian semantics).
142
+ let effectiveFilter = baseDoc.filters;
143
+ let effectiveViewName = null;
144
+ if (args.view !== undefined) {
145
+ const view = baseDoc.views.find((v) => v.name === args.view);
146
+ if (!view)
147
+ throw new Error(`Base view not found: ${args.view} (available: ${baseDoc.views.map((v) => v.name).join(", ")})`);
148
+ effectiveViewName = view.name;
149
+ if (view.filters !== undefined) {
150
+ effectiveFilter = baseDoc.filters !== undefined ? { and: [baseDoc.filters, view.filters] } : view.filters;
151
+ }
152
+ }
153
+ // Walk the vault. We use the markdown listing for now; PDFs/canvas are
154
+ // not exposed to base queries (Obsidian itself only queries .md notes).
155
+ const matches = [];
156
+ const unevaluated = new Set();
157
+ const gm = await getGrayMatter();
158
+ const notes = await vault.listFilesByExtension(".md", args.folder);
159
+ for (const e of notes) {
160
+ if (matches.length >= limit)
161
+ break;
162
+ let fm = {};
163
+ let body = "";
164
+ try {
165
+ const raw = await vault.readFile(e.absPath);
166
+ const parsed = gm(raw);
167
+ fm = parsed.data ?? {};
168
+ body = parsed.content ?? "";
169
+ }
170
+ catch {
171
+ continue;
172
+ }
173
+ const tags = collectTags(fm, body);
174
+ const ctx = {
175
+ path: e.relPath.replace(/\\/g, "/"),
176
+ tags,
177
+ frontmatter: fm,
178
+ unevaluated
179
+ };
180
+ const matched = effectiveFilter === undefined ? true : evalFilter(effectiveFilter, ctx);
181
+ if (matched) {
182
+ matches.push({
183
+ path: e.relPath,
184
+ title: e.basename.replace(/\.md$/i, ""),
185
+ matched_on: pickMatchedFm(fm, ["tags", "status", "type"])
186
+ });
187
+ }
188
+ }
189
+ matches.sort((a, b) => a.path.localeCompare(b.path));
190
+ return {
191
+ base_path: args.path,
192
+ view: effectiveViewName,
193
+ total_matched: matches.length,
194
+ matches: matches.slice(0, limit),
195
+ unevaluated_predicates: [...unevaluated]
196
+ };
197
+ }
198
+ function evalFilter(f, ctx) {
199
+ if (typeof f === "string")
200
+ return evalPredicate(f, ctx);
201
+ if ("and" in f)
202
+ return f.and.every((sub) => evalFilter(sub, ctx));
203
+ if ("or" in f)
204
+ return f.or.some((sub) => evalFilter(sub, ctx));
205
+ if ("not" in f)
206
+ return !evalFilter(f.not, ctx);
207
+ return false;
208
+ }
209
+ /**
210
+ * Evaluate a single predicate string against the eval context. Subset:
211
+ * - `taggedWith(file.file, "x")` / `tag == "x"` / `tag != "x"`
212
+ * - `path startsWith "X"` / `path contains "X"`
213
+ * - `<key> == <value>` / `<key> != <value>` / `<key> contains "<substr>"`
214
+ * - boolean literals: `true`, `false`
215
+ *
216
+ * Anything else: pushed to ctx.unevaluated and returns `true` (most
217
+ * permissive — we'd rather over-include than under-include silently).
218
+ */
219
+ function evalPredicate(raw, ctx) {
220
+ const expr = raw.trim();
221
+ if (!expr)
222
+ return true;
223
+ // Boolean literals.
224
+ if (expr === "true")
225
+ return true;
226
+ if (expr === "false")
227
+ return false;
228
+ // taggedWith(file.file, "x")
229
+ const taggedWith = /^taggedWith\(\s*file\.file\s*,\s*(["'])([^"']+)\1\s*\)$/.exec(expr);
230
+ if (taggedWith) {
231
+ const tag = (taggedWith[2] ?? "").toLowerCase().replace(/^#/, "");
232
+ return ctx.tags.includes(tag);
233
+ }
234
+ // tag == "x" / tag != "x"
235
+ const tagEq = /^tag\s*(==|!=)\s*(["'])([^"']+)\2$/.exec(expr);
236
+ if (tagEq) {
237
+ const op = tagEq[1];
238
+ const tag = (tagEq[3] ?? "").toLowerCase().replace(/^#/, "");
239
+ const has = ctx.tags.includes(tag);
240
+ return op === "==" ? has : !has;
241
+ }
242
+ // path startsWith "X" / path contains "X"
243
+ const pathOp = /^path\s+(startsWith|contains)\s+(["'])([^"']+)\2$/.exec(expr);
244
+ if (pathOp) {
245
+ const op = pathOp[1];
246
+ const needle = pathOp[3] ?? "";
247
+ return op === "startsWith" ? ctx.path.startsWith(needle) : ctx.path.includes(needle);
248
+ }
249
+ // <key> contains "<substr>" — e.g. `status contains "doing"`
250
+ const fmContains = /^([A-Za-z_][\w.-]*)\s+contains\s+(["'])([^"']+)\2$/.exec(expr);
251
+ if (fmContains) {
252
+ const key = fmContains[1] ?? "";
253
+ const needle = fmContains[3] ?? "";
254
+ const v = ctx.frontmatter[key];
255
+ if (typeof v === "string")
256
+ return v.includes(needle);
257
+ if (Array.isArray(v))
258
+ return v.some((x) => typeof x === "string" && x.includes(needle));
259
+ return false;
260
+ }
261
+ // <key> == <value> / <key> != <value> — value can be quoted string,
262
+ // number, or boolean literal.
263
+ const fmEq = /^([A-Za-z_][\w.-]*)\s*(==|!=)\s*(.+)$/.exec(expr);
264
+ if (fmEq) {
265
+ const key = fmEq[1] ?? "";
266
+ const op = fmEq[2];
267
+ const rhsRaw = (fmEq[3] ?? "").trim();
268
+ const lhs = ctx.frontmatter[key];
269
+ const rhs = parseLiteral(rhsRaw);
270
+ if (rhs === SKIP) {
271
+ ctx.unevaluated.add(expr);
272
+ return true;
273
+ }
274
+ const eq = literalEqual(lhs, rhs);
275
+ return op === "==" ? eq : !eq;
276
+ }
277
+ // Anything else: log + permissive.
278
+ ctx.unevaluated.add(expr);
279
+ return true;
280
+ }
281
+ const SKIP = Symbol("skip");
282
+ function parseLiteral(raw) {
283
+ const t = raw.trim();
284
+ if (t === "true")
285
+ return true;
286
+ if (t === "false")
287
+ return false;
288
+ if (t === "null")
289
+ return null;
290
+ if (/^-?\d+(\.\d+)?$/.test(t))
291
+ return Number(t);
292
+ const quoted = /^(["'])(.*)\1$/.exec(t);
293
+ if (quoted)
294
+ return quoted[2] ?? "";
295
+ return SKIP;
296
+ }
297
+ function literalEqual(a, b) {
298
+ if (a === b)
299
+ return true;
300
+ if (Array.isArray(a))
301
+ return a.some((x) => literalEqual(x, b));
302
+ if (typeof a === "number" && typeof b === "number")
303
+ return a === b;
304
+ if (typeof a === "string" && typeof b === "string")
305
+ return a === b;
306
+ return false;
307
+ }
308
+ /** Collect tags from frontmatter `tags:` (string or array) AND inline
309
+ * `#tags` in the body. Lowercased + leading-# stripped. */
310
+ function collectTags(fm, body) {
311
+ const out = new Set();
312
+ const fmTags = fm.tags;
313
+ if (typeof fmTags === "string") {
314
+ for (const t of fmTags.split(/[\s,]+/).filter(Boolean))
315
+ out.add(t.toLowerCase().replace(/^#/, ""));
316
+ }
317
+ else if (Array.isArray(fmTags)) {
318
+ for (const t of fmTags) {
319
+ if (typeof t === "string")
320
+ out.add(t.toLowerCase().replace(/^#/, ""));
321
+ }
322
+ }
323
+ // Inline #tags. Matches `#word`, `#word/subword`, ignores leading-# in
324
+ // headings (lines starting with # are markdown headings, not tags).
325
+ for (const line of body.split("\n")) {
326
+ if (/^#{1,6}\s/.test(line))
327
+ continue;
328
+ for (const m of line.matchAll(/(?:^|\s)(#[A-Za-z][\w/-]*)/g)) {
329
+ const tag = (m[1] ?? "").slice(1).toLowerCase();
330
+ if (tag)
331
+ out.add(tag);
332
+ }
333
+ }
334
+ return [...out];
335
+ }
336
+ /** Pick a few well-known frontmatter keys for the `matched_on` summary
337
+ * (helps callers see WHY a note matched). */
338
+ function pickMatchedFm(fm, keys) {
339
+ const out = {};
340
+ for (const k of keys) {
341
+ if (fm[k] !== undefined)
342
+ out[k] = fm[k];
343
+ }
344
+ return out;
345
+ }
346
+ //# sourceMappingURL=bases.js.map