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.
- package/README.md +278 -240
- package/dist/index.js +152 -64
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,277 +1,156 @@
|
|
|
1
1
|
# smoking-mirror
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Your AI can see your notes without reading them.**
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/smoking-mirror)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://modelcontextprotocol.io/)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
|
|
10
|
+
**47 tools** · **Privacy by design** · **200x token savings** · **<10ms queries**
|
|
7
11
|
|
|
8
12
|
---
|
|
9
13
|
|
|
10
|
-
## The Problem
|
|
14
|
+
## The Problem
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
34
|
+
### What smoking-mirror sends: just the structure
|
|
59
35
|
|
|
60
|
-
|
|
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
|
-
**
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
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
|
-
##
|
|
69
|
+
## See It Work
|
|
123
70
|
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
97
|
+
---
|
|
147
98
|
|
|
148
|
-
|
|
99
|
+
## How It Works
|
|
149
100
|
|
|
150
101
|
```
|
|
151
|
-
|
|
152
|
-
│
|
|
153
|
-
│
|
|
154
|
-
│
|
|
155
|
-
│
|
|
156
|
-
│
|
|
157
|
-
│
|
|
158
|
-
│
|
|
159
|
-
|
|
160
|
-
│
|
|
161
|
-
│
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
|
182
|
-
|
|
|
183
|
-
|
|
|
184
|
-
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
|
188
|
-
|
|
189
|
-
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
##
|
|
138
|
+
## Quick Start
|
|
257
139
|
|
|
258
|
-
###
|
|
140
|
+
### Step 0: Install Claude Code
|
|
259
141
|
|
|
260
|
-
|
|
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
|
-
|
|
145
|
+
npm install -g @anthropic-ai/claude-code
|
|
268
146
|
```
|
|
269
147
|
|
|
270
|
-
###
|
|
148
|
+
### Step 1: Add to your `.mcp.json`
|
|
271
149
|
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
|
301
|
+
Then Claude can surgically `Read` only the 2-3 notes it actually needs.
|
|
319
302
|
|
|
320
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
563
|
+
const allOrphans = findOrphanNotes(index, folder);
|
|
564
|
+
const orphans = allOrphans.slice(offset, offset + limit);
|
|
530
565
|
const output = {
|
|
531
|
-
orphan_count:
|
|
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
|
|
613
|
+
const allHubs = findHubNotes(index, min_links);
|
|
614
|
+
const hubs = allHubs.slice(offset, offset + limit);
|
|
575
615
|
const output = {
|
|
576
|
-
hub_count:
|
|
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("
|
|
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
|
|
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:
|
|
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("
|
|
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
|
|
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
|
-
|
|
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:
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2762
|
+
total_count: allResults.length,
|
|
2763
|
+
returned_count: result.length,
|
|
2676
2764
|
sources: result
|
|
2677
2765
|
}, null, 2) }]
|
|
2678
2766
|
};
|