summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.rules/changelog/2026-03/28/03.md32
-rw-r--r--.rules/changelog/2026-03/28/04.md37
-rw-r--r--.rules/default/project_conventions.md100
-rw-r--r--src/context/obsidian-markdown-rules.json115
-rw-r--r--src/context/system-prompt.json73
-rw-r--r--src/context/tools/batch-delete-file.json25
-rw-r--r--src/context/tools/batch-edit-file.json25
-rw-r--r--src/context/tools/batch-grep-search.json25
-rw-r--r--src/context/tools/batch-move-file.json25
-rw-r--r--src/context/tools/batch-search-files.json25
-rw-r--r--src/context/tools/batch-set-frontmatter.json25
-rw-r--r--src/context/tools/create-file.json28
-rw-r--r--src/context/tools/delete-file.json24
-rw-r--r--src/context/tools/edit-file.json32
-rw-r--r--src/context/tools/get-current-note.json19
-rw-r--r--src/context/tools/grep-search.json28
-rw-r--r--src/context/tools/move-file.json28
-rw-r--r--src/context/tools/read-file.json24
-rw-r--r--src/context/tools/search-files.json24
-rw-r--r--src/context/tools/set-frontmatter.json28
-rw-r--r--src/context/vault-context-template.json13
-rw-r--r--src/ollama-client.ts193
-rw-r--r--src/tools.ts459
-rw-r--r--src/vault-context.ts33
-rw-r--r--tsconfig.json1
25 files changed, 978 insertions, 463 deletions
diff --git a/.rules/changelog/2026-03/28/03.md b/.rules/changelog/2026-03/28/03.md
new file mode 100644
index 0000000..ed76953
--- /dev/null
+++ b/.rules/changelog/2026-03/28/03.md
@@ -0,0 +1,32 @@
+# Changelog — 2026-03-28 — 03
+
+## Context Separation: Extract all AI context into JSON files
+
+### Summary
+Extracted all hardcoded AI prompts, tool definitions, tool metadata, and vault context templates from TypeScript source code into standalone JSON files under `src/context/`. This makes all AI-facing context easily discoverable and editable without needing to understand TypeScript.
+
+### New Files
+- `src/context/system-prompt.json` — structured system prompt (tool instructions, linking rules, editing workflow, batch tools, etc.)
+- `src/context/vault-context-template.json` — template for formatting vault context into the system prompt
+- `src/context/tools/search-files.json` — search_files tool definition + metadata
+- `src/context/tools/read-file.json` — read_file tool definition + metadata
+- `src/context/tools/delete-file.json` — delete_file tool definition + metadata
+- `src/context/tools/get-current-note.json` — get_current_note tool definition + metadata
+- `src/context/tools/edit-file.json` — edit_file tool definition + metadata
+- `src/context/tools/grep-search.json` — grep_search tool definition + metadata
+- `src/context/tools/create-file.json` — create_file tool definition + metadata
+- `src/context/tools/move-file.json` — move_file tool definition + metadata
+- `src/context/tools/set-frontmatter.json` — set_frontmatter tool definition + metadata
+- `src/context/tools/batch-search-files.json` — batch_search_files tool definition + metadata
+- `src/context/tools/batch-grep-search.json` — batch_grep_search tool definition + metadata
+- `src/context/tools/batch-delete-file.json` — batch_delete_file tool definition + metadata
+- `src/context/tools/batch-move-file.json` — batch_move_file tool definition + metadata
+- `src/context/tools/batch-set-frontmatter.json` — batch_set_frontmatter tool definition + metadata
+- `src/context/tools/batch-edit-file.json` — batch_edit_file tool definition + metadata
+- `.rules/default/project_conventions.md` — new project conventions doc (context separation is rule #1)
+
+### Modified Files
+- `tsconfig.json` — added `resolveJsonModule: true`
+- `src/ollama-client.ts` — imports system-prompt.json, builds TOOL_SYSTEM_PROMPT from structured JSON via `buildToolSystemPrompt()`
+- `src/vault-context.ts` — imports vault-context-template.json, uses template to format vault context
+- `src/tools.ts` — imports all 15 tool JSON files, TOOL_REGISTRY spreads JSON context and only defines runtime callbacks inline
diff --git a/.rules/changelog/2026-03/28/04.md b/.rules/changelog/2026-03/28/04.md
new file mode 100644
index 0000000..075e6eb
--- /dev/null
+++ b/.rules/changelog/2026-03/28/04.md
@@ -0,0 +1,37 @@
+# Changelog — 2026-03-28 #04
+
+## Summary
+
+Added Obsidian Markdown rules context, strengthened system prompt to improve AI tool usage behavior, and fixed "search first" bias.
+
+## New Files
+
+- **`src/context/obsidian-markdown-rules.json`** — Obsidian-specific Markdown syntax reference (wikilinks, embeds, block identifiers, frontmatter/properties, tags, callouts, highlights, comments, task lists, numbered list rules). Trimmed to only Obsidian-unique features to minimize token usage.
+
+## Modified Files
+
+### `src/context/system-prompt.json`
+
+- **Intro rewritten**: Strongly instructs the AI to USE tools proactively. Explicitly says "do NOT call search_files first" and "Never say 'I don't have access'". Vault context paths should be used directly.
+- **Added `confirmationLinks` section**: Requires AI to include wiki-links to affected files in task completion messages (e.g., "Created [[path/to/note]]" not just "Done").
+- **Added `embedVsCopy` section**: Clarifies that "embed" means `![[Note Name]]` syntax (Obsidian live preview), NOT reading and copying note text. "Copy" means duplicate the literal content.
+- **Removed `search_files` references** from `linkingToNotes`, `editingFiles`, and `searchingContents` sections to prevent the model from searching before reading.
+
+### `src/ollama-client.ts`
+
+- Imported `obsidian-markdown-rules.json`.
+- Added `buildMarkdownRulesPrompt()` function that formats the Obsidian Markdown rules JSON into a compact system prompt section.
+- Added rendering for `confirmationLinks` and `embedVsCopy` sections in `buildToolSystemPrompt()`.
+
+### Tool JSON files (7 files)
+
+Removed "as returned by search_files" / "from search_files" from tool descriptions in:
+- `read-file.json`
+- `delete-file.json`
+- `move-file.json`
+- `edit-file.json`
+- `set-frontmatter.json`
+- `batch-delete-file.json`
+- `batch-move-file.json`
+
+All now say "from the vault context or get_current_note" — no search prerequisite.
diff --git a/.rules/default/project_conventions.md b/.rules/default/project_conventions.md
new file mode 100644
index 0000000..c14b56c
--- /dev/null
+++ b/.rules/default/project_conventions.md
@@ -0,0 +1,100 @@
+# Project Conventions — AI Pulse
+
+---
+
+## 1. Context Separation (JSON Context Files)
+
+**All AI/LLM context, prompts, tool descriptions, and display text that is injected into prompts or shown to users MUST be stored in JSON files, not hardcoded in TypeScript source code.**
+
+This is the **most important convention** in this project. It ensures that anyone — including non-developers — can review and improve the context the AI receives without needing to understand TypeScript.
+
+### Directory Structure
+
+```
+src/context/
+├── system-prompt.json # The system prompt injected when tools are available
+├── vault-context-template.json # Template for formatting vault context into the system prompt
+└── tools/ # One file per tool — metadata + Ollama definition
+ ├── search-files.json
+ ├── read-file.json
+ ├── delete-file.json
+ ├── get-current-note.json
+ ├── edit-file.json
+ ├── grep-search.json
+ ├── create-file.json
+ ├── move-file.json
+ ├── set-frontmatter.json
+ ├── batch-search-files.json
+ ├── batch-grep-search.json
+ ├── batch-delete-file.json
+ ├── batch-move-file.json
+ ├── batch-set-frontmatter.json
+ └── batch-edit-file.json
+```
+
+### What Goes in JSON
+
+- Tool definitions sent to Ollama (name, description, parameters, parameter descriptions)
+- Tool metadata shown in the UI (label, friendlyName, description)
+- Tool configuration (id, requiresApproval, batchOf)
+- System prompt text and structure
+- Vault context formatting templates
+
+### What Stays in TypeScript
+
+- Runtime logic: `execute` functions, `summarize` callbacks, `approvalMessage` builders
+- Type definitions and interfaces
+- Business logic (agent loop, streaming, approval flow)
+
+### How to Add a New Tool
+
+1. Create `src/context/tools/<tool-name>.json` with id, label, description, friendlyName, requiresApproval, and the full Ollama tool definition.
+2. Import the JSON in `src/tools.ts`.
+3. Add a `TOOL_REGISTRY` entry that spreads the JSON context and adds only the runtime callbacks (`summarize`, `summarizeResult`, `execute`, and optionally `approvalMessage`).
+
+### How to Edit Context
+
+To change what the AI "knows" or how it behaves:
+1. Edit the relevant JSON file in `src/context/`.
+2. Rebuild. The changes are picked up automatically since JSON files are imported at build time.
+
+---
+
+## 2. TypeScript Standards
+
+- **Strict mode** is enabled. See `tsconfig.json` for the full list of strict flags.
+- **Never use `any`.** Use `unknown` and narrow with type guards.
+- **`resolveJsonModule`** is enabled so JSON files can be imported with type safety.
+- Follow the rules in `.rules/default/typescript.md` (if present) or the project's `.cursorrules`.
+
+---
+
+## 3. File Organization
+
+| Directory | Purpose |
+|-----------|---------|
+| `src/` | All TypeScript source code |
+| `src/context/` | JSON context files (prompts, tool definitions, templates) |
+| `src/context/tools/` | One JSON file per tool |
+| `.rules/` | Project rules, docs, and changelog |
+| `.rules/default/` | Convention documents |
+| `.rules/docs/` | API reference documentation |
+| `.rules/changelog/` | Change history |
+
+---
+
+## 4. Build System
+
+- **esbuild** bundles everything (including JSON imports) into `main.js`.
+- JSON imports are resolved at build time — no runtime file reads needed.
+- Run `npm run dev` for watch mode, `npm run build` for production.
+
+---
+
+## 5. Naming Conventions
+
+- Tool JSON files: `kebab-case.json` matching the tool id with underscores replaced by hyphens (e.g. `search_files` → `search-files.json`).
+- TypeScript files: `kebab-case.ts`.
+- Interfaces: `PascalCase`.
+- Functions and variables: `camelCase`.
+- Constants: `UPPER_SNAKE_CASE` for true module-level constants.
diff --git a/src/context/obsidian-markdown-rules.json b/src/context/obsidian-markdown-rules.json
new file mode 100644
index 0000000..a6244ce
--- /dev/null
+++ b/src/context/obsidian-markdown-rules.json
@@ -0,0 +1,115 @@
+{
+ "obsidianMarkdownRules": {
+ "header": "OBSIDIAN-SPECIFIC MARKDOWN SYNTAX:",
+ "description": "Obsidian extends standard Markdown with unique syntax. You already know standard Markdown (headings, bold, italic, lists, code blocks, tables, etc.). The rules below cover ONLY the Obsidian-specific extensions you MUST get right.",
+
+ "internalLinks": {
+ "header": "INTERNAL LINKS (WIKILINKS)",
+ "syntax": [
+ "[[Note Name]] — basic link",
+ "[[Note Name|Display Text]] — pipe separates target from display text (NO spaces around pipe)",
+ "[[Note Name#Heading]] — link to heading (hash INSIDE brackets)",
+ "[[Note Name#Heading#Subheading]] — chain hashes for nested headings",
+ "[[Note Name#^block-id]] — link to a block identifier",
+ "[[#Heading]] — link to heading in same note",
+ "[[#^block-id]] — link to block in same note"
+ ],
+ "commonMistakes": [
+ "WRONG: [[Note Name | Display Text]] with spaces around pipe. CORRECT: [[Note Name|Display Text]]",
+ "WRONG: [[Note Name]](#heading) — hash goes INSIDE brackets. CORRECT: [[Note Name#heading]]"
+ ]
+ },
+
+ "embeds": {
+ "header": "EMBEDDING FILES AND CONTENT — CRITICAL",
+ "description": "Add ! before a wikilink to EMBED content inline. This is the feature LLMs get wrong most often.",
+ "syntax": [
+ "![[Note Name]] — embed entire note content",
+ "![[Note Name#Heading]] — embed only that heading section",
+ "![[Note Name#^block-id]] — embed only that block",
+ "![[image.png]] — embed image",
+ "![[image.png|640x480]] — embed image with width x height",
+ "![[image.png|300]] — embed image width only (height scales)",
+ "![[recording.mp3]] — embed audio",
+ "![[document.pdf]] — embed PDF",
+ "![[document.pdf#page=3]] — embed PDF at page 3"
+ ],
+ "blockIdentifiers": [
+ "A ^identifier at the end of a paragraph marks it for linking/embedding: 'My text. ^my-id'",
+ "For block quotes/callouts/tables: put ^identifier on its OWN line with blank lines before and after",
+ "Valid characters: Latin letters, numbers, and hyphens only"
+ ],
+ "commonMistakes": [
+ "WRONG: ![Note](Note.md) — this is image syntax. Use ![[Note Name]] for embeds.",
+ "WRONG: {{embed: Note}} — not valid. Use ![[Note Name]].",
+ "WRONG: [[Note Name]] without ! — that is a link, not an embed. You MUST include !.",
+ "WRONG: ![[Note Name|^block-id]] — block ID goes after #. CORRECT: ![[Note Name#^block-id]]"
+ ]
+ },
+
+ "frontmatter": {
+ "header": "PROPERTIES / FRONTMATTER (YAML)",
+ "description": "YAML metadata at the very top of a note, delimited by --- lines. Must be the FIRST thing in the file.",
+ "keyRules": [
+ "Tags in frontmatter do NOT use # prefix: ' - journal' NOT ' - #journal'",
+ "Internal links in properties MUST be quoted: 'link: \"[[Note Name]]\"'",
+ "Use 'tags' and 'aliases' (plural), not 'tag' or 'alias' (deprecated)",
+ "No Markdown formatting in properties — plain data only",
+ "No content or blank lines before the opening ---"
+ ],
+ "example": "---\ntags:\n - journal\n - personal\naliases:\n - My Journal Entry\ndate: 2024-08-21\n---"
+ },
+
+ "tags": {
+ "header": "TAGS",
+ "rules": [
+ "In body text: #tagname. In frontmatter: no # prefix.",
+ "Must contain at least one non-numerical character (#1984 invalid, #y1984 valid)",
+ "No spaces — use camelCase, snake_case, or kebab-case",
+ "Nested tags use /: #inbox/to-read",
+ "Case-insensitive: #Tag and #TAG are identical"
+ ]
+ },
+
+ "callouts": {
+ "header": "CALLOUTS",
+ "description": "Styled blockquotes with a type identifier on the first line.",
+ "syntax": [
+ "> [!type] Optional Title\n> Content",
+ "> [!type]- Title — foldable, collapsed by default (NO space before -)",
+ "> [!type]+ Title — foldable, expanded by default"
+ ],
+ "types": "note, abstract/summary/tldr, info, todo, tip/hint/important, success/check/done, question/help/faq, warning/caution/attention, failure/fail/missing, danger/error, bug, example, quote/cite",
+ "commonMistakes": [
+ "WRONG: > [!type] - Title (space before -). CORRECT: > [!type]- Title",
+ "WRONG: > [! type] (space inside brackets). CORRECT: > [!type]"
+ ]
+ },
+
+ "obsidianOnlyFormatting": {
+ "header": "OBSIDIAN-ONLY FORMATTING",
+ "syntax": [
+ "==highlighted text== — highlight (Obsidian-specific)",
+ "%%comment text%% — comment visible only in editing view",
+ "%%\nBlock comment spanning multiple lines\n%%"
+ ]
+ },
+
+ "numberedLists": {
+ "header": "NUMBERED LISTS",
+ "rules": [
+ "When writing or editing numbered lists, ALWAYS ensure consecutive numbering starting from 1.",
+ "If you insert or remove items, renumber ALL subsequent items — no gaps or duplicates."
+ ]
+ },
+
+ "taskLists": {
+ "header": "TASK LISTS (OBSIDIAN-SPECIFIC STATUSES)",
+ "syntax": [
+ "- [ ] Incomplete task",
+ "- [x] Completed task",
+ "- [?] or - [-] — any character inside brackets marks custom status"
+ ]
+ }
+ }
+}
diff --git a/src/context/system-prompt.json b/src/context/system-prompt.json
new file mode 100644
index 0000000..f44e8d6
--- /dev/null
+++ b/src/context/system-prompt.json
@@ -0,0 +1,73 @@
+{
+ "toolSystemPrompt": {
+ "intro": "You are a helpful assistant with access to tools for interacting with an Obsidian vault. You MUST use your tools to fulfill user requests — do NOT tell the user to do things manually when you have a tool that can do it. If the user asks you to read, summarize, search, create, edit, move, or delete a note, USE THE APPROPRIATE TOOL immediately. Never say 'I don't have access' or 'you would need to' when a tool exists for the task. Your system prompt includes an auto-injected VAULT CONTEXT section containing the folder tree, tags, and recently modified files. This context is always up to date — it is re-collected every time the user sends a message. The vault context contains the EXACT paths of all files in the vault. USE THESE PATHS DIRECTLY — do NOT call search_files first. If the user asks about a file and you can see its path in the vault context, call read_file on it immediately. NEVER guess or fabricate file paths — always use exact paths from the vault context or get_current_note.",
+ "linkingToNotes": {
+ "header": "LINKING TO NOTES — MANDATORY FORMAT:",
+ "description": "When referencing any note that exists in the vault, you MUST use Obsidian wiki-link syntax.",
+ "format": "[[exact file path without .md extension]]",
+ "rules": [
+ "ALWAYS use the full vault-relative path minus the .md extension. Example: a file at 'projects/2024/my-note.md' MUST be linked as [[projects/2024/my-note]].",
+ "NEVER use just the basename when the file is inside a subfolder. WRONG: [[my-note]] CORRECT: [[projects/2024/my-note]]",
+ "For files in the vault root (no folder), use just the name: [[my-note]].",
+ "NEVER include the .md extension in the link: WRONG: [[my-note.md]] CORRECT: [[my-note]]",
+ "To show different display text, use a pipe: [[projects/2024/my-note|My Note]].",
+ "Get the exact path from the vault context or get_current_note output, strip the .md extension, and use that as the link target.",
+ "Link to notes whenever helpful — search results, related notes, files you read or edited. Links let the user click to navigate directly."
+ ]
+ },
+ "editingFiles": {
+ "header": "EDITING FILES — MANDATORY WORKFLOW:",
+ "description": "The edit_file tool performs a find-and-replace. You provide old_text (the exact text currently in the file) and new_text (what to replace it with). If old_text does not match the file contents exactly, the edit WILL FAIL.",
+ "steps": [
+ "Get the file path from the vault context or get_current_note.",
+ "Call read_file to see the CURRENT content of the file.",
+ "Copy the exact text you want to change from the read_file output and use it as old_text.",
+ "Call edit_file with the correct old_text and your new_text."
+ ],
+ "warnings": [
+ "NEVER skip step 2. NEVER guess what the file contains — always read it first.",
+ "If the file is empty (read_file returned no content), you may set old_text to an empty string to write initial content.",
+ "If the file is NOT empty, old_text MUST NOT be empty — copy the exact passage you want to change from the read_file output.",
+ "old_text must include enough surrounding context (a few lines) to uniquely identify the location in the file. Preserve the exact whitespace, indentation, and newlines from the read_file output."
+ ]
+ },
+ "creatingFiles": "Use create_file to make new notes. It will fail if the file already exists — use edit_file for existing files. Parent folders are created automatically.",
+ "movingFiles": "Use move_file to move or rename a file. All [[wiki-links]] across the vault are automatically updated.",
+ "searchingContents": "Use grep_search to find text inside file contents (like grep). The vault context already contains all file paths — use those paths directly with read_file. Do NOT call search_files before read_file.",
+ "frontmatterManagement": "When you read a file with read_file, its YAML frontmatter is automatically included as a parsed JSON block at the top of the output. Use set_frontmatter to add, update, or remove frontmatter properties (tags, aliases, categories, etc.). set_frontmatter is MUCH safer than edit_file for metadata changes — it preserves YAML formatting. ALWAYS prefer set_frontmatter over edit_file when modifying tags, aliases, or other frontmatter fields. RECOMMENDED: Read the file first to see existing frontmatter before calling set_frontmatter.",
+ "approvalNote": "Some tools (such as delete_file, edit_file, create_file, and move_file) require user approval before they execute. If the user declines an action, ask them why so you can better assist them.",
+ "batchTools": "When you need to perform the same type of operation on multiple files, prefer batch tools over calling individual tools repeatedly. Available batch tools: batch_search_files, batch_grep_search, batch_delete_file, batch_move_file, batch_set_frontmatter, batch_edit_file. Batch tools accept an array of operations and execute them all in one call, reporting per-item success/failure. Batch tools that modify files (delete, move, edit, set_frontmatter) require a single user approval for the entire batch. The parameters for batch tools use JSON arrays passed as strings. IMPORTANT: For batch_edit_file, you MUST still read each file first to get exact content before editing.",
+ "confirmationLinks": {
+ "header": "CONFIRMATION MESSAGES — INCLUDE WIKI-LINKS:",
+ "description": "When completing tasks that involve creating, editing, moving, or deleting files, your confirmation message MUST include wiki-links to the affected files. This lets the user click to navigate directly to the result.",
+ "rules": [
+ "After creating a file, link to it: 'Created [[path/to/new-note]] with your content.'",
+ "After editing a file, link to it: 'Updated [[path/to/note]] with the requested changes.'",
+ "After moving a file, link to the new location: 'Moved to [[new/path/to/note]].'",
+ "After deleting a file, mention the path (no link since it no longer exists): 'Deleted path/to/note.md.'",
+ "When referencing multiple files, link to each one.",
+ "NEVER say just 'Done' or 'Created a note' without linking to the specific file(s)."
+ ],
+ "examples": {
+ "wrong": "Created a new note about project planning.",
+ "correct": "Created [[projects/planning/project-notes]] with your project planning notes."
+ }
+ },
+ "embedVsCopy": {
+ "header": "EMBED vs COPY — CRITICAL DISTINCTION:",
+ "description": "When the user says 'embed' a note, they mean use the Obsidian embed syntax ![[Note Name]] which renders a live preview of the note's content. They do NOT mean read the note and paste its literal text into another file.",
+ "rules": [
+ "'Embed' = insert ![[Note Name]] so Obsidian renders the note content inline via its preview feature. Do NOT read_file and copy the text.",
+ "'Copy' or 'paste the contents' = actually read the note and insert its literal text into another file.",
+ "When the user asks to embed multiple notes, write one ![[Note Name]] line per note. Do NOT read each note and duplicate the content.",
+ "Embeds can target headings (![[Note#Heading]]) or blocks (![[Note#^block-id]]) — not just whole notes.",
+ "If unsure whether the user wants an embed or a copy, default to embed (![[Note Name]])."
+ ],
+ "examples": {
+ "userSays": "Embed my project notes into a master note",
+ "wrong": "Reading each note with read_file and pasting all the text into the master note",
+ "correct": "Writing ![[project-note-1]]\n![[project-note-2]]\n![[project-note-3]] into the master note"
+ }
+ }
+ }
+}
diff --git a/src/context/tools/batch-delete-file.json b/src/context/tools/batch-delete-file.json
new file mode 100644
index 0000000..f1e5e9a
--- /dev/null
+++ b/src/context/tools/batch-delete-file.json
@@ -0,0 +1,25 @@
+{
+ "id": "batch_delete_file",
+ "label": "Batch Delete Files",
+ "description": "Delete multiple files at once (requires approval).",
+ "friendlyName": "Batch Delete Files",
+ "requiresApproval": true,
+ "batchOf": "delete_file",
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "batch_delete_file",
+ "description": "Delete multiple files from the Obsidian vault in a single call. Files are moved to the system trash. If some files fail (e.g. not found), the operation continues with the remaining files and reports per-file results. All file paths must be exact vault-relative paths (from the vault context or get_current_note). This action requires user approval for the entire batch.",
+ "parameters": {
+ "type": "object",
+ "required": ["file_paths"],
+ "properties": {
+ "file_paths": {
+ "type": "string",
+ "description": "A JSON array of vault-relative file paths to delete. Example: [\"folder/note1.md\", \"folder/note2.md\"]"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/batch-edit-file.json b/src/context/tools/batch-edit-file.json
new file mode 100644
index 0000000..c7e5040
--- /dev/null
+++ b/src/context/tools/batch-edit-file.json
@@ -0,0 +1,25 @@
+{
+ "id": "batch_edit_file",
+ "label": "Batch Edit Files",
+ "description": "Edit multiple files at once (requires approval).",
+ "friendlyName": "Batch Edit Files",
+ "requiresApproval": true,
+ "batchOf": "edit_file",
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "batch_edit_file",
+ "description": "Edit multiple files in the Obsidian vault in a single call. Each operation performs a find-and-replace on one file. IMPORTANT: You MUST call read_file on each target file BEFORE using this tool. Copy the exact text from read_file output for each old_text. If some operations fail, the rest continue and per-file results are reported. Use this instead of calling edit_file repeatedly when making changes across multiple files. This action requires user approval for the entire batch.",
+ "parameters": {
+ "type": "object",
+ "required": ["operations"],
+ "properties": {
+ "operations": {
+ "type": "string",
+ "description": "A JSON array of edit operations. Each object must have \"file_path\", \"old_text\", and \"new_text\". Example: [{\"file_path\": \"note1.md\", \"old_text\": \"old content\", \"new_text\": \"new content\"}, {\"file_path\": \"note2.md\", \"old_text\": \"foo\", \"new_text\": \"bar\"}]"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/batch-grep-search.json b/src/context/tools/batch-grep-search.json
new file mode 100644
index 0000000..04f0740
--- /dev/null
+++ b/src/context/tools/batch-grep-search.json
@@ -0,0 +1,25 @@
+{
+ "id": "batch_grep_search",
+ "label": "Batch Search File Contents",
+ "description": "Run multiple content searches in one call.",
+ "friendlyName": "Batch Search Contents",
+ "requiresApproval": false,
+ "batchOf": "grep_search",
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "batch_grep_search",
+ "description": "Run multiple content searches across vault markdown files in a single call. Each query searches independently. Use this when you need to search for several different text patterns at once instead of calling grep_search repeatedly.",
+ "parameters": {
+ "type": "object",
+ "required": ["queries"],
+ "properties": {
+ "queries": {
+ "type": "string",
+ "description": "A JSON array of query objects. Each object must have a \"query\" field and optionally a \"file_pattern\" field. Example: [{\"query\": \"TODO\", \"file_pattern\": \"projects/\"}, {\"query\": \"meeting agenda\"}]"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/batch-move-file.json b/src/context/tools/batch-move-file.json
new file mode 100644
index 0000000..e0d6482
--- /dev/null
+++ b/src/context/tools/batch-move-file.json
@@ -0,0 +1,25 @@
+{
+ "id": "batch_move_file",
+ "label": "Batch Move/Rename Files",
+ "description": "Move or rename multiple files at once (requires approval).",
+ "friendlyName": "Batch Move Files",
+ "requiresApproval": true,
+ "batchOf": "move_file",
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "batch_move_file",
+ "description": "Move or rename multiple files in the Obsidian vault in a single call. All internal links are automatically updated for each file. If some operations fail, the rest continue and per-file results are reported. Target folders are created automatically. All file paths must be exact vault-relative paths (from the vault context or get_current_note). This action requires user approval for the entire batch.",
+ "parameters": {
+ "type": "object",
+ "required": ["operations"],
+ "properties": {
+ "operations": {
+ "type": "string",
+ "description": "A JSON array of move operations. Each object must have \"file_path\" (current path) and \"new_path\" (destination). Example: [{\"file_path\": \"old/note.md\", \"new_path\": \"new/note.md\"}, {\"file_path\": \"a.md\", \"new_path\": \"archive/a.md\"}]"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/batch-search-files.json b/src/context/tools/batch-search-files.json
new file mode 100644
index 0000000..feb8749
--- /dev/null
+++ b/src/context/tools/batch-search-files.json
@@ -0,0 +1,25 @@
+{
+ "id": "batch_search_files",
+ "label": "Batch Search File Names",
+ "description": "Run multiple file-name searches in one call.",
+ "friendlyName": "Batch Search Files",
+ "requiresApproval": false,
+ "batchOf": "search_files",
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "batch_search_files",
+ "description": "Run multiple file-name searches in a single call. Each query searches vault file names/paths independently. Use this when you need to search for several different terms at once instead of calling search_files repeatedly.",
+ "parameters": {
+ "type": "object",
+ "required": ["queries"],
+ "properties": {
+ "queries": {
+ "type": "string",
+ "description": "A JSON array of search query strings. Example: [\"meeting notes\", \"project plan\", \"2024\"]"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/batch-set-frontmatter.json b/src/context/tools/batch-set-frontmatter.json
new file mode 100644
index 0000000..eaf4a62
--- /dev/null
+++ b/src/context/tools/batch-set-frontmatter.json
@@ -0,0 +1,25 @@
+{
+ "id": "batch_set_frontmatter",
+ "label": "Batch Set Frontmatter",
+ "description": "Update frontmatter on multiple files at once (requires approval).",
+ "friendlyName": "Batch Set Frontmatter",
+ "requiresApproval": true,
+ "batchOf": "set_frontmatter",
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "batch_set_frontmatter",
+ "description": "Update YAML frontmatter properties on multiple files in a single call. Each operation specifies a file and the properties to set. Existing properties not mentioned are left unchanged. Set a value to null to remove it. If some operations fail, the rest continue and per-file results are reported. Use this instead of calling set_frontmatter repeatedly when updating multiple files. RECOMMENDED: Read files first to see existing frontmatter before updating. This action requires user approval for the entire batch.",
+ "parameters": {
+ "type": "object",
+ "required": ["operations"],
+ "properties": {
+ "operations": {
+ "type": "string",
+ "description": "A JSON array of frontmatter operations. Each object must have \"file_path\" and \"properties\" (a JSON object of key-value pairs). Example: [{\"file_path\": \"note1.md\", \"properties\": {\"tags\": [\"ai\"], \"status\": \"done\"}}, {\"file_path\": \"note2.md\", \"properties\": {\"tags\": [\"research\"]}}]"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/create-file.json b/src/context/tools/create-file.json
new file mode 100644
index 0000000..8ee2113
--- /dev/null
+++ b/src/context/tools/create-file.json
@@ -0,0 +1,28 @@
+{
+ "id": "create_file",
+ "label": "Create File",
+ "description": "Create a new file in the vault (requires approval).",
+ "friendlyName": "Create File",
+ "requiresApproval": true,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "create_file",
+ "description": "Create a new file in the Obsidian vault. Parent folders are created automatically if they don't exist. Fails if a file already exists at the path — use edit_file to modify existing files. This action requires user approval.",
+ "parameters": {
+ "type": "object",
+ "required": ["file_path"],
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "The vault-relative path for the new file (e.g. 'folder/new-note.md')."
+ },
+ "content": {
+ "type": "string",
+ "description": "The text content to write to the new file. Defaults to empty string if not provided."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/delete-file.json b/src/context/tools/delete-file.json
new file mode 100644
index 0000000..e6f0d66
--- /dev/null
+++ b/src/context/tools/delete-file.json
@@ -0,0 +1,24 @@
+{
+ "id": "delete_file",
+ "label": "Delete File",
+ "description": "Delete a file from the vault (requires approval).",
+ "friendlyName": "Delete File",
+ "requiresApproval": true,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "delete_file",
+ "description": "Delete a file from the Obsidian vault. The file is moved to the system trash. The file_path must be an exact vault-relative path (from the vault context or get_current_note). This action requires user approval.",
+ "parameters": {
+ "type": "object",
+ "required": ["file_path"],
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "The vault-relative path to the file to delete (e.g. 'folder/note.md')."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/edit-file.json b/src/context/tools/edit-file.json
new file mode 100644
index 0000000..6f3b665
--- /dev/null
+++ b/src/context/tools/edit-file.json
@@ -0,0 +1,32 @@
+{
+ "id": "edit_file",
+ "label": "Edit File",
+ "description": "Find and replace text in a vault file (requires approval).",
+ "friendlyName": "Edit File",
+ "requiresApproval": true,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "edit_file",
+ "description": "Edit a file in the Obsidian vault by finding and replacing text. IMPORTANT: You MUST call read_file on the target file BEFORE calling edit_file so you can see its exact current content. Copy the exact text you want to change from the read_file output and use it as old_text. old_text must match a passage in the file exactly (including whitespace and newlines). Only the first occurrence of old_text is replaced with new_text. SPECIAL CASE: If the file is empty (read_file returned no content), set old_text to an empty string to write initial content. If old_text is empty but the file is NOT empty, the edit will be rejected. The file_path must be an exact vault-relative path (from the vault context or get_current_note). This action requires user approval.",
+ "parameters": {
+ "type": "object",
+ "required": ["file_path", "old_text", "new_text"],
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "The vault-relative path to the file (e.g. 'folder/note.md')."
+ },
+ "old_text": {
+ "type": "string",
+ "description": "The exact text to find in the file, copied verbatim from read_file output. Include enough surrounding lines to uniquely identify the location. Preserve all whitespace and newlines exactly. Only set to an empty string when the file itself is empty."
+ },
+ "new_text": {
+ "type": "string",
+ "description": "The text to replace old_text with. Use an empty string to delete the matched text."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/get-current-note.json b/src/context/tools/get-current-note.json
new file mode 100644
index 0000000..9a0ac39
--- /dev/null
+++ b/src/context/tools/get-current-note.json
@@ -0,0 +1,19 @@
+{
+ "id": "get_current_note",
+ "label": "Get Current Note",
+ "description": "Get the file path of the currently open note.",
+ "friendlyName": "Get Current Note",
+ "requiresApproval": false,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "get_current_note",
+ "description": "Get the vault-relative file path of the note currently open in the editor. Use this to find out which note the user is looking at. Returns an exact path that can be used with read_file or edit_file.",
+ "parameters": {
+ "type": "object",
+ "required": [],
+ "properties": {}
+ }
+ }
+ }
+}
diff --git a/src/context/tools/grep-search.json b/src/context/tools/grep-search.json
new file mode 100644
index 0000000..966de46
--- /dev/null
+++ b/src/context/tools/grep-search.json
@@ -0,0 +1,28 @@
+{
+ "id": "grep_search",
+ "label": "Search File Contents",
+ "description": "Search for text across all markdown files in the vault.",
+ "friendlyName": "Search Contents",
+ "requiresApproval": false,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "grep_search",
+ "description": "Search for a text string across all markdown file contents in the vault. Returns matching lines with file paths and line numbers (e.g. 'folder/note.md:12: matching line'). Case-insensitive. Optionally filter by file path pattern.",
+ "parameters": {
+ "type": "object",
+ "required": ["query"],
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "The text to search for in file contents. Case-insensitive."
+ },
+ "file_pattern": {
+ "type": "string",
+ "description": "Optional filter: only search files whose path contains this string (e.g. 'journal/' or 'project')."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/move-file.json b/src/context/tools/move-file.json
new file mode 100644
index 0000000..6ea4dbb
--- /dev/null
+++ b/src/context/tools/move-file.json
@@ -0,0 +1,28 @@
+{
+ "id": "move_file",
+ "label": "Move/Rename File",
+ "description": "Move or rename a file and auto-update all links (requires approval).",
+ "friendlyName": "Move File",
+ "requiresApproval": true,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "move_file",
+ "description": "Move or rename a file in the Obsidian vault. All internal links throughout the vault are automatically updated to reflect the new path. Target folders are created automatically if they don't exist. The file_path must be an exact vault-relative path (from the vault context or get_current_note). This action requires user approval.",
+ "parameters": {
+ "type": "object",
+ "required": ["file_path", "new_path"],
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "The current vault-relative path of the file (e.g. 'folder/note.md')."
+ },
+ "new_path": {
+ "type": "string",
+ "description": "The new vault-relative path for the file (e.g. 'new-folder/renamed-note.md')."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/read-file.json b/src/context/tools/read-file.json
new file mode 100644
index 0000000..5d9de8c
--- /dev/null
+++ b/src/context/tools/read-file.json
@@ -0,0 +1,24 @@
+{
+ "id": "read_file",
+ "label": "Read File Contents",
+ "description": "Read the full text content of a file in the vault.",
+ "friendlyName": "Read File",
+ "requiresApproval": false,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "read_file",
+ "description": "Read the full text content of a file in the Obsidian vault. If the file has YAML frontmatter, it is also returned as a parsed JSON block at the top of the output. The file_path must be an exact vault-relative path (from the vault context or get_current_note).",
+ "parameters": {
+ "type": "object",
+ "required": ["file_path"],
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "The vault-relative path to the file (e.g. 'folder/note.md')."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/search-files.json b/src/context/tools/search-files.json
new file mode 100644
index 0000000..04d698a
--- /dev/null
+++ b/src/context/tools/search-files.json
@@ -0,0 +1,24 @@
+{
+ "id": "search_files",
+ "label": "Search File Names",
+ "description": "Search for files in the vault by name or path.",
+ "friendlyName": "Search Files",
+ "requiresApproval": false,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "search_files",
+ "description": "Search for files in the Obsidian vault by name or path. Returns a list of exact file paths. Use these exact paths for any subsequent file operations.",
+ "parameters": {
+ "type": "object",
+ "required": ["query"],
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "The search query to match against file names and paths."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/tools/set-frontmatter.json b/src/context/tools/set-frontmatter.json
new file mode 100644
index 0000000..d6af0b2
--- /dev/null
+++ b/src/context/tools/set-frontmatter.json
@@ -0,0 +1,28 @@
+{
+ "id": "set_frontmatter",
+ "label": "Set Frontmatter",
+ "description": "Add or update YAML frontmatter properties (requires approval).",
+ "friendlyName": "Set Frontmatter",
+ "requiresApproval": true,
+ "definition": {
+ "type": "function",
+ "function": {
+ "name": "set_frontmatter",
+ "description": "Add or update YAML frontmatter properties on a note. Pass a JSON object of key-value pairs to set. Existing properties not mentioned are left unchanged. Set a value to null to remove that property. Use this for tags, aliases, categories, dates, or any custom metadata. For tags, use an array of strings (e.g. [\"ai\", \"research\"]). This is safer than edit_file for metadata changes because it preserves YAML formatting. RECOMMENDED: Call read_file first to see existing frontmatter before updating. The file_path must be an exact vault-relative path (from the vault context or get_current_note). This action requires user approval.",
+ "parameters": {
+ "type": "object",
+ "required": ["file_path", "properties"],
+ "properties": {
+ "file_path": {
+ "type": "string",
+ "description": "The vault-relative path to the file (e.g. 'folder/note.md')."
+ },
+ "properties": {
+ "type": "string",
+ "description": "A JSON object of frontmatter key-value pairs to set. Example: {\"tags\": [\"ai\", \"research\"], \"category\": \"notes\", \"status\": \"draft\"}. Set a value to null to remove that property."
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/context/vault-context-template.json b/src/context/vault-context-template.json
new file mode 100644
index 0000000..949a7c4
--- /dev/null
+++ b/src/context/vault-context-template.json
@@ -0,0 +1,13 @@
+{
+ "prefix": "VAULT CONTEXT (auto-injected summary of the user's Obsidian vault):",
+ "fields": {
+ "vaultName": "Vault name: {vaultName}",
+ "totalNotes": "Total notes: {totalNotes}",
+ "totalFolders": "Total folders: {totalFolders}"
+ },
+ "sections": {
+ "folderTree": "Folder structure:\n```\n{folderTree}\n```",
+ "tagTaxonomy": "Tags in use:\n{tagTaxonomy}",
+ "recentFiles": "Recently modified notes:\n{recentFiles}"
+ }
+}
diff --git a/src/ollama-client.ts b/src/ollama-client.ts
index 0eaae74..4a184f6 100644
--- a/src/ollama-client.ts
+++ b/src/ollama-client.ts
@@ -2,6 +2,8 @@ import { Platform, requestUrl } from "obsidian";
import type { App } from "obsidian";
import type { OllamaToolDefinition } from "./tools";
import { findToolByName } from "./tools";
+import systemPromptData from "./context/system-prompt.json";
+import markdownRulesData from "./context/obsidian-markdown-rules.json";
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
@@ -103,62 +105,143 @@ interface AgentLoopOptions {
}
/**
- * System prompt injected when tools are available.
+ * Build the Obsidian Markdown rules section from the structured JSON context.
+ * Only includes Obsidian-specific syntax (wikilinks, embeds, callouts,
+ * frontmatter, tags, etc.) — standard Markdown is omitted since the model
+ * already knows it. This keeps the prompt compact.
*/
-const TOOL_SYSTEM_PROMPT =
- "You are a helpful assistant with access to tools for interacting with an Obsidian vault. " +
- "When you use the search_files tool, the results contain exact file paths. " +
- "You MUST use these exact paths when calling read_file, edit_file, or referencing files. " +
- "NEVER guess or modify file paths — always use the paths returned by search_files or get_current_note verbatim.\n\n" +
- "LINKING TO NOTES — MANDATORY FORMAT:\n" +
- "When referencing any note that exists in the vault, you MUST use Obsidian wiki-link syntax.\n" +
- "FORMAT: [[exact file path without .md extension]]\n" +
- "RULES:\n" +
- "1. ALWAYS use the full vault-relative path minus the .md extension.\n" +
- " Example: a file at 'projects/2024/my-note.md' MUST be linked as [[projects/2024/my-note]].\n" +
- "2. NEVER use just the basename when the file is inside a subfolder.\n" +
- " WRONG: [[my-note]] CORRECT: [[projects/2024/my-note]]\n" +
- "3. For files in the vault root (no folder), use just the name: [[my-note]].\n" +
- "4. NEVER include the .md extension in the link: WRONG: [[my-note.md]] CORRECT: [[my-note]]\n" +
- "5. To show different display text, use a pipe: [[projects/2024/my-note|My Note]].\n" +
- "6. Get the exact path from search_files, read_file, or get_current_note output, strip the .md extension, and use that as the link target.\n" +
- "7. Link to notes whenever helpful — search results, related notes, files you read or edited. Links let the user click to navigate directly.\n\n" +
- "EDITING FILES — MANDATORY WORKFLOW:\n" +
- "The edit_file tool performs a find-and-replace. You provide old_text (the exact text currently in the file) and new_text (what to replace it with). " +
- "If old_text does not match the file contents exactly, the edit WILL FAIL.\n" +
- "Therefore you MUST follow this sequence every time you edit a file:\n" +
- "1. Get the file path (use search_files or get_current_note).\n" +
- "2. Call read_file to see the CURRENT content of the file.\n" +
- "3. Copy the exact text you want to change from the read_file output and use it as old_text.\n" +
- "4. Call edit_file with the correct old_text and your new_text.\n" +
- "NEVER skip step 2. NEVER guess what the file contains — always read it first.\n" +
- "If the file is empty (read_file returned no content), you may set old_text to an empty string to write initial content.\n" +
- "If the file is NOT empty, old_text MUST NOT be empty — copy the exact passage you want to change from the read_file output.\n" +
- "old_text must include enough surrounding context (a few lines) to uniquely identify the location in the file. " +
- "Preserve the exact whitespace, indentation, and newlines from the read_file output.\n\n" +
- "CREATING FILES:\n" +
- "Use create_file to make new notes. It will fail if the file already exists — use edit_file for existing files. " +
- "Parent folders are created automatically.\n\n" +
- "MOVING/RENAMING FILES:\n" +
- "Use move_file to move or rename a file. All [[wiki-links]] across the vault are automatically updated.\n\n" +
- "SEARCHING FILE CONTENTS:\n" +
- "Use grep_search to find text inside file contents (like grep). " +
- "Use search_files to find files by name/path. Use grep_search to find files containing specific text.\n\n" +
- "FRONTMATTER MANAGEMENT:\n" +
- "When you read a file with read_file, its YAML frontmatter is automatically included as a parsed JSON block at the top of the output. " +
- "Use set_frontmatter to add, update, or remove frontmatter properties (tags, aliases, categories, etc.). " +
- "set_frontmatter is MUCH safer than edit_file for metadata changes \u2014 it preserves YAML formatting. " +
- "ALWAYS prefer set_frontmatter over edit_file when modifying tags, aliases, or other frontmatter fields. " +
- "RECOMMENDED: Read the file first to see existing frontmatter before calling set_frontmatter.\n\n" +
- "Some tools (such as delete_file, edit_file, create_file, and move_file) require user approval before they execute. " +
- "If the user declines an action, ask them why so you can better assist them.\n\n" +
- "BATCH TOOLS:\n" +
- "When you need to perform the same type of operation on multiple files, prefer batch tools over calling individual tools repeatedly. " +
- "Available batch tools: batch_search_files, batch_grep_search, batch_delete_file, batch_move_file, batch_set_frontmatter, batch_edit_file. " +
- "Batch tools accept an array of operations and execute them all in one call, reporting per-item success/failure. " +
- "Batch tools that modify files (delete, move, edit, set_frontmatter) require a single user approval for the entire batch. " +
- "The parameters for batch tools use JSON arrays passed as strings. " +
- "IMPORTANT: For batch_edit_file, you MUST still read each file first to get exact content before editing.";
+function buildMarkdownRulesPrompt(): string {
+ const r = markdownRulesData.obsidianMarkdownRules;
+ const sections: string[] = [];
+
+ sections.push(`${r.header}\n${r.description}`);
+
+ const fmtList = (items: string[]): string =>
+ items.map((item) => ` - ${item}`).join("\n");
+
+ const fmtMistakes = (items: string[]): string =>
+ items.map((m, i) => ` ${i + 1}. ${m}`).join("\n");
+
+ // Internal Links
+ const il = r.internalLinks;
+ sections.push(
+ `${il.header}\n${fmtList(il.syntax)}\n` +
+ `Common mistakes:\n${fmtMistakes(il.commonMistakes)}`,
+ );
+
+ // Embeds
+ const em = r.embeds;
+ sections.push(
+ `${em.header}\n${em.description}\n${fmtList(em.syntax)}\n` +
+ `Block identifiers:\n${fmtList(em.blockIdentifiers)}\n` +
+ `Common mistakes:\n${fmtMistakes(em.commonMistakes)}`,
+ );
+
+ // Frontmatter
+ const fm = r.frontmatter;
+ sections.push(
+ `${fm.header}\n${fm.description}\n` +
+ `Key rules:\n${fmtList(fm.keyRules)}\n` +
+ `Example:\n${fm.example}`,
+ );
+
+ // Tags
+ sections.push(`${r.tags.header}\n${fmtList(r.tags.rules)}`);
+
+ // Callouts
+ const co = r.callouts;
+ sections.push(
+ `${co.header}\n${co.description}\n${fmtList(co.syntax)}\n` +
+ `Types: ${co.types}\n` +
+ `Common mistakes:\n${fmtMistakes(co.commonMistakes)}`,
+ );
+
+ // Obsidian-only formatting
+ sections.push(`${r.obsidianOnlyFormatting.header}\n${fmtList(r.obsidianOnlyFormatting.syntax)}`);
+
+ // Numbered lists
+ sections.push(`${r.numberedLists.header}\n${fmtList(r.numberedLists.rules)}`);
+
+ // Task lists
+ sections.push(`${r.taskLists.header}\n${fmtList(r.taskLists.syntax)}`);
+
+ return sections.join("\n\n");
+}
+
+/**
+ * Build the system prompt from the structured JSON context.
+ */
+function buildToolSystemPrompt(): string {
+ const p = systemPromptData.toolSystemPrompt;
+ const sections: string[] = [];
+
+ sections.push(p.intro);
+
+ // Linking to notes
+ const linkRules = p.linkingToNotes.rules
+ .map((rule, i) => `${i + 1}. ${rule}`)
+ .join("\n");
+ sections.push(
+ `${p.linkingToNotes.header}\n` +
+ `${p.linkingToNotes.description}\n` +
+ `FORMAT: ${p.linkingToNotes.format}\n` +
+ `RULES:\n${linkRules}`,
+ );
+
+ // Editing files
+ const editSteps = p.editingFiles.steps
+ .map((step, i) => `${i + 1}. ${step}`)
+ .join("\n");
+ const editWarnings = p.editingFiles.warnings.join("\n");
+ sections.push(
+ `${p.editingFiles.header}\n` +
+ `${p.editingFiles.description}\n` +
+ `Therefore you MUST follow this sequence every time you edit a file:\n${editSteps}\n` +
+ editWarnings,
+ );
+
+ // Simple sections
+ sections.push(`CREATING FILES:\n${p.creatingFiles}`);
+ sections.push(`MOVING/RENAMING FILES:\n${p.movingFiles}`);
+ sections.push(`SEARCHING FILE CONTENTS:\n${p.searchingContents}`);
+ sections.push(`FRONTMATTER MANAGEMENT:\n${p.frontmatterManagement}`);
+ sections.push(p.approvalNote);
+ sections.push(`BATCH TOOLS:\n${p.batchTools}`);
+
+ // Confirmation messages with wiki-links
+ const cl = p.confirmationLinks;
+ const clRules = cl.rules
+ .map((rule, i) => `${i + 1}. ${rule}`)
+ .join("\n");
+ sections.push(
+ `${cl.header}\n` +
+ `${cl.description}\n` +
+ `RULES:\n${clRules}\n` +
+ `WRONG: ${cl.examples.wrong}\n` +
+ `CORRECT: ${cl.examples.correct}`,
+ );
+
+ // Embed vs Copy distinction
+ const ev = p.embedVsCopy;
+ const evRules = ev.rules
+ .map((rule, i) => `${i + 1}. ${rule}`)
+ .join("\n");
+ sections.push(
+ `${ev.header}\n` +
+ `${ev.description}\n` +
+ `RULES:\n${evRules}\n` +
+ `Example: User says: "${ev.examples.userSays}"\n` +
+ `WRONG: ${ev.examples.wrong}\n` +
+ `CORRECT: ${ev.examples.correct}`,
+ );
+
+ // Obsidian Markdown rules
+ sections.push(buildMarkdownRulesPrompt());
+
+ return sections.join("\n\n");
+}
+
+const TOOL_SYSTEM_PROMPT = buildToolSystemPrompt();
/**
* Shared agent loop: injects the system prompt, calls the strategy for each
diff --git a/src/tools.ts b/src/tools.ts
index 636d813..a4eb16e 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -1,6 +1,23 @@
import type { App } from "obsidian";
import { TFile } from "obsidian";
+// Tool context JSON imports
+import searchFilesCtx from "./context/tools/search-files.json";
+import readFileCtx from "./context/tools/read-file.json";
+import deleteFileCtx from "./context/tools/delete-file.json";
+import getCurrentNoteCtx from "./context/tools/get-current-note.json";
+import editFileCtx from "./context/tools/edit-file.json";
+import grepSearchCtx from "./context/tools/grep-search.json";
+import createFileCtx from "./context/tools/create-file.json";
+import moveFileCtx from "./context/tools/move-file.json";
+import setFrontmatterCtx from "./context/tools/set-frontmatter.json";
+import batchSearchFilesCtx from "./context/tools/batch-search-files.json";
+import batchGrepSearchCtx from "./context/tools/batch-grep-search.json";
+import batchDeleteFileCtx from "./context/tools/batch-delete-file.json";
+import batchMoveFileCtx from "./context/tools/batch-move-file.json";
+import batchSetFrontmatterCtx from "./context/tools/batch-set-frontmatter.json";
+import batchEditFileCtx from "./context/tools/batch-edit-file.json";
+
/**
* Schema for an Ollama tool definition (function calling).
*/
@@ -18,6 +35,28 @@ export interface OllamaToolDefinition {
}
/**
+ * Shape of a tool context JSON file.
+ */
+interface ToolContext {
+ id: string;
+ label: string;
+ description: string;
+ friendlyName: string;
+ requiresApproval: boolean;
+ batchOf?: string;
+ definition: OllamaToolDefinition;
+}
+
+/**
+ * Cast a tool context JSON import to the ToolContext type.
+ * The JSON imports are typed as their literal shapes; this asserts
+ * they conform to the ToolContext interface at the boundary.
+ */
+function asToolContext(json: Record<string, unknown>): ToolContext {
+ return json as unknown as ToolContext;
+}
+
+/**
* Metadata for a tool the user can enable/disable.
*/
export interface ToolEntry {
@@ -621,14 +660,13 @@ async function executeBatchEditFile(app: App, args: Record<string, unknown>): Pr
/**
* All available tools for the plugin.
+ * Metadata (id, label, description, friendlyName, requiresApproval, batchOf, definition)
+ * is loaded from JSON context files in src/context/tools/.
+ * Only runtime logic (summarize, summarizeResult, approvalMessage, execute) is defined here.
*/
export const TOOL_REGISTRY: ToolEntry[] = [
{
- id: "search_files",
- label: "Search File Names",
- description: "Search for files in the vault by name or path.",
- friendlyName: "Search Files",
- requiresApproval: false,
+ ...asToolContext(searchFilesCtx as Record<string, unknown>),
summarize: (args) => {
const query = typeof args.query === "string" ? args.query : "";
if (query === "" && args.queries !== undefined) {
@@ -649,31 +687,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
const count = lines.length - (moreMatch !== null ? 1 : 0) + extraCount;
return `${count} result${count === 1 ? "" : "s"} found`;
},
- definition: {
- type: "function",
- function: {
- name: "search_files",
- description: "Search for files in the Obsidian vault by name or path. Returns a list of exact file paths. Use these exact paths for any subsequent file operations.",
- parameters: {
- type: "object",
- required: ["query"],
- properties: {
- query: {
- type: "string",
- description: "The search query to match against file names and paths.",
- },
- },
- },
- },
- },
execute: executeSearchFiles,
},
{
- id: "read_file",
- label: "Read File Contents",
- description: "Read the full text content of a file in the vault.",
- friendlyName: "Read File",
- requiresApproval: false,
+ ...asToolContext(readFileCtx as Record<string, unknown>),
summarize: (args) => {
const filePath = typeof args.file_path === "string" ? args.file_path : "";
return `"/${filePath}"`;
@@ -685,31 +702,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
const lines = result.split("\n").length;
return `${lines} line${lines === 1 ? "" : "s"} read`;
},
- definition: {
- type: "function",
- function: {
- name: "read_file",
- description: "Read the full text content of a file in the Obsidian vault. If the file has YAML frontmatter, it is also returned as a parsed JSON block at the top of the output. The file_path must be an exact path as returned by search_files.",
- parameters: {
- type: "object",
- required: ["file_path"],
- properties: {
- file_path: {
- type: "string",
- description: "The vault-relative path to the file (e.g. 'folder/note.md').",
- },
- },
- },
- },
- },
execute: executeReadFile,
},
{
- id: "delete_file",
- label: "Delete File",
- description: "Delete a file from the vault (requires approval).",
- friendlyName: "Delete File",
- requiresApproval: true,
+ ...asToolContext(deleteFileCtx as Record<string, unknown>),
approvalMessage: (args) => {
const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
return `Delete "${filePath}"?`;
@@ -727,31 +723,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
}
return "File deleted";
},
- definition: {
- type: "function",
- function: {
- name: "delete_file",
- description: "Delete a file from the Obsidian vault. The file is moved to the system trash. The file_path must be an exact path as returned by search_files. This action requires user approval.",
- parameters: {
- type: "object",
- required: ["file_path"],
- properties: {
- file_path: {
- type: "string",
- description: "The vault-relative path to the file to delete (e.g. 'folder/note.md').",
- },
- },
- },
- },
- },
execute: executeDeleteFile,
},
{
- id: "get_current_note",
- label: "Get Current Note",
- description: "Get the file path of the currently open note.",
- friendlyName: "Get Current Note",
- requiresApproval: false,
+ ...asToolContext(getCurrentNoteCtx as Record<string, unknown>),
summarize: () => "Checking active note",
summarizeResult: (result) => {
if (result.startsWith("Error")) {
@@ -759,26 +734,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
}
return `"/${result}"`;
},
- definition: {
- type: "function",
- function: {
- name: "get_current_note",
- description: "Get the vault-relative file path of the note currently open in the editor. Use this to find out which note the user is looking at. Returns an exact path that can be used with read_file or edit_file.",
- parameters: {
- type: "object",
- required: [],
- properties: {},
- },
- },
- },
execute: executeGetCurrentNote,
},
{
- id: "edit_file",
- label: "Edit File",
- description: "Find and replace text in a vault file (requires approval).",
- friendlyName: "Edit File",
- requiresApproval: true,
+ ...asToolContext(editFileCtx as Record<string, unknown>),
approvalMessage: (args) => {
const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
return `Edit "${filePath}"?`;
@@ -796,47 +755,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
}
return "File edited";
},
- definition: {
- type: "function",
- function: {
- name: "edit_file",
- description: "Edit a file in the Obsidian vault by finding and replacing text. " +
- "IMPORTANT: You MUST call read_file on the target file BEFORE calling edit_file so you can see its exact current content. " +
- "Copy the exact text you want to change from the read_file output and use it as old_text. " +
- "old_text must match a passage in the file exactly (including whitespace and newlines). " +
- "Only the first occurrence of old_text is replaced with new_text. " +
- "SPECIAL CASE: If the file is empty (read_file returned no content), set old_text to an empty string to write initial content. " +
- "If old_text is empty but the file is NOT empty, the edit will be rejected. " +
- "The file_path must be an exact path from search_files or get_current_note. " +
- "This action requires user approval.",
- parameters: {
- type: "object",
- required: ["file_path", "old_text", "new_text"],
- properties: {
- file_path: {
- type: "string",
- description: "The vault-relative path to the file (e.g. 'folder/note.md').",
- },
- old_text: {
- type: "string",
- description: "The exact text to find in the file, copied verbatim from read_file output. Include enough surrounding lines to uniquely identify the location. Preserve all whitespace and newlines exactly. Only set to an empty string when the file itself is empty.",
- },
- new_text: {
- type: "string",
- description: "The text to replace old_text with. Use an empty string to delete the matched text.",
- },
- },
- },
- },
- },
execute: executeEditFile,
},
{
- id: "grep_search",
- label: "Search File Contents",
- description: "Search for text across all markdown files in the vault.",
- friendlyName: "Search Contents",
- requiresApproval: false,
+ ...asToolContext(grepSearchCtx as Record<string, unknown>),
summarize: (args) => {
const query = typeof args.query === "string" ? args.query : "";
const filePattern = typeof args.file_pattern === "string" ? args.file_pattern : "";
@@ -858,35 +780,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
const count = cappedMatch !== null ? `${cappedMatch[1] ?? "?"}+` : `${lines.length}`;
return `${count} match${lines.length === 1 ? "" : "es"} found`;
},
- definition: {
- type: "function",
- function: {
- name: "grep_search",
- description: "Search for a text string across all markdown file contents in the vault. Returns matching lines with file paths and line numbers (e.g. 'folder/note.md:12: matching line'). Case-insensitive. Optionally filter by file path pattern.",
- parameters: {
- type: "object",
- required: ["query"],
- properties: {
- query: {
- type: "string",
- description: "The text to search for in file contents. Case-insensitive.",
- },
- file_pattern: {
- type: "string",
- description: "Optional filter: only search files whose path contains this string (e.g. 'journal/' or 'project').",
- },
- },
- },
- },
- },
execute: executeGrepSearch,
},
{
- id: "create_file",
- label: "Create File",
- description: "Create a new file in the vault (requires approval).",
- friendlyName: "Create File",
- requiresApproval: true,
+ ...asToolContext(createFileCtx as Record<string, unknown>),
approvalMessage: (args) => {
const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
return `Create "${filePath}"?`;
@@ -904,35 +801,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
}
return "File created";
},
- definition: {
- type: "function",
- function: {
- name: "create_file",
- description: "Create a new file in the Obsidian vault. Parent folders are created automatically if they don't exist. Fails if a file already exists at the path — use edit_file to modify existing files. This action requires user approval.",
- parameters: {
- type: "object",
- required: ["file_path"],
- properties: {
- file_path: {
- type: "string",
- description: "The vault-relative path for the new file (e.g. 'folder/new-note.md').",
- },
- content: {
- type: "string",
- description: "The text content to write to the new file. Defaults to empty string if not provided.",
- },
- },
- },
- },
- },
execute: executeCreateFile,
},
{
- id: "move_file",
- label: "Move/Rename File",
- description: "Move or rename a file and auto-update all links (requires approval).",
- friendlyName: "Move File",
- requiresApproval: true,
+ ...asToolContext(moveFileCtx as Record<string, unknown>),
approvalMessage: (args) => {
const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
const newPath = typeof args.new_path === "string" ? args.new_path : "unknown";
@@ -941,7 +813,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [
summarize: (args) => {
const filePath = typeof args.file_path === "string" ? args.file_path : "";
const newPath = typeof args.new_path === "string" ? args.new_path : "";
- return `"/${filePath}" → "/${newPath}"`;
+ return `"/${filePath}" \u2192 "/${newPath}"`;
},
summarizeResult: (result) => {
if (result.startsWith("Error")) {
@@ -952,35 +824,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
}
return "File moved";
},
- definition: {
- type: "function",
- function: {
- name: "move_file",
- description: "Move or rename a file in the Obsidian vault. All internal links throughout the vault are automatically updated to reflect the new path. Target folders are created automatically if they don't exist. The file_path must be an exact path as returned by search_files. This action requires user approval.",
- parameters: {
- type: "object",
- required: ["file_path", "new_path"],
- properties: {
- file_path: {
- type: "string",
- description: "The current vault-relative path of the file (e.g. 'folder/note.md').",
- },
- new_path: {
- type: "string",
- description: "The new vault-relative path for the file (e.g. 'new-folder/renamed-note.md').",
- },
- },
- },
- },
- },
execute: executeMoveFile,
},
{
- id: "set_frontmatter",
- label: "Set Frontmatter",
- description: "Add or update YAML frontmatter properties (requires approval).",
- friendlyName: "Set Frontmatter",
- requiresApproval: true,
+ ...asToolContext(setFrontmatterCtx as Record<string, unknown>),
approvalMessage: (args) => {
const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
const props = typeof args.properties === "object" && args.properties !== null
@@ -993,7 +840,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [
const props = typeof args.properties === "object" && args.properties !== null
? Object.keys(args.properties as Record<string, unknown>)
: [];
- return `"/${filePath}" — ${props.join(", ")}`;
+ return `"/${filePath}" \u2014 ${props.join(", ")}`;
},
summarizeResult: (result) => {
if (result.startsWith("Error")) {
@@ -1004,46 +851,11 @@ export const TOOL_REGISTRY: ToolEntry[] = [
}
return "Frontmatter updated";
},
- definition: {
- type: "function",
- function: {
- name: "set_frontmatter",
- description: "Add or update YAML frontmatter properties on a note. " +
- "Pass a JSON object of key-value pairs to set. " +
- "Existing properties not mentioned are left unchanged. " +
- "Set a value to null to remove that property. " +
- "Use this for tags, aliases, categories, dates, or any custom metadata. " +
- "For tags, use an array of strings (e.g. [\"ai\", \"research\"]). " +
- "This is safer than edit_file for metadata changes because it preserves YAML formatting. " +
- "RECOMMENDED: Call read_file first to see existing frontmatter before updating. " +
- "The file_path must be an exact path from search_files or get_current_note. " +
- "This action requires user approval.",
- parameters: {
- type: "object",
- required: ["file_path", "properties"],
- properties: {
- file_path: {
- type: "string",
- description: "The vault-relative path to the file (e.g. 'folder/note.md').",
- },
- properties: {
- type: "string",
- description: 'A JSON object of frontmatter key-value pairs to set. Example: {"tags": ["ai", "research"], "category": "notes", "status": "draft"}. Set a value to null to remove that property.',
- },
- },
- },
- },
- },
execute: executeSetFrontmatter,
},
// --- Batch tools ---
{
- id: "batch_search_files",
- label: "Batch Search File Names",
- description: "Run multiple file-name searches in one call.",
- friendlyName: "Batch Search Files",
- requiresApproval: false,
- batchOf: "search_files",
+ ...asToolContext(batchSearchFilesCtx as Record<string, unknown>),
summarize: (args) => {
const queries = parseArrayArg(args.queries);
const count = queries !== null ? queries.length : 0;
@@ -1054,32 +866,10 @@ export const TOOL_REGISTRY: ToolEntry[] = [
const sections = result.split("--- Query").length - 1;
return `${sections} search${sections === 1 ? "" : "es"} completed`;
},
- definition: {
- type: "function",
- function: {
- name: "batch_search_files",
- description: "Run multiple file-name searches in a single call. Each query searches vault file names/paths independently. Use this when you need to search for several different terms at once instead of calling search_files repeatedly.",
- parameters: {
- type: "object",
- required: ["queries"],
- properties: {
- queries: {
- type: "string",
- description: 'A JSON array of search query strings. Example: ["meeting notes", "project plan", "2024"]',
- },
- },
- },
- },
- },
execute: executeBatchSearchFiles,
},
{
- id: "batch_grep_search",
- label: "Batch Search File Contents",
- description: "Run multiple content searches in one call.",
- friendlyName: "Batch Search Contents",
- requiresApproval: false,
- batchOf: "grep_search",
+ ...asToolContext(batchGrepSearchCtx as Record<string, unknown>),
summarize: (args) => {
const queries = parseArrayArg(args.queries);
const count = queries !== null ? queries.length : 0;
@@ -1090,36 +880,14 @@ export const TOOL_REGISTRY: ToolEntry[] = [
const sections = result.split("--- Query").length - 1;
return `${sections} search${sections === 1 ? "" : "es"} completed`;
},
- definition: {
- type: "function",
- function: {
- name: "batch_grep_search",
- description: "Run multiple content searches across vault markdown files in a single call. Each query searches independently. Use this when you need to search for several different text patterns at once instead of calling grep_search repeatedly.",
- parameters: {
- type: "object",
- required: ["queries"],
- properties: {
- queries: {
- type: "string",
- description: 'A JSON array of query objects. Each object must have a "query" field and optionally a "file_pattern" field. Example: [{"query": "TODO", "file_pattern": "projects/"}, {"query": "meeting agenda"}]',
- },
- },
- },
- },
- },
execute: executeBatchGrepSearch,
},
{
- id: "batch_delete_file",
- label: "Batch Delete Files",
- description: "Delete multiple files at once (requires approval).",
- friendlyName: "Batch Delete Files",
- requiresApproval: true,
- batchOf: "delete_file",
+ ...asToolContext(batchDeleteFileCtx as Record<string, unknown>),
approvalMessage: (args) => {
const filePaths = parseArrayArg(args.file_paths);
if (filePaths === null || filePaths.length === 0) return "Delete files?";
- const list = filePaths.map((fp) => ` • ${typeof fp === "string" ? fp : "(invalid)"}`);
+ const list = filePaths.map((fp) => ` \u2022 ${typeof fp === "string" ? fp : "(invalid)"}`);
return `Delete ${filePaths.length} file${filePaths.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
},
summarize: (args) => {
@@ -1134,41 +902,19 @@ export const TOOL_REGISTRY: ToolEntry[] = [
if (match !== null) return `${match[1]} deleted, ${match[2]} failed`;
return "Batch delete complete";
},
- definition: {
- type: "function",
- function: {
- name: "batch_delete_file",
- description: "Delete multiple files from the Obsidian vault in a single call. Files are moved to the system trash. If some files fail (e.g. not found), the operation continues with the remaining files and reports per-file results. All file paths must be exact paths as returned by search_files. This action requires user approval for the entire batch.",
- parameters: {
- type: "object",
- required: ["file_paths"],
- properties: {
- file_paths: {
- type: "string",
- description: 'A JSON array of vault-relative file paths to delete. Example: ["folder/note1.md", "folder/note2.md"]',
- },
- },
- },
- },
- },
execute: executeBatchDeleteFile,
},
{
- id: "batch_move_file",
- label: "Batch Move/Rename Files",
- description: "Move or rename multiple files at once (requires approval).",
- friendlyName: "Batch Move Files",
- requiresApproval: true,
- batchOf: "move_file",
+ ...asToolContext(batchMoveFileCtx as Record<string, unknown>),
approvalMessage: (args) => {
const operations = parseArrayArg(args.operations);
if (operations === null || operations.length === 0) return "Move files?";
const list = operations.map((op) => {
- if (typeof op !== "object" || op === null) return " • (invalid entry)";
+ if (typeof op !== "object" || op === null) return " \u2022 (invalid entry)";
const o = op as Record<string, unknown>;
const from = typeof o.file_path === "string" ? o.file_path : "?";
const to = typeof o.new_path === "string" ? o.new_path : "?";
- return ` • ${from} → ${to}`;
+ return ` \u2022 ${from} \u2192 ${to}`;
});
return `Move ${operations.length} file${operations.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
},
@@ -1184,37 +930,15 @@ export const TOOL_REGISTRY: ToolEntry[] = [
if (match !== null) return `${match[1]} moved, ${match[2]} failed`;
return "Batch move complete";
},
- definition: {
- type: "function",
- function: {
- name: "batch_move_file",
- description: "Move or rename multiple files in the Obsidian vault in a single call. All internal links are automatically updated for each file. If some operations fail, the rest continue and per-file results are reported. Target folders are created automatically. All file paths must be exact paths as returned by search_files. This action requires user approval for the entire batch.",
- parameters: {
- type: "object",
- required: ["operations"],
- properties: {
- operations: {
- type: "string",
- description: 'A JSON array of move operations. Each object must have "file_path" (current path) and "new_path" (destination). Example: [{"file_path": "old/note.md", "new_path": "new/note.md"}, {"file_path": "a.md", "new_path": "archive/a.md"}]',
- },
- },
- },
- },
- },
execute: executeBatchMoveFile,
},
{
- id: "batch_set_frontmatter",
- label: "Batch Set Frontmatter",
- description: "Update frontmatter on multiple files at once (requires approval).",
- friendlyName: "Batch Set Frontmatter",
- requiresApproval: true,
- batchOf: "set_frontmatter",
+ ...asToolContext(batchSetFrontmatterCtx as Record<string, unknown>),
approvalMessage: (args) => {
const operations = parseArrayArg(args.operations);
if (operations === null || operations.length === 0) return "Update frontmatter?";
const list = operations.map((op) => {
- if (typeof op !== "object" || op === null) return " • (invalid entry)";
+ if (typeof op !== "object" || op === null) return " \u2022 (invalid entry)";
const o = op as Record<string, unknown>;
const fp = typeof o.file_path === "string" ? o.file_path : "?";
let propsStr = "";
@@ -1226,7 +950,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [
propsStr = Object.keys(parsed).join(", ");
} catch { propsStr = "(properties)"; }
}
- return ` • ${fp}: ${propsStr}`;
+ return ` \u2022 ${fp}: ${propsStr}`;
});
return `Update frontmatter on ${operations.length} file${operations.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
},
@@ -1242,46 +966,18 @@ export const TOOL_REGISTRY: ToolEntry[] = [
if (match !== null) return `${match[1]} updated, ${match[2]} failed`;
return "Batch frontmatter update complete";
},
- definition: {
- type: "function",
- function: {
- name: "batch_set_frontmatter",
- description: "Update YAML frontmatter properties on multiple files in a single call. " +
- "Each operation specifies a file and the properties to set. " +
- "Existing properties not mentioned are left unchanged. Set a value to null to remove it. " +
- "If some operations fail, the rest continue and per-file results are reported. " +
- "Use this instead of calling set_frontmatter repeatedly when updating multiple files. " +
- "RECOMMENDED: Read files first to see existing frontmatter before updating. " +
- "This action requires user approval for the entire batch.",
- parameters: {
- type: "object",
- required: ["operations"],
- properties: {
- operations: {
- type: "string",
- description: 'A JSON array of frontmatter operations. Each object must have "file_path" and "properties" (a JSON object of key-value pairs). Example: [{"file_path": "note1.md", "properties": {"tags": ["ai"], "status": "done"}}, {"file_path": "note2.md", "properties": {"tags": ["research"]}}]',
- },
- },
- },
- },
- },
execute: executeBatchSetFrontmatter,
},
{
- id: "batch_edit_file",
- label: "Batch Edit Files",
- description: "Edit multiple files at once (requires approval).",
- friendlyName: "Batch Edit Files",
- requiresApproval: true,
- batchOf: "edit_file",
+ ...asToolContext(batchEditFileCtx as Record<string, unknown>),
approvalMessage: (args) => {
const operations = parseArrayArg(args.operations);
if (operations === null || operations.length === 0) return "Edit files?";
const list = operations.map((op) => {
- if (typeof op !== "object" || op === null) return " • (invalid entry)";
+ if (typeof op !== "object" || op === null) return " \u2022 (invalid entry)";
const o = op as Record<string, unknown>;
const fp = typeof o.file_path === "string" ? o.file_path : "?";
- return ` • ${fp}`;
+ return ` \u2022 ${fp}`;
});
return `Edit ${operations.length} file${operations.length === 1 ? "" : "s"}?\n${list.join("\n")}`;
},
@@ -1297,29 +993,6 @@ export const TOOL_REGISTRY: ToolEntry[] = [
if (match !== null) return `${match[1]} edited, ${match[2]} failed`;
return "Batch edit complete";
},
- definition: {
- type: "function",
- function: {
- name: "batch_edit_file",
- description: "Edit multiple files in the Obsidian vault in a single call. " +
- "Each operation performs a find-and-replace on one file. " +
- "IMPORTANT: You MUST call read_file on each target file BEFORE using this tool. " +
- "Copy the exact text from read_file output for each old_text. " +
- "If some operations fail, the rest continue and per-file results are reported. " +
- "Use this instead of calling edit_file repeatedly when making changes across multiple files. " +
- "This action requires user approval for the entire batch.",
- parameters: {
- type: "object",
- required: ["operations"],
- properties: {
- operations: {
- type: "string",
- description: 'A JSON array of edit operations. Each object must have "file_path", "old_text", and "new_text". Example: [{"file_path": "note1.md", "old_text": "old content", "new_text": "new content"}, {"file_path": "note2.md", "old_text": "foo", "new_text": "bar"}]',
- },
- },
- },
- },
- },
execute: executeBatchEditFile,
},
];
diff --git a/src/vault-context.ts b/src/vault-context.ts
index c20ddfa..6ac55ca 100644
--- a/src/vault-context.ts
+++ b/src/vault-context.ts
@@ -1,4 +1,5 @@
import type { App } from "obsidian";
+import vaultContextTemplate from "./context/vault-context-template.json";
/**
* Collected vault context summary injected into the AI system prompt.
@@ -152,21 +153,23 @@ export function collectVaultContext(app: App, maxRecentFiles: number): VaultCont
}
/**
- * Format the vault context into a system prompt block.
+ * Format the vault context into a system prompt block using the JSON template.
*/
export function formatVaultContext(ctx: VaultContext): string {
- return (
- "VAULT CONTEXT (auto-injected summary of the user's Obsidian vault):\n\n" +
- `Vault name: ${ctx.vaultName}\n` +
- `Total notes: ${ctx.totalNotes}\n` +
- `Total folders: ${ctx.totalFolders}\n\n` +
- "Folder structure:\n" +
- "```\n" +
- ctx.folderTree + "\n" +
- "```\n\n" +
- "Tags in use:\n" +
- ctx.tagTaxonomy + "\n\n" +
- "Recently modified notes:\n" +
- ctx.recentFiles
- );
+ const t = vaultContextTemplate;
+ const lines: string[] = [];
+
+ lines.push(t.prefix);
+ lines.push("");
+ lines.push(t.fields.vaultName.replace("{vaultName}", ctx.vaultName));
+ lines.push(t.fields.totalNotes.replace("{totalNotes}", String(ctx.totalNotes)));
+ lines.push(t.fields.totalFolders.replace("{totalFolders}", String(ctx.totalFolders)));
+ lines.push("");
+ lines.push(t.sections.folderTree.replace("{folderTree}", ctx.folderTree));
+ lines.push("");
+ lines.push(t.sections.tagTaxonomy.replace("{tagTaxonomy}", ctx.tagTaxonomy));
+ lines.push("");
+ lines.push(t.sections.recentFiles.replace("{recentFiles}", ctx.recentFiles));
+
+ return lines.join("\n");
}
diff --git a/tsconfig.json b/tsconfig.json
index ed8ae97..2c072c4 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,6 +7,7 @@
"target": "ES6",
"allowJs": true,
"strict": true,
+ "resolveJsonModule": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,