@oomkapwn/enquire-mcp 3.0.1 → 3.2.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,159 @@
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.2.0] — 2026-05-09
6
+
7
+ **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.**
8
+
9
+ ### What is a `.base` file?
10
+
11
+ 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).
12
+
13
+ ### Added
14
+
15
+ - **`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.
16
+ - **`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.
17
+ - **`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.
18
+
19
+ ### Filter DSL — supported subset
20
+
21
+ ```
22
+ tag == "x" # frontmatter or inline #tag membership
23
+ tag != "x" # negation
24
+ taggedWith(file.file, "x") # alias for tag ==
25
+ path startsWith "X" # path prefix
26
+ path contains "X" # path substring
27
+ <frontmatter_key> == <value> # equality (string/number/bool)
28
+ <frontmatter_key> != <value>
29
+ <frontmatter_key> contains "<sub>" # substring or array-element substring
30
+ and: [...] # combinator
31
+ or: [...] # combinator
32
+ not: ... # combinator
33
+ ```
34
+
35
+ 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.
36
+
37
+ 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.
38
+
39
+ ### Example
40
+
41
+ `Notes/Open tasks.base`:
42
+ ```yaml
43
+ filters: 'status != "done"'
44
+ views:
45
+ - type: table
46
+ name: "High priority"
47
+ filters: 'priority == "high"'
48
+ ```
49
+
50
+ Agent call:
51
+ ```jsonc
52
+ {
53
+ "tool": "obsidian_query_base",
54
+ "args": { "path": "Notes/Open tasks.base", "view": "High priority" }
55
+ }
56
+ ```
57
+
58
+ Result: every note in the vault where frontmatter `status != "done"` AND `priority == "high"`, with citation-ready paths.
59
+
60
+ ### API additions (`src/bases.ts`)
61
+
62
+ New module:
63
+ - `parseBase(yamlText): ParsedBase` — schema-validated YAML parse via lazy `js-yaml` + `zod` shape check.
64
+ - `listBases(vault, args)` / `readBase(vault, args)` / `queryBase(vault, args)`.
65
+ - Type exports: `ParsedBase`, `BaseFilter`, `BaseSummary`, `BaseDocument`, `BaseQueryHit`, `BaseQueryResult`.
66
+
67
+ ### Surface counts
68
+
69
+ - **43 production tools** (was 40): +3 always-on (`list_bases`, `read_base`, `query_base`).
70
+ - **19 MCP prompts**: unchanged.
71
+ - **3 MCP resources**: unchanged.
72
+
73
+ ### Tests
74
+
75
+ 633 unit tests pass (was 612 in v3.1.0, +21 new in `tests/bases.test.ts`):
76
+ - **YAML parsing (4):** canonical doc example, minimal base, empty base, recursive and/or/not.
77
+ - **listBases (3):** empty vault, normal listing with view names, malformed `.base` survives.
78
+ - **readBase (2):** parsed structure with normalized view names, path traversal rejected.
79
+ - **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.
80
+
81
+ ### Migration
82
+
83
+ **No-op for default users.** New tools are additive. Existing tools / prompts / resources unchanged.
84
+
85
+ ### Why this matters competitively
86
+
87
+ 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.**
88
+
89
+ ## [3.1.0] — 2026-05-09
90
+
91
+ **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.
92
+
93
+ ### Added — `obsidian_hyde_search` tool (HyDE retrieval)
94
+
95
+ [Hypothetical Document Embeddings](https://arxiv.org/abs/2212.10496) (Gao et al, 2023) wired into the always-on read tool surface. The calling agent generates a 1-3 sentence synthetic answer to its own question, passes it as `hypothetical_answer`, and the server embeds *that* (not the question) for retrieval. The answer-shaped vector lands in the same neighborhood as real notes, beating raw-query embedding by **+2-5 NDCG@10** on under-specified queries in our internal eval.
96
+
97
+ ```jsonc
98
+ {
99
+ "tool": "obsidian_hyde_search",
100
+ "args": {
101
+ "query": "what did I learn about RRF",
102
+ "hypothetical_answer": "RRF (Reciprocal Rank Fusion) combines ranked lists from multiple retrievers by summing 1/(k+rank). Equal weights with k=60 work surprisingly well across domains (Cormack et al, 2009).",
103
+ "limit": 10
104
+ }
105
+ }
106
+ ```
107
+
108
+ Server stays LLM-free — the agent does the LLM call to produce the hypothetical answer. Response includes a `hyde: true` flag for client-side audit. Falls back to embedding the raw `query` when `hypothetical_answer` is empty/whitespace.
109
+
110
+ Uses the same `.embed.db` as `obsidian_embeddings_search`. Picks up HNSW persistence (v2.16+) automatically when `--use-hnsw` is set.
111
+
112
+ ### Added — `vault_research` MCP prompt (sub-question decomposition)
113
+
114
+ Multi-hop research workflow. Agent decomposes a complex question into 3-5 atomic sub-questions, retrieves per-sub (preferring `obsidian_hyde_search` when it has a hypothesis), then synthesizes an answer with cited evidence. Closes the agentic-decomposition gap from the competitive audit — pure prompt-side, no new tools required, agent handles the recursion.
115
+
116
+ Output structure: synthesis paragraph + bulleted "Evidence" section with `[[Path/To/Note.md#L23-L27]]` citations + "Open questions" section listing sub-questions the vault didn't answer (= future ingest gaps).
117
+
118
+ ### Added — `vault_synthesis_page` MCP prompt
119
+
120
+ Karpathy LLM-Wiki **synthesis** loop (vs the existing `vault_synth` which is the *ingest* loop). Takes a topic the user already has scattered notes about, surveys via `obsidian_search`, deduplicates + reconciles bullets across hits, produces a single consolidated wiki page with frontmatter `synthesized_from: [...]` and `[[wikilink]]` citations to every contributing source. Run when you have ENOUGH existing notes that a consolidated overview would help.
121
+
122
+ ### API additions
123
+
124
+ `src/tools.ts`:
125
+ - `pickEmbedTextForHyde(args): { text, usedHyde }` — exported pure helper that decides whether to embed `query` or `hypothetical_answer`. Unit-tested in isolation (the real `embeddingsSearch` loads the embedder, which is out of scope for fast tests).
126
+ - `EmbedSearchResponse.hyde?: boolean` — present + true when retrieval used HyDE.
127
+ - `embeddingsSearch` accepts `hypothetical_answer` arg (backwards compatible).
128
+
129
+ `src/index.ts`:
130
+ - 1 new always-on read tool registration: `obsidian_hyde_search`.
131
+ - 2 new MCP prompts: `vault_research`, `vault_synthesis_page`.
132
+
133
+ ### Tools / prompts surface
134
+
135
+ - **40 production tools** (was 39 in v3.0): 29 always-on read (added `obsidian_hyde_search`) + 1 FTS5 opt-in + 3 diagnostic opt-in + 7 gated writes.
136
+ - **19 MCP prompts** (was 17): added `vault_research` + `vault_synthesis_page`.
137
+ - **3 MCP resources**: unchanged.
138
+
139
+ ### Tests
140
+
141
+ 612 unit tests pass (was 606 in v3.0.1, +6 new):
142
+ - `pickEmbedTextForHyde` (6): undefined/empty/whitespace fallback to query, trimmed hypothetical takes precedence, query NOT trimmed when not HyDE (preserves whitespace contract for CJK / code-block queries), hypothetical wins over non-empty query.
143
+
144
+ Plus the existing `docs-consistency.test.ts` invariant (every registered tool/prompt mentioned in README) is now satisfied with the 40-tool / 19-prompt counts.
145
+
146
+ ### Migration
147
+
148
+ **No-op for default users.** Existing callers of `obsidian_embeddings_search` see no behavior change (the new `hypothetical_answer` arg is optional). New tool / prompts are additive.
149
+
150
+ ### Deferred
151
+
152
+ The competitive audit shortlisted a Smart Connections cache importer (`enquire-mcp import-smart-connections`) as "small effort / high adoption impact." On closer inspection, the Smart Connections `.smart-env/multi/*.ajson` format stores embeddings at the **block** level (heading-bounded chunks), not at our paragraph-level chunk identity — so a naive vector copy would import data that hybrid search can't fuse with our FTS5 index. Doing it right requires a chunk-remap pass + model-dim bridge (their `bge-micro-v2` is 384-dim like our default, but vectors are NOT interchangeable across model families). Deferred to v3.1.x with explicit design first.
153
+
154
+ ### Strategic position
155
+
156
+ v3.1 closes the "agentic-RAG" capability gap from the v3.0 audit. Combined with v2.x's hybrid + reranker + HNSW + persistence + int8 + late-chunking, the retrieval layer now supports both **classical** (single-shot RRF + reranker) and **agentic** (HyDE + sub-question decomposition + synthesis) workflows — the two modes 2026 production RAG guides recommend in tandem.
157
+
5
158
  ## [3.0.1] — 2026-05-09
6
159
 
7
160
  **Patch release: npm registry metadata refresh** so the most advanced Obsidian MCP server actually surfaces in AI/agent search and on npmjs.com.
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
- **39 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
- | **39 production tools** (28 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,21 +123,21 @@ graph LR
123
123
 
124
124
  ---
125
125
 
126
- ## 🛠️ All 39 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
 
130
130
  | Category | Tools |
131
131
  |---|---|
132
- | **Search & retrieval** | `obsidian_search` (umbrella, RRF-fused) · `obsidian_search_text` · `obsidian_full_text_search` · `obsidian_semantic_search` · `obsidian_embeddings_search` · `obsidian_find_similar` |
132
+ | **Search & retrieval** | `obsidian_search` (umbrella, RRF-fused) · `obsidian_hyde_search` (HyDE-augmented, v3.1.0) · `obsidian_search_text` · `obsidian_full_text_search` · `obsidian_semantic_search` · `obsidian_embeddings_search` · `obsidian_find_similar` |
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
 
140
- Plus 3 MCP resources (`obsidian://vault/info`, `obsidian://note/{path}`, `obsidian://chunk/{n}/{path}`) and 17 **MCP prompts** (`summarize_recent_edits` · `review_tag` · `find_orphans` · `weekly_review` · `extract_todos` · `process_inbox` · `consolidate_tags` · `find_duplicates` · `lint_wiki` · `monthly_review` · `search_with_query_expansion` · `vault_synth` · `vault_wiki_compile` · `vault_lint_extended` · `vault_capture` · `vault_persona_search` · `vault_automation_setup`) for common vault workflows.
140
+ Plus 3 MCP resources (`obsidian://vault/info`, `obsidian://note/{path}`, `obsidian://chunk/{n}/{path}`) and 19 **MCP prompts** (`summarize_recent_edits` · `review_tag` · `find_orphans` · `weekly_review` · `extract_todos` · `process_inbox` · `consolidate_tags` · `find_duplicates` · `lint_wiki` · `monthly_review` · `search_with_query_expansion` · `vault_synth` · `vault_wiki_compile` · `vault_lint_extended` · `vault_capture` · `vault_persona_search` · `vault_automation_setup` · `vault_research` · `vault_synthesis_page`) for common vault workflows.
141
141
 
142
142
  ---
143
143
 
@@ -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