@owrede/vault-memory 0.8.3 → 0.9.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 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.1** — Body-hash short-circuit (migration 006): frontmatter-only edits (the common case for `update_frontmatter`, `/log-fact`, `/import-person`) no longer trigger chunk + embedding regeneration. A new `body_hash` column on `notes` lets the watcher detect "body unchanged, frontmatter changed" and keep all existing chunks/embeddings in place — saving one Ollama roundtrip per chunk per frontmatter edit. Legacy rows pre-migration self-heal on next touch.
10
+
11
+ Previous: **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.
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 # 324 tests across 35 files (v0.9.1)
203
218
  npm run build
204
219
  ```
205
220
 
package/dist/cli.js CHANGED
@@ -302,7 +302,7 @@ function runMigration005(db) {
302
302
  }
303
303
  }
304
304
  }
305
- var INITIAL_SCHEMA, MIGRATION_002_ALIASES, MIGRATION_003_FIX_DELETE_FKS, MIGRATION_004_VARIABLE_DIMS, MIGRATIONS;
305
+ var INITIAL_SCHEMA, MIGRATION_002_ALIASES, MIGRATION_003_FIX_DELETE_FKS, MIGRATION_004_VARIABLE_DIMS, MIGRATION_006_BODY_HASH, MIGRATIONS;
306
306
  var init_schema = __esm({
307
307
  "src/db/schema.ts"() {
308
308
  "use strict";
@@ -310,6 +310,9 @@ var init_schema = __esm({
310
310
  INITIAL_SCHEMA = `
311
311
  -- \u2500\u2500 3.1 Raw Layer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
312
312
 
313
+ -- Migration 006 adds body_hash to this table (kept out of v1 schema so
314
+ -- the migration chain has historical accuracy and frequent DB-rebuild
315
+ -- tests do not trip over duplicate-column errors).
313
316
  CREATE TABLE IF NOT EXISTS notes (
314
317
  id INTEGER PRIMARY KEY AUTOINCREMENT,
315
318
  path TEXT NOT NULL UNIQUE,
@@ -493,6 +496,10 @@ INSERT INTO embeddings_1024 (chunk_id, model_id, vector)
493
496
  SELECT chunk_id, model_id, vector FROM embeddings;
494
497
 
495
498
  DROP TABLE embeddings;
499
+ `;
500
+ MIGRATION_006_BODY_HASH = `
501
+ ALTER TABLE notes ADD COLUMN body_hash TEXT;
502
+ CREATE INDEX IF NOT EXISTS idx_notes_body_hash ON notes(body_hash);
496
503
  `;
497
504
  MIGRATIONS = [
498
505
  {
@@ -519,6 +526,11 @@ DROP TABLE embeddings;
519
526
  version: 5,
520
527
  description: "add partition key on model_id (two models per dim can coexist)",
521
528
  run: runMigration005
529
+ },
530
+ {
531
+ version: 6,
532
+ description: "add body_hash for frontmatter-only-change short-circuit",
533
+ sql: MIGRATION_006_BODY_HASH
522
534
  }
523
535
  ];
524
536
  }
@@ -540,8 +552,8 @@ var init_notes = __esm({
540
552
  "SELECT * FROM notes WHERE id = ?"
541
553
  );
542
554
  this._insert = db.prepare(`
543
- INSERT INTO notes (path, content, frontmatter, title, hash, mtime, word_count, created_at, updated_at)
544
- VALUES (@path, @content, @frontmatter, @title, @hash, @mtime, @word_count, @now, @now)
555
+ INSERT INTO notes (path, content, frontmatter, title, hash, body_hash, mtime, word_count, created_at, updated_at)
556
+ VALUES (@path, @content, @frontmatter, @title, @hash, @body_hash, @mtime, @word_count, @now, @now)
545
557
  `);
546
558
  this._update = db.prepare(`
547
559
  UPDATE notes
@@ -549,6 +561,7 @@ var init_notes = __esm({
549
561
  frontmatter = @frontmatter,
550
562
  title = @title,
551
563
  hash = @hash,
564
+ body_hash = @body_hash,
552
565
  mtime = @mtime,
553
566
  word_count = @word_count,
554
567
  updated_at = @now
@@ -583,6 +596,7 @@ var init_notes = __esm({
583
596
  frontmatter: input.frontmatter,
584
597
  title: input.title,
585
598
  hash: input.hash,
599
+ body_hash: input.bodyHash,
586
600
  mtime: input.mtime,
587
601
  word_count: input.wordCount,
588
602
  now
@@ -595,6 +609,7 @@ var init_notes = __esm({
595
609
  frontmatter: input.frontmatter,
596
610
  title: input.title,
597
611
  hash: input.hash,
612
+ body_hash: input.bodyHash,
598
613
  mtime: input.mtime,
599
614
  word_count: input.wordCount,
600
615
  now
@@ -2297,6 +2312,9 @@ function canonicalJsonStringify(value) {
2297
2312
  function computeNoteHash(content, frontmatter) {
2298
2313
  return sha256(content + canonicalJsonStringify(frontmatter ?? {}));
2299
2314
  }
2315
+ function computeBodyHash(content) {
2316
+ return sha256(content);
2317
+ }
2300
2318
  var init_hash = __esm({
2301
2319
  "src/reader/hash.ts"() {
2302
2320
  "use strict";
@@ -2536,6 +2554,7 @@ async function parseNote(absolutePath, vaultRoot) {
2536
2554
  const frontmatter = fmData !== void 0 && Object.keys(fmData).length > 0 ? fmData : null;
2537
2555
  const title = extractTitle(content) ?? path3.basename(absolutePath, ".md");
2538
2556
  const hash = computeNoteHash(content, frontmatter);
2557
+ const bodyHash = computeBodyHash(content);
2539
2558
  const mtime = Math.floor(stat.mtimeMs);
2540
2559
  const bodyLinks = extractWikilinks(content);
2541
2560
  const frontmatterLinks = extractFrontmatterWikilinks(frontmatter);
@@ -2550,6 +2569,7 @@ async function parseNote(absolutePath, vaultRoot) {
2550
2569
  frontmatter,
2551
2570
  title,
2552
2571
  hash,
2572
+ bodyHash,
2553
2573
  mtime,
2554
2574
  wikilinks,
2555
2575
  wordCount
@@ -3038,6 +3058,7 @@ async function indexVault(vault, options) {
3038
3058
  frontmatter: parsed.frontmatter ? JSON.stringify(parsed.frontmatter) : null,
3039
3059
  title: parsed.title,
3040
3060
  hash: parsed.hash,
3061
+ bodyHash: parsed.bodyHash,
3041
3062
  mtime: parsed.mtime,
3042
3063
  wordCount: parsed.wordCount
3043
3064
  });
@@ -3499,6 +3520,7 @@ async function updateFrontmatter(input) {
3499
3520
  frontmatter: fmJson,
3500
3521
  title,
3501
3522
  hash: newHash,
3523
+ bodyHash: computeBodyHash(content),
3502
3524
  mtime: Math.floor(stat.mtimeMs),
3503
3525
  wordCount
3504
3526
  });
@@ -3582,6 +3604,31 @@ async function indexNote(options) {
3582
3604
  isNew: false
3583
3605
  };
3584
3606
  }
3607
+ if (existing && existing.body_hash && existing.body_hash === parsed.bodyHash) {
3608
+ const upsert2 = vault.db.notes.upsertByPath({
3609
+ path: parsed.relativePath,
3610
+ content: parsed.content,
3611
+ frontmatter: parsed.frontmatter ? JSON.stringify(parsed.frontmatter) : null,
3612
+ title: parsed.title,
3613
+ hash: parsed.hash,
3614
+ bodyHash: parsed.bodyHash,
3615
+ mtime: parsed.mtime,
3616
+ wordCount: parsed.wordCount
3617
+ });
3618
+ vault.db.aliases.setForNote(
3619
+ upsert2.id,
3620
+ extractAliases(parsed.frontmatter)
3621
+ );
3622
+ vault.db.wikilinks.deleteByNote(upsert2.id);
3623
+ insertWikilinks2(vault, upsert2.id, parsed.wikilinks);
3624
+ return {
3625
+ status: "indexed",
3626
+ notePath: parsed.relativePath,
3627
+ noteId: upsert2.id,
3628
+ chunksCreated: 0,
3629
+ isNew: false
3630
+ };
3631
+ }
3585
3632
  const activeModel = vault.db.models.getActive();
3586
3633
  if (!activeModel) {
3587
3634
  throw new Error(
@@ -3599,6 +3646,7 @@ async function indexNote(options) {
3599
3646
  frontmatter: parsed.frontmatter ? JSON.stringify(parsed.frontmatter) : null,
3600
3647
  title: parsed.title,
3601
3648
  hash: parsed.hash,
3649
+ bodyHash: parsed.bodyHash,
3602
3650
  mtime: parsed.mtime,
3603
3651
  wordCount: parsed.wordCount
3604
3652
  });
@@ -4133,6 +4181,7 @@ async function writeNote(input) {
4133
4181
  frontmatter: written.frontmatter ? JSON.stringify(written.frontmatter) : null,
4134
4182
  title,
4135
4183
  hash: written.hash,
4184
+ bodyHash: computeBodyHash(written.content),
4136
4185
  mtime: Math.floor(stat.mtimeMs),
4137
4186
  wordCount: countWords3(written.content)
4138
4187
  });
@@ -4652,7 +4701,13 @@ var init_watcher2 = __esm({
4652
4701
  // src/server.ts
4653
4702
  var server_exports = {};
4654
4703
  __export(server_exports, {
4655
- serve: () => serve
4704
+ aggregateTopFrontmatterKeys: () => aggregateTopFrontmatterKeys,
4705
+ aggregateTopTags: () => aggregateTopTags,
4706
+ decodeNoteId: () => decodeNoteId,
4707
+ encodeNoteId: () => encodeNoteId,
4708
+ obsidianUrl: () => obsidianUrl,
4709
+ serve: () => serve,
4710
+ truncateSnippet: () => truncateSnippet
4656
4711
  });
4657
4712
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4658
4713
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -5031,6 +5086,57 @@ async function serve() {
5031
5086
  limit: { type: "integer", minimum: 1, maximum: 200, default: 20 }
5032
5087
  }
5033
5088
  }
5089
+ },
5090
+ {
5091
+ name: "search",
5092
+ 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.",
5093
+ inputSchema: {
5094
+ type: "object",
5095
+ required: ["query"],
5096
+ properties: {
5097
+ query: { type: "string" },
5098
+ limit: { type: "integer", minimum: 1, maximum: 50, default: 10 }
5099
+ }
5100
+ }
5101
+ },
5102
+ {
5103
+ name: "fetch",
5104
+ description: "OB1-compatible fetch adapter. Resolves an opaque id (from `search`) to {id, title, text, url, metadata}. Backed by read_note.",
5105
+ inputSchema: {
5106
+ type: "object",
5107
+ required: ["id"],
5108
+ properties: {
5109
+ id: {
5110
+ type: "string",
5111
+ description: "Opaque id from `search` results, format: <vault>:<vault-relative-path>"
5112
+ }
5113
+ }
5114
+ }
5115
+ },
5116
+ {
5117
+ name: "vault_stats",
5118
+ 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.",
5119
+ inputSchema: {
5120
+ type: "object",
5121
+ properties: {
5122
+ vault: { type: "string", description: "Optional. Omit for all vaults." }
5123
+ }
5124
+ }
5125
+ },
5126
+ {
5127
+ name: "recent_notes",
5128
+ 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.",
5129
+ inputSchema: {
5130
+ type: "object",
5131
+ properties: {
5132
+ vault: { type: "string", description: "Optional. Omit for all vaults." },
5133
+ limit: { type: "integer", minimum: 1, maximum: 200, default: 20 },
5134
+ since: {
5135
+ type: "integer",
5136
+ description: "Optional unix-ms threshold. Only notes with mtime > since."
5137
+ }
5138
+ }
5139
+ }
5034
5140
  }
5035
5141
  ]
5036
5142
  }));
@@ -5211,6 +5317,39 @@ async function serve() {
5211
5317
  const runs = getIndexRuns({ vault, limit: parsed.limit });
5212
5318
  return ok({ runs, count: runs.length });
5213
5319
  }
5320
+ case "search": {
5321
+ const parsed = SearchCompatArgs.parse(args2 ?? {});
5322
+ return ok(
5323
+ await handleSearchCompat(
5324
+ manager,
5325
+ ollama,
5326
+ defaultModel,
5327
+ activeVault,
5328
+ parsed.query,
5329
+ parsed.limit,
5330
+ reranker
5331
+ )
5332
+ );
5333
+ }
5334
+ case "fetch": {
5335
+ const parsed = FetchCompatArgs.parse(args2 ?? {});
5336
+ return ok(handleFetchCompat(manager, parsed.id));
5337
+ }
5338
+ case "vault_stats": {
5339
+ const parsed = VaultStatsArgs.parse(args2 ?? {});
5340
+ return ok(handleVaultStats(manager, parsed.vault));
5341
+ }
5342
+ case "recent_notes": {
5343
+ const parsed = RecentNotesArgs.parse(args2 ?? {});
5344
+ return ok(
5345
+ handleRecentNotes(
5346
+ manager,
5347
+ parsed.vault,
5348
+ parsed.limit,
5349
+ parsed.since
5350
+ )
5351
+ );
5352
+ }
5214
5353
  default:
5215
5354
  return errorResponse(`Unknown tool: ${name}`);
5216
5355
  }
@@ -5409,6 +5548,181 @@ async function handleSearchHybrid(manager, ollama, defaultModel, activeVault, qu
5409
5548
  }
5410
5549
  return out;
5411
5550
  }
5551
+ function encodeNoteId(vault, path5) {
5552
+ return `${vault}:${path5}`;
5553
+ }
5554
+ function decodeNoteId(id) {
5555
+ const idx = id.indexOf(":");
5556
+ if (idx <= 0 || idx === id.length - 1) {
5557
+ throw new Error(
5558
+ `Invalid id: ${id}. Expected format <vault>:<vault-relative-path>.`
5559
+ );
5560
+ }
5561
+ return { vault: id.slice(0, idx), path: id.slice(idx + 1) };
5562
+ }
5563
+ function obsidianUrl(vaultName, notePath) {
5564
+ return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(notePath)}`;
5565
+ }
5566
+ async function handleSearchCompat(manager, ollama, defaultModel, activeVault, query, limit, reranker) {
5567
+ const { targets, skipped } = resolveVaultTargets(manager, void 0, activeVault);
5568
+ if (targets.length === 0) {
5569
+ return {
5570
+ results: [],
5571
+ note: skipped.length > 0 ? `All eligible vaults are indexing; skipped: ${skipped.join(", ")}.` : "No vaults configured."
5572
+ };
5573
+ }
5574
+ const hits = await hybridSearch({
5575
+ query,
5576
+ embeddingModel: defaultModel,
5577
+ ollama,
5578
+ vaults: targets,
5579
+ topK: limit,
5580
+ rrfK: 60,
5581
+ includeBreakdown: false,
5582
+ reranker
5583
+ });
5584
+ const seen = /* @__PURE__ */ new Set();
5585
+ const results = [];
5586
+ for (const h of hits) {
5587
+ const noteKey = `${h.vault}:${h.notePath}`;
5588
+ if (seen.has(noteKey)) continue;
5589
+ seen.add(noteKey);
5590
+ results.push({
5591
+ id: encodeNoteId(h.vault, h.notePath),
5592
+ title: h.noteTitle ?? h.notePath,
5593
+ url: obsidianUrl(h.vault, h.notePath),
5594
+ snippet: truncateSnippet(h.chunkText, 280)
5595
+ });
5596
+ if (results.length >= limit) break;
5597
+ }
5598
+ const out = { results };
5599
+ if (skipped.length > 0) {
5600
+ out.note = `Skipped vault(s) currently indexing: ${skipped.join(", ")}.`;
5601
+ }
5602
+ return out;
5603
+ }
5604
+ function truncateSnippet(text, max) {
5605
+ const collapsed = text.replace(/\s+/g, " ").trim();
5606
+ if (collapsed.length <= max) return collapsed;
5607
+ return collapsed.slice(0, max - 1).trimEnd() + "\u2026";
5608
+ }
5609
+ function handleFetchCompat(manager, id) {
5610
+ const { vault: vaultName, path: path5 } = decodeNoteId(id);
5611
+ const vault = manager.require(vaultName);
5612
+ const note = vault.db.notes.getByPath(path5);
5613
+ if (!note) {
5614
+ throw new Error(`Note not found: ${vaultName}/${path5}`);
5615
+ }
5616
+ const metadata = {
5617
+ vault: vaultName,
5618
+ path: note.path,
5619
+ mtime: note.mtime,
5620
+ hash: note.hash,
5621
+ word_count: note.word_count
5622
+ };
5623
+ if (note.frontmatter) {
5624
+ try {
5625
+ metadata.frontmatter = JSON.parse(note.frontmatter);
5626
+ } catch {
5627
+ }
5628
+ }
5629
+ return {
5630
+ id,
5631
+ title: note.title ?? note.path,
5632
+ text: note.content,
5633
+ url: obsidianUrl(vaultName, note.path),
5634
+ metadata
5635
+ };
5636
+ }
5637
+ function handleVaultStats(manager, vaultFilter) {
5638
+ const targets = vaultFilter ? [manager.require(vaultFilter)] : manager.list();
5639
+ const stats = targets.map((v) => {
5640
+ const total_notes = v.db.notes.countAll();
5641
+ const wordRow = v.db.handle.prepare(
5642
+ "SELECT SUM(word_count) AS total FROM notes"
5643
+ ).get();
5644
+ const lastRun = v.db.audit.listRuns(1)[0];
5645
+ const activeModel = v.db.models.getActive();
5646
+ return {
5647
+ vault: v.config.name,
5648
+ vault_path: v.config.path,
5649
+ total_notes,
5650
+ total_words: wordRow?.total ?? 0,
5651
+ embedding_model: activeModel?.name ?? v.config.embedding_model ?? null,
5652
+ indexed_at: lastRun?.finished_at ?? null,
5653
+ top_tags: aggregateTopTags(v.db.handle, 10),
5654
+ top_frontmatter_keys: aggregateTopFrontmatterKeys(v.db.handle, 10)
5655
+ };
5656
+ });
5657
+ if (vaultFilter) {
5658
+ return stats[0];
5659
+ }
5660
+ return { vaults: stats, count: stats.length };
5661
+ }
5662
+ function aggregateTopTags(db, limit) {
5663
+ const rows = db.prepare(
5664
+ `
5665
+ SELECT je.value AS tag, COUNT(*) AS count
5666
+ FROM notes
5667
+ JOIN json_each(json_extract(notes.frontmatter, '$.tags')) AS je
5668
+ WHERE notes.frontmatter IS NOT NULL
5669
+ AND json_type(notes.frontmatter, '$.tags') = 'array'
5670
+ AND typeof(je.value) = 'text'
5671
+ GROUP BY je.value
5672
+ ORDER BY count DESC, tag ASC
5673
+ LIMIT ?
5674
+ `
5675
+ ).all(limit);
5676
+ return rows;
5677
+ }
5678
+ function aggregateTopFrontmatterKeys(db, limit) {
5679
+ const rows = db.prepare(
5680
+ `
5681
+ SELECT je.key AS key, COUNT(*) AS count
5682
+ FROM notes
5683
+ JOIN json_each(notes.frontmatter) AS je
5684
+ WHERE notes.frontmatter IS NOT NULL
5685
+ AND json_type(notes.frontmatter) = 'object'
5686
+ GROUP BY je.key
5687
+ ORDER BY count DESC, key ASC
5688
+ LIMIT ?
5689
+ `
5690
+ ).all(limit);
5691
+ return rows;
5692
+ }
5693
+ function handleRecentNotes(manager, vaultFilter, limit, since) {
5694
+ const targets = vaultFilter ? [manager.require(vaultFilter)] : manager.list();
5695
+ const all = [];
5696
+ for (const v of targets) {
5697
+ const rows = since !== void 0 ? v.db.handle.prepare(
5698
+ "SELECT path, title, mtime, word_count, frontmatter FROM notes WHERE mtime > ? ORDER BY mtime DESC LIMIT ?"
5699
+ ).all(since, limit) : v.db.handle.prepare(
5700
+ "SELECT path, title, mtime, word_count, frontmatter FROM notes ORDER BY mtime DESC LIMIT ?"
5701
+ ).all(limit);
5702
+ for (const r of rows) {
5703
+ let tags = null;
5704
+ if (r.frontmatter) {
5705
+ try {
5706
+ const fm = JSON.parse(r.frontmatter);
5707
+ if (Array.isArray(fm.tags)) {
5708
+ tags = fm.tags.filter((t) => typeof t === "string");
5709
+ }
5710
+ } catch {
5711
+ }
5712
+ }
5713
+ all.push({
5714
+ vault: v.config.name,
5715
+ path: r.path,
5716
+ title: r.title,
5717
+ mtime: r.mtime,
5718
+ word_count: r.word_count,
5719
+ tags
5720
+ });
5721
+ }
5722
+ }
5723
+ all.sort((a, b) => b.mtime - a.mtime);
5724
+ return { notes: all.slice(0, limit), count: Math.min(all.length, limit) };
5725
+ }
5412
5726
  function ok(data) {
5413
5727
  return {
5414
5728
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
@@ -5420,7 +5734,7 @@ function errorResponse(message) {
5420
5734
  content: [{ type: "text", text: message }]
5421
5735
  };
5422
5736
  }
5423
- var VERSION, ReadNoteArgs, SearchArgs, HybridSearchArgs, VaultPathArgs, ForwardLinksArgs, FindBrokenLinksArgs, PredicateSchema, QueryFrontmatterArgs, WriteNoteArgs, UpdateFrontmatterArgs, DeleteNoteArgs, AuditLogArgs, IndexRunsArgs, ListModelsArgs, StartShadowIndexArgs, SwitchActiveModelArgs, VacuumEmbeddingsArgs;
5737
+ var VERSION, ReadNoteArgs, SearchArgs, HybridSearchArgs, VaultPathArgs, ForwardLinksArgs, FindBrokenLinksArgs, PredicateSchema, QueryFrontmatterArgs, WriteNoteArgs, UpdateFrontmatterArgs, DeleteNoteArgs, AuditLogArgs, IndexRunsArgs, ListModelsArgs, StartShadowIndexArgs, SwitchActiveModelArgs, VacuumEmbeddingsArgs, SearchCompatArgs, FetchCompatArgs, VaultStatsArgs, RecentNotesArgs;
5424
5738
  var init_server = __esm({
5425
5739
  "src/server.ts"() {
5426
5740
  "use strict";
@@ -5437,7 +5751,7 @@ var init_server = __esm({
5437
5751
  init_audit3();
5438
5752
  init_watcher2();
5439
5753
  init_indexer2();
5440
- VERSION = "0.8.1";
5754
+ VERSION = "0.9.1";
5441
5755
  ReadNoteArgs = z3.object({
5442
5756
  vault: z3.string(),
5443
5757
  path: z3.string()
@@ -5528,6 +5842,21 @@ var init_server = __esm({
5528
5842
  VacuumEmbeddingsArgs = z3.object({
5529
5843
  vault: z3.string()
5530
5844
  });
5845
+ SearchCompatArgs = z3.object({
5846
+ query: z3.string().min(1),
5847
+ limit: z3.number().int().positive().max(50).optional().default(10)
5848
+ });
5849
+ FetchCompatArgs = z3.object({
5850
+ id: z3.string().min(1)
5851
+ });
5852
+ VaultStatsArgs = z3.object({
5853
+ vault: z3.string().optional()
5854
+ });
5855
+ RecentNotesArgs = z3.object({
5856
+ vault: z3.string().optional(),
5857
+ limit: z3.number().int().positive().max(200).optional().default(20),
5858
+ since: z3.number().int().nonnegative().optional()
5859
+ });
5531
5860
  }
5532
5861
  });
5533
5862