@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 +137 -0
- package/README.md +4 -4
- 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/embeddings.d.ts.map +1 -1
- package/dist/embeddings.js +33 -0
- package/dist/embeddings.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -2
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
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
|
-
**
|
|
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,7 +123,7 @@ 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
|
|
|
@@ -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 &
|
|
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
|
|
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
|