@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 +153 -0
- package/README.md +6 -6
- package/dist/bases.d.ts +115 -0
- package/dist/bases.d.ts.map +1 -0
- package/dist/bases.js +346 -0
- package/dist/bases.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +191 -1
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +34 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +28 -2
- package/dist/tools.js.map +1 -1
- package/docs/api.md +1 -1
- package/package.json +8 -2
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
|
-
**
|
|
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
|
-
| **
|
|
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
|
|
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 &
|
|
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
|
|
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
|
|
package/dist/bases.d.ts
ADDED
|
@@ -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
|