@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/CHANGELOG.md +249 -0
- package/README.md +35 -8
- package/dist/fts5.d.ts +11 -0
- package/dist/fts5.d.ts.map +1 -1
- package/dist/fts5.js +77 -11
- package/dist/fts5.js.map +1 -1
- package/dist/http-transport.d.ts +92 -0
- package/dist/http-transport.d.ts.map +1 -0
- package/dist/http-transport.js +384 -0
- package/dist/http-transport.js.map +1 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +567 -47
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +128 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +523 -67
- package/dist/tools.js.map +1 -1
- package/docs/api.md +3 -1
- package/docs/http-transport.md +305 -0
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
:
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|