@owrede/vault-memory 0.8.3 → 0.9.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
@@ -6,7 +6,9 @@ Reads one or more Obsidian vaults, indexes them with local embeddings via Ollama
6
6
 
7
7
  ## Status
8
8
 
9
- **v0.8.1** — Phase 8 (real ONNX cross-encoder reranker) + search-quality fixes. See `_research/vault-memory-spec.md` in a consuming vault for the design contract, `_research/vault-memory-eval-v2-results.md` for retrieval-quality benchmarks, and `_research/vault-memory-eval-v3-spec.md` for the planned reranker eval.
9
+ **v0.9.0** — Agent-Compatibility & Self-Orientation: OB1-compatible `search`/`fetch` tools so ChatGPT Custom Connectors, Claude.ai, and Deep-Research modes can use vault-memory as a connector; `vault_stats` and `recent_notes` for agent self-orientation on first connect. See `_research/vault-memory-openbrain-comparison.md` in a consuming vault for the gap analysis driving these additions.
10
+
11
+ Previous: **v0.8.3** — Phase 8 (real ONNX cross-encoder reranker) + search-quality fixes + skills consolidation.
10
12
 
11
13
  ## Architecture in one paragraph
12
14
 
@@ -183,7 +185,7 @@ exclude_globs = [".obsidian/**", ".trash/**", "_research/**", ".claude/**"]
183
185
  # secondary_embedding_model = "qwen3-embedding:0.6b"
184
186
  ```
185
187
 
186
- ## MCP tools (18)
188
+ ## MCP tools (22)
187
189
 
188
190
  **Discovery & Read:** `list_vaults`, `read_note`
189
191
  **Search:** `search_semantic`, `search_text`, `search_hybrid` — all support optional `exclude_paths` (glob) and an explicit `vaults` filter; responses include a `note` field when vaults were skipped (e.g. mid-indexing)
@@ -193,13 +195,26 @@ exclude_globs = [".obsidian/**", ".trash/**", "_research/**", ".claude/**"]
193
195
  **Audit:** `audit_log`, `index_runs`
194
196
  **Model management (Phase 7c):** `list_models`, `start_shadow_index`, `switch_active_model`
195
197
  **Maintenance (v0.7.3):** `vacuum_embeddings` — drop orphaned embedding rows whose chunk_id no longer exists
198
+ **Agent-Compatibility (v0.9.0):** `search`, `fetch` — OB1-compatible flat-shape adapters for ChatGPT Custom Connectors, Claude.ai, and Deep-Research modes. Backed by the hybrid (semantic+BM25+RRF) retrieval pipeline, so connector users get vault-memory's full search quality through the standardized interface.
199
+ **Agent self-orientation (v0.9.0):** `vault_stats`, `recent_notes` — vault overview (note count, top tags, top frontmatter keys, last index run) and recently-modified notes (mtime DESC). Use these on first connect to brief an agent on what's in the vault and what the user has been working on.
200
+
201
+ ### Connector compatibility (v0.9.0)
202
+
203
+ `search`/`fetch` follow the flat-shape spec used by OB1 and adopted by ChatGPT Custom Connectors / Claude.ai / Deep-Research:
204
+
205
+ ```
206
+ search({query, limit}) → { results: [{ id, title, url, snippet }] }
207
+ fetch({id}) → { id, title, text, url, metadata }
208
+ ```
209
+
210
+ `id` is the opaque format `<vault>:<vault-relative-path>`. `url` is an `obsidian://open?…` URL — connectors render it as a clickable link that opens the note locally. Use the richer `search_hybrid` / `read_note` tools when working with a vault-memory-aware client (Claude Code's MCP integration); use `search` / `fetch` when integrating with a connector ecosystem that expects the standard shape.
196
211
 
197
212
  ## Development
198
213
 
199
214
  ```bash
200
215
  npm install
201
216
  npm run dev # MCP server on stdio with hot reload
202
- npm test # 278 tests across 33 files (v0.8.1)
217
+ npm test # 318 tests across 36 files (v0.9.0)
203
218
  npm run build
204
219
  ```
205
220
 
package/dist/cli.js CHANGED
@@ -4652,7 +4652,13 @@ var init_watcher2 = __esm({
4652
4652
  // src/server.ts
4653
4653
  var server_exports = {};
4654
4654
  __export(server_exports, {
4655
- serve: () => serve
4655
+ aggregateTopFrontmatterKeys: () => aggregateTopFrontmatterKeys,
4656
+ aggregateTopTags: () => aggregateTopTags,
4657
+ decodeNoteId: () => decodeNoteId,
4658
+ encodeNoteId: () => encodeNoteId,
4659
+ obsidianUrl: () => obsidianUrl,
4660
+ serve: () => serve,
4661
+ truncateSnippet: () => truncateSnippet
4656
4662
  });
4657
4663
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4658
4664
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -5031,6 +5037,57 @@ async function serve() {
5031
5037
  limit: { type: "integer", minimum: 1, maximum: 200, default: 20 }
5032
5038
  }
5033
5039
  }
5040
+ },
5041
+ {
5042
+ name: "search",
5043
+ description: "OB1-compatible search adapter. Returns a flat list of {id, title, url, snippet} for connector ecosystems (ChatGPT Custom Connectors, Claude.ai, Deep-Research). Backed by hybrid (semantic+BM25+RRF) search. For richer output use search_hybrid.",
5044
+ inputSchema: {
5045
+ type: "object",
5046
+ required: ["query"],
5047
+ properties: {
5048
+ query: { type: "string" },
5049
+ limit: { type: "integer", minimum: 1, maximum: 50, default: 10 }
5050
+ }
5051
+ }
5052
+ },
5053
+ {
5054
+ name: "fetch",
5055
+ description: "OB1-compatible fetch adapter. Resolves an opaque id (from `search`) to {id, title, text, url, metadata}. Backed by read_note.",
5056
+ inputSchema: {
5057
+ type: "object",
5058
+ required: ["id"],
5059
+ properties: {
5060
+ id: {
5061
+ type: "string",
5062
+ description: "Opaque id from `search` results, format: <vault>:<vault-relative-path>"
5063
+ }
5064
+ }
5065
+ }
5066
+ },
5067
+ {
5068
+ name: "vault_stats",
5069
+ description: "Vault overview for agent self-orientation: note/word counts, top tags, top frontmatter keys, embedding model, last index run. Omit `vault` to get all configured vaults.",
5070
+ inputSchema: {
5071
+ type: "object",
5072
+ properties: {
5073
+ vault: { type: "string", description: "Optional. Omit for all vaults." }
5074
+ }
5075
+ }
5076
+ },
5077
+ {
5078
+ name: "recent_notes",
5079
+ description: "List recently modified notes (mtime DESC). Use for agent self-orientation: 'what has the user been working on lately?'. No vector search, just SQL.",
5080
+ inputSchema: {
5081
+ type: "object",
5082
+ properties: {
5083
+ vault: { type: "string", description: "Optional. Omit for all vaults." },
5084
+ limit: { type: "integer", minimum: 1, maximum: 200, default: 20 },
5085
+ since: {
5086
+ type: "integer",
5087
+ description: "Optional unix-ms threshold. Only notes with mtime > since."
5088
+ }
5089
+ }
5090
+ }
5034
5091
  }
5035
5092
  ]
5036
5093
  }));
@@ -5211,6 +5268,39 @@ async function serve() {
5211
5268
  const runs = getIndexRuns({ vault, limit: parsed.limit });
5212
5269
  return ok({ runs, count: runs.length });
5213
5270
  }
5271
+ case "search": {
5272
+ const parsed = SearchCompatArgs.parse(args2 ?? {});
5273
+ return ok(
5274
+ await handleSearchCompat(
5275
+ manager,
5276
+ ollama,
5277
+ defaultModel,
5278
+ activeVault,
5279
+ parsed.query,
5280
+ parsed.limit,
5281
+ reranker
5282
+ )
5283
+ );
5284
+ }
5285
+ case "fetch": {
5286
+ const parsed = FetchCompatArgs.parse(args2 ?? {});
5287
+ return ok(handleFetchCompat(manager, parsed.id));
5288
+ }
5289
+ case "vault_stats": {
5290
+ const parsed = VaultStatsArgs.parse(args2 ?? {});
5291
+ return ok(handleVaultStats(manager, parsed.vault));
5292
+ }
5293
+ case "recent_notes": {
5294
+ const parsed = RecentNotesArgs.parse(args2 ?? {});
5295
+ return ok(
5296
+ handleRecentNotes(
5297
+ manager,
5298
+ parsed.vault,
5299
+ parsed.limit,
5300
+ parsed.since
5301
+ )
5302
+ );
5303
+ }
5214
5304
  default:
5215
5305
  return errorResponse(`Unknown tool: ${name}`);
5216
5306
  }
@@ -5409,6 +5499,181 @@ async function handleSearchHybrid(manager, ollama, defaultModel, activeVault, qu
5409
5499
  }
5410
5500
  return out;
5411
5501
  }
5502
+ function encodeNoteId(vault, path5) {
5503
+ return `${vault}:${path5}`;
5504
+ }
5505
+ function decodeNoteId(id) {
5506
+ const idx = id.indexOf(":");
5507
+ if (idx <= 0 || idx === id.length - 1) {
5508
+ throw new Error(
5509
+ `Invalid id: ${id}. Expected format <vault>:<vault-relative-path>.`
5510
+ );
5511
+ }
5512
+ return { vault: id.slice(0, idx), path: id.slice(idx + 1) };
5513
+ }
5514
+ function obsidianUrl(vaultName, notePath) {
5515
+ return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(notePath)}`;
5516
+ }
5517
+ async function handleSearchCompat(manager, ollama, defaultModel, activeVault, query, limit, reranker) {
5518
+ const { targets, skipped } = resolveVaultTargets(manager, void 0, activeVault);
5519
+ if (targets.length === 0) {
5520
+ return {
5521
+ results: [],
5522
+ note: skipped.length > 0 ? `All eligible vaults are indexing; skipped: ${skipped.join(", ")}.` : "No vaults configured."
5523
+ };
5524
+ }
5525
+ const hits = await hybridSearch({
5526
+ query,
5527
+ embeddingModel: defaultModel,
5528
+ ollama,
5529
+ vaults: targets,
5530
+ topK: limit,
5531
+ rrfK: 60,
5532
+ includeBreakdown: false,
5533
+ reranker
5534
+ });
5535
+ const seen = /* @__PURE__ */ new Set();
5536
+ const results = [];
5537
+ for (const h of hits) {
5538
+ const noteKey = `${h.vault}:${h.notePath}`;
5539
+ if (seen.has(noteKey)) continue;
5540
+ seen.add(noteKey);
5541
+ results.push({
5542
+ id: encodeNoteId(h.vault, h.notePath),
5543
+ title: h.noteTitle ?? h.notePath,
5544
+ url: obsidianUrl(h.vault, h.notePath),
5545
+ snippet: truncateSnippet(h.chunkText, 280)
5546
+ });
5547
+ if (results.length >= limit) break;
5548
+ }
5549
+ const out = { results };
5550
+ if (skipped.length > 0) {
5551
+ out.note = `Skipped vault(s) currently indexing: ${skipped.join(", ")}.`;
5552
+ }
5553
+ return out;
5554
+ }
5555
+ function truncateSnippet(text, max) {
5556
+ const collapsed = text.replace(/\s+/g, " ").trim();
5557
+ if (collapsed.length <= max) return collapsed;
5558
+ return collapsed.slice(0, max - 1).trimEnd() + "\u2026";
5559
+ }
5560
+ function handleFetchCompat(manager, id) {
5561
+ const { vault: vaultName, path: path5 } = decodeNoteId(id);
5562
+ const vault = manager.require(vaultName);
5563
+ const note = vault.db.notes.getByPath(path5);
5564
+ if (!note) {
5565
+ throw new Error(`Note not found: ${vaultName}/${path5}`);
5566
+ }
5567
+ const metadata = {
5568
+ vault: vaultName,
5569
+ path: note.path,
5570
+ mtime: note.mtime,
5571
+ hash: note.hash,
5572
+ word_count: note.word_count
5573
+ };
5574
+ if (note.frontmatter) {
5575
+ try {
5576
+ metadata.frontmatter = JSON.parse(note.frontmatter);
5577
+ } catch {
5578
+ }
5579
+ }
5580
+ return {
5581
+ id,
5582
+ title: note.title ?? note.path,
5583
+ text: note.content,
5584
+ url: obsidianUrl(vaultName, note.path),
5585
+ metadata
5586
+ };
5587
+ }
5588
+ function handleVaultStats(manager, vaultFilter) {
5589
+ const targets = vaultFilter ? [manager.require(vaultFilter)] : manager.list();
5590
+ const stats = targets.map((v) => {
5591
+ const total_notes = v.db.notes.countAll();
5592
+ const wordRow = v.db.handle.prepare(
5593
+ "SELECT SUM(word_count) AS total FROM notes"
5594
+ ).get();
5595
+ const lastRun = v.db.audit.listRuns(1)[0];
5596
+ const activeModel = v.db.models.getActive();
5597
+ return {
5598
+ vault: v.config.name,
5599
+ vault_path: v.config.path,
5600
+ total_notes,
5601
+ total_words: wordRow?.total ?? 0,
5602
+ embedding_model: activeModel?.name ?? v.config.embedding_model ?? null,
5603
+ indexed_at: lastRun?.finished_at ?? null,
5604
+ top_tags: aggregateTopTags(v.db.handle, 10),
5605
+ top_frontmatter_keys: aggregateTopFrontmatterKeys(v.db.handle, 10)
5606
+ };
5607
+ });
5608
+ if (vaultFilter) {
5609
+ return stats[0];
5610
+ }
5611
+ return { vaults: stats, count: stats.length };
5612
+ }
5613
+ function aggregateTopTags(db, limit) {
5614
+ const rows = db.prepare(
5615
+ `
5616
+ SELECT je.value AS tag, COUNT(*) AS count
5617
+ FROM notes
5618
+ JOIN json_each(json_extract(notes.frontmatter, '$.tags')) AS je
5619
+ WHERE notes.frontmatter IS NOT NULL
5620
+ AND json_type(notes.frontmatter, '$.tags') = 'array'
5621
+ AND typeof(je.value) = 'text'
5622
+ GROUP BY je.value
5623
+ ORDER BY count DESC, tag ASC
5624
+ LIMIT ?
5625
+ `
5626
+ ).all(limit);
5627
+ return rows;
5628
+ }
5629
+ function aggregateTopFrontmatterKeys(db, limit) {
5630
+ const rows = db.prepare(
5631
+ `
5632
+ SELECT je.key AS key, COUNT(*) AS count
5633
+ FROM notes
5634
+ JOIN json_each(notes.frontmatter) AS je
5635
+ WHERE notes.frontmatter IS NOT NULL
5636
+ AND json_type(notes.frontmatter) = 'object'
5637
+ GROUP BY je.key
5638
+ ORDER BY count DESC, key ASC
5639
+ LIMIT ?
5640
+ `
5641
+ ).all(limit);
5642
+ return rows;
5643
+ }
5644
+ function handleRecentNotes(manager, vaultFilter, limit, since) {
5645
+ const targets = vaultFilter ? [manager.require(vaultFilter)] : manager.list();
5646
+ const all = [];
5647
+ for (const v of targets) {
5648
+ const rows = since !== void 0 ? v.db.handle.prepare(
5649
+ "SELECT path, title, mtime, word_count, frontmatter FROM notes WHERE mtime > ? ORDER BY mtime DESC LIMIT ?"
5650
+ ).all(since, limit) : v.db.handle.prepare(
5651
+ "SELECT path, title, mtime, word_count, frontmatter FROM notes ORDER BY mtime DESC LIMIT ?"
5652
+ ).all(limit);
5653
+ for (const r of rows) {
5654
+ let tags = null;
5655
+ if (r.frontmatter) {
5656
+ try {
5657
+ const fm = JSON.parse(r.frontmatter);
5658
+ if (Array.isArray(fm.tags)) {
5659
+ tags = fm.tags.filter((t) => typeof t === "string");
5660
+ }
5661
+ } catch {
5662
+ }
5663
+ }
5664
+ all.push({
5665
+ vault: v.config.name,
5666
+ path: r.path,
5667
+ title: r.title,
5668
+ mtime: r.mtime,
5669
+ word_count: r.word_count,
5670
+ tags
5671
+ });
5672
+ }
5673
+ }
5674
+ all.sort((a, b) => b.mtime - a.mtime);
5675
+ return { notes: all.slice(0, limit), count: Math.min(all.length, limit) };
5676
+ }
5412
5677
  function ok(data) {
5413
5678
  return {
5414
5679
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
@@ -5420,7 +5685,7 @@ function errorResponse(message) {
5420
5685
  content: [{ type: "text", text: message }]
5421
5686
  };
5422
5687
  }
5423
- var VERSION, ReadNoteArgs, SearchArgs, HybridSearchArgs, VaultPathArgs, ForwardLinksArgs, FindBrokenLinksArgs, PredicateSchema, QueryFrontmatterArgs, WriteNoteArgs, UpdateFrontmatterArgs, DeleteNoteArgs, AuditLogArgs, IndexRunsArgs, ListModelsArgs, StartShadowIndexArgs, SwitchActiveModelArgs, VacuumEmbeddingsArgs;
5688
+ var VERSION, ReadNoteArgs, SearchArgs, HybridSearchArgs, VaultPathArgs, ForwardLinksArgs, FindBrokenLinksArgs, PredicateSchema, QueryFrontmatterArgs, WriteNoteArgs, UpdateFrontmatterArgs, DeleteNoteArgs, AuditLogArgs, IndexRunsArgs, ListModelsArgs, StartShadowIndexArgs, SwitchActiveModelArgs, VacuumEmbeddingsArgs, SearchCompatArgs, FetchCompatArgs, VaultStatsArgs, RecentNotesArgs;
5424
5689
  var init_server = __esm({
5425
5690
  "src/server.ts"() {
5426
5691
  "use strict";
@@ -5437,7 +5702,7 @@ var init_server = __esm({
5437
5702
  init_audit3();
5438
5703
  init_watcher2();
5439
5704
  init_indexer2();
5440
- VERSION = "0.8.1";
5705
+ VERSION = "0.9.0";
5441
5706
  ReadNoteArgs = z3.object({
5442
5707
  vault: z3.string(),
5443
5708
  path: z3.string()
@@ -5528,6 +5793,21 @@ var init_server = __esm({
5528
5793
  VacuumEmbeddingsArgs = z3.object({
5529
5794
  vault: z3.string()
5530
5795
  });
5796
+ SearchCompatArgs = z3.object({
5797
+ query: z3.string().min(1),
5798
+ limit: z3.number().int().positive().max(50).optional().default(10)
5799
+ });
5800
+ FetchCompatArgs = z3.object({
5801
+ id: z3.string().min(1)
5802
+ });
5803
+ VaultStatsArgs = z3.object({
5804
+ vault: z3.string().optional()
5805
+ });
5806
+ RecentNotesArgs = z3.object({
5807
+ vault: z3.string().optional(),
5808
+ limit: z3.number().int().positive().max(200).optional().default(20),
5809
+ since: z3.number().int().nonnegative().optional()
5810
+ });
5531
5811
  }
5532
5812
  });
5533
5813