@oomkapwn/enquire-mcp 2.0.0 → 2.5.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.5.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
@@ -380,7 +380,11 @@ async function syncEmbedDb(vault, db, embedder) {
380
380
  if (chunks.length >= 30) {
381
381
  process.stderr.write(`enquire: ${e.relPath} → ${chunks.length} chunks (this one will be slow; consider splitting the note)\n`);
382
382
  }
383
- const vectors = await embedder.embed(chunks.map((c) => c.text));
383
+ // v2.1.0: prepend heading breadcrumb to embedded text so the model sees
384
+ // structural context. Free win at zero token cost — Chroma 2024 +
385
+ // NAACL 2025 show +2-5 NDCG@10 from breadcrumb prepending. The text
386
+ // stored in `text_preview` (for snippets) stays clean.
387
+ const vectors = await embedder.embed(chunks.map((c) => (c.breadcrumb ? `${c.breadcrumb}\n\n${c.text}` : c.text)));
384
388
  const rows = chunks.map((c, i) => {
385
389
  const vector = vectors[i];
386
390
  if (!vector)
@@ -865,12 +869,84 @@ function registerReadTools(server, vault, ftsIndex, diagnosticSearchTools) {
865
869
  embedding_model: z
866
870
  .string()
867
871
  .optional()
868
- .describe("Override the embedding model alias (default 'multilingual'). Only consulted if a .embed.db exists.")
872
+ .describe("Override the embedding model alias (default 'multilingual'). Only consulted if a .embed.db exists."),
873
+ granularity: z
874
+ .enum(["note", "block"])
875
+ .optional()
876
+ .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."),
877
+ graph_boost: z
878
+ .boolean()
879
+ .optional()
880
+ .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
881
  }
870
882
  }, async (args) => {
871
883
  const embedFile = embedDbPath(vault.root);
872
884
  return textResult(await searchHybrid(vault, args, { ftsIndex, embedFile }));
873
885
  });
886
+ server.registerTool("obsidian_chat_thread_read", {
887
+ title: "Read parsed chat thread from a note",
888
+ 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.",
889
+ annotations: { ...READ_ONLY, title: "Read chat thread" },
890
+ inputSchema: {
891
+ note_path: z.string().min(1).describe("Vault-relative path to the note hosting the thread")
892
+ }
893
+ }, async (args) => textResult(await chatThreadRead(vault, args)));
894
+ // v2.2.0: context pack — Smart Connections "Send to Smart Context" pattern,
895
+ // MCP-native (works with any AI client, not just Obsidian).
896
+ server.registerTool("obsidian_context_pack", {
897
+ title: "Pack vault context for an AI question (token-budgeted)",
898
+ 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.",
899
+ annotations: { ...READ_ONLY, title: "Context pack" },
900
+ inputSchema: {
901
+ query: z.string().min(1).describe("Topic or question to gather context for"),
902
+ budget_tokens: z
903
+ .number()
904
+ .int()
905
+ .positive()
906
+ .max(32000)
907
+ .optional()
908
+ .describe("Approximate token budget (default 4000, ~4 chars/token)"),
909
+ folder: z.string().optional().describe("Restrict retrieval to this folder (vault-relative)"),
910
+ include_backlinks: z
911
+ .boolean()
912
+ .optional()
913
+ .describe("Include 1-line backlink summaries for top-3 notes (default true)"),
914
+ recent_dailies: z
915
+ .number()
916
+ .int()
917
+ .min(0)
918
+ .max(30)
919
+ .optional()
920
+ .describe("Include the last N daily-format notes (YYYY-MM-DD basenames). Default 0 (off).")
921
+ }
922
+ }, async (args) => {
923
+ const embedFile = embedDbPath(vault.root);
924
+ return textResult(await contextPack(vault, args, { ftsIndex, embedFile }));
925
+ });
926
+ // v2.3.0: frontmatter atomic ops — read.
927
+ server.registerTool("obsidian_frontmatter_get", {
928
+ title: "Read note frontmatter (full or single key)",
929
+ 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.",
930
+ annotations: { ...READ_ONLY, title: "Get frontmatter" },
931
+ inputSchema: {
932
+ path: z.string().optional().describe("Vault-relative path"),
933
+ title: z.string().optional().describe("Note title (filename without .md, accepts periodic aliases)"),
934
+ key: z.string().optional().describe("Single key to read; omit for full frontmatter")
935
+ }
936
+ }, async (args) => textResult(await frontmatterGet(vault, args)));
937
+ server.registerTool("obsidian_frontmatter_search", {
938
+ title: "Find notes by frontmatter predicate",
939
+ 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).",
940
+ annotations: { ...READ_ONLY, title: "Search frontmatter" },
941
+ inputSchema: {
942
+ key: z.string().min(1).describe("Frontmatter key to test"),
943
+ equals: z.unknown().optional().describe("Strict equality predicate (JSON.stringify comparison)"),
944
+ exists: z.boolean().optional().describe("Predicate: key must exist (any value)"),
945
+ contains: z.unknown().optional().describe("For array values, value must be a member"),
946
+ folder: z.string().optional().describe("Restrict search to a folder"),
947
+ limit: z.number().int().positive().max(1000).optional().describe("Max matches (default 100)")
948
+ }
949
+ }, async (args) => textResult(await frontmatterSearch(vault, args)));
874
950
  }
875
951
  function registerWriteTools(server, vault) {
876
952
  // destructiveHint=true: `obsidian_create_note` with overwrite=true replaces a
@@ -956,6 +1032,35 @@ function registerWriteTools(server, vault) {
956
1032
  .describe("Allow overwriting an existing file at the archive destination (default false)")
957
1033
  }
958
1034
  }, async (args) => textResult(await archiveNote(vault, args)));
1035
+ // v2.2.0: append message to a note's chat thread.
1036
+ server.registerTool("obsidian_chat_thread_append", {
1037
+ title: "Append message to note-tethered chat thread",
1038
+ 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.",
1039
+ annotations: { ...WRITE, title: "Append chat thread" },
1040
+ inputSchema: {
1041
+ note_path: z.string().min(1).describe("Vault-relative path to the note hosting the thread"),
1042
+ role: z.enum(["user", "assistant", "system"]).describe("Role of the message being appended"),
1043
+ content: z.string().min(1).describe("Message body (markdown allowed)"),
1044
+ thread_title: z
1045
+ .string()
1046
+ .optional()
1047
+ .describe("Optional thread title — used when the note is created from scratch")
1048
+ }
1049
+ }, async (args) => textResult(await chatThreadAppend(vault, args)));
1050
+ // v2.3.0: surgical frontmatter writes (set / unset / bulk).
1051
+ server.registerTool("obsidian_frontmatter_set", {
1052
+ title: "Set/unset frontmatter keys atomically",
1053
+ 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.",
1054
+ annotations: { ...WRITE, title: "Set frontmatter" },
1055
+ inputSchema: {
1056
+ path: z.string().optional().describe("Vault-relative path"),
1057
+ title: z.string().optional().describe("Note title (filename without .md)"),
1058
+ set: z
1059
+ .record(z.string(), z.unknown())
1060
+ .describe("Keys to set. Pass `null` as value to delete a key (e.g. {status: 'published', draft: null})"),
1061
+ dry_run: z.boolean().optional().describe("Preview the diff without writing (default false)")
1062
+ }
1063
+ }, async (args) => textResult(await frontmatterSet(vault, args)));
959
1064
  }
960
1065
  function registerChunkResource(server, idx, vault) {
961
1066
  // Chunk-level addressing — closes the v0.10 roadmap item from issue #10
@@ -1311,6 +1416,303 @@ DO NOT actually modify any notes. This is a proposal pass — the user runs the
1311
1416
  }
1312
1417
  ]
1313
1418
  }));
1419
+ // v2.1.0: multi-query expansion as a prompt template (NOT a server-side
1420
+ // LLM call — that would violate the MCP boundary). The agent paraphrases
1421
+ // the user's question N ways, calls obsidian_search per paraphrase, then
1422
+ // RRF-fuses the results client-side. Boosts recall on terse / ambiguous
1423
+ // queries by 5-15 NDCG@10 vs single-pass search. Pure prompt eng.
1424
+ server.registerPrompt("search_with_query_expansion", {
1425
+ title: "Search with multi-query expansion",
1426
+ 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.",
1427
+ argsSchema: {
1428
+ query: z.string().describe("The user's original question / search query"),
1429
+ n_paraphrases: z.string().optional().describe("How many paraphrases to generate (default 4)"),
1430
+ limit: z.string().optional().describe("Top-K hits per paraphrase before fusion (default 10)")
1431
+ }
1432
+ }, ({ query, n_paraphrases, limit }) => ({
1433
+ messages: [
1434
+ {
1435
+ role: "user",
1436
+ content: {
1437
+ type: "text",
1438
+ text: `High-recall retrieval over my Obsidian vault. The user asked: "${query}"
1439
+
1440
+ 1. Generate ${n_paraphrases ?? 4} short paraphrases of the question. Mix:
1441
+ - 1 keyword-focused (good for BM25): noun phrases, technical terms
1442
+ - 1 semantic-focused (good for embeddings): natural-language restating
1443
+ - 1-2 step-back: a more general question whose answer would contain this one
1444
+ - Optionally 1 in another language if my vault is bilingual
1445
+
1446
+ 2. For each paraphrase, call \`obsidian_search\` with \`query=<paraphrase>\` and \`limit=${limit ?? 10}\`.
1447
+
1448
+ 3. Reciprocal Rank Fusion: assign each hit a score of 1/(60+rank), sum across paraphrases per note path, sort descending.
1449
+
1450
+ 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."
1451
+
1452
+ 5. If a hit appears in only ONE paraphrase, mark it as "low-confidence — only retrieved by paraphrase #N" — these are speculative.
1453
+
1454
+ The goal is recall + observability: the user sees not just the answer but WHY each note ranked.`
1455
+ }
1456
+ }
1457
+ ]
1458
+ }));
1459
+ // v2.4.0 — Karpathy LLM-Wiki workflow prompts.
1460
+ // Reference: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
1461
+ // Karpathy named three workflows: ingest, query, lint. We had `query` and
1462
+ // `lint` since v1.5. v2.4.0 adds `ingest`-style workflows + `compile`/
1463
+ // `synth` patterns that close the loop. Position: enquire-mcp = the
1464
+ // open-source backend for Karpathy-style LLM Wikis on top of Obsidian.
1465
+ server.registerPrompt("vault_synth", {
1466
+ title: "Synthesize a vault wiki page from sources (Karpathy-style ingest)",
1467
+ 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.",
1468
+ argsSchema: {
1469
+ source: z
1470
+ .string()
1471
+ .describe("Source content to ingest — paste a paragraph, an arXiv abstract, a URL transcript, etc."),
1472
+ target_folder: z
1473
+ .string()
1474
+ .optional()
1475
+ .describe("Where new wiki pages should land (vault-relative, default 'Wiki/')")
1476
+ }
1477
+ }, ({ source, target_folder }) => ({
1478
+ messages: [
1479
+ {
1480
+ role: "user",
1481
+ content: {
1482
+ type: "text",
1483
+ text: `Karpathy LLM-Wiki **ingest** workflow on this source:
1484
+
1485
+ \`\`\`
1486
+ ${source}
1487
+ \`\`\`
1488
+
1489
+ Steps:
1490
+
1491
+ 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).
1492
+
1493
+ 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:
1494
+ - **EXISTS** (top hit score > 0.04 and same scope) → propose an APPEND to the existing note
1495
+ - **PARTIAL** (related but doesn't cover this angle) → propose a new note that \`[[wikilinks]]\` to the existing one
1496
+ - **NEW** → propose a fresh wiki page in \`${target_folder ?? "Wiki/"}\`
1497
+
1498
+ 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.
1499
+
1500
+ 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.
1501
+
1502
+ 5. **Output a transactional plan.** Don't write yet. Output a JSON-like list:
1503
+ \`\`\`
1504
+ [
1505
+ { action: "create" | "append", path: "Wiki/Foo.md", reason: "...", body_preview: "..." },
1506
+ ...
1507
+ ]
1508
+ \`\`\`
1509
+ Then ask the user to approve. ONLY write after explicit approval, using \`obsidian_create_note\` / \`obsidian_append_to_note\`.
1510
+
1511
+ This is the Karpathy LLM-Wiki ingest loop applied to Obsidian. Goal: knowledge that compounds over time, with every claim traceable to its source.`
1512
+ }
1513
+ }
1514
+ ]
1515
+ }));
1516
+ server.registerPrompt("vault_wiki_compile", {
1517
+ title: "Compile vault index + log (Karpathy-style maintenance)",
1518
+ 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.",
1519
+ argsSchema: {
1520
+ since_minutes: z.string().optional().describe("Window for 'recently changed' notes (default 10080 = 7 days)"),
1521
+ wiki_folder: z.string().optional().describe("Wiki folder root (default 'Wiki/')")
1522
+ }
1523
+ }, ({ since_minutes, wiki_folder }) => ({
1524
+ messages: [
1525
+ {
1526
+ role: "user",
1527
+ content: {
1528
+ type: "text",
1529
+ text: `Karpathy LLM-Wiki **compile** workflow.
1530
+
1531
+ Step 1 — Scan recent changes:
1532
+ - \`obsidian_get_recent_edits since_minutes=${since_minutes ?? 10080} folder=${wiki_folder ?? "Wiki"}\`
1533
+ - For each, \`obsidian_read_note format=map\` to get headings + frontmatter only (cheap).
1534
+
1535
+ Step 2 — Regenerate index.md:
1536
+ - Group notes by frontmatter \`tags\` and by folder.
1537
+ - For each cluster (≥3 notes), produce a heading + bullet list of \`[[wikilinks]]\` to the cluster members.
1538
+ - Add a "Recent" section listing the 10 most recently modified.
1539
+ - Use \`obsidian_validate_note_proposal\` to catch any broken wikilinks BEFORE writing.
1540
+ - Write via \`obsidian_create_note overwrite=true\` to \`${wiki_folder ?? "Wiki"}/index.md\`.
1541
+
1542
+ Step 3 — Append to log.md:
1543
+ - A bullet per note touched in the window: \`- 2026-05-08 — [[NoteTitle]] (created|updated): one-line summary\`
1544
+ - Append via \`obsidian_append_to_note\`. The log accumulates compile history.
1545
+
1546
+ Step 4 — Surface gaps:
1547
+ - Run \`obsidian_lint_wiki\` to enumerate orphans / broken / stubs / stale.
1548
+ - Add the gap summary to the bottom of \`index.md\` so the next compile sees it.
1549
+
1550
+ Idempotent. Re-run weekly.`
1551
+ }
1552
+ }
1553
+ ]
1554
+ }));
1555
+ server.registerPrompt("vault_lint_extended", {
1556
+ title: "Extended vault lint (orphans + contradictions + stale claims + missing cross-refs)",
1557
+ 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).",
1558
+ argsSchema: {
1559
+ folder: z.string().optional().describe("Restrict to a folder (default whole vault)")
1560
+ }
1561
+ }, ({ folder }) => ({
1562
+ messages: [
1563
+ {
1564
+ role: "user",
1565
+ content: {
1566
+ type: "text",
1567
+ text: `Extended lint pass on${folder ? ` ${folder}` : " the whole vault"}.
1568
+
1569
+ Phase 1 — structural (\`obsidian_lint_wiki${folder ? ` folder=${folder}` : ""}\`):
1570
+ - Surface orphans / broken / stubs / stale per the existing tool. Skim the report.
1571
+
1572
+ Phase 2 — semantic contradictions:
1573
+ - For each top-30 note (by recent-edits window), pick 1-2 strong claims (declarative sentences in the body).
1574
+ - For each claim, run \`obsidian_search query="<claim paraphrased to negate>" min_signals=2\` — multi-ranker consensus on the OPPOSITE statement.
1575
+ - If a hit comes back with score > 0.04, flag as a potential contradiction. Output: A says X, B says ¬X, suggest reconciliation.
1576
+
1577
+ Phase 3 — stale claims:
1578
+ - 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").
1579
+ - If the date is > 6 months old, surface as "potentially stale: <note> claims X with date Y".
1580
+
1581
+ Phase 4 — missing cross-references:
1582
+ - For each top-15 note, get its outbound \`[[wikilinks]]\` (via \`obsidian_get_outbound_links\`).
1583
+ - Read the body. Check for wiki page TITLES (use \`obsidian_list_notes\` for the list) mentioned in plain text WITHOUT \`[[\` brackets.
1584
+ - For each, propose a rewrite that adds the brackets. \`obsidian_validate_note_proposal\` first.
1585
+
1586
+ Output: a single markdown report with sections per phase. End with the top 5 highest-leverage fixes.`
1587
+ }
1588
+ }
1589
+ ]
1590
+ }));
1591
+ server.registerPrompt("vault_capture", {
1592
+ title: "Capture a quick thought into the vault (write don't organize)",
1593
+ 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.",
1594
+ argsSchema: {
1595
+ text: z.string().describe("The thought to capture — free-form text"),
1596
+ target_hint: z
1597
+ .string()
1598
+ .optional()
1599
+ .describe("Optional hint: 'daily', 'new-note', or a path/topic to bias destination")
1600
+ }
1601
+ }, ({ text, target_hint }) => ({
1602
+ messages: [
1603
+ {
1604
+ role: "user",
1605
+ content: {
1606
+ type: "text",
1607
+ text: `Capture this thought into my vault, Mem.ai-style: figure out where it goes, propose a diff, ask before writing.
1608
+
1609
+ Thought:
1610
+ \`\`\`
1611
+ ${text}
1612
+ \`\`\`
1613
+
1614
+ Hint: ${target_hint ?? "(none — auto-detect)"}
1615
+
1616
+ Decision tree:
1617
+
1618
+ 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\`.
1619
+
1620
+ 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?"
1621
+
1622
+ 3. **New wiki page?** If thought contains 1-3 distinct concepts that don't have existing notes, run \`vault_synth\` workflow on it.
1623
+
1624
+ 4. **Inbox catch-all.** If steps 1-3 give nothing high-confidence, propose \`obsidian_create_note path="Inbox/<timestamp>-<3-word-slug>.md"\`.
1625
+
1626
+ 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.
1627
+
1628
+ Goal: zero filing burden on the user. The AI does the indexing.`
1629
+ }
1630
+ }
1631
+ ]
1632
+ }));
1633
+ // v2.5.0 — agentic prompts (Khoj parity, lite scope).
1634
+ // Agent personas + scheduled automations as prompts that orchestrate
1635
+ // existing tools. Pure agent-side: no server-side state, no LLM calls.
1636
+ // HTTP transport is a separate larger-scope sprint (planned post v2.5).
1637
+ server.registerPrompt("vault_persona_search", {
1638
+ title: "Search the vault as a named persona (folder-scoped + tuned)",
1639
+ 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.",
1640
+ argsSchema: {
1641
+ persona: z
1642
+ .string()
1643
+ .describe("Persona name + traits (e.g. 'research-assistant: cite sources, ignore drafts, tldr first')"),
1644
+ folder: z.string().describe("Folder to scope retrieval to (vault-relative)"),
1645
+ query: z.string().describe("The user's question")
1646
+ }
1647
+ }, ({ persona, folder, query }) => ({
1648
+ messages: [
1649
+ {
1650
+ role: "user",
1651
+ content: {
1652
+ type: "text",
1653
+ text: `Acting as **${persona}**, with retrieval scoped to \`${folder}\`.
1654
+
1655
+ User question: ${query}
1656
+
1657
+ Steps:
1658
+
1659
+ 1. \`obsidian_search query="${query}" folder="${folder}" limit=15\` — hybrid retrieval inside the persona's scope.
1660
+ 2. For each top-3 hit, \`obsidian_read_note\` to load the body.
1661
+ 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).
1662
+ 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?").
1663
+
1664
+ Stay in the persona for the entire response. If asked something out-of-scope (e.g. research-assistant asked about cooking), politely redirect.`
1665
+ }
1666
+ }
1667
+ ]
1668
+ }));
1669
+ server.registerPrompt("vault_automation_setup", {
1670
+ title: "Set up a scheduled vault query (Khoj-style automations)",
1671
+ 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.",
1672
+ argsSchema: {
1673
+ intent: z
1674
+ .string()
1675
+ .describe("What you want automated (e.g. 'every Monday 9am, show me all notes touched last week and highlight unresolved questions')")
1676
+ }
1677
+ }, ({ intent }) => ({
1678
+ messages: [
1679
+ {
1680
+ role: "user",
1681
+ content: {
1682
+ type: "text",
1683
+ text: `User wants this automation: "${intent}"
1684
+
1685
+ Steps:
1686
+
1687
+ 1. **Parse the intent.** Identify:
1688
+ - **Cadence:** cron expression (daily/weekly/monthly + time)
1689
+ - **Source:** which obsidian tool answers this? (\`get_recent_edits\`, \`obsidian_search\`, \`lint_wiki\`, \`paper_audit\`, etc.)
1690
+ - **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
1691
+
1692
+ 2. **Propose the automation as a JSON spec.** Example:
1693
+ \`\`\`json
1694
+ {
1695
+ "name": "weekly-review",
1696
+ "cron": "0 9 * * 1",
1697
+ "tool_sequence": [
1698
+ { "tool": "obsidian_get_recent_edits", "args": { "since_minutes": 10080 } },
1699
+ { "tool": "obsidian_open_questions", "args": { "limit": 20 } }
1700
+ ],
1701
+ "sink": { "type": "append_to_note", "path": "Daily/{{today}}.md", "header": "## Weekly review" }
1702
+ }
1703
+ \`\`\`
1704
+
1705
+ 3. **Show the spec, ask user to confirm.**
1706
+
1707
+ 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.
1708
+
1709
+ 5. **Smoke once.** Before the first scheduled run, execute the tool sequence ONCE manually so the user verifies output shape. Show the produced markdown.
1710
+
1711
+ This is the Khoj automation pattern translated to MCP: research that comes to you instead of you remembering to ask for it.`
1712
+ }
1713
+ }
1714
+ ]
1715
+ }));
1314
1716
  }
1315
1717
  function parsePositiveInt(raw, flag) {
1316
1718
  const n = Number(raw);