neuro-vault-mcp 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -61
- package/dist/cli.d.ts +19 -3
- package/dist/cli.js +154 -42
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,133 +1,272 @@
|
|
|
1
1
|
# Neuro Vault MCP
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Semantic search over your Obsidian vault — right inside your AI assistant.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/neuro-vault-mcp)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
> "What did I write about that idea last month?"
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
Neuro Vault MCP connects your Obsidian vault to any MCP-compatible AI assistant. It reuses the embeddings already computed by [Smart Connections](https://github.com/brianpetro/obsidian-smart-connections) — no re-indexing, no extra infrastructure, no API keys.
|
|
12
|
+
|
|
13
|
+
---
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## How It Works
|
|
14
16
|
|
|
15
|
-
```text
|
|
16
|
-
<vault>/.smart-env/multi/*.ajson
|
|
17
17
|
```
|
|
18
|
+
Your question
|
|
19
|
+
│
|
|
20
|
+
▼
|
|
21
|
+
AI assistant
|
|
22
|
+
│ rewrites to keyword queries + picks mode
|
|
23
|
+
▼
|
|
24
|
+
search_notes (MCP tool)
|
|
25
|
+
│
|
|
26
|
+
├─ vector search over Smart Connections embeddings
|
|
27
|
+
├─ block-level search (all modes)
|
|
28
|
+
└─ expansion via similar notes (deep mode)
|
|
29
|
+
│
|
|
30
|
+
▼
|
|
31
|
+
Ranked results with note paths, similarity scores, section headings
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The server loads `.smart-env/multi/*.ajson` into memory at startup and keeps it there. No background processes, no watchers, no database.
|
|
18
35
|
|
|
19
|
-
|
|
36
|
+
---
|
|
20
37
|
|
|
21
38
|
## Requirements
|
|
22
39
|
|
|
23
|
-
- Node.js 20
|
|
24
|
-
-
|
|
25
|
-
- Smart Connections data
|
|
40
|
+
- Node.js 20+
|
|
41
|
+
- Obsidian vault with [Smart Connections](https://github.com/brianpetro/obsidian-smart-connections) plugin (embeddings must be generated)
|
|
42
|
+
- Smart Connections data at `<vault>/.smart-env/multi/*.ajson`
|
|
26
43
|
|
|
27
|
-
|
|
44
|
+
---
|
|
28
45
|
|
|
29
|
-
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
**1. Install**
|
|
30
49
|
|
|
31
50
|
```bash
|
|
32
51
|
npm install -g neuro-vault-mcp
|
|
33
52
|
```
|
|
34
53
|
|
|
35
|
-
|
|
54
|
+
**2. Configure your MCP client**
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
npx -y neuro-vault-mcp --vault /absolute/path/to/vault
|
|
39
|
-
```
|
|
56
|
+
For Claude Code (`~/.claude/settings.json` or `.claude/settings.json` in your project):
|
|
40
57
|
|
|
41
|
-
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"neuro-vault": {
|
|
62
|
+
"command": "neuro-vault-mcp",
|
|
63
|
+
"args": ["--vault", "/absolute/path/to/your/vault"]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
42
68
|
|
|
43
|
-
|
|
69
|
+
For Cursor or Windsurf (`.cursor/mcp.json` / `.windsurf/mcp.json`):
|
|
44
70
|
|
|
45
71
|
```json
|
|
46
72
|
{
|
|
47
|
-
"
|
|
48
|
-
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"neuro-vault": {
|
|
75
|
+
"command": "neuro-vault-mcp",
|
|
76
|
+
"args": ["--vault", "/absolute/path/to/your/vault"]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
49
79
|
}
|
|
50
80
|
```
|
|
51
81
|
|
|
52
|
-
|
|
82
|
+
Or run without installing using `npx`:
|
|
53
83
|
|
|
54
84
|
```json
|
|
55
85
|
{
|
|
56
86
|
"mcpServers": {
|
|
57
87
|
"neuro-vault": {
|
|
58
|
-
"command": "
|
|
59
|
-
"args": ["--vault", "/absolute/path/to/vault"]
|
|
88
|
+
"command": "npx",
|
|
89
|
+
"args": ["-y", "neuro-vault-mcp", "--vault", "/absolute/path/to/your/vault"]
|
|
60
90
|
}
|
|
61
91
|
}
|
|
62
92
|
}
|
|
63
93
|
```
|
|
64
94
|
|
|
65
|
-
|
|
95
|
+
**3. Try it**
|
|
66
96
|
|
|
67
|
-
|
|
97
|
+
Ask your assistant:
|
|
68
98
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
99
|
+
> "What did I write about building AI agents?"
|
|
100
|
+
|
|
101
|
+
> "Find my notes on productivity systems"
|
|
102
|
+
|
|
103
|
+
> "What are all my ideas related to embeddings?"
|
|
104
|
+
|
|
105
|
+
On first run the embedding model downloads automatically (~40 MB). Subsequent starts are fast.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Features
|
|
110
|
+
|
|
111
|
+
### Mode-Based Search
|
|
112
|
+
|
|
113
|
+
Every search picks a mode that controls retrieval depth:
|
|
114
|
+
|
|
115
|
+
| Mode | Use when | Limit | Threshold | Expansion |
|
|
116
|
+
| ------- | --------------------------------- | ----- | --------- | --------- |
|
|
117
|
+
| `quick` | Specific question, need 1-2 notes | 3 | 0.50 | off |
|
|
118
|
+
| `deep` | Broad topic, need an overview | 8 | 0.35 | on |
|
|
119
|
+
|
|
120
|
+
The AI assistant picks the mode automatically based on your question. You can also pass it explicitly.
|
|
121
|
+
|
|
122
|
+
### Block-Level Results (Deep Mode)
|
|
72
123
|
|
|
73
|
-
|
|
124
|
+
In `deep` mode the server also searches by individual note sections (blocks), not just whole notes. This surfaces the exact paragraphs that are most relevant, not just the note they live in.
|
|
74
125
|
|
|
75
|
-
|
|
126
|
+
### Expansion (Deep Mode)
|
|
127
|
+
|
|
128
|
+
After finding top results, the server uses their embeddings to discover neighboring notes. This catches related notes that don't directly match your query but are semantically close to what you found.
|
|
129
|
+
|
|
130
|
+
### Automatic Fallback
|
|
131
|
+
|
|
132
|
+
When vector search returns nothing, the server retries with a lower similarity threshold (0.3). If still nothing — the AI assistant can search vault files using its own tools.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Tools Reference
|
|
76
137
|
|
|
77
138
|
### `search_notes`
|
|
78
139
|
|
|
79
|
-
Search the
|
|
140
|
+
Search the vault by semantic similarity.
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
search_notes({
|
|
144
|
+
query: string, // short keyword query (1-4 words)
|
|
145
|
+
mode?: "quick" | "deep", // default: "quick"
|
|
146
|
+
threshold?: number, // override mode default (0–1)
|
|
147
|
+
expansion?: boolean, // override mode default
|
|
148
|
+
expansion_limit?: number, // how many top results to expand (default: 3)
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Returns `results` (ranked notes) and `blockResults` (ranked sections — scoped to matched notes in quick mode, all sources in deep mode).
|
|
153
|
+
|
|
154
|
+
**Tips for better results:**
|
|
80
155
|
|
|
81
|
-
|
|
156
|
+
- Use short keyword queries (1–4 words), not full sentences
|
|
157
|
+
- Call multiple times for synonyms and translations: `"embeddings"`, then `"векторний пошук"`, then `"vector search"`
|
|
158
|
+
- Lower the threshold to `0.3` if nothing comes back
|
|
159
|
+
- Use `deep` mode for exploratory questions
|
|
82
160
|
|
|
83
|
-
|
|
84
|
-
- `limit` `number` optional, default `10`, must be a positive integer
|
|
85
|
-
- `threshold` `number` optional, default `0.5`, must be between `0` and `1`
|
|
161
|
+
---
|
|
86
162
|
|
|
87
163
|
### `get_similar_notes`
|
|
88
164
|
|
|
89
|
-
Find notes similar to a
|
|
165
|
+
Find notes similar to a given note path.
|
|
90
166
|
|
|
91
|
-
|
|
167
|
+
```typescript
|
|
168
|
+
get_similar_notes({
|
|
169
|
+
note_path: string, // vault-relative POSIX path, e.g. "Projects/neuro-vault.md"
|
|
170
|
+
limit?: number, // default: 10
|
|
171
|
+
threshold?: number, // default: 0.5
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Use this after `search_notes` finds a relevant note — it discovers related content without needing a text query.
|
|
92
176
|
|
|
93
|
-
|
|
94
|
-
- `limit` `number` optional, default `10`, must be a positive integer
|
|
95
|
-
- `threshold` `number` optional, default `0.5`, must be between `0` and `1`
|
|
177
|
+
---
|
|
96
178
|
|
|
97
179
|
### `find_duplicates`
|
|
98
180
|
|
|
99
181
|
Find note pairs with high embedding similarity.
|
|
100
182
|
|
|
101
|
-
|
|
183
|
+
```typescript
|
|
184
|
+
find_duplicates({
|
|
185
|
+
threshold?: number, // default: 0.9
|
|
186
|
+
})
|
|
187
|
+
```
|
|
102
188
|
|
|
103
|
-
|
|
189
|
+
Useful for vault maintenance: identifies notes that cover the same topic and could be merged.
|
|
190
|
+
|
|
191
|
+
---
|
|
104
192
|
|
|
105
193
|
### `get_stats`
|
|
106
194
|
|
|
107
|
-
Report corpus
|
|
195
|
+
Report loaded corpus statistics.
|
|
196
|
+
|
|
197
|
+
Returns: `{ totalNotes, totalBlocks, embeddingDimension, modelKey }`
|
|
108
198
|
|
|
109
|
-
|
|
199
|
+
---
|
|
110
200
|
|
|
111
|
-
|
|
201
|
+
## AGENTS.md / CLAUDE.md Snippet
|
|
112
202
|
|
|
113
|
-
|
|
203
|
+
Add this to your `AGENTS.md` or `CLAUDE.md` to help the AI assistant use the vault effectively:
|
|
114
204
|
|
|
115
|
-
|
|
205
|
+
```markdown
|
|
206
|
+
## Vault search
|
|
116
207
|
|
|
117
|
-
|
|
208
|
+
Use the `search_notes` MCP tool to search my Obsidian vault before answering questions about my notes, projects, or ideas.
|
|
209
|
+
|
|
210
|
+
Search protocol:
|
|
211
|
+
|
|
212
|
+
1. Choose mode: `quick` for specific questions, `deep` for broad topics
|
|
213
|
+
2. Rewrite the query: extract 2-4 keywords, remove filler words
|
|
214
|
+
3. Call search_notes once per concept — use separate calls for synonyms and UA↔EN translations
|
|
215
|
+
|
|
216
|
+
Skip vault search for: general programming questions, translations, tasks with no personal knowledge component.
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Configuration
|
|
222
|
+
|
|
223
|
+
### CLI Arguments
|
|
224
|
+
|
|
225
|
+
| Argument | Required | Description |
|
|
226
|
+
| --------- | -------- | --------------------------------------------- |
|
|
227
|
+
| `--vault` | yes | Absolute path to the Obsidian vault directory |
|
|
228
|
+
| `--help` | no | Show help |
|
|
229
|
+
|
|
230
|
+
### Startup Behavior
|
|
231
|
+
|
|
232
|
+
- Smart Connections `.ajson` files are loaded into memory once at startup
|
|
233
|
+
- The embedding model (`TaylorAI/bge-micro-v2`) is downloaded on first run and cached by `@xenova/transformers`
|
|
234
|
+
- If the vault path is missing or Smart Connections data is absent, the server exits immediately with an error
|
|
235
|
+
|
|
236
|
+
### Troubleshooting
|
|
237
|
+
|
|
238
|
+
**"Smart Connections directory does not exist"** — make sure the Smart Connections plugin has run and generated embeddings. Open Obsidian, let Smart Connections finish indexing, then restart the MCP server.
|
|
239
|
+
|
|
240
|
+
**First startup is slow** — the embedding model (~40 MB) is downloading. Subsequent starts use the cached model.
|
|
241
|
+
|
|
242
|
+
**Search returns nothing** — try lowering the threshold: `threshold: 0.3`. Also check that `get_stats` shows a non-zero `totalNotes`.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Development
|
|
118
247
|
|
|
119
248
|
```bash
|
|
120
|
-
npm run build
|
|
121
|
-
npm run
|
|
122
|
-
npm run
|
|
123
|
-
npm run format
|
|
124
|
-
npm run
|
|
249
|
+
npm run build # compile TypeScript to dist/
|
|
250
|
+
npm run test # run tests with vitest
|
|
251
|
+
npm run lint # ESLint
|
|
252
|
+
npm run format # check formatting with Prettier
|
|
253
|
+
npm run format:write # fix formatting
|
|
125
254
|
```
|
|
126
255
|
|
|
127
|
-
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Limitations
|
|
128
259
|
|
|
129
260
|
- Requires Smart Connections `.ajson` files already present in the vault
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
261
|
+
- In-memory search only — no persistent index, no background re-indexing
|
|
262
|
+
- stdio transport only — not HTTP or SSE
|
|
263
|
+
- Local vault path only — no remote vaults
|
|
264
|
+
- Embedding model loaded at startup; first run can be slow
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
ISC — see [LICENSE](LICENSE).
|
|
271
|
+
|
|
272
|
+
Changelog: [Releases](https://github.com/AlexMost/neuro-vault/releases)
|
package/dist/cli.d.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
|
|
5
|
+
interface RetrievalOutput {
|
|
6
|
+
results: SearchResult[];
|
|
7
|
+
blockResults?: BlockSearchResult[];
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
interface SmartBlock {
|
|
6
11
|
key: string;
|
|
7
12
|
heading: string;
|
|
@@ -13,14 +18,15 @@ interface SmartSource {
|
|
|
13
18
|
embedding: number[];
|
|
14
19
|
blocks: SmartBlock[];
|
|
15
20
|
}
|
|
16
|
-
interface
|
|
21
|
+
interface BlockSearchResult {
|
|
22
|
+
path: string;
|
|
17
23
|
heading: string;
|
|
18
24
|
lines: [number, number];
|
|
25
|
+
similarity: number;
|
|
19
26
|
}
|
|
20
27
|
interface SearchResult {
|
|
21
28
|
path: string;
|
|
22
29
|
similarity: number;
|
|
23
|
-
blocks: SearchResultBlock[];
|
|
24
30
|
}
|
|
25
31
|
interface DuplicatePair {
|
|
26
32
|
note_a: string;
|
|
@@ -39,15 +45,25 @@ interface SearchEngine {
|
|
|
39
45
|
limit?: number;
|
|
40
46
|
excludePath?: string;
|
|
41
47
|
}): SearchResult[];
|
|
48
|
+
findBlockNeighbors(args: {
|
|
49
|
+
queryVector: number[];
|
|
50
|
+
sources: Iterable<SmartSource>;
|
|
51
|
+
threshold: number;
|
|
52
|
+
limit?: number;
|
|
53
|
+
}): BlockSearchResult[];
|
|
42
54
|
findDuplicates(args: {
|
|
43
55
|
sources: Iterable<SmartSource>;
|
|
44
56
|
threshold: number;
|
|
45
57
|
}): DuplicatePair[];
|
|
46
58
|
}
|
|
59
|
+
type SearchMode = 'quick' | 'deep';
|
|
47
60
|
interface SearchNotesInput {
|
|
48
61
|
query: string;
|
|
62
|
+
mode?: SearchMode;
|
|
49
63
|
limit?: number;
|
|
50
64
|
threshold?: number;
|
|
65
|
+
expansion?: boolean;
|
|
66
|
+
expansion_limit?: number;
|
|
51
67
|
}
|
|
52
68
|
interface GetSimilarNotesInput {
|
|
53
69
|
note_path: string;
|
|
@@ -72,7 +88,7 @@ interface ToolHandlerDependencies {
|
|
|
72
88
|
modelKey: string;
|
|
73
89
|
}
|
|
74
90
|
interface ToolHandlers {
|
|
75
|
-
searchNotes(input: SearchNotesInput): Promise<
|
|
91
|
+
searchNotes(input: SearchNotesInput): Promise<RetrievalOutput>;
|
|
76
92
|
getSimilarNotes(input: GetSimilarNotesInput): Promise<SearchResult[]>;
|
|
77
93
|
findDuplicates(input?: FindDuplicatesInput): Promise<DuplicatePair[]>;
|
|
78
94
|
getStats(): Promise<ToolStats>;
|
package/dist/cli.js
CHANGED
|
@@ -13929,6 +13929,10 @@ function parseAjsonContent(content) {
|
|
|
13929
13929
|
}
|
|
13930
13930
|
pos++;
|
|
13931
13931
|
while (pos < len && (content[pos] === " " || content[pos] === " ")) pos++;
|
|
13932
|
+
if (pos + 4 <= len && content.slice(pos, pos + 4) === "null") {
|
|
13933
|
+
pos += 4;
|
|
13934
|
+
continue;
|
|
13935
|
+
}
|
|
13932
13936
|
if (pos >= len || content[pos] !== "{") {
|
|
13933
13937
|
throw new Error(`Expected opening brace for key "${key}" in AJSON content`);
|
|
13934
13938
|
}
|
|
@@ -14095,7 +14099,6 @@ function getEmbeddingDimension(sources) {
|
|
|
14095
14099
|
}
|
|
14096
14100
|
|
|
14097
14101
|
// src/search-engine.ts
|
|
14098
|
-
var MAX_BLOCKS_PER_RESULT = 5;
|
|
14099
14102
|
function validateVector(vector, label) {
|
|
14100
14103
|
if (vector.length === 0) {
|
|
14101
14104
|
throw new Error(`${label} must not be empty`);
|
|
@@ -14150,14 +14153,7 @@ function toSearchResults(queryVector, sources, threshold, excludePath) {
|
|
|
14150
14153
|
);
|
|
14151
14154
|
const similarity = cosineSimilarity(queryVector, source.embedding);
|
|
14152
14155
|
if (similarity >= threshold) {
|
|
14153
|
-
results.push({
|
|
14154
|
-
path: source.path,
|
|
14155
|
-
similarity,
|
|
14156
|
-
blocks: source.blocks.slice(0, MAX_BLOCKS_PER_RESULT).map(({ heading, lines }) => ({
|
|
14157
|
-
heading,
|
|
14158
|
-
lines
|
|
14159
|
-
}))
|
|
14160
|
-
});
|
|
14156
|
+
results.push({ path: source.path, similarity });
|
|
14161
14157
|
}
|
|
14162
14158
|
}
|
|
14163
14159
|
results.sort(compareSearchResults);
|
|
@@ -14173,6 +14169,41 @@ function findNeighbors({
|
|
|
14173
14169
|
const results = toSearchResults(queryVector, sources, threshold, excludePath);
|
|
14174
14170
|
return typeof limit === "number" ? results.slice(0, limit) : results;
|
|
14175
14171
|
}
|
|
14172
|
+
function compareBlockSearchResults(left, right) {
|
|
14173
|
+
return right.similarity - left.similarity || compareStrings(left.path, right.path);
|
|
14174
|
+
}
|
|
14175
|
+
function findBlockNeighbors({
|
|
14176
|
+
queryVector,
|
|
14177
|
+
sources,
|
|
14178
|
+
threshold,
|
|
14179
|
+
limit
|
|
14180
|
+
}) {
|
|
14181
|
+
const results = [];
|
|
14182
|
+
for (const source of sources) {
|
|
14183
|
+
for (const block of source.blocks) {
|
|
14184
|
+
if (block.embedding.length === 0) {
|
|
14185
|
+
continue;
|
|
14186
|
+
}
|
|
14187
|
+
ensureSameDimensions(
|
|
14188
|
+
queryVector,
|
|
14189
|
+
block.embedding,
|
|
14190
|
+
"Query vector",
|
|
14191
|
+
`Block vector for ${block.key}`
|
|
14192
|
+
);
|
|
14193
|
+
const similarity = cosineSimilarity(queryVector, block.embedding);
|
|
14194
|
+
if (similarity >= threshold) {
|
|
14195
|
+
results.push({
|
|
14196
|
+
path: source.path,
|
|
14197
|
+
heading: block.heading,
|
|
14198
|
+
lines: block.lines,
|
|
14199
|
+
similarity
|
|
14200
|
+
});
|
|
14201
|
+
}
|
|
14202
|
+
}
|
|
14203
|
+
}
|
|
14204
|
+
results.sort(compareBlockSearchResults);
|
|
14205
|
+
return typeof limit === "number" ? results.slice(0, limit) : results;
|
|
14206
|
+
}
|
|
14176
14207
|
function findDuplicates({
|
|
14177
14208
|
sources,
|
|
14178
14209
|
threshold
|
|
@@ -14209,6 +14240,86 @@ function findDuplicates({
|
|
|
14209
14240
|
|
|
14210
14241
|
// src/tool-handlers.ts
|
|
14211
14242
|
import path3 from "path";
|
|
14243
|
+
|
|
14244
|
+
// src/retrieval-policy.ts
|
|
14245
|
+
var FALLBACK_THRESHOLD = 0.3;
|
|
14246
|
+
var QUICK_BLOCK_LIMIT = 5;
|
|
14247
|
+
var MODE_DEFAULTS = {
|
|
14248
|
+
quick: { limit: 3, threshold: 0.5, expansion: false, expansionLimit: 0 },
|
|
14249
|
+
deep: { limit: 8, threshold: 0.35, expansion: true, expansionLimit: 3 }
|
|
14250
|
+
};
|
|
14251
|
+
function deduplicateResults(results) {
|
|
14252
|
+
const best = /* @__PURE__ */ new Map();
|
|
14253
|
+
for (const result of results) {
|
|
14254
|
+
const existing = best.get(result.path);
|
|
14255
|
+
if (!existing || result.similarity > existing.similarity) {
|
|
14256
|
+
best.set(result.path, result);
|
|
14257
|
+
}
|
|
14258
|
+
}
|
|
14259
|
+
return Array.from(best.values());
|
|
14260
|
+
}
|
|
14261
|
+
async function executeRetrieval(input) {
|
|
14262
|
+
const { query, mode, sources, embeddingProvider, searchEngine } = input;
|
|
14263
|
+
const modeConfig = MODE_DEFAULTS[mode];
|
|
14264
|
+
const threshold = input.threshold ?? modeConfig.threshold;
|
|
14265
|
+
const expansion = input.expansion ?? modeConfig.expansion;
|
|
14266
|
+
const expansionLimit = input.expansionLimit ?? modeConfig.expansionLimit;
|
|
14267
|
+
const limit = modeConfig.limit;
|
|
14268
|
+
const queryVector = await embeddingProvider.embed(query);
|
|
14269
|
+
let vectorResults = searchEngine.findNeighbors({
|
|
14270
|
+
queryVector,
|
|
14271
|
+
sources: sources.values(),
|
|
14272
|
+
threshold,
|
|
14273
|
+
limit
|
|
14274
|
+
});
|
|
14275
|
+
if (vectorResults.length === 0 && threshold > FALLBACK_THRESHOLD) {
|
|
14276
|
+
vectorResults = searchEngine.findNeighbors({
|
|
14277
|
+
queryVector,
|
|
14278
|
+
sources: sources.values(),
|
|
14279
|
+
threshold: FALLBACK_THRESHOLD,
|
|
14280
|
+
limit
|
|
14281
|
+
});
|
|
14282
|
+
}
|
|
14283
|
+
let blockResults;
|
|
14284
|
+
if (mode === "deep") {
|
|
14285
|
+
blockResults = searchEngine.findBlockNeighbors({
|
|
14286
|
+
queryVector,
|
|
14287
|
+
sources: sources.values(),
|
|
14288
|
+
threshold,
|
|
14289
|
+
limit
|
|
14290
|
+
});
|
|
14291
|
+
} else if (vectorResults.length > 0) {
|
|
14292
|
+
const matchedPaths = new Set(vectorResults.map((r) => r.path));
|
|
14293
|
+
const matchedSources = [...sources.values()].filter((s) => matchedPaths.has(s.path));
|
|
14294
|
+
blockResults = searchEngine.findBlockNeighbors({
|
|
14295
|
+
queryVector,
|
|
14296
|
+
sources: matchedSources,
|
|
14297
|
+
threshold: 0,
|
|
14298
|
+
limit: QUICK_BLOCK_LIMIT
|
|
14299
|
+
});
|
|
14300
|
+
}
|
|
14301
|
+
if (expansion && vectorResults.length > 0) {
|
|
14302
|
+
const topResults = vectorResults.slice(0, expansionLimit);
|
|
14303
|
+
for (const topResult of topResults) {
|
|
14304
|
+
const source = sources.get(topResult.path);
|
|
14305
|
+
if (!source || source.embedding.length === 0) continue;
|
|
14306
|
+
const expansionNeighbors = searchEngine.findNeighbors({
|
|
14307
|
+
queryVector: source.embedding,
|
|
14308
|
+
sources: sources.values(),
|
|
14309
|
+
threshold,
|
|
14310
|
+
limit
|
|
14311
|
+
});
|
|
14312
|
+
vectorResults = deduplicateResults([...vectorResults, ...expansionNeighbors]);
|
|
14313
|
+
}
|
|
14314
|
+
}
|
|
14315
|
+
vectorResults = vectorResults.slice(0, limit);
|
|
14316
|
+
return {
|
|
14317
|
+
results: vectorResults,
|
|
14318
|
+
...blockResults !== void 0 ? { blockResults } : {}
|
|
14319
|
+
};
|
|
14320
|
+
}
|
|
14321
|
+
|
|
14322
|
+
// src/tool-handlers.ts
|
|
14212
14323
|
var DEFAULT_SEARCH_LIMIT = 10;
|
|
14213
14324
|
var DEFAULT_SEARCH_THRESHOLD = 0.5;
|
|
14214
14325
|
var DEFAULT_DUPLICATE_THRESHOLD = 0.9;
|
|
@@ -14259,7 +14370,7 @@ function normalizeNotePath(notePath) {
|
|
|
14259
14370
|
}
|
|
14260
14371
|
function normalizeQuery(query) {
|
|
14261
14372
|
const normalized = query.trim();
|
|
14262
|
-
if (
|
|
14373
|
+
if (normalized.length === 0) {
|
|
14263
14374
|
throw new ToolHandlerError("INVALID_ARGUMENT", "query must not be empty", {
|
|
14264
14375
|
details: { field: "query" }
|
|
14265
14376
|
});
|
|
@@ -14343,25 +14454,20 @@ function createToolHandlers({
|
|
|
14343
14454
|
return {
|
|
14344
14455
|
async searchNotes(input) {
|
|
14345
14456
|
const query = normalizeQuery(input.query);
|
|
14346
|
-
const
|
|
14347
|
-
const threshold = readThreshold(input.threshold,
|
|
14348
|
-
|
|
14349
|
-
try {
|
|
14350
|
-
queryVector = await embeddingProvider.embed(query);
|
|
14351
|
-
} catch (error48) {
|
|
14352
|
-
throw wrapDependencyError(error48, "Failed to embed query text", {
|
|
14353
|
-
modelKey,
|
|
14354
|
-
operation: "search_notes"
|
|
14355
|
-
});
|
|
14356
|
-
}
|
|
14457
|
+
const mode = input.mode ?? "quick";
|
|
14458
|
+
const threshold = input.threshold !== void 0 ? readThreshold(input.threshold, input.threshold, "threshold") : void 0;
|
|
14459
|
+
const expansionLimit = input.expansion_limit !== void 0 ? readPositiveInteger(input.expansion_limit, 3, "expansion_limit") : void 0;
|
|
14357
14460
|
try {
|
|
14358
|
-
return
|
|
14359
|
-
|
|
14360
|
-
|
|
14361
|
-
loader.sources.values(),
|
|
14461
|
+
return await executeRetrieval({
|
|
14462
|
+
query,
|
|
14463
|
+
mode,
|
|
14362
14464
|
threshold,
|
|
14363
|
-
|
|
14364
|
-
|
|
14465
|
+
expansion: input.expansion,
|
|
14466
|
+
expansionLimit,
|
|
14467
|
+
sources: loader.sources,
|
|
14468
|
+
embeddingProvider,
|
|
14469
|
+
searchEngine
|
|
14470
|
+
});
|
|
14365
14471
|
} catch (error48) {
|
|
14366
14472
|
throw wrapDependencyError(error48, "Failed to search notes", {
|
|
14367
14473
|
modelKey,
|
|
@@ -14430,8 +14536,11 @@ var require2 = createRequire(import.meta.url);
|
|
|
14430
14536
|
var { name: SERVER_NAME, version: SERVER_VERSION } = require2("../package.json");
|
|
14431
14537
|
var searchNotesSchema = external_exports.object({
|
|
14432
14538
|
query: external_exports.string(),
|
|
14539
|
+
mode: external_exports.enum(["quick", "deep"]).optional(),
|
|
14433
14540
|
limit: external_exports.number().int().positive().optional(),
|
|
14434
|
-
threshold: external_exports.number().min(0).max(1).optional()
|
|
14541
|
+
threshold: external_exports.number().min(0).max(1).optional(),
|
|
14542
|
+
expansion: external_exports.boolean().optional(),
|
|
14543
|
+
expansion_limit: external_exports.number().int().positive().optional()
|
|
14435
14544
|
});
|
|
14436
14545
|
var getSimilarNotesSchema = external_exports.object({
|
|
14437
14546
|
note_path: external_exports.string(),
|
|
@@ -14441,23 +14550,26 @@ var getSimilarNotesSchema = external_exports.object({
|
|
|
14441
14550
|
var findDuplicatesSchema = external_exports.object({
|
|
14442
14551
|
threshold: external_exports.number().min(0).max(1).optional()
|
|
14443
14552
|
});
|
|
14444
|
-
var SERVER_INSTRUCTIONS = `
|
|
14445
|
-
|
|
14446
|
-
## Search strategy
|
|
14553
|
+
var SERVER_INSTRUCTIONS = `Semantic search over an Obsidian vault. Use when the user's vault may contain relevant context \u2014 their notes, projects, plans, tasks, learning materials, or ideas. This includes both direct requests ("find my notes on X") and questions where vault context would improve the answer ("what's on my agenda?", "what was I working on?").
|
|
14447
14554
|
|
|
14448
|
-
|
|
14555
|
+
## How to search
|
|
14449
14556
|
|
|
14450
|
-
|
|
14451
|
-
1. Extract the core nouns and concepts from the user's message \u2014 strip
|
|
14452
|
-
2.
|
|
14557
|
+
### 1. Write the query
|
|
14558
|
+
1. Extract the core nouns and concepts from the user's message \u2014 strip filler words and verbs. From "remind me what I wanted to build with LLM agents" the key concepts are "LLM", "agents", "build".
|
|
14559
|
+
2. Search each concept separately and in small combinations: "LLM", "agents", "LLM agents", "AI projects".
|
|
14453
14560
|
3. Try synonyms and reformulations \u2014 the note may use different wording than the query.
|
|
14454
|
-
|
|
14455
|
-
|
|
14456
|
-
5. Once you find a relevant note, use get_similar_notes to discover related content.
|
|
14561
|
+
4. The vault may contain notes in multiple languages. Search in the language of the user's message + English.
|
|
14562
|
+
5. If a search returns no results, lower the threshold to 0.3 before giving up.
|
|
14457
14563
|
|
|
14458
|
-
|
|
14564
|
+
### 2. Choose mode
|
|
14565
|
+
- **quick** (default) \u2014 returns up to 3 notes, no expansion. Use for specific lookups.
|
|
14566
|
+
- **deep** \u2014 returns up to 8 notes + expands via similar notes. Use for broad topics.
|
|
14567
|
+
- Pass \`expansion: true\` in quick mode if you want expansion there too.
|
|
14459
14568
|
|
|
14460
|
-
|
|
14569
|
+
### 3. Use the results
|
|
14570
|
+
- \`results\` \u2014 notes ranked by similarity; read the file by path
|
|
14571
|
+
- \`blockResults\` \u2014 sections ranked by relevance; use heading + line range to jump to the relevant part
|
|
14572
|
+
- After finding a relevant note, call get_similar_notes to discover related content
|
|
14461
14573
|
`;
|
|
14462
14574
|
function defaultServerFactory() {
|
|
14463
14575
|
return new McpServer(
|
|
@@ -14541,7 +14653,7 @@ function createNeuroVaultServer({
|
|
|
14541
14653
|
"search_notes",
|
|
14542
14654
|
{
|
|
14543
14655
|
title: "Search Notes",
|
|
14544
|
-
description:
|
|
14656
|
+
description: 'Search notes by semantic similarity. Pass a short keyword query (1-4 words). Choose mode: "quick" for specific lookups (1-2 notes), "deep" for broad topic overview with block-level search and expansion. Call multiple times with different queries for synonyms or multi-language searches.',
|
|
14545
14657
|
inputSchema: searchNotesSchema
|
|
14546
14658
|
},
|
|
14547
14659
|
async (args) => invokeTool(() => handlers.searchNotes(args))
|
|
@@ -14582,7 +14694,7 @@ function ensureCorpusIsUsable(corpus) {
|
|
|
14582
14694
|
async function startNeuroVaultServer(config2, deps = {}) {
|
|
14583
14695
|
const loadCorpus = deps.loadCorpus ?? loadSmartConnectionsCorpus;
|
|
14584
14696
|
const embeddingServiceFactory = deps.embeddingServiceFactory ?? ((modelId) => new EmbeddingService({ modelKey: modelId }));
|
|
14585
|
-
const searchEngine = deps.searchEngine ?? { findNeighbors, findDuplicates };
|
|
14697
|
+
const searchEngine = deps.searchEngine ?? { findNeighbors, findBlockNeighbors, findDuplicates };
|
|
14586
14698
|
const serverFactory = deps.serverFactory ?? defaultServerFactory;
|
|
14587
14699
|
const transportFactory = deps.transportFactory ?? defaultTransportFactory;
|
|
14588
14700
|
const corpus = await loadCorpus(config2.smartEnvPath, config2.modelKey);
|