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 CHANGED
@@ -1,133 +1,272 @@
1
1
  # Neuro Vault MCP
2
2
 
3
- Neuro Vault MCP is a Model Context Protocol server for semantic search over an Obsidian vault that already has Smart Connections embeddings and note data on disk. It reads Smart Connections `.smart-env/multi/*.ajson` files, loads the corpus into memory, and exposes MCP tools over stdio so compatible clients can search the vault without extra infrastructure.
3
+ **Semantic search over your Obsidian vault — right inside your AI assistant.**
4
4
 
5
- ## Overview
5
+ [![npm version](https://img.shields.io/npm/v/neuro-vault-mcp)](https://www.npmjs.com/package/neuro-vault-mcp)
6
+ [![Node.js](https://img.shields.io/node/v/neuro-vault-mcp)](https://nodejs.org)
7
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
8
 
7
- The server is started by an MCP host or from the command line with an explicit vault path:
9
+ > "What did I write about that idea last month?"
8
10
 
9
- ```bash
10
- neuro-vault-mcp --vault /absolute/path/to/vault
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
- The vault path must be absolute. Neuro Vault MCP expects Smart Connections data in:
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
- On first run, the query embedding model is downloaded automatically. That initial model download may add noticeable startup latency.
36
+ ---
20
37
 
21
38
  ## Requirements
22
39
 
23
- - Node.js 20 or newer
24
- - An Obsidian vault on the local machine
25
- - Smart Connections data present in `<vault>/.smart-env/multi/*.ajson`
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
- ## Installation
44
+ ---
28
45
 
29
- Install globally:
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
- Or run it on demand with `npx`:
54
+ **2. Configure your MCP client**
36
55
 
37
- ```bash
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
- ## MCP Configuration Example
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
- Most MCP hosts just need the binary and vault argument:
69
+ For Cursor or Windsurf (`.cursor/mcp.json` / `.windsurf/mcp.json`):
44
70
 
45
71
  ```json
46
72
  {
47
- "command": "neuro-vault-mcp",
48
- "args": ["--vault", "/absolute/path/to/vault"]
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
- If your host uses a more explicit JSON configuration, the same launch command applies:
82
+ Or run without installing using `npx`:
53
83
 
54
84
  ```json
55
85
  {
56
86
  "mcpServers": {
57
87
  "neuro-vault": {
58
- "command": "neuro-vault-mcp",
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
- ## CLI Usage
95
+ **3. Try it**
66
96
 
67
- After installing globally, launch the server directly from your shell:
97
+ Ask your assistant:
68
98
 
69
- ```bash
70
- neuro-vault-mcp --vault /absolute/path/to/vault
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
- The process speaks MCP over stdio, so it is intended to be started by a compatible client or wrapper process rather than visited in a browser.
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
- ## Available Tools
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 loaded corpus by semantic similarity.
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
- Parameters:
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
- - `query` `string` required
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 vault-relative note path.
165
+ Find notes similar to a given note path.
90
166
 
91
- Parameters:
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
- - `note_path` `string` required, vault-relative and POSIX-style
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
- Parameters:
183
+ ```typescript
184
+ find_duplicates({
185
+ threshold?: number, // default: 0.9
186
+ })
187
+ ```
102
188
 
103
- - `threshold` `number` optional, default `0.9`, must be between `0` and `1`
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 and embedding statistics.
195
+ Report loaded corpus statistics.
196
+
197
+ Returns: `{ totalNotes, totalBlocks, embeddingDimension, modelKey }`
108
198
 
109
- Parameters:
199
+ ---
110
200
 
111
- - none
201
+ ## AGENTS.md / CLAUDE.md Snippet
112
202
 
113
- ## Tool Output
203
+ Add this to your `AGENTS.md` or `CLAUDE.md` to help the AI assistant use the vault effectively:
114
204
 
115
- All search-style tools return note paths relative to the vault, similarity scores, and the note blocks preserved from Smart Connections data. `get_stats` returns the loaded corpus size, block count, embedding dimension, and model key.
205
+ ```markdown
206
+ ## Vault search
116
207
 
117
- ## Development Commands
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 lint
122
- npm run format
123
- npm run format:write
124
- npm run test
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
- ## Limitations for v1
256
+ ---
257
+
258
+ ## Limitations
128
259
 
129
260
  - Requires Smart Connections `.ajson` files already present in the vault
130
- - Uses in-memory search only, with no persistent index
131
- - Starts over stdio only, not HTTP or SSE
132
- - Loads the embedding model at startup, so the first run can be slow
133
- - Expects a local vault path and does not support remote vaults
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 SearchResultBlock {
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<SearchResult[]>;
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 (!normalized) {
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 limit = readPositiveInteger(input.limit, DEFAULT_SEARCH_LIMIT, "limit");
14347
- const threshold = readThreshold(input.threshold, DEFAULT_SEARCH_THRESHOLD, "threshold");
14348
- let queryVector;
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 toSearchResults2(
14359
- searchEngine,
14360
- queryVector,
14361
- loader.sources.values(),
14461
+ return await executeRetrieval({
14462
+ query,
14463
+ mode,
14362
14464
  threshold,
14363
- limit
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 = `This server provides semantic search over an Obsidian vault using Smart Connections embeddings.
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
- Semantic search works best with short, focused queries (1-4 words). Long sentences or full questions dilute the signal and often return no results.
14555
+ ## How to search
14449
14556
 
14450
- When looking for information:
14451
- 1. Extract the core nouns and concepts from the user's message \u2014 strip away filler words, verbs, and context. For example, from "remind me what I wanted to build with LLM agents" the key concepts are "LLM", "agents", "build" \u2014 not the full sentence.
14452
- 2. Start with several SHORT keyword queries rather than one long phrase. Search each key concept separately and in small combinations. For example, try: "LLM", "agents", "LLM agents", "AI projects".
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
- 3. The vault may contain notes in multiple languages. Try queries in each language the user speaks (e.g. both Ukrainian and English).
14455
- 4. If a search returns no results, lower the threshold to 0.3 before giving up.
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
- ## Reading results
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
- Search results include block headings and line ranges \u2014 use these as pointers to read specific sections of the matched notes rather than reading entire files.
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: "Search notes by semantic similarity. Use short keyword queries (1-4 words), not full sentences. Make multiple calls with different keywords, synonyms, and languages to get comprehensive results. Lower the threshold (e.g. 0.3) if no results are found.",
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);