smoking-mirror 1.2.0 → 1.2.1

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.
Files changed (3) hide show
  1. package/README.md +278 -240
  2. package/dist/index.js +152 -64
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,277 +1,156 @@
1
1
  # smoking-mirror
2
2
 
3
- > Your vault's secrets stay yours. Claude sees only what it needs.
3
+ **Your AI can see your notes without reading them.**
4
4
 
5
5
  [![npm version](https://badge.fury.io/js/smoking-mirror.svg)](https://www.npmjs.com/package/smoking-mirror)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![MCP](https://img.shields.io/badge/MCP-Compatible-blue)](https://modelcontextprotocol.io/)
8
+ [![Node](https://img.shields.io/badge/Node-%3E%3D18-green)](https://nodejs.org/)
9
+
10
+ **47 tools** · **Privacy by design** · **200x token savings** · **<10ms queries**
7
11
 
8
12
  ---
9
13
 
10
- ## The Problem with AI + Your Notes
14
+ ## The Problem
11
15
 
12
- ```
13
- ┌─────────────────────────────────────────────────────────────────┐
14
- │ THE OLD WAY │
15
- │ │
16
- │ "Claude, find notes about Project X" │
17
- │ │
18
- │ ┌──────────┐ ENTIRE VAULT ┌──────────┐ │
19
- │ │ Claude │ ◄──────────────────── │ 1,500 │ │
20
- │ │ Code │ (~50MB of text) │ Notes │ │
21
- │ └──────────┘ └──────────┘ │
22
- │ │
23
- │ • Your private journals? Sent. │
24
- │ • Financial notes? Sent. │
25
- │ • That embarrassing poetry? Definitely sent. │
26
- │ • Token bill? Astronomical. │
27
- │ │
28
- └─────────────────────────────────────────────────────────────────┘
29
- ```
16
+ **Your journals are private.** Traditional AI reads everything to help you find anything.
17
+
18
+ That's like giving a stranger your diary to help you find a recipe.
19
+
20
+ ### What traditional AI receives: your actual content
30
21
 
31
22
  ```
32
- ┌─────────────────────────────────────────────────────────────────┐
33
- │ THE SMOKING-MIRROR WAY │
34
- │ │
35
- │ "Claude, find notes about Project X" │
36
- │ │
37
- │ ┌──────────┐ STRUCTURED QUERY ┌──────────┐ │
38
- │ │ Claude │ ◄───────────────────► smoking │ │
39
- │ │ Code │ { backlinks: [...], │ -mirror │ │
40
- │ └──────────┘ tags: [...] } └────┬─────┘ │
41
- │ │ │
42
- │ ONLY searches │ │
43
- │ the index! ▼ │
44
- │ ┌──────────┐ │
45
- │ │ 1,500 │ │
46
- │ │ Notes │ │
47
- │ └──────────┘ │
48
- │ │
49
- │ • Your secrets? Never leave your machine. │
50
- │ • Claude sees? Filenames, links, tags. That's it. │
51
- │ • Token usage? Minimal. Blazing fast. │
52
- │ │
53
- └─────────────────────────────────────────────────────────────────┘
23
+ "March 15, 2024 - Job Hunt Update
24
+
25
+ Feeling anxious about the Google interview tomorrow. Sarah said I should
26
+ practice the STAR method tonight. Bank account down to $2,400 after paying
27
+ rent. Really need this job.
28
+
29
+ TODO: Review system design notes, call Mom about her surgery..."
54
30
  ```
55
31
 
56
- ---
32
+ Your private thoughts, finances, health info—all sent to AI. ~2,000 tokens per note.
57
33
 
58
- ## What IS smoking-mirror?
34
+ ### What smoking-mirror sends: just the structure
59
35
 
60
- "Smoking mirror" (tezcatl in Nahuatl) is the literal translation of **obsidian**. This MCP server acts as a reflective surface that reveals the *structure* of your vault—without exposing the *content*.
36
+ ```json
37
+ {
38
+ "path": "journal/2024-03-15.md",
39
+ "links": ["Resume", "Google Prep", "Interview Notes"],
40
+ "tags": ["#job-hunt", "#daily"],
41
+ "backlinks": 3,
42
+ "modified": "2024-03-15"
43
+ }
44
+ ```
61
45
 
62
- **47 tools** that answer questions like:
63
- - "What links to this note?" → **Backlinks returned, content untouched**
64
- - "Find orphan notes" → **Paths only, privacy intact**
65
- - "Search by #tag or frontmatter" → **Metadata queries, not content dumps**
46
+ Claude knows this note exists and connects to your job search. Claude has **no idea** what you actually wrote. ~50 tokens.
66
47
 
67
48
  ---
68
49
 
69
- ## Privacy Architecture
50
+ ## The Solution: Map, Not Territory
70
51
 
71
- ```
72
- YOUR MACHINE │ CLOUD
73
- ────────────────────────────────────── │ ────────────────
74
-
75
- ┌─────────────────┐ │
76
- │ Obsidian │ │
77
- │ Vault │ │
78
- │ ┌───────────┐ │ │
79
- │ │ 📓 Notes │ │ NEVER LEAVES │
80
- │ │ 📊 Data │ │ ───────────► │ ❌ Blocked
81
- │ │ 📝 Journal│ │ │
82
- │ └───────────┘ │ │
83
- └────────┬────────┘ │
84
- │ │
85
- │ Parse locally │
86
- ▼ │
87
- ┌─────────────────┐ │
88
- │ smoking-mirror │ │
89
- │ ┌───────────┐ │ │
90
- │ │ Index │ │ │
91
- │ │ • links │ │ │
92
- │ │ • tags │ │ │
93
- │ │ • paths │ │ │
94
- │ └───────────┘ │ │
95
- └────────┬────────┘ │
96
- │ │
97
- │ Structured responses only │
98
- ▼ │
99
- ┌─────────────────┐ API calls ┌─────────────────┐
100
- │ Claude Code │ ───────────────► │ Claude AI │
101
- │ │ (metadata only) │ │
102
- │ "Find hubs" │ ◄─────────────── │ (processes │
103
- │ │ { paths, counts }│ structure) │
104
- └─────────────────┘ └─────────────────┘
105
- ```
52
+ smoking-mirror gives AI the **map** of your vault—not the **territory**.
106
53
 
107
- **What Claude receives:**
108
- - File paths and names
109
- - Link relationships (A → B)
110
- - Tag lists
111
- - Frontmatter keys/values
112
- - Word counts, modification dates
54
+ Think of it like asking a librarian: *"What books connect to this topic?"* They check the card catalog and tell you the shelf locations. They don't read every book to find out.
113
55
 
114
- **What Claude NEVER receives:**
115
- - Your actual note content (unless you explicitly Read it)
116
- - Personal journals
117
- - Private thoughts
118
- - Sensitive data
56
+ **What AI learns:**
57
+ - Which notes link to which (the graph)
58
+ - What tags and folders exist (the structure)
59
+ - When things were modified (the timeline)
60
+ - What's orphaned or highly connected (the patterns)
61
+
62
+ **What AI never sees:**
63
+ - Your actual words
64
+ - Your private thoughts
65
+ - Your sensitive data
119
66
 
120
67
  ---
121
68
 
122
- ## Speed Demon
69
+ ## See It Work
123
70
 
124
- Forget waiting for full-vault searches. smoking-mirror pre-indexes everything.
71
+ ```
72
+ You: "Find notes about my job search"
73
+
74
+ Claude sees: Claude doesn't see:
75
+ ───────────────────────────────── ─────────────────────────────────
76
+ 📁 career/job-hunt.md "I'm so nervous about the
77
+ → links to: [[Resume]] Google interview tomorrow..."
78
+ → links to: [[Companies List]]
79
+ → links to: [[Interview Prep]]
80
+ → tagged: #career #2024
81
+ → modified: 3 days ago
82
+ ```
125
83
 
126
84
  ```
127
- ┌────────────────────────────────────────────────────────────────┐
128
- │ PERFORMANCE BENCHMARKS │
129
- ├────────────────┬───────────────┬───────────────┬───────────────┤
130
- │ Vault Size │ Index Build │ Query Time │ Memory
131
- ├────────────────┼───────────────┼───────────────┼───────────────┤
132
- │ 100 notes │ <200ms │ <10ms │ ~20MB │
133
- │ 500 notes │ <500ms │ <10ms │ ~30MB │
134
- 1,500 notes │ <2s │ <10ms │ ~50MB │
135
- │ 5,000 notes │ <5s │ <10ms │ ~100MB │
136
- └────────────────┴───────────────┴───────────────┴───────────────┘
137
-
138
-
139
- Queries are INSTANT because
140
- they hit an in-memory index,
141
- not your filesystem.
85
+ You: "What's connected to my daily journal?"
86
+
87
+ Claude sees: Claude doesn't see:
88
+ ───────────────────────────────── ─────────────────────────────────
89
+ 📁 journal/2024-01-15.md Your actual journal entries.
90
+ 47 notes link here Not a single word.
91
+ tags: #reflection #gratitude
92
+ part of: journal/ folder
142
93
  ```
143
94
 
144
- ---
95
+ Then—and only then—you can tell Claude to read the specific notes you choose.
145
96
 
146
- ## Token Economy
97
+ ---
147
98
 
148
- Every character Claude reads costs you tokens. Here's the math:
99
+ ## How It Works
149
100
 
150
101
  ```
151
- ┌─────────────────────────────────────────────────────────────────┐
152
- TOKEN COMPARISON
153
-
154
- Traditional approach: "Read all notes with #project tag"
155
- ─────────────────────────────────────────────────────────
156
- 📄 50 notes × ~2,000 tokens each = 100,000 tokens
157
- 💰 Cost: ~$0.30 per query (Claude pricing)
158
-
159
- │ smoking-mirror: search_notes({ has_tag: "project" }) │
160
- ─────────────────────────────────────────────────────────
161
- 📊 Returns: paths, titles, metadata
162
- │ 🎯 ~500 tokens total │
163
- │ 💰 Cost: ~$0.0015 per query │
164
- │ │
165
- │ ═══════════════════════════════════════════════════════════ │
166
- │ SAVINGS: 200x fewer tokens per query │
167
- │ ═══════════════════════════════════════════════════════════ │
168
- │ │
169
- └─────────────────────────────────────────────────────────────────┘
102
+ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
103
+ │ │ │ │
104
+ Your Vault ───► │ smoking-mirror │ ───► │ Claude │
105
+ │ (local) │ │ │
106
+ 📓 Notes │ │ │ Sees only: │
107
+ 📊 Data │ │ Builds index │ │ • paths
108
+ 📝 Journal │ │ of structure │ │ • links
109
+ │ │ │ • tags │
110
+ └──────────────┘ └──────────────────┘ └──────────────┘
111
+
112
+ NEVER LEAVES
113
+ └───────────────────────────────────────────────┘
114
+ your machine
170
115
  ```
171
116
 
172
- Then Claude can surgically `Read` only the 2-3 notes it actually needs.
173
-
174
117
  ---
175
118
 
176
- ## 47 Tools at Your Service
177
-
178
- ### Graph Intelligence
179
- | Tool | What it does |
180
- |------|--------------|
181
- | `get_backlinks` | Who's linking to this note? |
182
- | `get_forward_links` | What does this note link to? |
183
- | `find_orphan_notes` | Notes nobody loves (no incoming links) |
184
- | `find_hub_notes` | Your vault's superstars (highly connected) |
185
-
186
- ### Wikilink Services
187
- | Tool | What it does |
188
- |------|--------------|
189
- | `suggest_wikilinks` | "You mentioned 'John'—want to link to [[John Smith]]?" |
190
- | `validate_links` | Find broken links before they embarrass you |
191
- | `find_broken_links` | [[Dead Note]] → nowhere |
192
- | `get_unlinked_mentions` | "John" mentioned 47 times, linked 3 times |
193
-
194
- ### Vault Health
195
- | Tool | What it does |
196
- |------|--------------|
197
- | `get_vault_stats` | The big picture: notes, links, tags, orphans |
198
- | `get_folder_structure` | Your vault's anatomy |
199
- | `get_activity_summary` | What's been happening lately? |
200
-
201
- ### Smart Search
202
- | Tool | What it does |
203
- |------|--------------|
204
- | `search_notes` | Query by frontmatter, tags, folders—Dataview vibes |
205
- | `get_recent_notes` | Modified in the last N days |
206
- | `get_stale_notes` | Important notes gathering dust |
207
- | `get_notes_modified_on` | What happened on 2024-01-15? |
208
- | `get_notes_in_range` | Activity between two dates |
209
-
210
- ### Deep Graph Analysis
211
- | Tool | What it does |
212
- |------|--------------|
213
- | `get_link_path` | How do these two notes connect? (A → B → C → D) |
214
- | `get_common_neighbors` | What do these notes have in common? |
215
- | `find_bidirectional_links` | Mutual admirers (A ↔ B) |
216
- | `find_dead_ends` | Popular notes that link nowhere |
217
- | `find_sources` | Link-givers, not link-receivers |
218
- | `get_connection_strength` | How related are these notes, really? |
219
-
220
- ### Structure Analysis
221
- | Tool | What it does |
222
- |------|--------------|
223
- | `get_note_structure` | Full heading tree and sections |
224
- | `get_headings` | Just the headings, quick |
225
- | `get_section_content` | Extract a specific section |
226
- | `find_sections` | Find all "## References" across the vault |
227
-
228
- ### Task Management
229
- | Tool | What it does |
230
- |------|--------------|
231
- | `get_all_tasks` | Every `- [ ]` in your vault |
232
- | `get_tasks_from_note` | Tasks in a specific note |
233
- | `get_tasks_with_due_dates` | What's due? |
234
-
235
- ### Frontmatter Intelligence
236
- | Tool | What it does |
237
- |------|--------------|
238
- | `get_frontmatter_schema` | What fields exist across your vault? |
239
- | `get_field_values` | All unique values for `status` field |
240
- | `find_frontmatter_inconsistencies` | `status: "done"` vs `status: true` chaos |
241
-
242
- ### Temporal Analysis
243
- | Tool | What it does |
244
- |------|--------------|
245
- | `get_contemporaneous_notes` | Edited around the same time |
246
- | `get_note_metadata` | Quick stats without reading content |
247
-
248
- ### System
249
- | Tool | What it does |
250
- |------|--------------|
251
- | `refresh_index` | Rebuilt after Obsidian changes |
252
- | `get_all_entities` | Every linkable thing (titles + aliases) |
119
+ ## 47 Ways to Navigate
120
+
121
+ | Category | Tools | What you can ask |
122
+ |----------|:-----:|------------------|
123
+ | **Graph Intelligence** | 4 | Who links here? Where do orphans hide? |
124
+ | **Wikilink Services** | 4 | Suggest links, find broken ones |
125
+ | **Vault Health** | 3 | Stats, structure, activity |
126
+ | **Smart Search** | 5 | By date, tag, frontmatter |
127
+ | **Deep Graph Analysis** | 6 | Find paths between notes, connection strength |
128
+ | **Structure Analysis** | 4 | Headings, sections, TOC |
129
+ | **Task Management** | 3 | Extract todos, due dates |
130
+ | **Frontmatter Intelligence** | 3 | Schema discovery, field values |
131
+ | **Temporal Analysis** | 2 | Notes modified together |
132
+ | **System** | 2 | Refresh index, list entities |
133
+
134
+ [**Full 47-tool reference →**](docs/tools-reference.md)
253
135
 
254
136
  ---
255
137
 
256
- ## Installation
138
+ ## Quick Start
257
139
 
258
- ### Quick Install (Claude Code CLI)
140
+ ### Step 0: Install Claude Code
259
141
 
260
- **macOS / Linux:**
261
- ```bash
262
- claude mcp add smoking-mirror -e OBSIDIAN_VAULT_PATH=/path/to/your/vault -- npx -y smoking-mirror
263
- ```
142
+ If you haven't already, install [Claude Code](https://claude.ai/code):
264
143
 
265
- **Windows:**
266
144
  ```bash
267
- claude mcp add smoking-mirror -e OBSIDIAN_VAULT_PATH=C:\path\to\your\vault -- cmd /c npx -y smoking-mirror
145
+ npm install -g @anthropic-ai/claude-code
268
146
  ```
269
147
 
270
- ### Manual Configuration
148
+ ### Step 1: Add to your `.mcp.json`
271
149
 
272
- Add to your `.mcp.json`:
150
+ Create or edit `.mcp.json` in your project root (or `~/.claude/.mcp.json` for global access):
273
151
 
274
152
  **Windows:**
153
+
275
154
  ```json
276
155
  {
277
156
  "mcpServers": {
@@ -287,6 +166,7 @@ Add to your `.mcp.json`:
287
166
  ```
288
167
 
289
168
  **macOS / Linux:**
169
+
290
170
  ```json
291
171
  {
292
172
  "mcpServers": {
@@ -301,35 +181,190 @@ Add to your `.mcp.json`:
301
181
  }
302
182
  ```
303
183
 
304
- ### Verify
184
+ ### Step 2: Verify
305
185
 
306
186
  ```bash
307
- claude mcp list
308
- # Should show: smoking-mirror ✓
187
+ claude mcp list # Should show: smoking-mirror ✓
188
+ ```
189
+
190
+ ### Step 3: Try it
191
+
192
+ ```
193
+ You: "What are my most connected notes?"
194
+ Claude: find_hub_notes({ min_links: 5 })
195
+ → Returns paths and connection counts, not content
196
+ ```
197
+
198
+ ### Alternative: CLI One-liner
199
+
200
+ If you prefer the command line:
201
+
202
+ ```bash
203
+ # macOS / Linux
204
+ claude mcp add smoking-mirror -e OBSIDIAN_VAULT_PATH=/path/to/vault -- npx -y smoking-mirror
205
+
206
+ # Windows
207
+ claude mcp add smoking-mirror -e OBSIDIAN_VAULT_PATH=C:\path\to\vault -- cmd /c npx -y smoking-mirror
208
+ ```
209
+
210
+ ---
211
+
212
+ ## Part of a Product Family
213
+
214
+ ### smoking-mirror + obsidian-scribe
215
+
216
+ | Layer | What it does | Tools |
217
+ |-------|--------------|-------|
218
+ | **smoking-mirror** | Intelligence layer | 47 MCP tools for vault queries |
219
+ | **[obsidian-scribe](https://github.com/bencassie/obsidian-scribe)** | Workflow layer | 21 Claude Code skills for common tasks |
220
+
221
+ **Together:** Complete Obsidian + AI experience.
222
+
223
+ **obsidian-scribe skills include:**
224
+ - `/vault-health` — Comprehensive vault diagnostics
225
+ - `/vault-orphans` — Find unlinked notes
226
+ - `/vault-hubs` — Detect knowledge hubs
227
+ - `/vault-gaps` — Find mentioned but undocumented topics
228
+ - `/rollup` — Hierarchical summarization (daily → weekly → monthly → yearly)
229
+
230
+ *Using smoking-mirror? Open a PR to add your project!*
231
+
232
+ ---
233
+
234
+ ## What IS smoking-mirror?
235
+
236
+ "Smoking mirror" (*tezcatl* in Nahuatl) is the literal translation of **obsidian**. This MCP server acts as a reflective surface that reveals the *structure* of your vault—without exposing the *content*.
237
+
238
+ ---
239
+
240
+ # Technical Details
241
+
242
+ *Everything below is for developers and curious power users.*
243
+
244
+ ---
245
+
246
+ ## How It Compares
247
+
248
+ | Feature | smoking-mirror | mcp-obsidian | obsidian-mcp-server |
249
+ |---------|---------------|--------------|---------------------|
250
+ | **Approach** | Metadata-first (privacy) | Full content access | Full content access |
251
+ | **Offline support** | Yes (no DB needed) | Yes | Requires Obsidian API |
252
+ | **Graph tools** | 47 specialized tools | Basic read/write | Basic operations |
253
+ | **Token efficiency** | ~200x savings | Standard | Standard |
254
+ | **Unique features** | Backlinks, orphans, hubs, link paths, sections, frontmatter analysis | Direct vault access | Obsidian plugin integration |
255
+ | **Best for** | Privacy + large vaults | Full content workflows | Plugin users |
256
+
257
+ *Each approach has trade-offs. Choose based on your privacy needs and workflow.*
258
+
259
+ ---
260
+
261
+ ## Performance
262
+
263
+ ```
264
+ ┌────────────────────────────────────────────────────────────────┐
265
+ │ PERFORMANCE BENCHMARKS │
266
+ ├────────────────┬───────────────┬───────────────┬───────────────┤
267
+ │ Vault Size │ Index Build │ Query Time │ Memory │
268
+ ├────────────────┼───────────────┼───────────────┼───────────────┤
269
+ │ 100 notes │ <200ms │ <10ms │ ~20MB │
270
+ │ 500 notes │ <500ms │ <10ms │ ~30MB │
271
+ │ 1,500 notes │ <2s │ <10ms │ ~50MB │
272
+ │ 5,000 notes │ <5s │ <10ms │ ~100MB │
273
+ └────────────────┴───────────────┴───────────────┴───────────────┘
274
+
275
+ Queries are INSTANT because they hit an in-memory index, not your filesystem.
309
276
  ```
310
277
 
311
278
  ---
312
279
 
313
- ## Real-World Example
280
+ ## Token Economy
314
281
 
282
+ Every character Claude reads costs you tokens. Here's the math:
283
+
284
+ ```
285
+ Traditional approach: "Read all notes with #project tag"
286
+ ─────────────────────────────────────────────────────────
287
+ 📄 50 notes × ~2,000 tokens each = 100,000 tokens
288
+ 💰 Cost: ~$0.30 per query (Claude pricing)
289
+
290
+ smoking-mirror: search_notes({ has_tag: "project" })
291
+ ─────────────────────────────────────────────────────────
292
+ 📊 Returns: paths, titles, metadata
293
+ 🎯 ~500 tokens total
294
+ 💰 Cost: ~$0.0015 per query
295
+
296
+ ═══════════════════════════════════════════════════════════
297
+ SAVINGS: 200x fewer tokens per query
298
+ ═══════════════════════════════════════════════════════════
315
299
  ```
316
- You: "What are my most connected project notes?"
317
300
 
318
- Claude uses: find_hub_notes({ min_links: 10 })
301
+ Then Claude can surgically `Read` only the 2-3 notes it actually needs.
319
302
 
320
- Response (what Claude actually sees):
321
- {
322
- "hubs": [
323
- { "path": "projects/Main Project.md", "connections": 47 },
324
- { "path": "areas/Work.md", "connections": 34 },
325
- { "path": "resources/API Docs.md", "connections": 28 }
326
- ]
327
- }
303
+ ---
328
304
 
329
- Your actual note content? Still safely on your disk.
330
- Claude knows the STRUCTURE, not the SECRETS.
305
+ ## Privacy Architecture
306
+
307
+ ```
308
+ YOUR MACHINE │ CLOUD
309
+ ────────────────────────────────────── │ ────────────────
310
+
311
+ ┌─────────────────┐ │
312
+ │ Obsidian │ │
313
+ │ Vault │ │
314
+ │ ┌───────────┐ │ │
315
+ │ │ 📓 Notes │ │ NEVER LEAVES │
316
+ │ │ 📊 Data │ │ ───────────► │ ❌ Blocked
317
+ │ │ 📝 Journal│ │ │
318
+ │ └───────────┘ │ │
319
+ └────────┬────────┘ │
320
+ │ │
321
+ │ Parse locally │
322
+ ▼ │
323
+ ┌─────────────────┐ │
324
+ │ smoking-mirror │ │
325
+ │ ┌───────────┐ │ │
326
+ │ │ Index │ │ │
327
+ │ │ • links │ │ │
328
+ │ │ • tags │ │ │
329
+ │ │ • paths │ │ │
330
+ │ └───────────┘ │ │
331
+ └────────┬────────┘ │
332
+ │ │
333
+ │ Structured responses only │
334
+ ▼ │
335
+ ┌─────────────────┐ API calls ┌─────────────────┐
336
+ │ Claude Code │ ───────────────► │ Claude AI │
337
+ │ │ (metadata only) │ │
338
+ │ "Find hubs" │ ◄─────────────── │ (processes │
339
+ │ │ { paths, counts }│ structure) │
340
+ └─────────────────┘ └─────────────────┘
331
341
  ```
332
342
 
343
+ **What Claude receives:**
344
+ - File paths and names
345
+ - Link relationships (A → B)
346
+ - Tag lists
347
+ - Frontmatter keys/values
348
+ - Word counts, modification dates
349
+
350
+ **What Claude NEVER receives:**
351
+ - Your actual note content (unless you explicitly Read it)
352
+ - Personal journals
353
+ - Private thoughts
354
+ - Sensitive data
355
+
356
+ ---
357
+
358
+ ## Security & Privacy
359
+
360
+ - **Content never leaves your machine** — Claude sees paths, tags, links, not your words
361
+ - **No network calls** — Works fully offline
362
+ - **No database** — Pure file parsing, nothing persisted beyond runtime
363
+ - **Opt-in content reading** — Only when you explicitly use Claude's `Read` tool
364
+ - **MIT licensed** — Audit the code yourself
365
+
366
+ Your vault path is provided to the locally-running MCP server. No data is transmitted to external services by smoking-mirror itself.
367
+
333
368
  ---
334
369
 
335
370
  ## Architecture
@@ -367,6 +402,7 @@ Claude knows the STRUCTURE, not the SECRETS.
367
402
  ```
368
403
 
369
404
  **Key Design Decisions:**
405
+
370
406
  - **File-first**: Parses markdown directly, no database
371
407
  - **Works offline**: No connection to Obsidian app needed
372
408
  - **Eager loading**: Full index on startup (fine for <5000 notes)
@@ -392,6 +428,7 @@ Claude knows the STRUCTURE, not the SECRETS.
392
428
  We wanted to use `obsidian-dataview` as a library, but it requires Obsidian's internal `CachedMetadata` API and cannot run standalone. See [this discussion](https://github.com/blacksmithgu/obsidian-dataview/discussions/1811).
393
429
 
394
430
  Instead, smoking-mirror:
431
+
395
432
  - Parses markdown directly using `gray-matter`
396
433
  - Builds its own in-memory graph index
397
434
  - Provides ~80% of Dataview functionality with simpler syntax
@@ -441,6 +478,7 @@ MIT - Ben Cassie
441
478
  - [Model Context Protocol](https://modelcontextprotocol.io/)
442
479
  - [Claude Code](https://claude.ai/code)
443
480
  - [Obsidian](https://obsidian.md/)
481
+ - [obsidian-scribe](https://github.com/bencassie/obsidian-scribe) - Claude Code plugin powered by smoking-mirror
444
482
 
445
483
  ---
446
484
 
package/dist/index.js CHANGED
@@ -13,10 +13,27 @@ var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
13
13
  ".git",
14
14
  "node_modules"
15
15
  ]);
16
+ var WINDOWS_MAX_PATH = 260;
17
+ var EMOJI_REGEX = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F1E0}-\u{1F1FF}]/u;
18
+ function isValidPath(fullPath, fileName) {
19
+ if (EMOJI_REGEX.test(fileName)) {
20
+ return { valid: false, reason: "contains emoji characters" };
21
+ }
22
+ if (fullPath.length >= WINDOWS_MAX_PATH) {
23
+ return { valid: false, reason: `path length ${fullPath.length} exceeds Windows limit of ${WINDOWS_MAX_PATH}` };
24
+ }
25
+ return { valid: true };
26
+ }
16
27
  async function scanVault(vaultPath2) {
17
28
  const files = [];
18
29
  async function scan(dir, relativePath = "") {
19
- const entries = await fs.promises.readdir(dir, { withFileTypes: true });
30
+ let entries;
31
+ try {
32
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
33
+ } catch (err) {
34
+ console.error(`Warning: Could not read directory ${dir}:`, err);
35
+ return;
36
+ }
20
37
  for (const entry of entries) {
21
38
  const fullPath = path.join(dir, entry.name);
22
39
  const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
@@ -24,8 +41,17 @@ async function scanVault(vaultPath2) {
24
41
  if (EXCLUDED_DIRS.has(entry.name)) {
25
42
  continue;
26
43
  }
27
- await scan(fullPath, relPath);
44
+ try {
45
+ await scan(fullPath, relPath);
46
+ } catch (err) {
47
+ console.error(`Warning: Could not scan directory ${fullPath}:`, err);
48
+ }
28
49
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
50
+ const validation = isValidPath(fullPath, entry.name);
51
+ if (!validation.valid) {
52
+ console.warn(`Skipping ${relPath}: ${validation.reason}`);
53
+ continue;
54
+ }
29
55
  try {
30
56
  const stats = await fs.promises.stat(fullPath);
31
57
  files.push({
@@ -393,6 +419,7 @@ var BacklinkItemSchema = z.object({
393
419
  var GetBacklinksOutputSchema = {
394
420
  note: z.string().describe("The resolved note path"),
395
421
  backlink_count: z.number().describe("Total number of backlinks found"),
422
+ returned_count: z.number().describe("Number of backlinks returned (may be limited)"),
396
423
  backlinks: z.array(BacklinkItemSchema).describe("List of backlinks")
397
424
  };
398
425
  function registerGraphTools(server2, getIndex, getVaultPath) {
@@ -403,11 +430,13 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
403
430
  description: "Get all notes that link TO the specified note. Returns the source file paths and line numbers where links appear.",
404
431
  inputSchema: {
405
432
  path: z.string().describe('Path to the note (e.g., "daily/2024-01-15.md" or just "My Note")'),
406
- include_context: z.boolean().default(true).describe("Include surrounding text for context")
433
+ include_context: z.boolean().default(true).describe("Include surrounding text for context"),
434
+ limit: z.number().default(50).describe("Maximum number of results to return"),
435
+ offset: z.number().default(0).describe("Number of results to skip (for pagination)")
407
436
  },
408
437
  outputSchema: GetBacklinksOutputSchema
409
438
  },
410
- async ({ path: notePath, include_context }) => {
439
+ async ({ path: notePath, include_context, limit, offset }) => {
411
440
  const index = getIndex();
412
441
  const vaultPath2 = getVaultPath();
413
442
  let resolvedPath = notePath;
@@ -419,7 +448,8 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
419
448
  resolvedPath = notePath + ".md";
420
449
  }
421
450
  }
422
- const backlinks = getBacklinksForNote(index, resolvedPath);
451
+ const allBacklinks = getBacklinksForNote(index, resolvedPath);
452
+ const backlinks = allBacklinks.slice(offset, offset + limit);
423
453
  const results = await Promise.all(
424
454
  backlinks.map(async (bl) => {
425
455
  const result = {
@@ -434,7 +464,8 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
434
464
  );
435
465
  const output = {
436
466
  note: resolvedPath,
437
- backlink_count: results.length,
467
+ backlink_count: allBacklinks.length,
468
+ returned_count: results.length,
438
469
  backlinks: results
439
470
  };
440
471
  return {
@@ -511,6 +542,7 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
511
542
  });
512
543
  const FindOrphansOutputSchema = {
513
544
  orphan_count: z.number().describe("Total number of orphan notes found"),
545
+ returned_count: z.number().describe("Number of orphans returned (may be limited)"),
514
546
  folder: z.string().optional().describe("Folder filter if specified"),
515
547
  orphans: z.array(OrphanNoteSchema).describe("List of orphan notes")
516
548
  };
@@ -520,15 +552,19 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
520
552
  title: "Find Orphan Notes",
521
553
  description: "Find notes that have no backlinks (no other notes link to them). Useful for finding disconnected content.",
522
554
  inputSchema: {
523
- folder: z.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")')
555
+ folder: z.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")'),
556
+ limit: z.number().default(50).describe("Maximum number of results to return"),
557
+ offset: z.number().default(0).describe("Number of results to skip (for pagination)")
524
558
  },
525
559
  outputSchema: FindOrphansOutputSchema
526
560
  },
527
- async ({ folder }) => {
561
+ async ({ folder, limit, offset }) => {
528
562
  const index = getIndex();
529
- const orphans = findOrphanNotes(index, folder);
563
+ const allOrphans = findOrphanNotes(index, folder);
564
+ const orphans = allOrphans.slice(offset, offset + limit);
530
565
  const output = {
531
- orphan_count: orphans.length,
566
+ orphan_count: allOrphans.length,
567
+ returned_count: orphans.length,
532
568
  folder,
533
569
  orphans: orphans.map((o) => ({
534
570
  path: o.path,
@@ -556,6 +592,7 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
556
592
  });
557
593
  const FindHubsOutputSchema = {
558
594
  hub_count: z.number().describe("Total number of hub notes found"),
595
+ returned_count: z.number().describe("Number of hubs returned (may be limited)"),
559
596
  min_links: z.number().describe("Minimum connection threshold used"),
560
597
  hubs: z.array(HubNoteSchema).describe("List of hub notes, sorted by total connections")
561
598
  };
@@ -565,15 +602,19 @@ function registerGraphTools(server2, getIndex, getVaultPath) {
565
602
  title: "Find Hub Notes",
566
603
  description: "Find highly connected notes (hubs) that have many links to/from other notes. Useful for identifying key concepts.",
567
604
  inputSchema: {
568
- min_links: z.number().default(5).describe("Minimum total connections (backlinks + forward links) to qualify as a hub")
605
+ min_links: z.number().default(5).describe("Minimum total connections (backlinks + forward links) to qualify as a hub"),
606
+ limit: z.number().default(50).describe("Maximum number of results to return"),
607
+ offset: z.number().default(0).describe("Number of results to skip (for pagination)")
569
608
  },
570
609
  outputSchema: FindHubsOutputSchema
571
610
  },
572
- async ({ min_links }) => {
611
+ async ({ min_links, limit, offset }) => {
573
612
  const index = getIndex();
574
- const hubs = findHubNotes(index, min_links);
613
+ const allHubs = findHubNotes(index, min_links);
614
+ const hubs = allHubs.slice(offset, offset + limit);
575
615
  const output = {
576
- hub_count: hubs.length,
616
+ hub_count: allHubs.length,
617
+ returned_count: hubs.length,
577
618
  min_links,
578
619
  hubs: hubs.map((h) => ({
579
620
  path: h.path,
@@ -670,7 +711,8 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
670
711
  });
671
712
  const SuggestWikilinksOutputSchema = {
672
713
  input_length: z2.number().describe("Length of the input text"),
673
- suggestion_count: z2.number().describe("Number of suggestions found"),
714
+ suggestion_count: z2.number().describe("Total number of suggestions found"),
715
+ returned_count: z2.number().describe("Number of suggestions returned (may be limited)"),
674
716
  suggestions: z2.array(SuggestionSchema).describe("List of wikilink suggestions")
675
717
  };
676
718
  server2.registerTool(
@@ -679,16 +721,20 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
679
721
  title: "Suggest Wikilinks",
680
722
  description: "Analyze text and suggest where wikilinks could be added. Finds mentions of existing note titles and aliases.",
681
723
  inputSchema: {
682
- text: z2.string().describe("The text to analyze for potential wikilinks")
724
+ text: z2.string().describe("The text to analyze for potential wikilinks"),
725
+ limit: z2.number().default(50).describe("Maximum number of suggestions to return"),
726
+ offset: z2.number().default(0).describe("Number of suggestions to skip (for pagination)")
683
727
  },
684
728
  outputSchema: SuggestWikilinksOutputSchema
685
729
  },
686
- async ({ text }) => {
730
+ async ({ text, limit, offset }) => {
687
731
  const index = getIndex();
688
- const matches = findEntityMatches(text, index.entities);
732
+ const allMatches = findEntityMatches(text, index.entities);
733
+ const matches = allMatches.slice(offset, offset + limit);
689
734
  const output = {
690
735
  input_length: text.length,
691
- suggestion_count: matches.length,
736
+ suggestion_count: allMatches.length,
737
+ returned_count: matches.length,
692
738
  suggestions: matches
693
739
  };
694
740
  return {
@@ -712,7 +758,8 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
712
758
  scope: z2.string().describe('What was validated (note path or "all")'),
713
759
  total_links: z2.number().describe("Total number of links checked"),
714
760
  valid_links: z2.number().describe("Number of valid links"),
715
- broken_links: z2.number().describe("Number of broken links"),
761
+ broken_links: z2.number().describe("Total number of broken links"),
762
+ returned_count: z2.number().describe("Number of broken links returned (may be limited)"),
716
763
  broken: z2.array(BrokenLinkSchema).describe("List of broken links")
717
764
  };
718
765
  function findSimilarEntity(target, entities) {
@@ -735,13 +782,15 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
735
782
  title: "Validate Links",
736
783
  description: "Check wikilinks in a note (or all notes) and report broken links. Optionally suggests fixes.",
737
784
  inputSchema: {
738
- path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes.")
785
+ path: z2.string().optional().describe("Path to a specific note to validate. If omitted, validates all notes."),
786
+ limit: z2.number().default(50).describe("Maximum number of broken links to return"),
787
+ offset: z2.number().default(0).describe("Number of broken links to skip (for pagination)")
739
788
  },
740
789
  outputSchema: ValidateLinksOutputSchema
741
790
  },
742
- async ({ path: notePath }) => {
791
+ async ({ path: notePath, limit, offset }) => {
743
792
  const index = getIndex();
744
- const broken = [];
793
+ const allBroken = [];
745
794
  let totalLinks = 0;
746
795
  let validLinks = 0;
747
796
  let notesToCheck;
@@ -769,7 +818,7 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
769
818
  validLinks++;
770
819
  } else {
771
820
  const suggestion = findSimilarEntity(link.target, index.entities);
772
- broken.push({
821
+ allBroken.push({
773
822
  source: sourcePath,
774
823
  target: link.target,
775
824
  line: link.line,
@@ -778,11 +827,13 @@ function registerWikilinkTools(server2, getIndex, getVaultPath) {
778
827
  }
779
828
  }
780
829
  }
830
+ const broken = allBroken.slice(offset, offset + limit);
781
831
  const output = {
782
832
  scope: notePath || "all",
783
833
  total_links: totalLinks,
784
834
  valid_links: validLinks,
785
- broken_links: broken.length,
835
+ broken_links: allBroken.length,
836
+ returned_count: broken.length,
786
837
  broken
787
838
  };
788
839
  return {
@@ -808,7 +859,8 @@ function registerHealthTools(server2, getIndex, getVaultPath) {
808
859
  });
809
860
  const FindBrokenLinksOutputSchema = {
810
861
  scope: z3.string().describe('Folder searched, or "all" for entire vault'),
811
- broken_count: z3.number().describe("Number of broken links found"),
862
+ broken_count: z3.number().describe("Total number of broken links found"),
863
+ returned_count: z3.number().describe("Number of broken links returned (may be limited)"),
812
864
  affected_notes: z3.number().describe("Number of notes with broken links"),
813
865
  broken_links: z3.array(BrokenLinkSchema).describe("List of broken links")
814
866
  };
@@ -818,13 +870,15 @@ function registerHealthTools(server2, getIndex, getVaultPath) {
818
870
  title: "Find Broken Links",
819
871
  description: "Find all wikilinks that point to non-existent notes. Useful for vault maintenance.",
820
872
  inputSchema: {
821
- folder: z3.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")')
873
+ folder: z3.string().optional().describe('Limit search to a specific folder (e.g., "daily-notes/")'),
874
+ limit: z3.number().default(50).describe("Maximum number of results to return"),
875
+ offset: z3.number().default(0).describe("Number of results to skip (for pagination)")
822
876
  },
823
877
  outputSchema: FindBrokenLinksOutputSchema
824
878
  },
825
- async ({ folder }) => {
879
+ async ({ folder, limit, offset }) => {
826
880
  const index = getIndex();
827
- const brokenLinks = [];
881
+ const allBrokenLinks = [];
828
882
  const affectedNotes = /* @__PURE__ */ new Set();
829
883
  for (const note of index.notes.values()) {
830
884
  if (folder && !note.path.startsWith(folder)) {
@@ -833,7 +887,7 @@ function registerHealthTools(server2, getIndex, getVaultPath) {
833
887
  for (const link of note.outlinks) {
834
888
  const resolved = resolveTarget(index, link.target);
835
889
  if (!resolved) {
836
- brokenLinks.push({
890
+ allBrokenLinks.push({
837
891
  source: note.path,
838
892
  target: link.target,
839
893
  line: link.line
@@ -842,14 +896,16 @@ function registerHealthTools(server2, getIndex, getVaultPath) {
842
896
  }
843
897
  }
844
898
  }
845
- brokenLinks.sort((a, b) => {
899
+ allBrokenLinks.sort((a, b) => {
846
900
  const pathCompare = a.source.localeCompare(b.source);
847
901
  if (pathCompare !== 0) return pathCompare;
848
902
  return a.line - b.line;
849
903
  });
904
+ const brokenLinks = allBrokenLinks.slice(offset, offset + limit);
850
905
  const output = {
851
906
  scope: folder || "all",
852
- broken_count: brokenLinks.length,
907
+ broken_count: allBrokenLinks.length,
908
+ returned_count: brokenLinks.length,
853
909
  affected_notes: affectedNotes.size,
854
910
  broken_links: brokenLinks
855
911
  };
@@ -2277,16 +2333,20 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2277
2333
  title: "Get Notes Modified On Date",
2278
2334
  description: "Get all notes that were modified on a specific date.",
2279
2335
  inputSchema: {
2280
- date: z6.string().describe("Date in YYYY-MM-DD format")
2336
+ date: z6.string().describe("Date in YYYY-MM-DD format"),
2337
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2338
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2281
2339
  }
2282
2340
  },
2283
- async ({ date }) => {
2341
+ async ({ date, limit, offset }) => {
2284
2342
  const index = getIndex();
2285
- const result = getNotesModifiedOn(index, date);
2343
+ const allResults = getNotesModifiedOn(index, date);
2344
+ const result = allResults.slice(offset, offset + limit);
2286
2345
  return {
2287
2346
  content: [{ type: "text", text: JSON.stringify({
2288
2347
  date,
2289
- count: result.length,
2348
+ total_count: allResults.length,
2349
+ returned_count: result.length,
2290
2350
  notes: result.map((n) => ({
2291
2351
  ...n,
2292
2352
  created: n.created?.toISOString(),
@@ -2303,17 +2363,21 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2303
2363
  description: "Get all notes modified within a date range.",
2304
2364
  inputSchema: {
2305
2365
  start_date: z6.string().describe("Start date in YYYY-MM-DD format"),
2306
- end_date: z6.string().describe("End date in YYYY-MM-DD format")
2366
+ end_date: z6.string().describe("End date in YYYY-MM-DD format"),
2367
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2368
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2307
2369
  }
2308
2370
  },
2309
- async ({ start_date, end_date }) => {
2371
+ async ({ start_date, end_date, limit, offset }) => {
2310
2372
  const index = getIndex();
2311
- const result = getNotesInRange(index, start_date, end_date);
2373
+ const allResults = getNotesInRange(index, start_date, end_date);
2374
+ const result = allResults.slice(offset, offset + limit);
2312
2375
  return {
2313
2376
  content: [{ type: "text", text: JSON.stringify({
2314
2377
  start_date,
2315
2378
  end_date,
2316
- count: result.length,
2379
+ total_count: allResults.length,
2380
+ returned_count: result.length,
2317
2381
  notes: result.map((n) => ({
2318
2382
  ...n,
2319
2383
  created: n.created?.toISOString(),
@@ -2356,17 +2420,21 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2356
2420
  description: "Find notes that were edited around the same time as a given note.",
2357
2421
  inputSchema: {
2358
2422
  path: z6.string().describe("Path to the reference note"),
2359
- hours: z6.number().default(24).describe("Time window in hours")
2423
+ hours: z6.number().default(24).describe("Time window in hours"),
2424
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2425
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2360
2426
  }
2361
2427
  },
2362
- async ({ path: path6, hours }) => {
2428
+ async ({ path: path6, hours, limit, offset }) => {
2363
2429
  const index = getIndex();
2364
- const result = getContemporaneousNotes(index, path6, hours);
2430
+ const allResults = getContemporaneousNotes(index, path6, hours);
2431
+ const result = allResults.slice(offset, offset + limit);
2365
2432
  return {
2366
2433
  content: [{ type: "text", text: JSON.stringify({
2367
2434
  reference_note: path6,
2368
2435
  window_hours: hours,
2369
- count: result.length,
2436
+ total_count: allResults.length,
2437
+ returned_count: result.length,
2370
2438
  notes: result.map((n) => ({
2371
2439
  ...n,
2372
2440
  modified: n.modified.toISOString()
@@ -2478,18 +2546,22 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2478
2546
  description: "Find all sections across vault matching a heading pattern.",
2479
2547
  inputSchema: {
2480
2548
  pattern: z6.string().describe("Regex pattern to match heading text"),
2481
- folder: z6.string().optional().describe("Limit to notes in this folder")
2549
+ folder: z6.string().optional().describe("Limit to notes in this folder"),
2550
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2551
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2482
2552
  }
2483
2553
  },
2484
- async ({ pattern, folder }) => {
2554
+ async ({ pattern, folder, limit, offset }) => {
2485
2555
  const index = getIndex();
2486
2556
  const vaultPath2 = getVaultPath();
2487
- const result = await findSections(index, pattern, vaultPath2, folder);
2557
+ const allResults = await findSections(index, pattern, vaultPath2, folder);
2558
+ const result = allResults.slice(offset, offset + limit);
2488
2559
  return {
2489
2560
  content: [{ type: "text", text: JSON.stringify({
2490
2561
  pattern,
2491
2562
  folder,
2492
- count: result.length,
2563
+ total_count: allResults.length,
2564
+ returned_count: result.length,
2493
2565
  sections: result
2494
2566
  }, null, 2) }]
2495
2567
  };
@@ -2552,16 +2624,20 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2552
2624
  description: "Get tasks that have due dates, sorted by date.",
2553
2625
  inputSchema: {
2554
2626
  status: z6.enum(["open", "completed", "cancelled", "all"]).default("open").describe("Filter by status"),
2555
- folder: z6.string().optional().describe("Limit to notes in this folder")
2627
+ folder: z6.string().optional().describe("Limit to notes in this folder"),
2628
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2629
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2556
2630
  }
2557
2631
  },
2558
- async ({ status, folder }) => {
2632
+ async ({ status, folder, limit, offset }) => {
2559
2633
  const index = getIndex();
2560
2634
  const vaultPath2 = getVaultPath();
2561
- const result = await getTasksWithDueDates(index, vaultPath2, { status, folder });
2635
+ const allResults = await getTasksWithDueDates(index, vaultPath2, { status, folder });
2636
+ const result = allResults.slice(offset, offset + limit);
2562
2637
  return {
2563
2638
  content: [{ type: "text", text: JSON.stringify({
2564
- count: result.length,
2639
+ total_count: allResults.length,
2640
+ returned_count: result.length,
2565
2641
  tasks: result
2566
2642
  }, null, 2) }]
2567
2643
  };
@@ -2619,16 +2695,20 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2619
2695
  title: "Find Bidirectional Links",
2620
2696
  description: "Find pairs of notes that link to each other (mutual links).",
2621
2697
  inputSchema: {
2622
- path: z6.string().optional().describe("Limit to links involving this note")
2698
+ path: z6.string().optional().describe("Limit to links involving this note"),
2699
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2700
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2623
2701
  }
2624
2702
  },
2625
- async ({ path: path6 }) => {
2703
+ async ({ path: path6, limit, offset }) => {
2626
2704
  const index = getIndex();
2627
- const result = findBidirectionalLinks(index, path6);
2705
+ const allResults = findBidirectionalLinks(index, path6);
2706
+ const result = allResults.slice(offset, offset + limit);
2628
2707
  return {
2629
2708
  content: [{ type: "text", text: JSON.stringify({
2630
2709
  scope: path6 || "all",
2631
- count: result.length,
2710
+ total_count: allResults.length,
2711
+ returned_count: result.length,
2632
2712
  pairs: result
2633
2713
  }, null, 2) }]
2634
2714
  };
@@ -2641,16 +2721,20 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2641
2721
  description: "Find notes with backlinks but no outgoing links (consume but do not contribute).",
2642
2722
  inputSchema: {
2643
2723
  folder: z6.string().optional().describe("Limit to notes in this folder"),
2644
- min_backlinks: z6.number().default(1).describe("Minimum backlinks required")
2724
+ min_backlinks: z6.number().default(1).describe("Minimum backlinks required"),
2725
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2726
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2645
2727
  }
2646
2728
  },
2647
- async ({ folder, min_backlinks }) => {
2729
+ async ({ folder, min_backlinks, limit, offset }) => {
2648
2730
  const index = getIndex();
2649
- const result = findDeadEnds(index, folder, min_backlinks);
2731
+ const allResults = findDeadEnds(index, folder, min_backlinks);
2732
+ const result = allResults.slice(offset, offset + limit);
2650
2733
  return {
2651
2734
  content: [{ type: "text", text: JSON.stringify({
2652
2735
  criteria: { folder, min_backlinks },
2653
- count: result.length,
2736
+ total_count: allResults.length,
2737
+ returned_count: result.length,
2654
2738
  dead_ends: result
2655
2739
  }, null, 2) }]
2656
2740
  };
@@ -2663,16 +2747,20 @@ function registerPrimitiveTools(server2, getIndex, getVaultPath) {
2663
2747
  description: "Find notes with outgoing links but no backlinks (contribute but not referenced).",
2664
2748
  inputSchema: {
2665
2749
  folder: z6.string().optional().describe("Limit to notes in this folder"),
2666
- min_outlinks: z6.number().default(1).describe("Minimum outlinks required")
2750
+ min_outlinks: z6.number().default(1).describe("Minimum outlinks required"),
2751
+ limit: z6.number().default(50).describe("Maximum number of results to return"),
2752
+ offset: z6.number().default(0).describe("Number of results to skip (for pagination)")
2667
2753
  }
2668
2754
  },
2669
- async ({ folder, min_outlinks }) => {
2755
+ async ({ folder, min_outlinks, limit, offset }) => {
2670
2756
  const index = getIndex();
2671
- const result = findSources(index, folder, min_outlinks);
2757
+ const allResults = findSources(index, folder, min_outlinks);
2758
+ const result = allResults.slice(offset, offset + limit);
2672
2759
  return {
2673
2760
  content: [{ type: "text", text: JSON.stringify({
2674
2761
  criteria: { folder, min_outlinks },
2675
- count: result.length,
2762
+ total_count: allResults.length,
2763
+ returned_count: result.length,
2676
2764
  sources: result
2677
2765
  }, null, 2) }]
2678
2766
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoking-mirror",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Obsidian vault intelligence MCP server - graph queries, wikilink suggestions, vault health",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",