@oomkapwn/enquire-mcp 2.0.0 → 2.6.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/dist/index.js CHANGED
@@ -9,10 +9,10 @@ import { z } from "zod";
9
9
  import { EmbedDb } from "./embed-db.js";
10
10
  import { DEFAULT_MODEL_ALIAS, EMBEDDING_MODELS, loadEmbedder, resolveModel } from "./embeddings.js";
11
11
  import { chunkContent, defaultIndexFile, FtsIndex } from "./fts5.js";
12
- import { appendToNote, archiveNote, createNote, dataviewQuery, embeddingsSearch, findPath, findSimilar, getBacklinks, getNoteNeighbors, getOpenQuestions, getOutboundLinks, getRecentEdits, getUnresolvedWikilinks, getVaultStats, lintWiki, listCanvases, listNotes, listTags, openInUi, paperAudit, readCanvas, readNote, renameNote, replaceInNotes, resolveWikilink, searchHybrid, searchText, semanticSearch, validateNoteProposal } from "./tools.js";
12
+ import { appendToNote, archiveNote, chatThreadAppend, chatThreadRead, contextPack, createNote, dataviewQuery, embeddingsSearch, findPath, findSimilar, frontmatterGet, frontmatterSearch, frontmatterSet, getBacklinks, getNoteNeighbors, getOpenQuestions, getOutboundLinks, getRecentEdits, getUnresolvedWikilinks, getVaultStats, lintWiki, listCanvases, listNotes, listTags, openInUi, paperAudit, readCanvas, readNote, renameNote, replaceInNotes, resolveWikilink, searchHybrid, searchText, semanticSearch, validateNoteProposal } from "./tools.js";
13
13
  import { Vault } from "./vault.js";
14
14
  import { VaultWatcher } from "./watcher.js";
15
- const VERSION = "2.0.0";
15
+ const VERSION = "2.6.0";
16
16
  /** Default location for the persistent embedding index, alongside .fts5.db. */
17
17
  function embedDbPath(vaultRoot) {
18
18
  // Match the FTS5 location convention by stripping the .fts5.db extension
@@ -46,6 +46,79 @@ async function main() {
46
46
  .action(async (opts) => {
47
47
  await startServer(opts);
48
48
  });
49
+ // v2.6.0 — remote-MCP HTTP transport. Mirrors `serve` flags + adds HTTP
50
+ // surface (bearer auth, rate-limit, CORS). See docs/http-transport.md.
51
+ program
52
+ .command("serve-http")
53
+ .description("Start the MCP server over HTTP (Streamable HTTP transport). For remote-MCP use with claude.ai web, ChatGPT, Cursor HTTP mode, mobile clients. Requires --bearer-token (or --bearer-token-env). Bind to 127.0.0.1 by default — front with Tailscale Funnel / Cloudflare Tunnel for remote access.")
54
+ .requiredOption("--vault <path>", "Path to the Obsidian vault root")
55
+ .option("--port <n>", "TCP port (default 3000)", "3000")
56
+ .option("--host <host>", "Bind host (default 127.0.0.1 — explicit because 0.0.0.0 must be opt-in for remote-MCP)", "127.0.0.1")
57
+ .option("--bearer-token <token>", "Bearer token clients must present in the Authorization header. Generate with `enquire-mcp gen-token`. Required.")
58
+ .option("--bearer-token-env <name>", "Read the bearer token from this env var instead of --bearer-token (cleaner for systemd / .env / process listings). Either flag is required.")
59
+ .option("--mcp-path <path>", "URL path for the MCP endpoint (default /mcp)", "/mcp")
60
+ .option("--rate-limit <n>", "Max requests per minute per bearer token (default 120). Pass 0 to disable.", "120")
61
+ .option("--cors-origin <origin...>", "CORS allowlist (repeatable). Default empty — no Access-Control-Allow-Origin sent. Use '*' as a single entry to allow any origin (not compatible with credentialed Bearer requests; you almost always want explicit origins like https://claude.ai).")
62
+ .option("--health-path <path>", "URL path for the unauthenticated health probe (default /health)", "/health")
63
+ .option("--enable-write", "Enable the write tools (gated identically to stdio mode). Off by default.")
64
+ .option("--max-file-bytes <n>", "Max bytes for any single file read/write (default 5MB)")
65
+ .option("--cache-size <n>", "Max parsed-note cache entries (default 1024)")
66
+ .option("--persistent-cache", "Persist parsed-note cache to disk so cold starts skip re-parsing")
67
+ .option("--cache-file <path>", "Override the persistent-cache file location")
68
+ .option("--persistent-index", "Maintain a SQLite FTS5 inverted index for sub-100ms BM25-ranked search")
69
+ .option("--index-file <path>", "Override the FTS5 index file location")
70
+ .option("--tokenize <mode>", "FTS5 tokenize mode: 'unicode61' (default) or 'trigram'")
71
+ .option("--exclude-glob <pattern...>", "Privacy denylist (same semantics as `serve`).")
72
+ .option("--read-paths <pattern...>", "Privacy allowlist (same semantics as `serve`).")
73
+ .option("--watch", "Watch the vault for .md changes and refresh indexes incrementally.")
74
+ .option("--disabled-tools <name...>", "Skip registration of specific tools by name.")
75
+ .option("--enabled-tools <name...>", "Strict allowlist — when set, ONLY listed tools register.")
76
+ .option("--diagnostic-search-tools", "Register the four single-ranker search tools alongside obsidian_search.")
77
+ .action(async (opts) => {
78
+ const tokenFromArg = typeof opts.bearerToken === "string" ? opts.bearerToken.trim() : "";
79
+ const tokenFromEnv = typeof opts.bearerTokenEnv === "string" ? (process.env[opts.bearerTokenEnv] ?? "").trim() : "";
80
+ const bearerToken = tokenFromArg.length > 0 ? tokenFromArg : tokenFromEnv;
81
+ if (!bearerToken) {
82
+ process.stderr.write("enquire serve-http: --bearer-token (or --bearer-token-env <name>) is required.\n" +
83
+ " Generate one with: enquire-mcp gen-token\n");
84
+ process.exit(1);
85
+ }
86
+ // --port accepts 0 as "kernel-assigned ephemeral" — useful for tests
87
+ // and for scenarios where the user binds via a tunnel and doesn't
88
+ // care which local port. So we use a non-negative-integer check
89
+ // here, NOT parsePositiveInt (which would reject 0).
90
+ const portNum = Number(opts.port ?? "3000");
91
+ if (!Number.isFinite(portNum) || !Number.isInteger(portNum) || portNum < 0 || portNum > 65535) {
92
+ throw new Error(`--port must be an integer in [0, 65535]; got "${opts.port}"`);
93
+ }
94
+ const httpOpts = {
95
+ ...opts,
96
+ port: portNum,
97
+ host: opts.host ?? "127.0.0.1",
98
+ bearerToken,
99
+ mcpPath: opts.mcpPath ?? "/mcp",
100
+ rateLimitPerMinute: opts.rateLimit !== undefined ? Number(opts.rateLimit) : 120,
101
+ corsOrigins: opts.corsOrigin ?? [],
102
+ healthPath: opts.healthPath ?? "/health"
103
+ };
104
+ if (!Number.isFinite(httpOpts.rateLimitPerMinute) ||
105
+ httpOpts.rateLimitPerMinute < 0 ||
106
+ !Number.isInteger(httpOpts.rateLimitPerMinute)) {
107
+ throw new Error(`--rate-limit must be a non-negative integer; got "${opts.rateLimit}"`);
108
+ }
109
+ const { startHttpServer } = await import("./http-transport.js");
110
+ await startHttpServer(httpOpts);
111
+ });
112
+ // v2.6.0 — convenience helper. Same as `node -e
113
+ // 'console.log(require("crypto").randomBytes(32).toString("base64url"))'`
114
+ // but discoverable in --help.
115
+ program
116
+ .command("gen-token")
117
+ .description("Generate a fresh 32-byte base64url bearer token suitable for `serve-http --bearer-token`.")
118
+ .action(async () => {
119
+ const { generateBearerToken } = await import("./http-transport.js");
120
+ process.stdout.write(`${generateBearerToken()}\n`);
121
+ });
49
122
  program
50
123
  .command("clear-cache")
51
124
  .description("Delete the persistent-cache file for a given vault")
@@ -169,7 +242,13 @@ async function main() {
169
242
  });
170
243
  await program.parseAsync(process.argv);
171
244
  }
172
- async function startServer(opts) {
245
+ /**
246
+ * One-time bootstrap of the heavy deps (vault open + FTS5 sync + watcher).
247
+ * Idempotent on a per-call basis but NOT designed to be called multiple
248
+ * times in one process — the FTS5 sync would double-index. Stdio + HTTP
249
+ * each call this exactly once at startup.
250
+ */
251
+ export async function prepareServerDeps(opts) {
173
252
  const vault = new Vault(opts.vault, {
174
253
  enableWrite: !!opts.enableWrite,
175
254
  maxFileBytes: opts.maxFileBytes !== undefined ? parsePositiveInt(opts.maxFileBytes, "--max-file-bytes") : undefined,
@@ -198,6 +277,28 @@ async function startServer(opts) {
198
277
  throw err;
199
278
  }
200
279
  }
280
+ // Optional watcher — only when --watch is passed. Starts AFTER the initial
281
+ // FTS5 sync so we don't double-index files during boot.
282
+ let watcher = null;
283
+ if (opts.watch) {
284
+ watcher = new VaultWatcher({ vault, ftsIndex });
285
+ await watcher.start();
286
+ }
287
+ return {
288
+ vault,
289
+ ftsIndex,
290
+ watcher,
291
+ disabledTools: new Set(opts.disabledTools ?? []),
292
+ enabledTools: new Set(opts.enabledTools ?? []),
293
+ warningTracker: { printed: false }
294
+ };
295
+ }
296
+ /**
297
+ * Build a fresh `McpServer` over already-prepared deps. Cheap (just
298
+ * registers tool handlers — no I/O, no SQLite open). Stdio calls this once;
299
+ * HTTP calls it per session.
300
+ */
301
+ export function buildMcpServer(deps, opts) {
201
302
  const server = new McpServer({
202
303
  name: "enquire",
203
304
  version: VERSION
@@ -217,62 +318,75 @@ async function startServer(opts) {
217
318
  // unknown — typo or stale doc reference. Pre-fix, a typo in
218
319
  // `--disabled-tools obsidan_search` (note the missing `i`) silently
219
320
  // disabled nothing; now we log a warning so the user can correct it.
220
- const disabledTools = new Set(opts.disabledTools ?? []);
221
- const enabledTools = new Set(opts.enabledTools ?? []);
222
321
  const usedDisabled = new Set();
223
322
  const usedEnabled = new Set();
224
323
  const registeredNames = new Set();
225
- if (disabledTools.size > 0 || enabledTools.size > 0) {
324
+ // v2.6.0: only print skip-logging on the first build (stdio: once at boot;
325
+ // HTTP: once on first session). Subsequent HTTP sessions reuse the same
326
+ // gating decisions silently — no need to spam logs per request.
327
+ const verbose = !deps.warningTracker.printed;
328
+ if (deps.disabledTools.size > 0 || deps.enabledTools.size > 0) {
226
329
  const origRegisterTool = server.registerTool.bind(server);
227
330
  server.registerTool = (name, ...rest) => {
228
331
  registeredNames.add(name);
229
- if (enabledTools.size > 0) {
230
- if (enabledTools.has(name)) {
332
+ if (deps.enabledTools.size > 0) {
333
+ if (deps.enabledTools.has(name)) {
231
334
  usedEnabled.add(name);
232
335
  }
233
336
  else {
234
- process.stderr.write(`enquire: skipping tool ${name} (not in --enabled-tools allowlist)\n`);
337
+ if (verbose)
338
+ process.stderr.write(`enquire: skipping tool ${name} (not in --enabled-tools allowlist)\n`);
235
339
  return undefined;
236
340
  }
237
341
  }
238
- if (disabledTools.has(name)) {
342
+ if (deps.disabledTools.has(name)) {
239
343
  usedDisabled.add(name);
240
- process.stderr.write(`enquire: skipping tool ${name} (disabled by --disabled-tools)\n`);
344
+ if (verbose)
345
+ process.stderr.write(`enquire: skipping tool ${name} (disabled by --disabled-tools)\n`);
241
346
  return undefined;
242
347
  }
243
348
  return origRegisterTool(name, ...rest);
244
349
  };
245
350
  }
246
- registerReadTools(server, vault, ftsIndex, opts.diagnosticSearchTools ?? false);
247
- if (vault.writeEnabled)
248
- registerWriteTools(server, vault);
249
- if (ftsIndex && opts.diagnosticSearchTools)
250
- registerFtsTools(server, ftsIndex, vault);
251
- registerResources(server, vault);
252
- if (ftsIndex)
253
- registerChunkResource(server, ftsIndex, vault);
351
+ registerReadTools(server, deps.vault, deps.ftsIndex, opts.diagnosticSearchTools ?? false);
352
+ if (deps.vault.writeEnabled)
353
+ registerWriteTools(server, deps.vault);
354
+ if (deps.ftsIndex && opts.diagnosticSearchTools)
355
+ registerFtsTools(server, deps.ftsIndex, deps.vault);
356
+ registerResources(server, deps.vault);
357
+ if (deps.ftsIndex)
358
+ registerChunkResource(server, deps.ftsIndex, deps.vault);
254
359
  registerPrompts(server);
255
360
  // v2.0.0-beta.1: warn on unknown names AFTER all tools are registered.
256
361
  // We can't validate at parse time because the canonical list depends on
257
362
  // runtime config (e.g. --persistent-index gates obsidian_full_text_search,
258
363
  // --enable-write gates the 5 write tools). So we wait until everything is
259
364
  // registered, then diff the user's lists against what was actually seen.
260
- for (const name of disabledTools) {
261
- if (!usedDisabled.has(name)) {
262
- const hint = registeredNames.has(name)
263
- ? "" // shouldn't happen — would have been used
264
- : ` (no such tool registered; check spelling; available: ${[...registeredNames].sort().join(", ")})`;
265
- process.stderr.write(`enquire: warning --disabled-tools "${name}" did not match any tool${hint}\n`);
365
+ if (verbose) {
366
+ for (const name of deps.disabledTools) {
367
+ if (!usedDisabled.has(name)) {
368
+ const hint = registeredNames.has(name)
369
+ ? "" // shouldn't happen would have been used
370
+ : ` (no such tool registered; check spelling; available: ${[...registeredNames].sort().join(", ")})`;
371
+ process.stderr.write(`enquire: warning — --disabled-tools "${name}" did not match any tool${hint}\n`);
372
+ }
266
373
  }
267
- }
268
- for (const name of enabledTools) {
269
- if (!usedEnabled.has(name)) {
270
- const hint = registeredNames.has(name)
271
- ? ""
272
- : ` (no such tool; check spelling; available: ${[...registeredNames].sort().join(", ")})`;
273
- process.stderr.write(`enquire: warning — --enabled-tools "${name}" did not match any tool${hint}\n`);
374
+ for (const name of deps.enabledTools) {
375
+ if (!usedEnabled.has(name)) {
376
+ const hint = registeredNames.has(name)
377
+ ? ""
378
+ : ` (no such tool; check spelling; available: ${[...registeredNames].sort().join(", ")})`;
379
+ process.stderr.write(`enquire: warning --enabled-tools "${name}" did not match any tool${hint}\n`);
380
+ }
274
381
  }
382
+ deps.warningTracker.printed = true;
275
383
  }
384
+ return server;
385
+ }
386
+ async function startServer(opts) {
387
+ const deps = await prepareServerDeps(opts);
388
+ const { vault, ftsIndex, watcher } = deps;
389
+ const server = buildMcpServer(deps, opts);
276
390
  const transport = new StdioServerTransport();
277
391
  await server.connect(transport);
278
392
  if (vault.persistentCacheEnabled) {
@@ -305,12 +419,7 @@ async function startServer(opts) {
305
419
  void flush();
306
420
  });
307
421
  }
308
- // Optional watcher — only when --watch is passed. Starts AFTER the initial
309
- // FTS5 sync so we don't double-index files during boot.
310
- let watcher = null;
311
- if (opts.watch) {
312
- watcher = new VaultWatcher({ vault, ftsIndex });
313
- await watcher.start();
422
+ if (watcher) {
314
423
  const closeWatcher = () => {
315
424
  void watcher?.close();
316
425
  };
@@ -318,6 +427,21 @@ async function startServer(opts) {
318
427
  process.once("SIGTERM", closeWatcher);
319
428
  process.on("beforeExit", closeWatcher);
320
429
  }
430
+ process.stderr.write(`${formatReadyBanner(deps)} (transport=stdio)\n`);
431
+ if (ftsIndex) {
432
+ const closeFts = () => ftsIndex?.close();
433
+ process.once("SIGINT", closeFts);
434
+ process.once("SIGTERM", closeFts);
435
+ process.on("beforeExit", closeFts);
436
+ }
437
+ }
438
+ /**
439
+ * Shared "ready" banner used by stdio + HTTP startup paths so the runtime
440
+ * configuration summary is identical regardless of transport. Transport
441
+ * suffix is appended by the caller.
442
+ */
443
+ export function formatReadyBanner(deps) {
444
+ const { vault, ftsIndex, watcher, disabledTools, enabledTools } = deps;
321
445
  const writeMode = vault.writeEnabled ? "WRITE-ENABLED" : "read-only";
322
446
  const cacheMode = vault.persistentCacheEnabled ? `, persistent-cache=${vault.cacheFile}` : "";
323
447
  const ftsMode = ftsIndex ? `, fts5-index (${ftsIndex.totalFiles()} files / ${ftsIndex.totalChunks()} chunks)` : "";
@@ -327,13 +451,7 @@ async function startServer(opts) {
327
451
  const watchMode = watcher ? ", watch=on" : "";
328
452
  const disabledMode = disabledTools.size > 0 ? `, disabled-tools=${disabledTools.size}` : "";
329
453
  const enabledMode = enabledTools.size > 0 ? `, enabled-tools=${enabledTools.size}` : "";
330
- process.stderr.write(`enquire ${VERSION} ready (${writeMode}, vault=${vault.root}${cacheMode}${ftsMode}${privacyMode}${watchMode}${disabledMode}${enabledMode})\n`);
331
- if (ftsIndex) {
332
- const closeFts = () => ftsIndex?.close();
333
- process.once("SIGINT", closeFts);
334
- process.once("SIGTERM", closeFts);
335
- process.on("beforeExit", closeFts);
336
- }
454
+ return `enquire ${VERSION} ready (${writeMode}, vault=${vault.root}${cacheMode}${ftsMode}${privacyMode}${watchMode}${disabledMode}${enabledMode})`;
337
455
  }
338
456
  // v2.0 alpha — sync the persistent embedding index. Same incremental-rebuild
339
457
  // pattern as syncFtsIndex (mtime tracked in source_state); we only re-embed
@@ -380,7 +498,11 @@ async function syncEmbedDb(vault, db, embedder) {
380
498
  if (chunks.length >= 30) {
381
499
  process.stderr.write(`enquire: ${e.relPath} → ${chunks.length} chunks (this one will be slow; consider splitting the note)\n`);
382
500
  }
383
- const vectors = await embedder.embed(chunks.map((c) => c.text));
501
+ // v2.1.0: prepend heading breadcrumb to embedded text so the model sees
502
+ // structural context. Free win at zero token cost — Chroma 2024 +
503
+ // NAACL 2025 show +2-5 NDCG@10 from breadcrumb prepending. The text
504
+ // stored in `text_preview` (for snippets) stays clean.
505
+ const vectors = await embedder.embed(chunks.map((c) => (c.breadcrumb ? `${c.breadcrumb}\n\n${c.text}` : c.text)));
384
506
  const rows = chunks.map((c, i) => {
385
507
  const vector = vectors[i];
386
508
  if (!vector)
@@ -865,12 +987,84 @@ function registerReadTools(server, vault, ftsIndex, diagnosticSearchTools) {
865
987
  embedding_model: z
866
988
  .string()
867
989
  .optional()
868
- .describe("Override the embedding model alias (default 'multilingual'). Only consulted if a .embed.db exists.")
990
+ .describe("Override the embedding model alias (default 'multilingual'). Only consulted if a .embed.db exists."),
991
+ granularity: z
992
+ .enum(["note", "block"])
993
+ .optional()
994
+ .describe("v2.2.0: 'note' (default) returns one hit per note (best chunk wins). 'block' keeps each chunk as a distinct hit — useful when one note covers a topic in multiple paragraphs and you want the LLM to see all of them."),
995
+ graph_boost: z
996
+ .boolean()
997
+ .optional()
998
+ .describe("v2.3.0: post-RRF wikilink graph-boost — rerank top-K by counting how many OTHER top-K hits link to each one. Default ON. Set false to disable for diagnostic comparison. The 'only enquire-mcp does this' feature: generic vector stores can't do this without an Obsidian-aware layer.")
869
999
  }
870
1000
  }, async (args) => {
871
1001
  const embedFile = embedDbPath(vault.root);
872
1002
  return textResult(await searchHybrid(vault, args, { ftsIndex, embedFile }));
873
1003
  });
1004
+ server.registerTool("obsidian_chat_thread_read", {
1005
+ title: "Read parsed chat thread from a note",
1006
+ description: "Parse a note's `## Chat: <title>` block into structured messages with role/timestamp/content/line-range. Non-chat content in the same note is ignored. Read-only.",
1007
+ annotations: { ...READ_ONLY, title: "Read chat thread" },
1008
+ inputSchema: {
1009
+ note_path: z.string().min(1).describe("Vault-relative path to the note hosting the thread")
1010
+ }
1011
+ }, async (args) => textResult(await chatThreadRead(vault, args)));
1012
+ // v2.2.0: context pack — Smart Connections "Send to Smart Context" pattern,
1013
+ // MCP-native (works with any AI client, not just Obsidian).
1014
+ server.registerTool("obsidian_context_pack", {
1015
+ title: "Pack vault context for an AI question (token-budgeted)",
1016
+ description: "Given a question, retrieve the top relevant notes (via hybrid search), gather backlinks summaries + optionally recent dailies, deduplicate, pack to a token budget, return a single ready-to-paste markdown bundle. Saves the agent ~5 separate tool calls; produces a coherent context blob you can paste into any AI chat.",
1017
+ annotations: { ...READ_ONLY, title: "Context pack" },
1018
+ inputSchema: {
1019
+ query: z.string().min(1).describe("Topic or question to gather context for"),
1020
+ budget_tokens: z
1021
+ .number()
1022
+ .int()
1023
+ .positive()
1024
+ .max(32000)
1025
+ .optional()
1026
+ .describe("Approximate token budget (default 4000, ~4 chars/token)"),
1027
+ folder: z.string().optional().describe("Restrict retrieval to this folder (vault-relative)"),
1028
+ include_backlinks: z
1029
+ .boolean()
1030
+ .optional()
1031
+ .describe("Include 1-line backlink summaries for top-3 notes (default true)"),
1032
+ recent_dailies: z
1033
+ .number()
1034
+ .int()
1035
+ .min(0)
1036
+ .max(30)
1037
+ .optional()
1038
+ .describe("Include the last N daily-format notes (YYYY-MM-DD basenames). Default 0 (off).")
1039
+ }
1040
+ }, async (args) => {
1041
+ const embedFile = embedDbPath(vault.root);
1042
+ return textResult(await contextPack(vault, args, { ftsIndex, embedFile }));
1043
+ });
1044
+ // v2.3.0: frontmatter atomic ops — read.
1045
+ server.registerTool("obsidian_frontmatter_get", {
1046
+ title: "Read note frontmatter (full or single key)",
1047
+ description: "Return parsed YAML frontmatter for a note. With `key`, returns just that field's value. Without `key`, returns the whole frontmatter object. Read-only.",
1048
+ annotations: { ...READ_ONLY, title: "Get frontmatter" },
1049
+ inputSchema: {
1050
+ path: z.string().optional().describe("Vault-relative path"),
1051
+ title: z.string().optional().describe("Note title (filename without .md, accepts periodic aliases)"),
1052
+ key: z.string().optional().describe("Single key to read; omit for full frontmatter")
1053
+ }
1054
+ }, async (args) => textResult(await frontmatterGet(vault, args)));
1055
+ server.registerTool("obsidian_frontmatter_search", {
1056
+ title: "Find notes by frontmatter predicate",
1057
+ description: "Find every note where frontmatter.<key> matches a predicate. Useful as a precursor to bulk frontmatter_set: 'find all notes with status:draft and set their status to published'. Predicates are exclusive: pass exactly one of `equals` (strict equality), `exists` (key must be present), `contains` (for array values, member match).",
1058
+ annotations: { ...READ_ONLY, title: "Search frontmatter" },
1059
+ inputSchema: {
1060
+ key: z.string().min(1).describe("Frontmatter key to test"),
1061
+ equals: z.unknown().optional().describe("Strict equality predicate (JSON.stringify comparison)"),
1062
+ exists: z.boolean().optional().describe("Predicate: key must exist (any value)"),
1063
+ contains: z.unknown().optional().describe("For array values, value must be a member"),
1064
+ folder: z.string().optional().describe("Restrict search to a folder"),
1065
+ limit: z.number().int().positive().max(1000).optional().describe("Max matches (default 100)")
1066
+ }
1067
+ }, async (args) => textResult(await frontmatterSearch(vault, args)));
874
1068
  }
875
1069
  function registerWriteTools(server, vault) {
876
1070
  // destructiveHint=true: `obsidian_create_note` with overwrite=true replaces a
@@ -956,6 +1150,35 @@ function registerWriteTools(server, vault) {
956
1150
  .describe("Allow overwriting an existing file at the archive destination (default false)")
957
1151
  }
958
1152
  }, async (args) => textResult(await archiveNote(vault, args)));
1153
+ // v2.2.0: append message to a note's chat thread.
1154
+ server.registerTool("obsidian_chat_thread_append", {
1155
+ title: "Append message to note-tethered chat thread",
1156
+ description: "Add a user/assistant/system message to a note's `## Chat: <title>` block. Creates the note + heading if absent. Threads are stored as markdown so they're searchable, version-controllable, and survive across sessions / clients. Pair with `obsidian_chat_thread_read` to load past context. WRITE TOOL — only registered with --enable-write.",
1157
+ annotations: { ...WRITE, title: "Append chat thread" },
1158
+ inputSchema: {
1159
+ note_path: z.string().min(1).describe("Vault-relative path to the note hosting the thread"),
1160
+ role: z.enum(["user", "assistant", "system"]).describe("Role of the message being appended"),
1161
+ content: z.string().min(1).describe("Message body (markdown allowed)"),
1162
+ thread_title: z
1163
+ .string()
1164
+ .optional()
1165
+ .describe("Optional thread title — used when the note is created from scratch")
1166
+ }
1167
+ }, async (args) => textResult(await chatThreadAppend(vault, args)));
1168
+ // v2.3.0: surgical frontmatter writes (set / unset / bulk).
1169
+ server.registerTool("obsidian_frontmatter_set", {
1170
+ title: "Set/unset frontmatter keys atomically",
1171
+ description: "Surgical YAML manipulation: set one or more keys, or remove them by passing `null` as the value. Round-trips through gray-matter (same parser used at write time) so YAML formatting / quoting / type-coercion stays consistent. Returns `before` + `after` + list of changed keys for observability. `dry_run: true` shows the diff without writing.",
1172
+ annotations: { ...WRITE, title: "Set frontmatter" },
1173
+ inputSchema: {
1174
+ path: z.string().optional().describe("Vault-relative path"),
1175
+ title: z.string().optional().describe("Note title (filename without .md)"),
1176
+ set: z
1177
+ .record(z.string(), z.unknown())
1178
+ .describe("Keys to set. Pass `null` as value to delete a key (e.g. {status: 'published', draft: null})"),
1179
+ dry_run: z.boolean().optional().describe("Preview the diff without writing (default false)")
1180
+ }
1181
+ }, async (args) => textResult(await frontmatterSet(vault, args)));
959
1182
  }
960
1183
  function registerChunkResource(server, idx, vault) {
961
1184
  // Chunk-level addressing — closes the v0.10 roadmap item from issue #10
@@ -1311,6 +1534,303 @@ DO NOT actually modify any notes. This is a proposal pass — the user runs the
1311
1534
  }
1312
1535
  ]
1313
1536
  }));
1537
+ // v2.1.0: multi-query expansion as a prompt template (NOT a server-side
1538
+ // LLM call — that would violate the MCP boundary). The agent paraphrases
1539
+ // the user's question N ways, calls obsidian_search per paraphrase, then
1540
+ // RRF-fuses the results client-side. Boosts recall on terse / ambiguous
1541
+ // queries by 5-15 NDCG@10 vs single-pass search. Pure prompt eng.
1542
+ server.registerPrompt("search_with_query_expansion", {
1543
+ title: "Search with multi-query expansion",
1544
+ description: "Higher-recall retrieval: paraphrase the query 3-5 ways, call obsidian_search per paraphrase, fuse results. Boosts recall on terse / ambiguous queries by 5-15 NDCG@10 over a single-pass search. Pure agent-side orchestration — no server-side LLM calls.",
1545
+ argsSchema: {
1546
+ query: z.string().describe("The user's original question / search query"),
1547
+ n_paraphrases: z.string().optional().describe("How many paraphrases to generate (default 4)"),
1548
+ limit: z.string().optional().describe("Top-K hits per paraphrase before fusion (default 10)")
1549
+ }
1550
+ }, ({ query, n_paraphrases, limit }) => ({
1551
+ messages: [
1552
+ {
1553
+ role: "user",
1554
+ content: {
1555
+ type: "text",
1556
+ text: `High-recall retrieval over my Obsidian vault. The user asked: "${query}"
1557
+
1558
+ 1. Generate ${n_paraphrases ?? 4} short paraphrases of the question. Mix:
1559
+ - 1 keyword-focused (good for BM25): noun phrases, technical terms
1560
+ - 1 semantic-focused (good for embeddings): natural-language restating
1561
+ - 1-2 step-back: a more general question whose answer would contain this one
1562
+ - Optionally 1 in another language if my vault is bilingual
1563
+
1564
+ 2. For each paraphrase, call \`obsidian_search\` with \`query=<paraphrase>\` and \`limit=${limit ?? 10}\`.
1565
+
1566
+ 3. Reciprocal Rank Fusion: assign each hit a score of 1/(60+rank), sum across paraphrases per note path, sort descending.
1567
+
1568
+ 4. Return the top 10 fused results. For each: path, fused_score, which paraphrases hit it (and at what rank), and a 1-sentence "why this answers the original question."
1569
+
1570
+ 5. If a hit appears in only ONE paraphrase, mark it as "low-confidence — only retrieved by paraphrase #N" — these are speculative.
1571
+
1572
+ The goal is recall + observability: the user sees not just the answer but WHY each note ranked.`
1573
+ }
1574
+ }
1575
+ ]
1576
+ }));
1577
+ // v2.4.0 — Karpathy LLM-Wiki workflow prompts.
1578
+ // Reference: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
1579
+ // Karpathy named three workflows: ingest, query, lint. We had `query` and
1580
+ // `lint` since v1.5. v2.4.0 adds `ingest`-style workflows + `compile`/
1581
+ // `synth` patterns that close the loop. Position: enquire-mcp = the
1582
+ // open-source backend for Karpathy-style LLM Wikis on top of Obsidian.
1583
+ server.registerPrompt("vault_synth", {
1584
+ title: "Synthesize a vault wiki page from sources (Karpathy-style ingest)",
1585
+ description: "Karpathy LLM-Wiki ingest workflow: take raw source(s), extract entities/concepts/claims, decide which existing notes to update vs which new wiki pages to create, then propose drafts. The agent decides; this prompt sequences the calls. Cites every claim with the source location for trust.",
1586
+ argsSchema: {
1587
+ source: z
1588
+ .string()
1589
+ .describe("Source content to ingest — paste a paragraph, an arXiv abstract, a URL transcript, etc."),
1590
+ target_folder: z
1591
+ .string()
1592
+ .optional()
1593
+ .describe("Where new wiki pages should land (vault-relative, default 'Wiki/')")
1594
+ }
1595
+ }, ({ source, target_folder }) => ({
1596
+ messages: [
1597
+ {
1598
+ role: "user",
1599
+ content: {
1600
+ type: "text",
1601
+ text: `Karpathy LLM-Wiki **ingest** workflow on this source:
1602
+
1603
+ \`\`\`
1604
+ ${source}
1605
+ \`\`\`
1606
+
1607
+ Steps:
1608
+
1609
+ 1. **Extract concepts.** Identify 3-7 distinct concepts / entities / claims worth indexing. For each, propose a wiki page title (PascalCase or "Title Case" — match my vault's existing convention; check via \`obsidian_list_notes\` on a few sample folders).
1610
+
1611
+ 2. **Reconcile with vault.** For each concept, run \`obsidian_search\` (graph_boost ON, default) to find existing notes that ALREADY cover it. Three outcomes per concept:
1612
+ - **EXISTS** (top hit score > 0.04 and same scope) → propose an APPEND to the existing note
1613
+ - **PARTIAL** (related but doesn't cover this angle) → propose a new note that \`[[wikilinks]]\` to the existing one
1614
+ - **NEW** → propose a fresh wiki page in \`${target_folder ?? "Wiki/"}\`
1615
+
1616
+ 3. **Lint drafts before writing.** For each proposed write, call \`obsidian_validate_note_proposal\` to catch broken \`[[wikilinks]]\` / inconsistent tags / structurally-broken YAML BEFORE creating.
1617
+
1618
+ 4. **Cite every claim.** Each new note should have a "Source" frontmatter field referencing the input + a "Claims" section with one bullet per extracted claim, each with the source quote.
1619
+
1620
+ 5. **Output a transactional plan.** Don't write yet. Output a JSON-like list:
1621
+ \`\`\`
1622
+ [
1623
+ { action: "create" | "append", path: "Wiki/Foo.md", reason: "...", body_preview: "..." },
1624
+ ...
1625
+ ]
1626
+ \`\`\`
1627
+ Then ask the user to approve. ONLY write after explicit approval, using \`obsidian_create_note\` / \`obsidian_append_to_note\`.
1628
+
1629
+ This is the Karpathy LLM-Wiki ingest loop applied to Obsidian. Goal: knowledge that compounds over time, with every claim traceable to its source.`
1630
+ }
1631
+ }
1632
+ ]
1633
+ }));
1634
+ server.registerPrompt("vault_wiki_compile", {
1635
+ title: "Compile vault index + log (Karpathy-style maintenance)",
1636
+ description: "The LLM-Wiki maintenance step: scan the vault for new/updated notes since last compile, regenerate the top-level `index.md` (table of contents + concept clusters) and append to `log.md` (a chronological compile-log). Run weekly or after a batch ingest. Idempotent.",
1637
+ argsSchema: {
1638
+ since_minutes: z.string().optional().describe("Window for 'recently changed' notes (default 10080 = 7 days)"),
1639
+ wiki_folder: z.string().optional().describe("Wiki folder root (default 'Wiki/')")
1640
+ }
1641
+ }, ({ since_minutes, wiki_folder }) => ({
1642
+ messages: [
1643
+ {
1644
+ role: "user",
1645
+ content: {
1646
+ type: "text",
1647
+ text: `Karpathy LLM-Wiki **compile** workflow.
1648
+
1649
+ Step 1 — Scan recent changes:
1650
+ - \`obsidian_get_recent_edits since_minutes=${since_minutes ?? 10080} folder=${wiki_folder ?? "Wiki"}\`
1651
+ - For each, \`obsidian_read_note format=map\` to get headings + frontmatter only (cheap).
1652
+
1653
+ Step 2 — Regenerate index.md:
1654
+ - Group notes by frontmatter \`tags\` and by folder.
1655
+ - For each cluster (≥3 notes), produce a heading + bullet list of \`[[wikilinks]]\` to the cluster members.
1656
+ - Add a "Recent" section listing the 10 most recently modified.
1657
+ - Use \`obsidian_validate_note_proposal\` to catch any broken wikilinks BEFORE writing.
1658
+ - Write via \`obsidian_create_note overwrite=true\` to \`${wiki_folder ?? "Wiki"}/index.md\`.
1659
+
1660
+ Step 3 — Append to log.md:
1661
+ - A bullet per note touched in the window: \`- 2026-05-08 — [[NoteTitle]] (created|updated): one-line summary\`
1662
+ - Append via \`obsidian_append_to_note\`. The log accumulates compile history.
1663
+
1664
+ Step 4 — Surface gaps:
1665
+ - Run \`obsidian_lint_wiki\` to enumerate orphans / broken / stubs / stale.
1666
+ - Add the gap summary to the bottom of \`index.md\` so the next compile sees it.
1667
+
1668
+ Idempotent. Re-run weekly.`
1669
+ }
1670
+ }
1671
+ ]
1672
+ }));
1673
+ server.registerPrompt("vault_lint_extended", {
1674
+ title: "Extended vault lint (orphans + contradictions + stale claims + missing cross-refs)",
1675
+ description: "Beyond the structural lint of `obsidian_lint_wiki`: this prompt sequences a deeper inspection — contradictions across notes (semantic search for opposing claims), stale claims (notes with date references > 6mo old), missing cross-references (notes that mention an entity by name without `[[wikilinking]]` to its wiki page).",
1676
+ argsSchema: {
1677
+ folder: z.string().optional().describe("Restrict to a folder (default whole vault)")
1678
+ }
1679
+ }, ({ folder }) => ({
1680
+ messages: [
1681
+ {
1682
+ role: "user",
1683
+ content: {
1684
+ type: "text",
1685
+ text: `Extended lint pass on${folder ? ` ${folder}` : " the whole vault"}.
1686
+
1687
+ Phase 1 — structural (\`obsidian_lint_wiki${folder ? ` folder=${folder}` : ""}\`):
1688
+ - Surface orphans / broken / stubs / stale per the existing tool. Skim the report.
1689
+
1690
+ Phase 2 — semantic contradictions:
1691
+ - For each top-30 note (by recent-edits window), pick 1-2 strong claims (declarative sentences in the body).
1692
+ - For each claim, run \`obsidian_search query="<claim paraphrased to negate>" min_signals=2\` — multi-ranker consensus on the OPPOSITE statement.
1693
+ - If a hit comes back with score > 0.04, flag as a potential contradiction. Output: A says X, B says ¬X, suggest reconciliation.
1694
+
1695
+ Phase 3 — stale claims:
1696
+ - For each note, scan body for date patterns (\`/\\b(20\\d{2})-\\d{2}-\\d{2}\\b/\` or \`/\\b(20\\d{2})\\b/\` with words like "current"/"latest"/"now"/"upcoming").
1697
+ - If the date is > 6 months old, surface as "potentially stale: <note> claims X with date Y".
1698
+
1699
+ Phase 4 — missing cross-references:
1700
+ - For each top-15 note, get its outbound \`[[wikilinks]]\` (via \`obsidian_get_outbound_links\`).
1701
+ - Read the body. Check for wiki page TITLES (use \`obsidian_list_notes\` for the list) mentioned in plain text WITHOUT \`[[\` brackets.
1702
+ - For each, propose a rewrite that adds the brackets. \`obsidian_validate_note_proposal\` first.
1703
+
1704
+ Output: a single markdown report with sections per phase. End with the top 5 highest-leverage fixes.`
1705
+ }
1706
+ }
1707
+ ]
1708
+ }));
1709
+ server.registerPrompt("vault_capture", {
1710
+ title: "Capture a quick thought into the vault (write don't organize)",
1711
+ description: "Mem.ai-style 'write don't organize' UX: the user pastes a thought; we file it intelligently. Auto-detect destination (today's daily note vs new wiki page vs append to most-relevant existing note via hybrid search) and propose a diff for user approval before writing.",
1712
+ argsSchema: {
1713
+ text: z.string().describe("The thought to capture — free-form text"),
1714
+ target_hint: z
1715
+ .string()
1716
+ .optional()
1717
+ .describe("Optional hint: 'daily', 'new-note', or a path/topic to bias destination")
1718
+ }
1719
+ }, ({ text, target_hint }) => ({
1720
+ messages: [
1721
+ {
1722
+ role: "user",
1723
+ content: {
1724
+ type: "text",
1725
+ text: `Capture this thought into my vault, Mem.ai-style: figure out where it goes, propose a diff, ask before writing.
1726
+
1727
+ Thought:
1728
+ \`\`\`
1729
+ ${text}
1730
+ \`\`\`
1731
+
1732
+ Hint: ${target_hint ?? "(none — auto-detect)"}
1733
+
1734
+ Decision tree:
1735
+
1736
+ 1. **Daily?** If thought is conversational / reflective / time-bound (uses words like "today", "yesterday", "I'm thinking about", "TIL"), propose APPEND to today's daily note via \`obsidian_read_note title="today"\` → \`obsidian_append_to_note\`.
1737
+
1738
+ 2. **Continues an existing note?** Run \`obsidian_search query="<thought first 200 chars>" limit=5\`. If top hit has score > 0.05, propose APPEND to that note. Show the user: "this looks related to [[NoteTitle]] — append there?"
1739
+
1740
+ 3. **New wiki page?** If thought contains 1-3 distinct concepts that don't have existing notes, run \`vault_synth\` workflow on it.
1741
+
1742
+ 4. **Inbox catch-all.** If steps 1-3 give nothing high-confidence, propose \`obsidian_create_note path="Inbox/<timestamp>-<3-word-slug>.md"\`.
1743
+
1744
+ 5. **Show diff, ask, then write.** Always preview the proposed write to the user. Use \`obsidian_validate_note_proposal\` first. Write only after explicit approval.
1745
+
1746
+ Goal: zero filing burden on the user. The AI does the indexing.`
1747
+ }
1748
+ }
1749
+ ]
1750
+ }));
1751
+ // v2.5.0 — agentic prompts (Khoj parity, lite scope).
1752
+ // Agent personas + scheduled automations as prompts that orchestrate
1753
+ // existing tools. Pure agent-side: no server-side state, no LLM calls.
1754
+ // HTTP transport is a separate larger-scope sprint (planned post v2.5).
1755
+ server.registerPrompt("vault_persona_search", {
1756
+ title: "Search the vault as a named persona (folder-scoped + tuned)",
1757
+ description: "Khoj-style agent persona pattern: scope retrieval to a folder + apply a persona-specific lens to the response. Useful when you want 'research-assistant' behavior over `Research/` distinct from 'editor' over `Drafts/`. Pure prompt template — orchestrates existing search tools with a fixed scope/instructions.",
1758
+ argsSchema: {
1759
+ persona: z
1760
+ .string()
1761
+ .describe("Persona name + traits (e.g. 'research-assistant: cite sources, ignore drafts, tldr first')"),
1762
+ folder: z.string().describe("Folder to scope retrieval to (vault-relative)"),
1763
+ query: z.string().describe("The user's question")
1764
+ }
1765
+ }, ({ persona, folder, query }) => ({
1766
+ messages: [
1767
+ {
1768
+ role: "user",
1769
+ content: {
1770
+ type: "text",
1771
+ text: `Acting as **${persona}**, with retrieval scoped to \`${folder}\`.
1772
+
1773
+ User question: ${query}
1774
+
1775
+ Steps:
1776
+
1777
+ 1. \`obsidian_search query="${query}" folder="${folder}" limit=15\` — hybrid retrieval inside the persona's scope.
1778
+ 2. For each top-3 hit, \`obsidian_read_note\` to load the body.
1779
+ 3. Synthesize the answer through the persona's lens (e.g. research-assistant cites every claim with \`[[wikilinks]]\`; editor flags contradictions; project-PM extracts deliverables).
1780
+ 4. End with **3 follow-up questions** the user might ask next (use the persona's intent — research-assistant: "should I cite paper X?"; editor: "want me to flag the inconsistency between A and B?").
1781
+
1782
+ Stay in the persona for the entire response. If asked something out-of-scope (e.g. research-assistant asked about cooking), politely redirect.`
1783
+ }
1784
+ }
1785
+ ]
1786
+ }));
1787
+ server.registerPrompt("vault_automation_setup", {
1788
+ title: "Set up a scheduled vault query (Khoj-style automations)",
1789
+ description: "Walks you through creating a cron'd vault query whose results land as a daily note or get appended to a digest. Bridges enquire-mcp tools + the host's `scheduled-tasks` MCP (or any cron tool the agent has access to). Pure orchestration — no server-side state.",
1790
+ argsSchema: {
1791
+ intent: z
1792
+ .string()
1793
+ .describe("What you want automated (e.g. 'every Monday 9am, show me all notes touched last week and highlight unresolved questions')")
1794
+ }
1795
+ }, ({ intent }) => ({
1796
+ messages: [
1797
+ {
1798
+ role: "user",
1799
+ content: {
1800
+ type: "text",
1801
+ text: `User wants this automation: "${intent}"
1802
+
1803
+ Steps:
1804
+
1805
+ 1. **Parse the intent.** Identify:
1806
+ - **Cadence:** cron expression (daily/weekly/monthly + time)
1807
+ - **Source:** which obsidian tool answers this? (\`get_recent_edits\`, \`obsidian_search\`, \`lint_wiki\`, \`paper_audit\`, etc.)
1808
+ - **Sink:** how does the user want results? (a) append to today's daily note via \`append_to_note\`; (b) create a new note in \`Automations/\`; (c) just notify
1809
+
1810
+ 2. **Propose the automation as a JSON spec.** Example:
1811
+ \`\`\`json
1812
+ {
1813
+ "name": "weekly-review",
1814
+ "cron": "0 9 * * 1",
1815
+ "tool_sequence": [
1816
+ { "tool": "obsidian_get_recent_edits", "args": { "since_minutes": 10080 } },
1817
+ { "tool": "obsidian_open_questions", "args": { "limit": 20 } }
1818
+ ],
1819
+ "sink": { "type": "append_to_note", "path": "Daily/{{today}}.md", "header": "## Weekly review" }
1820
+ }
1821
+ \`\`\`
1822
+
1823
+ 3. **Show the spec, ask user to confirm.**
1824
+
1825
+ 4. **Register via the host's scheduled-tasks MCP** (if available) or output the cron config for manual paste. \`mcp__scheduled-tasks__create_scheduled_task\` is the standard target.
1826
+
1827
+ 5. **Smoke once.** Before the first scheduled run, execute the tool sequence ONCE manually so the user verifies output shape. Show the produced markdown.
1828
+
1829
+ This is the Khoj automation pattern translated to MCP: research that comes to you instead of you remembering to ask for it.`
1830
+ }
1831
+ }
1832
+ ]
1833
+ }));
1314
1834
  }
1315
1835
  function parsePositiveInt(raw, flag) {
1316
1836
  const n = Number(raw);