summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-24 00:25:03 +0900
committerAdam Malczewski <[email protected]>2026-03-24 00:25:03 +0900
commite2c88087f3926ec477ea099fd771d1bc9d11d7c5 (patch)
treebe33c4cdbd1fa67464779e7cd834f002c6cae438
parentdc2fa22c4d279199fb07a205a0c11eb155641f3d (diff)
downloadai-pulse-obsidian-plugin-e2c88087f3926ec477ea099fd771d1bc9d11d7c5.tar.gz
ai-pulse-obsidian-plugin-e2c88087f3926ec477ea099fd771d1bc9d11d7c5.zip
generate api docs
-rw-r--r--.gitignore46
-rw-r--r--.rules/changelog/2026-03/23/01.md14
-rw-r--r--.rules/changelog/2026-03/24/01.md16
-rw-r--r--.rules/changelog/2026-03/24/02.md17
-rw-r--r--.rules/default/obsidian.md31
-rw-r--r--.rules/default/ollama.md22
-rw-r--r--.rules/docs/obsidian/editor-api.md166
-rw-r--r--.rules/docs/obsidian/events-utilities.md330
-rw-r--r--.rules/docs/obsidian/metadata-cache.md214
-rw-r--r--.rules/docs/obsidian/plugin-lifecycle.md168
-rw-r--r--.rules/docs/obsidian/ui-components.md349
-rw-r--r--.rules/docs/obsidian/vault-api.md301
-rw-r--r--.rules/docs/obsidian/workspace-api.md221
-rw-r--r--.rules/docs/ollama/chat.md171
-rw-r--r--.rules/docs/ollama/embed.md56
-rw-r--r--.rules/docs/ollama/generate.md121
-rw-r--r--.rules/docs/ollama/list-models.md56
-rw-r--r--.rules/docs/ollama/show-model.md43
-rw-r--r--.rules/docs/ollama/version.md19
19 files changed, 2339 insertions, 22 deletions
diff --git a/.gitignore b/.gitignore
index e09a007..11416d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,24 @@
-# vscode
-.vscode
-
-# Intellij
-*.iml
-.idea
-
-# npm
-node_modules
-
-# Don't include the compiled main.js file in the repo.
-# They should be uploaded to GitHub releases instead.
-main.js
-
-# Exclude sourcemaps
-*.map
-
-# obsidian
-data.json
-
-# Exclude macOS Finder (System Explorer) View States
-.DS_Store
+# vscode
+.vscode
+
+# Intellij
+*.iml
+.idea
+
+# npm
+node_modules
+
+# Don't include the compiled main.js file in the repo.
+# They should be uploaded to GitHub releases instead.
+main.js
+
+# Exclude sourcemaps
+*.map
+
+# obsidian
+data.json
+
+# Exclude macOS Finder (System Explorer) View States
+.DS_Store
+
+obsidian-developer-docs
diff --git a/.rules/changelog/2026-03/23/01.md b/.rules/changelog/2026-03/23/01.md
new file mode 100644
index 0000000..45d46b5
--- /dev/null
+++ b/.rules/changelog/2026-03/23/01.md
@@ -0,0 +1,14 @@
+# Changelog — 2026-03-23 — 01
+
+## Added Ollama API documentation
+
+Created `.rules/docs/ollama/` with trimmed, agent-friendly API reference files:
+
+- `generate.md` — `POST /api/generate` (one-shot text generation)
+- `chat.md` — `POST /api/chat` (conversational chat with tool calling)
+- `embed.md` — `POST /api/embed` (vector embeddings)
+- `list-models.md` — `GET /api/tags` (list locally available models)
+- `show-model.md` — `POST /api/show` (model details and capabilities)
+- `version.md` — `GET /api/version` (server version / health check)
+
+All files were distilled from the official Ollama OpenAPI docs, stripped of boilerplate, and formatted as concise reference tables with examples.
diff --git a/.rules/changelog/2026-03/24/01.md b/.rules/changelog/2026-03/24/01.md
new file mode 100644
index 0000000..b461bbd
--- /dev/null
+++ b/.rules/changelog/2026-03/24/01.md
@@ -0,0 +1,16 @@
+# Changelog — 2026-03-24 #01
+
+## Created Obsidian API documentation for AI note organizer
+
+### Files Added
+- `.rules/docs/obsidian/README.md` — Compact AI-agent lookup table mapping tasks to doc files
+- `.rules/docs/obsidian/plugin-lifecycle.md` — Plugin class, manifest, settings pattern (AI organizer focused)
+- `.rules/docs/obsidian/vault-api.md` — Vault CRUD, FileManager, DataAdapter, AI batch/organize patterns
+- `.rules/docs/obsidian/metadata-cache.md` — MetadataCache, CachedMetadata, AI-specific query patterns
+- `.rules/docs/obsidian/workspace-api.md` — Workspace, leaves, custom views, MarkdownView
+- `.rules/docs/obsidian/ui-components.md` — Commands, settings tab, modals, menus, notices, ribbon
+- `.rules/docs/obsidian/editor-api.md` — Editor manipulation, markdown post processing
+- `.rules/docs/obsidian/events-utilities.md` — Events, Platform, Ollama API patterns, HTTP, utilities
+
+### Purpose
+Provide concise Obsidian plugin API reference for future AI agents building an AI-powered note management plugin with Ollama integration. Docs are self-contained — no need to reference the obsidian-developer-docs repo.
diff --git a/.rules/changelog/2026-03/24/02.md b/.rules/changelog/2026-03/24/02.md
new file mode 100644
index 0000000..9b3ff6f
--- /dev/null
+++ b/.rules/changelog/2026-03/24/02.md
@@ -0,0 +1,17 @@
+# Changelog — 2026-03-24 #02
+
+## Summary
+Created Ollama API README and moved both doc index files to `.rules/default/`.
+
+## Changes
+
+### Added
+- `.rules/default/ollama.md` — compact Ollama API doc index (new file)
+- `.rules/default/obsidian.md` — Obsidian API doc index (moved from `.rules/docs/obsidian/README.md`)
+
+### Removed
+- `.rules/docs/obsidian/README.md` — moved to `.rules/default/obsidian.md`
+- `.rules/docs/ollama/README.md` — moved to `.rules/default/ollama.md`
+
+### Modified
+- File path references in both index files updated from relative (e.g. `vault-api.md`) to full project-root-relative paths (e.g. `.rules/docs/obsidian/vault-api.md`)
diff --git a/.rules/default/obsidian.md b/.rules/default/obsidian.md
new file mode 100644
index 0000000..0330edd
--- /dev/null
+++ b/.rules/default/obsidian.md
@@ -0,0 +1,31 @@
+# Obsidian API Docs — AI Note Organizer
+
+This plugin organizes notes via AI powered by Ollama. Docs are in `.rules/docs/obsidian/`.
+
+## Where to Look
+
+| Need to... | File |
+|------------|------|
+| Set up plugin class, `onload`/`onunload`, manifest.json | `.rules/docs/obsidian/plugin-lifecycle.md` |
+| Load/save settings, `loadData()`/`saveData()` | `.rules/docs/obsidian/plugin-lifecycle.md` |
+| Read/write/create/delete/rename files | `.rules/docs/obsidian/vault-api.md` |
+| Modify frontmatter (`processFrontMatter`) | `.rules/docs/obsidian/vault-api.md` |
+| Move files into folders, auto-update links | `.rules/docs/obsidian/vault-api.md` |
+| Batch process notes, build note graph | `.rules/docs/obsidian/vault-api.md` |
+| Query tags, links, headings, frontmatter from cache | `.rules/docs/obsidian/metadata-cache.md` |
+| Find untagged/orphan notes, backlinks | `.rules/docs/obsidian/metadata-cache.md` |
+| Collect vault-wide metadata summary | `.rules/docs/obsidian/metadata-cache.md` |
+| React to file create/modify/delete/rename events | `.rules/docs/obsidian/vault-api.md` + `.rules/docs/obsidian/events-utilities.md` |
+| Wait for metadata cache readiness | `.rules/docs/obsidian/metadata-cache.md` |
+| Call Ollama API (generate, chat, embeddings, list models) | `.rules/docs/obsidian/events-utilities.md` |
+| Handle HTTP requests (`requestUrl`) | `.rules/docs/obsidian/events-utilities.md` |
+| Create custom sidebar/panel views | `.rules/docs/obsidian/workspace-api.md` |
+| Open/navigate files in workspace leaves | `.rules/docs/obsidian/workspace-api.md` |
+| Register commands (global, editor, conditional) | `.rules/docs/obsidian/ui-components.md` |
+| Build settings UI (text, toggle, dropdown, etc.) | `.rules/docs/obsidian/ui-components.md` |
+| Show modals, suggest modals, fuzzy search | `.rules/docs/obsidian/ui-components.md` |
+| Context menus, notices, ribbon, status bar | `.rules/docs/obsidian/ui-components.md` |
+| Manipulate editor content (cursor, selection, insert) | `.rules/docs/obsidian/editor-api.md` |
+| Custom markdown rendering / code blocks | `.rules/docs/obsidian/editor-api.md` |
+| Detect platform (desktop/mobile/OS) | `.rules/docs/obsidian/events-utilities.md` |
+| Use `moment.js`, debounce, protocol handlers | `.rules/docs/obsidian/events-utilities.md` |
diff --git a/.rules/default/ollama.md b/.rules/default/ollama.md
new file mode 100644
index 0000000..cf6f962
--- /dev/null
+++ b/.rules/default/ollama.md
@@ -0,0 +1,22 @@
+# Ollama API Docs — AI Note Organizer
+
+Local LLM inference via Ollama (`http://localhost:11434`). Docs are in `.rules/docs/ollama/`.
+
+## Where to Look
+
+| Need to... | File |
+|------------|------|
+| One-shot text completion, prompt/suffix, system prompt | `.rules/docs/ollama/generate.md` |
+| ModelOptions (temperature, top_k, top_p, seed, num_ctx, stop, etc.) | `.rules/docs/ollama/generate.md` |
+| Structured output via `format` (JSON / JSON schema) | `.rules/docs/ollama/generate.md` |
+| Load / unload a model (`keep_alive`) | `.rules/docs/ollama/generate.md` |
+| Streaming vs non-streaming response handling | `.rules/docs/ollama/generate.md` |
+| Thinking / reasoning traces (`think` param) | `.rules/docs/ollama/generate.md` |
+| Multi-turn conversation (chat history, roles) | `.rules/docs/ollama/chat.md` |
+| Tool / function calling (define tools, handle tool_calls) | `.rules/docs/ollama/chat.md` |
+| ChatMessage, ToolDefinition, ToolCall schemas | `.rules/docs/ollama/chat.md` |
+| Generate vector embeddings from text | `.rules/docs/ollama/embed.md` |
+| Batch embed multiple strings at once | `.rules/docs/ollama/embed.md` |
+| List locally available models | `.rules/docs/ollama/list-models.md` |
+| Get model details (parameters, template, license) | `.rules/docs/ollama/show-model.md` |
+| Check Ollama server version / health | `.rules/docs/ollama/version.md` |
diff --git a/.rules/docs/obsidian/editor-api.md b/.rules/docs/obsidian/editor-api.md
new file mode 100644
index 0000000..8db6463
--- /dev/null
+++ b/.rules/docs/obsidian/editor-api.md
@@ -0,0 +1,166 @@
+# Obsidian Editor API
+
+## Editor Class
+
+Access via `MarkdownView.editor` or through editor commands. Abstracts CodeMirror 5/6.
+
+```typescript
+// Get editor from active view
+const view = this.app.workspace.getActiveViewOfType(MarkdownView);
+if (view) {
+ const editor = view.editor;
+ // ...
+}
+
+// Or use editorCallback in commands
+this.addCommand({
+ id: 'my-cmd',
+ name: 'My Command',
+ editorCallback: (editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => {
+ // editor is available here
+ },
+});
+```
+
+### Cursor & Selection
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `getCursor(side?)` | `EditorPosition` | Get cursor position. `side`: `'from'`, `'to'`, `'head'`, `'anchor'` |
+| `setCursor(pos)` | `void` | Set cursor position |
+| `getSelection()` | `string` | Get selected text |
+| `setSelection(anchor, head?)` | `void` | Set selection range |
+| `setSelections(ranges, main?)` | `void` | Set multiple selections |
+| `listSelections()` | `EditorSelection[]` | Get all selections |
+| `somethingSelected()` | `boolean` | Check if anything is selected |
+| `wordAt(pos)` | `EditorRange \| null` | Get word boundaries at position |
+
+### EditorPosition
+
+```typescript
+interface EditorPosition {
+ line: number; // 0-based line number
+ ch: number; // 0-based character offset in line
+}
+```
+
+### Reading Content
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `getValue()` | `string` | Entire document content |
+| `getLine(line)` | `string` | Text at line (0-indexed) |
+| `getRange(from, to)` | `string` | Text in range |
+| `lineCount()` | `number` | Total lines |
+| `lastLine()` | `number` | Last line number |
+
+### Modifying Content
+
+| Method | Description |
+|--------|-------------|
+| `setValue(content)` | Replace entire document |
+| `setLine(n, text)` | Replace line content |
+| `replaceRange(text, from, to?, origin?)` | Replace text in range. If only `from`, inserts at position. |
+| `replaceSelection(text, origin?)` | Replace current selection |
+| `transaction(tx, origin?)` | Batch multiple changes atomically |
+| `processLines(read, write, ignoreEmpty?)` | Process each line |
+
+### Navigation
+
+| Method | Description |
+|--------|-------------|
+| `scrollTo(x, y)` | Scroll to position |
+| `scrollIntoView(range, center?)` | Scroll range into view |
+| `getScrollInfo()` | Get current scroll info |
+| `posToOffset(pos)` | Convert position to character offset |
+| `offsetToPos(offset)` | Convert character offset to position |
+
+### Other
+
+| Method | Description |
+|--------|-------------|
+| `focus()` / `blur()` | Focus/blur editor |
+| `hasFocus()` | Check if editor has focus |
+| `undo()` / `redo()` | Undo/redo |
+| `exec(command)` | Execute an editor command |
+| `refresh()` | Refresh editor display |
+
+### Common Patterns
+
+**Insert at cursor:**
+```typescript
+editor.replaceRange(text, editor.getCursor());
+```
+
+**Replace selection:**
+```typescript
+const sel = editor.getSelection();
+editor.replaceSelection(sel.toUpperCase());
+```
+
+**Insert at end of document:**
+```typescript
+const lastLine = editor.lastLine();
+const lastLineText = editor.getLine(lastLine);
+editor.replaceRange('\nNew content', { line: lastLine, ch: lastLineText.length });
+```
+
+## Markdown Post Processing
+
+Change how markdown renders in Reading view.
+
+### Post Processor
+
+```typescript
+this.registerMarkdownPostProcessor((element: HTMLElement, context: MarkdownPostProcessorContext) => {
+ // element = rendered HTML fragment
+ // Modify DOM as needed
+ const codeblocks = element.findAll('code');
+ for (const block of codeblocks) {
+ // Transform code blocks
+ }
+});
+```
+
+### Code Block Processor
+
+Register custom fenced code block handlers (like Mermaid):
+
+```typescript
+this.registerMarkdownCodeBlockProcessor('csv', (source: string, el: HTMLElement, ctx) => {
+ // source = raw text inside the code block
+ // el = container div (replaces <pre><code>)
+ const rows = source.split('\n').filter(r => r.length > 0);
+ const table = el.createEl('table');
+ // Build table from CSV data...
+});
+```
+
+Usage in markdown:
+````
+```csv
+Name,Age
+Alice,30
+Bob,25
+```
+````
+
+## MarkdownRenderer
+
+Static utility for rendering markdown to HTML:
+
+```typescript
+import { MarkdownRenderer } from 'obsidian';
+
+// Render markdown string to element
+await MarkdownRenderer.render(
+ this.app, // App instance
+ '**bold** text', // Markdown string
+ containerEl, // Target HTMLElement
+ sourcePath, // Source file path (for link resolution)
+ this // Component (for lifecycle management)
+);
+
+// Alternative (older API)
+await MarkdownRenderer.renderMarkdown(markdown, el, sourcePath, component);
+```
diff --git a/.rules/docs/obsidian/events-utilities.md b/.rules/docs/obsidian/events-utilities.md
new file mode 100644
index 0000000..2fa3a9e
--- /dev/null
+++ b/.rules/docs/obsidian/events-utilities.md
@@ -0,0 +1,330 @@
+# Obsidian Events, Utilities & Platform
+
+## Events System
+
+Many Obsidian classes extend `Events` (Vault, MetadataCache, Workspace, WorkspaceLeaf).
+
+### Events Base Class
+
+| Method | Description |
+|--------|-------------|
+| `on(name, callback, ctx?)` | Subscribe to event. Returns `EventRef`. |
+| `off(name, callback)` | Unsubscribe by callback |
+| `offref(ref)` | Unsubscribe by `EventRef` |
+| `trigger(name, ...data)` | Emit event |
+
+### Registering Events in Plugins
+
+**Always** use `this.registerEvent()` to auto-detach on unload:
+
+```typescript
+this.registerEvent(
+ this.app.vault.on('create', (file) => {
+ console.log('File created:', file.path);
+ })
+);
+```
+
+### Timing / Intervals
+
+Use `this.registerInterval()` for auto-cleanup:
+
+```typescript
+this.registerInterval(
+ window.setInterval(() => this.doPeriodicTask(), 60000)
+);
+```
+
+Use `window.setInterval` (not plain `setInterval`) to avoid TypeScript NodeJS/Browser confusion.
+
+### DOM Events
+
+```typescript
+this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
+ // Auto-detached on unload
+});
+```
+
+## Platform Detection
+
+```typescript
+import { Platform } from 'obsidian';
+
+Platform.isDesktop // true on desktop
+Platform.isMobile // true on mobile
+Platform.isDesktopApp // true on desktop app
+Platform.isMobileApp // true on mobile app
+Platform.isIosApp // iOS
+Platform.isAndroidApp // Android
+Platform.isPhone // phone form factor
+Platform.isTablet // tablet form factor
+Platform.isMacOS // macOS
+Platform.isWin // Windows
+Platform.isLinux // Linux
+Platform.isSafari // Safari engine
+```
+
+## HTTP Requests
+
+Use `requestUrl` for cross-platform HTTP (bypasses CORS):
+
+```typescript
+import { requestUrl, RequestUrlParam, RequestUrlResponse } from 'obsidian';
+
+const response: RequestUrlResponse = await requestUrl({
+ url: 'https://api.example.com/data',
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key: 'value' }),
+ contentType: 'application/json',
+ throw: true, // throw on 400+ status (default: true)
+});
+
+// Response properties:
+response.status; // number
+response.headers; // Record<string, string>
+response.text; // string
+response.json; // any (parsed JSON)
+response.arrayBuffer; // ArrayBuffer
+```
+
+### RequestUrlParam
+
+| Property | Type | Required | Description |
+|----------|------|----------|-------------|
+| `url` | `string` | Yes | Request URL |
+| `method` | `string` | No | HTTP method (GET, POST, etc.) |
+| `headers` | `Record<string, string>` | No | Request headers |
+| `body` | `string \| ArrayBuffer` | No | Request body |
+| `contentType` | `string` | No | Content-Type header shorthand |
+| `throw` | `boolean` | No | Throw on 400+ (default `true`) |
+
+## Moment.js
+
+Obsidian bundles Moment.js. Import directly:
+
+```typescript
+import { moment } from 'obsidian';
+
+const now = moment();
+const formatted = now.format('YYYY-MM-DD HH:mm:ss');
+const yesterday = moment().subtract(1, 'day');
+```
+
+## Debouncing
+
+```typescript
+import { debounce } from 'obsidian';
+
+const debouncedSave = debounce((data: string) => {
+ // Save logic
+}, 1000, true); // fn, wait ms, immediate?
+```
+
+Debouncer interface methods: `cancel()`, `run()` (flush immediately).
+
+## Obsidian Protocol Handler
+
+Handle `obsidian://` URLs:
+
+```typescript
+this.registerObsidianProtocolHandler('my-action', (params) => {
+ // params: ObsidianProtocolData (Record<string, string>)
+ // URL: obsidian://my-action?param1=value1&param2=value2
+ console.log(params.param1);
+});
+```
+
+## Secret Storage
+
+Secure credential storage (v1.11.4):
+
+```typescript
+const storage = this.app.secretStorage;
+
+// Store a secret
+await storage.set('api-key', 'sk-...');
+
+// Retrieve a secret
+const key = await storage.get('api-key');
+
+// Delete a secret
+await storage.delete('api-key');
+```
+
+## Ollama Communication Patterns
+
+The AI note organizer communicates with a local Ollama instance via its REST API.
+
+### Ollama API Endpoints
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `http://localhost:11434/api/generate` | POST | Single prompt completion |
+| `http://localhost:11434/api/chat` | POST | Multi-turn chat completion |
+| `http://localhost:11434/api/tags` | GET | List available models |
+| `http://localhost:11434/api/embeddings` | POST | Generate text embeddings |
+| `http://localhost:11434/api/show` | POST | Get model info |
+
+### Generate Request
+
+```typescript
+import { requestUrl } from 'obsidian';
+
+async function ollamaGenerate(
+ ollamaUrl: string,
+ model: string,
+ prompt: string,
+ system?: string
+): Promise<string> {
+ const response = await requestUrl({
+ url: `${ollamaUrl}/api/generate`,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model,
+ prompt,
+ system,
+ stream: false, // IMPORTANT: set false for non-streaming
+ }),
+ });
+ return response.json.response;
+}
+```
+
+### Chat Request
+
+```typescript
+interface OllamaMessage {
+ role: 'system' | 'user' | 'assistant';
+ content: string;
+}
+
+async function ollamaChat(
+ ollamaUrl: string,
+ model: string,
+ messages: OllamaMessage[]
+): Promise<string> {
+ const response = await requestUrl({
+ url: `${ollamaUrl}/api/chat`,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model,
+ messages,
+ stream: false,
+ }),
+ });
+ return response.json.message.content;
+}
+```
+
+### List Models
+
+```typescript
+async function ollamaListModels(ollamaUrl: string): Promise<string[]> {
+ const response = await requestUrl({
+ url: `${ollamaUrl}/api/tags`,
+ method: 'GET',
+ });
+ return response.json.models.map((m: any) => m.name);
+}
+```
+
+### Generate Embeddings
+
+```typescript
+async function ollamaEmbed(
+ ollamaUrl: string,
+ model: string,
+ input: string
+): Promise<number[]> {
+ const response = await requestUrl({
+ url: `${ollamaUrl}/api/embeddings`,
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model, prompt: input }),
+ });
+ return response.json.embedding;
+}
+```
+
+### Error Handling
+
+```typescript
+try {
+ const result = await ollamaGenerate(url, model, prompt);
+} catch (err) {
+ if (err.status === 0 || err.message?.includes('net')) {
+ new Notice('Cannot connect to Ollama. Is it running?');
+ } else {
+ new Notice(`Ollama error: ${err.message}`);
+ }
+}
+```
+
+### Settings Pattern for Ollama
+
+```typescript
+interface AISettings {
+ ollamaUrl: string; // default: 'http://localhost:11434'
+ model: string; // default: 'llama3.2'
+ embeddingModel: string; // default: 'nomic-embed-text'
+ autoProcess: boolean;
+ systemPrompt: string;
+}
+```
+
+### Note Analysis Pattern
+
+```typescript
+async function analyzeNote(app: App, settings: AISettings, file: TFile) {
+ const content = await app.vault.cachedRead(file);
+ const cache = app.metadataCache.getFileCache(file);
+
+ const prompt = `Analyze this note and suggest tags, related topics, and a brief summary.
+
+Title: ${file.basename}
+Existing tags: ${cache?.frontmatter?.tags?.join(', ') || 'none'}
+Content:
+${content}`;
+
+ const result = await ollamaGenerate(settings.ollamaUrl, settings.model, prompt);
+
+ // Parse AI response and update frontmatter
+ const parsed = JSON.parse(result); // assuming structured output
+ await app.fileManager.processFrontMatter(file, (fm) => {
+ fm.tags = parsed.tags;
+ fm.summary = parsed.summary;
+ fm.aiProcessed = new Date().toISOString();
+ });
+}
+```
+
+## Useful Import Summary
+
+```typescript
+import {
+ // Core
+ App, Plugin, Component, PluginManifest,
+ // Files
+ TFile, TFolder, TAbstractFile, Vault, FileManager,
+ // Metadata
+ MetadataCache, CachedMetadata, FrontMatterCache,
+ // Workspace
+ Workspace, WorkspaceLeaf, View, ItemView, MarkdownView,
+ // UI
+ Modal, SuggestModal, FuzzySuggestModal,
+ Setting, PluginSettingTab,
+ Menu, MenuItem, Notice,
+ // Editor
+ Editor, EditorPosition, EditorRange, MarkdownRenderer,
+ // Events
+ Events, EventRef,
+ // Utilities
+ Platform, moment, debounce, requestUrl,
+ // Types
+ Command, Hotkey, IconName, ViewCreator,
+ RequestUrlParam, RequestUrlResponse,
+} from 'obsidian';
+```
diff --git a/.rules/docs/obsidian/metadata-cache.md b/.rules/docs/obsidian/metadata-cache.md
new file mode 100644
index 0000000..8ff5cf9
--- /dev/null
+++ b/.rules/docs/obsidian/metadata-cache.md
@@ -0,0 +1,214 @@
+# Obsidian MetadataCache API
+
+Access via `this.app.metadataCache`. Extends `Events`.
+
+The MetadataCache provides parsed/indexed metadata for all vault files without reading file content directly.
+
+## Terminology
+
+- **Linktext**: Internal link with path + subpath, e.g., `My note#Heading`
+- **Linkpath / path**: The path portion of a linktext
+- **Subpath**: The heading/block ID portion of a linktext
+
+## Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `resolvedLinks` | `Record<string, Record<string, number>>` | Map: source path → { dest path → link count }. All paths are vault-absolute. |
+| `unresolvedLinks` | `Record<string, Record<string, number>>` | Map: source path → { unknown dest → count } |
+
+## Methods
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `getFileCache(file: TFile)` | `CachedMetadata \| null` | Get cached metadata for a file |
+| `getCache(path: string)` | `CachedMetadata \| null` | Get cached metadata by path (v0.14.5) |
+| `getFirstLinkpathDest(linkpath, sourcePath)` | `TFile \| null` | Resolve a link path to a file (best match). (v0.12.5) |
+| `fileToLinktext(file, sourcePath, omitMdExtension?)` | `string` | Generate linktext for a file. Uses filename if unique, full path otherwise. |
+
+## MetadataCache Events
+
+| Event | Callback Args | Description |
+|-------|--------------|-------------|
+| `'changed'` | `(file: TFile, data: string, cache: CachedMetadata)` | File indexed, cache updated. **Not fired on rename** — use vault `rename` event. |
+| `'deleted'` | `(file: TFile, prevCache: CachedMetadata \| null)` | File deleted. Previous cache provided best-effort. |
+| `'resolve'` | `(file: TFile)` | File's links resolved in `resolvedLinks`/`unresolvedLinks`. |
+| `'resolved'` | `()` | All files resolved. Fires after initial load and each subsequent modification. |
+
+```typescript
+// Example: React to metadata changes
+this.registerEvent(
+ this.app.metadataCache.on('changed', (file, data, cache) => {
+ if (cache.frontmatter?.tags?.includes('important')) {
+ console.log('Important file changed:', file.path);
+ }
+ })
+);
+```
+
+## CachedMetadata Interface
+
+Returned by `getFileCache()` / `getCache()`. All properties are optional.
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `frontmatter` | `FrontMatterCache` | Parsed YAML frontmatter as key-value object |
+| `frontmatterPosition` | `Pos` | Position of frontmatter block in file (v1.4.0) |
+| `frontmatterLinks` | `FrontmatterLinkCache[]` | Links found in frontmatter (v1.4.0) |
+| `headings` | `HeadingCache[]` | All headings |
+| `links` | `LinkCache[]` | All internal `[[links]]` |
+| `embeds` | `EmbedCache[]` | All `![[embeds]]` |
+| `tags` | `TagCache[]` | All inline `#tags` |
+| `listItems` | `ListItemCache[]` | All list items |
+| `sections` | `SectionCache[]` | Root-level markdown blocks |
+| `blocks` | `Record<string, BlockCache>` | Named blocks (by block ID) |
+| `footnotes` | `FootnoteCache[]` | Footnote definitions (v1.6.6) |
+| `footnoteRefs` | `FootnoteRefCache[]` | Footnote references (v1.8.7) |
+| `referenceLinks` | `ReferenceLinkCache[]` | Reference-style links (v1.8.7) |
+
+### FrontMatterCache
+
+A plain object with YAML frontmatter keys as properties. Access properties directly:
+
+```typescript
+const cache = this.app.metadataCache.getFileCache(file);
+if (cache?.frontmatter) {
+ const title = cache.frontmatter.title;
+ const tags = cache.frontmatter.tags; // string[]
+ const aliases = cache.frontmatter.aliases; // string[]
+}
+```
+
+### HeadingCache
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `heading` | `string` | Heading text |
+| `level` | `number` | 1-6 |
+| `position` | `Pos` | Position in file |
+
+### LinkCache
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `link` | `string` | Link destination |
+| `displayText` | `string?` | Display text if different (from `[[page|display]]`) |
+| `original` | `string` | Raw text as written in document |
+| `position` | `Pos` | Position in file |
+
+### TagCache
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `tag` | `string` | Tag including `#` prefix (e.g., `"#mytag"`) |
+| `position` | `Pos` | Position in file |
+
+### Pos (Position)
+
+```typescript
+interface Pos {
+ start: Loc; // { line: number, col: number, offset: number }
+ end: Loc;
+}
+```
+
+### FrontMatterInfo
+
+Returned by `getFrontMatterInfo(content: string)` standalone function:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `exists` | `boolean` | Whether frontmatter block exists |
+| `frontmatter` | `string` | String content of frontmatter |
+| `from` | `number` | Start offset (excluding `---`) |
+| `to` | `number` | End offset (excluding `---`) |
+| `contentStart` | `number` | Offset where frontmatter block ends (including `---`) |
+
+## Common Patterns
+
+### Get all tags for a file
+```typescript
+const cache = this.app.metadataCache.getFileCache(file);
+const inlineTags = cache?.tags?.map(t => t.tag) ?? [];
+const fmTags = cache?.frontmatter?.tags ?? [];
+const allTags = [...inlineTags, ...fmTags];
+```
+
+### Find all files linking to a file
+```typescript
+function getBacklinks(app: App, filePath: string): string[] {
+ const backlinks: string[] = [];
+ for (const [source, links] of Object.entries(app.metadataCache.resolvedLinks)) {
+ if (links[filePath]) {
+ backlinks.push(source);
+ }
+ }
+ return backlinks;
+}
+```
+
+### Resolve a link
+```typescript
+const targetFile = this.app.metadataCache.getFirstLinkpathDest('My Note', currentFile.path);
+if (targetFile) {
+ const content = await this.app.vault.cachedRead(targetFile);
+}
+```
+
+### Collect all vault metadata for AI context
+```typescript
+function collectVaultSummary(app: App): { path: string; tags: string[]; links: string[]; headings: string[] }[] {
+ return app.vault.getMarkdownFiles().map(file => {
+ const cache = app.metadataCache.getFileCache(file);
+ return {
+ path: file.path,
+ tags: [
+ ...(cache?.tags?.map(t => t.tag) ?? []),
+ ...(cache?.frontmatter?.tags ?? []),
+ ],
+ links: cache?.links?.map(l => l.link) ?? [],
+ headings: cache?.headings?.map(h => h.heading) ?? [],
+ };
+ });
+}
+```
+
+### Find untagged notes (candidates for AI tagging)
+```typescript
+function getUntaggedNotes(app: App): TFile[] {
+ return app.vault.getMarkdownFiles().filter(file => {
+ const cache = app.metadataCache.getFileCache(file);
+ const inlineTags = cache?.tags ?? [];
+ const fmTags = cache?.frontmatter?.tags ?? [];
+ return inlineTags.length === 0 && fmTags.length === 0;
+ });
+}
+```
+
+### Find orphan notes (no inbound or outbound links)
+```typescript
+function getOrphanNotes(app: App): TFile[] {
+ const { resolvedLinks } = app.metadataCache;
+ const linked = new Set<string>();
+ for (const [source, targets] of Object.entries(resolvedLinks)) {
+ if (Object.keys(targets).length > 0) linked.add(source);
+ for (const dest of Object.keys(targets)) linked.add(dest);
+ }
+ return app.vault.getMarkdownFiles().filter(f => !linked.has(f.path));
+}
+```
+
+### Wait for metadata cache to be ready
+```typescript
+// In onload(), ensure all metadata is indexed before processing:
+if (this.app.metadataCache.resolvedLinks) {
+ // Already resolved
+ this.startProcessing();
+} else {
+ this.registerEvent(
+ this.app.metadataCache.on('resolved', () => {
+ this.startProcessing();
+ })
+ );
+}
+```
diff --git a/.rules/docs/obsidian/plugin-lifecycle.md b/.rules/docs/obsidian/plugin-lifecycle.md
new file mode 100644
index 0000000..d6adaa6
--- /dev/null
+++ b/.rules/docs/obsidian/plugin-lifecycle.md
@@ -0,0 +1,168 @@
+# Obsidian Plugin Lifecycle & Structure
+
+## manifest.json
+
+Every plugin requires a `manifest.json` in the plugin root:
+
+```json
+{
+ "id": "my-plugin-id",
+ "name": "Display Name",
+ "version": "1.0.0",
+ "minAppVersion": "0.15.0",
+ "description": "What the plugin does",
+ "author": "Author Name",
+ "authorUrl": "https://example.com",
+ "isDesktopOnly": false,
+ "fundingUrl": "https://buymeacoffee.com"
+}
+```
+
+- `id` (string, required): Globally unique. Cannot contain `obsidian`. Must match plugin folder name for local dev.
+- `name` (string, required): Display name.
+- `version` (string, required): Semantic versioning `x.y.z`.
+- `minAppVersion` (string, required): Minimum Obsidian version.
+- `description` (string, required): Plugin description.
+- `isDesktopOnly` (boolean, required): Set `true` if using NodeJS/Electron APIs.
+- `fundingUrl` (string|object, optional): Single URL string or `{ "Label": "url" }` object.
+
+## Plugin Class
+
+```typescript
+import { Plugin } from 'obsidian';
+
+export default class MyPlugin extends Plugin {
+ async onload() {
+ // Called when plugin is enabled. Register all resources here.
+ }
+
+ async onunload() {
+ // Called when plugin is disabled. Release all resources.
+ }
+}
+```
+
+`Plugin extends Component`. Access `this.app` (App) and `this.manifest` (PluginManifest).
+
+### Key Plugin Methods
+
+| Method | Description |
+|--------|-------------|
+| `onload()` | Setup: register commands, views, events, settings |
+| `onunload()` | Cleanup: runs automatically for registered resources |
+| `onUserEnable()` | Called when user explicitly enables plugin. Safe to open views here. (v1.7.2) |
+| `loadData(): Promise<any>` | Load `data.json` from plugin folder |
+| `saveData(data: any): Promise<void>` | Save to `data.json` in plugin folder |
+| `onExternalSettingsChange()` | Called when `data.json` modified externally (e.g., Sync). (v1.5.7) |
+| `addCommand(command)` | Register global command (auto-prefixed with plugin id/name) |
+| `addRibbonIcon(icon, title, callback)` | Add icon to left sidebar ribbon |
+| `addStatusBarItem(): HTMLElement` | Add status bar item (desktop only) |
+| `addSettingTab(tab)` | Register settings tab |
+| `registerView(type, factory)` | Register custom view type |
+| `registerEvent(eventRef)` | Register event (auto-detached on unload) |
+| `registerDomEvent(el, type, cb)` | Register DOM event (auto-detached on unload) |
+| `registerInterval(id)` | Register interval (auto-cancelled on unload). Use `window.setInterval()`. |
+| `registerMarkdownPostProcessor(fn, sortOrder)` | Register reading-mode post processor |
+| `registerMarkdownCodeBlockProcessor(lang, handler)` | Register custom code block handler |
+| `registerEditorExtension(extension)` | Register CodeMirror 6 extension |
+| `registerEditorSuggest(suggest)` | Register live typing suggestions (v0.12.7) |
+| `registerObsidianProtocolHandler(action, handler)` | Handle `obsidian://` URLs |
+| `registerExtensions(exts, viewType)` | Register file extensions for a view type |
+| `removeCommand(commandId)` | Dynamically remove a command (v1.7.2) |
+
+## Component Class (Base)
+
+`Plugin` extends `Component`. All Component methods are available:
+
+| Method | Description |
+|--------|-------------|
+| `addChild(component)` | Add child component (loaded if parent loaded) |
+| `removeChild(component)` | Remove and unload child component |
+| `register(cb)` | Register cleanup callback for unload |
+| `load()` / `unload()` | Manually control lifecycle |
+
+## App Object
+
+Access via `this.app` in Plugin or any View. Core properties:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `vault` | `Vault` | File/folder operations |
+| `workspace` | `Workspace` | UI layout, leaves, views |
+| `metadataCache` | `MetadataCache` | Cached file metadata (links, tags, frontmatter) |
+| `fileManager` | `FileManager` | High-level file operations (rename with link updates, frontmatter) |
+| `keymap` | `Keymap` | Keyboard shortcut management |
+| `scope` | `Scope` | Current keyboard scope |
+| `secretStorage` | `SecretStorage` | Secure storage for secrets (v1.11.4) |
+
+### App Methods
+
+- `isDarkMode(): boolean` - Check if dark mode is active (v1.10.0)
+- `loadLocalStorage(key): string | null` - Load vault-specific localStorage value
+- `saveLocalStorage(key, data)` - Save vault-specific localStorage value. Pass `null` to clear.
+
+## Settings Pattern (AI Note Organizer)
+
+```typescript
+interface AIOrganizerSettings {
+ ollamaUrl: string;
+ model: string;
+ embeddingModel: string;
+ autoProcessOnCreate: boolean;
+ autoProcessOnModify: boolean;
+ excludeFolders: string[];
+ systemPrompt: string;
+ batchSize: number;
+}
+
+const DEFAULT_SETTINGS: Partial<AIOrganizerSettings> = {
+ ollamaUrl: 'http://localhost:11434',
+ model: 'llama3.2',
+ embeddingModel: 'nomic-embed-text',
+ autoProcessOnCreate: false,
+ autoProcessOnModify: false,
+ excludeFolders: ['.obsidian', 'templates'],
+ systemPrompt: 'You are a note organization assistant.',
+ batchSize: 10,
+};
+
+export default class AIOrganizer extends Plugin {
+ settings: AIOrganizerSettings;
+
+ async onload() {
+ await this.loadSettings();
+ this.addSettingTab(new AIOrganizerSettingTab(this.app, this));
+
+ // Register commands for AI operations
+ this.addCommand({
+ id: 'analyze-current-note',
+ name: 'Analyze current note with AI',
+ editorCallback: async (editor, ctx) => { /* ... */ },
+ });
+
+ this.addCommand({
+ id: 'batch-organize',
+ name: 'Batch organize all notes',
+ callback: async () => { /* ... */ },
+ });
+ }
+
+ async loadSettings() {
+ this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
+ }
+
+ async saveSettings() {
+ await this.saveData(this.settings);
+ }
+}
+```
+
+**Warning**: `Object.assign` does shallow copy. Deep copy nested objects (like `excludeFolders` array) manually.
+
+## File Structure
+
+A typical plugin folder in `.obsidian/plugins/<plugin-id>/`:
+- `manifest.json` - Plugin metadata
+- `main.js` - Compiled plugin code
+- `styles.css` - Optional CSS styles
+- `data.json` - Auto-created by `saveData()`
diff --git a/.rules/docs/obsidian/ui-components.md b/.rules/docs/obsidian/ui-components.md
new file mode 100644
index 0000000..1187e08
--- /dev/null
+++ b/.rules/docs/obsidian/ui-components.md
@@ -0,0 +1,349 @@
+# Obsidian UI Components
+
+## Commands
+
+Register via `this.addCommand()` in `onload()`. Command `id` and `name` are auto-prefixed with plugin id/name.
+
+### Command Interface
+
+| Property | Type | Required | Description |
+|----------|------|----------|-------------|
+| `id` | `string` | Yes | Globally unique ID |
+| `name` | `string` | Yes | Human-friendly name for command palette |
+| `icon` | `IconName` | No | Icon for toolbar |
+| `hotkeys` | `Hotkey[]` | No | Default hotkeys. Avoid in shared plugins. |
+| `repeatable` | `boolean` | No | Repeat on held hotkey |
+| `mobileOnly` | `boolean` | No | Mobile-only command |
+
+### Callback Types (mutually exclusive, pick one)
+
+**`callback`**: Simple global command.
+```typescript
+this.addCommand({
+ id: 'my-command',
+ name: 'My Command',
+ callback: () => { /* do something */ },
+});
+```
+
+**`checkCallback`**: Conditional command. Called twice: first with `checking=true` (return `true` if available, `false` to hide), then `checking=false` to execute.
+```typescript
+this.addCommand({
+ id: 'my-command',
+ name: 'My Command',
+ checkCallback: (checking: boolean) => {
+ const canRun = someCondition();
+ if (canRun) {
+ if (!checking) { doAction(); }
+ return true;
+ }
+ return false;
+ },
+});
+```
+
+**`editorCallback`**: Only available when editor is active. Overrides `callback`/`checkCallback`.
+```typescript
+this.addCommand({
+ id: 'my-command',
+ name: 'My Command',
+ editorCallback: (editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => {
+ const selection = editor.getSelection();
+ editor.replaceSelection(selection.toUpperCase());
+ },
+});
+```
+
+**`editorCheckCallback`**: Conditional editor command. Overrides all others.
+
+### Hotkey Format
+```typescript
+hotkeys: [{ modifiers: ['Mod', 'Shift'], key: 'a' }]
+// 'Mod' = Ctrl on Windows/Linux, Cmd on macOS
+```
+
+## Settings Tab
+
+```typescript
+import { App, PluginSettingTab, Setting } from 'obsidian';
+
+export class MySettingTab extends PluginSettingTab {
+ plugin: MyPlugin;
+
+ constructor(app: App, plugin: MyPlugin) {
+ super(app, plugin);
+ this.plugin = plugin;
+ }
+
+ display(): void {
+ const { containerEl } = this;
+ containerEl.empty();
+
+ // Section heading
+ new Setting(containerEl).setName('General').setHeading();
+
+ // Text input
+ new Setting(containerEl)
+ .setName('API Key')
+ .setDesc('Your API key')
+ .addText(text => text
+ .setPlaceholder('Enter key...')
+ .setValue(this.plugin.settings.apiKey)
+ .onChange(async (value) => {
+ this.plugin.settings.apiKey = value;
+ await this.plugin.saveSettings();
+ }));
+ }
+}
+```
+
+Register: `this.addSettingTab(new MySettingTab(this.app, this));`
+
+### Setting Class
+
+Constructor: `new Setting(containerEl)`. Chainable methods:
+
+| Method | Description |
+|--------|-------------|
+| `setName(name)` | Set setting name |
+| `setDesc(desc)` | Set description (string or DocumentFragment) |
+| `setHeading()` | Make this a section heading |
+| `setClass(cls)` | Add CSS class |
+| `setDisabled(disabled)` | Disable setting (v1.2.3) |
+| `setTooltip(tooltip, options?)` | Add tooltip (v1.1.0) |
+| `clear()` | Clear all components |
+| `then(cb)` | Chain callback |
+
+### Input Components
+
+| Method | Component | Description |
+|--------|-----------|-------------|
+| `addText(cb)` | `TextComponent` | Single-line text input |
+| `addTextArea(cb)` | `TextAreaComponent` | Multi-line text |
+| `addSearch(cb)` | `SearchComponent` | Searchable input |
+| `addToggle(cb)` | `ToggleComponent` | Boolean toggle |
+| `addDropdown(cb)` | `DropdownComponent` | Select dropdown. `.addOption(value, display)` |
+| `addSlider(cb)` | `SliderComponent` | Numeric slider. `.setDynamicTooltip()` |
+| `addButton(cb)` | `ButtonComponent` | Button. `.setButtonText()`, `.setCta()`, `.onClick()` |
+| `addExtraButton(cb)` | `ExtraButtonComponent` | Small icon button. `.setIcon()` |
+| `addColorPicker(cb)` | `ColorComponent` | Color picker. `.setValue('#hex')` |
+| `addProgressBar(cb)` | `ProgressBarComponent` | Progress bar. `.setValue(0-100)` |
+| `addMomentFormat(cb)` | `MomentFormatComponent` | Date format with live preview |
+| `addComponent(cb)` | Any `BaseComponent` | Custom component (v1.11.0) |
+
+Common component methods: `.setValue()`, `.getValue()`, `.onChange(cb)`, `.setPlaceholder()`, `.setDisabled()`.
+
+## Modals
+
+### Basic Modal
+
+```typescript
+import { App, Modal, Setting } from 'obsidian';
+
+export class MyModal extends Modal {
+ result: string;
+ onSubmit: (result: string) => void;
+
+ constructor(app: App, onSubmit: (result: string) => void) {
+ super(app);
+ this.onSubmit = onSubmit;
+ this.setTitle('Enter Value');
+
+ new Setting(this.contentEl)
+ .setName('Value')
+ .addText(text => text.onChange(v => { this.result = v; }));
+
+ new Setting(this.contentEl)
+ .addButton(btn => btn.setButtonText('Submit').setCta()
+ .onClick(() => { this.close(); this.onSubmit(this.result); }));
+ }
+}
+
+// Usage:
+new MyModal(this.app, (result) => {
+ new Notice(`Got: ${result}`);
+}).open();
+```
+
+### Modal Properties & Methods
+
+| Property/Method | Description |
+|-----------------|-------------|
+| `app` | App reference |
+| `containerEl` / `modalEl` / `contentEl` / `titleEl` | DOM elements |
+| `scope` | Keyboard scope |
+| `open()` | Show the modal |
+| `close()` | Hide the modal |
+| `onOpen()` | Override for setup |
+| `onClose()` | Override for cleanup |
+| `setTitle(title)` | Set title text |
+| `setContent(content)` | Set content (string or DocumentFragment) |
+| `setCloseCallback(cb)` | Custom close handler (v1.10.0) |
+
+### SuggestModal<T>
+
+List selection modal. User types to filter.
+
+```typescript
+export class FileSuggestModal extends SuggestModal<TFile> {
+ getSuggestions(query: string): TFile[] {
+ return this.app.vault.getMarkdownFiles()
+ .filter(f => f.path.toLowerCase().includes(query.toLowerCase()));
+ }
+
+ renderSuggestion(file: TFile, el: HTMLElement) {
+ el.createEl('div', { text: file.basename });
+ el.createEl('small', { text: file.path });
+ }
+
+ onChooseSuggestion(file: TFile, evt: MouseEvent | KeyboardEvent) {
+ new Notice(`Selected: ${file.path}`);
+ }
+}
+```
+
+Additional properties: `inputEl`, `resultContainerEl`, `emptyStateText`, `limit`.
+Methods: `setPlaceholder()`, `setInstructions()`.
+
+### FuzzySuggestModal<T>
+
+Fuzzy search modal. Only need `getItems()`, `getItemText()`, `onChooseItem()`.
+
+```typescript
+export class MyFuzzyModal extends FuzzySuggestModal<TFile> {
+ getItems(): TFile[] {
+ return this.app.vault.getMarkdownFiles();
+ }
+ getItemText(file: TFile): string {
+ return file.path;
+ }
+ onChooseItem(file: TFile, evt: MouseEvent | KeyboardEvent) {
+ new Notice(`Selected: ${file.path}`);
+ }
+}
+```
+
+## Context Menus
+
+```typescript
+import { Menu, Notice } from 'obsidian';
+
+// Custom menu
+const menu = new Menu();
+menu.addItem(item => item.setTitle('Action').setIcon('icon-name').onClick(() => { ... }));
+menu.addSeparator();
+menu.addItem(item => item.setTitle('Another').onClick(() => { ... }));
+menu.showAtMouseEvent(event);
+// Or: menu.showAtPosition({ x: 20, y: 20 });
+```
+
+### Menu Methods
+
+| Method | Description |
+|--------|-------------|
+| `addItem(cb: (item: MenuItem) => any)` | Add menu item |
+| `addSeparator()` | Add separator |
+| `showAtMouseEvent(evt)` | Show at mouse position |
+| `showAtPosition(pos, doc?)` | Show at `{x, y}` position |
+| `close()` / `hide()` | Close menu |
+| `onHide(callback)` | Called when menu hides |
+| `setNoIcon()` | Remove icon column |
+| `setUseNativeMenu(bool)` | Force native/DOM menu (desktop) |
+
+### MenuItem Methods
+
+| Method | Description |
+|--------|-------------|
+| `setTitle(title)` | Item label |
+| `setIcon(icon)` | Item icon |
+| `onClick(callback)` | Click handler |
+| `setChecked(checked)` | Show checkmark |
+| `setDisabled(disabled)` | Disable item |
+| `setSection(section)` | Set section (for ordering) |
+| `setWarning(isWarning)` | Warning style |
+
+### Adding to Built-in Menus
+
+```typescript
+// File context menu
+this.registerEvent(
+ this.app.workspace.on('file-menu', (menu, file) => {
+ menu.addItem(item => item.setTitle('My Action').onClick(() => { ... }));
+ })
+);
+
+// Editor context menu
+this.registerEvent(
+ this.app.workspace.on('editor-menu', (menu, editor, view) => {
+ menu.addItem(item => item.setTitle('My Action').onClick(() => { ... }));
+ })
+);
+```
+
+## Notice (Toast Notifications)
+
+```typescript
+import { Notice } from 'obsidian';
+
+// Basic notice (auto-hides)
+new Notice('Operation complete!');
+
+// Custom duration (ms). 0 = stays until clicked.
+new Notice('Important message', 10000);
+
+// Access elements
+const notice = new Notice('Processing...');
+notice.setMessage('Done!'); // Update message
+notice.hide(); // Hide programmatically
+// notice.noticeEl, notice.messageEl, notice.containerEl — DOM access
+```
+
+## Ribbon Actions
+
+```typescript
+this.addRibbonIcon('dice', 'Tooltip text', (evt: MouseEvent) => {
+ console.log('Ribbon clicked');
+});
+```
+
+First arg is icon name. Users can remove ribbon icons, so provide alternative access (commands).
+
+## Status Bar (Desktop Only)
+
+```typescript
+const statusBarEl = this.addStatusBarItem();
+statusBarEl.createEl('span', { text: 'Status text' });
+```
+
+Multiple items get auto-spaced. Group elements in one item for custom spacing.
+
+## HTML Elements
+
+Obsidian extends `HTMLElement` with helper methods:
+
+```typescript
+// Create elements
+containerEl.createEl('h1', { text: 'Title' });
+containerEl.createEl('div', { cls: 'my-class', text: 'Content' });
+containerEl.createEl('a', { text: 'Link', attr: { href: '...', target: '_blank' } });
+
+// Nested
+const parent = containerEl.createEl('div');
+parent.createEl('span', { text: 'Child' });
+
+// createSpan / createDiv shortcuts
+containerEl.createDiv({ cls: 'wrapper' });
+
+// Conditional CSS class
+el.toggleClass('active', isActive);
+
+// Clear contents
+containerEl.empty();
+```
+
+### Styling
+
+Add `styles.css` in plugin root. Use Obsidian CSS variables for theme compatibility:
+- `--background-modifier-border` — border color
+- `--text-muted` — muted text color
+- Many more available (see Obsidian CSS variable reference)
diff --git a/.rules/docs/obsidian/vault-api.md b/.rules/docs/obsidian/vault-api.md
new file mode 100644
index 0000000..feaf08e
--- /dev/null
+++ b/.rules/docs/obsidian/vault-api.md
@@ -0,0 +1,301 @@
+# Obsidian Vault API — Files & Folders
+
+## TAbstractFile (abstract base)
+
+Base class for both files and folders. Check type with `instanceof TFile` or `instanceof TFolder`.
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `name` | `string` | Filename with extension (e.g., `"note.md"`) |
+| `path` | `string` | Vault-relative path (e.g., `"folder/note.md"`) |
+| `parent` | `TFolder \| null` | Parent folder (`null` for vault root) |
+| `vault` | `Vault` | Reference to the vault |
+
+## TFile
+
+Represents a file. Extends `TAbstractFile`.
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `basename` | `string` | Filename without extension (e.g., `"note"`) |
+| `extension` | `string` | File extension (e.g., `"md"`) |
+| `stat` | `FileStats` | File statistics |
+
+### FileStats
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ctime` | `number` | Creation time (unix ms) |
+| `mtime` | `number` | Last modified time (unix ms) |
+| `size` | `number` | Size in bytes |
+
+## TFolder
+
+Represents a folder. Extends `TAbstractFile`.
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `children` | `TAbstractFile[]` | Direct children (files and subfolders) |
+
+| Method | Description |
+|--------|-------------|
+| `isRoot(): boolean` | Whether this is the vault root folder |
+
+## Vault Class
+
+Access via `this.app.vault`. Extends `Events`.
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `adapter` | `DataAdapter` | Low-level file system access |
+| `configDir` | `string` | Config folder path (usually `.obsidian`) |
+
+### Querying Files
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `getFiles()` | `TFile[]` | All files in vault |
+| `getMarkdownFiles()` | `TFile[]` | All `.md` files |
+| `getAllLoadedFiles()` | `TAbstractFile[]` | All files and folders |
+| `getAllFolders(includeRoot?)` | `TFolder[]` | All folders |
+| `getAbstractFileByPath(path)` | `TAbstractFile \| null` | Get file or folder by path |
+| `getFileByPath(path)` | `TFile \| null` | Get file by path |
+| `getFolderByPath(path)` | `TFolder \| null` | Get folder by path |
+| `getRoot()` | `TFolder` | Vault root folder |
+| `getName()` | `string` | Vault name |
+| `getResourcePath(file)` | `string` | URI for browser engine (e.g., images) |
+
+### Reading Files
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `read(file)` | `Promise<string>` | Read from disk. Use when you intend to modify the content. |
+| `cachedRead(file)` | `Promise<string>` | Read from cache. Better performance for display-only. |
+| `readBinary(file)` | `Promise<ArrayBuffer>` | Read binary file |
+
+**When to use which**: Use `cachedRead()` for display. Use `read()` if you'll modify and write back (avoids stale data). Cache is flushed on save or external change.
+
+### Writing Files
+
+| Method | Description |
+|--------|-------------|
+| `create(path, data, options?)` | Create new plaintext file. Returns `Promise<TFile>`. |
+| `createBinary(path, data, options?)` | Create new binary file |
+| `createFolder(path)` | Create new folder |
+| `modify(file, data, options?)` | Overwrite file content |
+| `modifyBinary(file, data, options?)` | Overwrite binary content |
+| `append(file, data, options?)` | Append text to file |
+| `appendBinary(file, data, options?)` | Append binary data |
+| `process(file, fn, options?)` | **Preferred**: Atomically read-modify-save. `fn(data) => newData` is synchronous. |
+
+**Important**: Always prefer `process()` over separate `read()`/`modify()` to prevent data loss from concurrent changes.
+
+#### Async Modification Pattern
+
+```typescript
+// For async operations between read and write:
+const content = await vault.cachedRead(file);
+const newContent = await transformAsync(content);
+await vault.process(file, (data) => {
+ if (data !== content) {
+ // File changed since we read it — handle conflict
+ return data; // or merge, or prompt user
+ }
+ return newContent;
+});
+```
+
+### Moving/Renaming/Deleting Files
+
+| Method | Description |
+|--------|-------------|
+| `rename(file, newPath)` | Rename/move. **Does NOT update links.** Use `FileManager.renameFile()` instead. |
+| `copy(file, newPath)` | Copy file or folder |
+| `delete(file, force?)` | Permanently delete |
+| `trash(file, system?)` | Move to trash. `system=true` for OS trash, `false` for `.trash/` folder. |
+
+### Static Methods
+
+| Method | Description |
+|--------|-------------|
+| `Vault.recurseChildren(root, cb)` | Recursively iterate all children |
+
+### Vault Events
+
+Subscribe via `this.app.vault.on(...)`. Always wrap with `this.registerEvent(...)`.
+
+| Event | Callback Args | Description |
+|-------|--------------|-------------|
+| `'create'` | `(file: TAbstractFile)` | File created. Also fires on vault load for existing files. Register inside `workspace.onLayoutReady()` to skip initial load events. |
+| `'modify'` | `(file: TAbstractFile)` | File modified |
+| `'delete'` | `(file: TAbstractFile)` | File deleted |
+| `'rename'` | `(file: TAbstractFile, oldPath: string)` | File renamed/moved |
+
+```typescript
+// Example: Listen for file creation
+this.registerEvent(
+ this.app.vault.on('create', (file) => {
+ if (file instanceof TFile) {
+ console.log('New file:', file.path);
+ }
+ })
+);
+```
+
+## FileManager Class
+
+Access via `this.app.fileManager`. Higher-level file operations.
+
+| Method | Description |
+|--------|-------------|
+| `renameFile(file, newPath)` | Rename/move and **auto-update all links** per user preferences |
+| `trashFile(file)` | Delete respecting user's trash preferences |
+| `promptForDeletion(file)` | Show confirmation dialog before deleting |
+| `generateMarkdownLink(file, sourcePath, subpath?, alias?)` | Generate `[[link]]` or `[alias](path)` based on user preferences |
+| `getNewFileParent(sourcePath, newFilePath)` | Get folder for new files based on user preferences |
+| `getAvailablePathForAttachment(filename, sourcePath)` | Resolve unique attachment path, deduplicating if needed |
+| `processFrontMatter(file, fn, options?)` | Atomically read/modify/save frontmatter as JS object |
+
+### Frontmatter Processing
+
+```typescript
+// Add or update frontmatter properties
+await this.app.fileManager.processFrontMatter(file, (fm) => {
+ fm.tags = ['ai', 'organized'];
+ fm.lastProcessed = new Date().toISOString();
+});
+```
+
+The `fm` object is mutated directly. Handle errors from this method.
+
+## DataAdapter Interface
+
+Low-level filesystem access via `this.app.vault.adapter`. Prefer Vault API when possible.
+
+| Method | Description |
+|--------|-------------|
+| `exists(path, sensitive?)` | Check if path exists |
+| `read(path)` / `readBinary(path)` | Read file by path string |
+| `write(path, data)` / `writeBinary(path, data)` | Write file (creates if needed) |
+| `append(path, data)` | Append to file |
+| `process(path, fn)` | Atomic read-modify-save |
+| `mkdir(path)` | Create directory |
+| `list(path)` | List files/folders (non-recursive) |
+| `stat(path)` | Get file metadata |
+| `remove(path)` | Delete file |
+| `rmdir(path, recursive?)` | Remove directory |
+| `rename(path, newPath)` | Rename/move |
+| `copy(path, newPath)` | Copy file |
+| `trashLocal(path)` | Move to `.trash/` |
+| `trashSystem(path)` | Move to OS trash |
+| `getResourcePath(path)` | Get browser-usable URI |
+
+**Note**: Vault API only accesses files visible in the app. Hidden folders (like `.obsidian`) require the DataAdapter.
+
+## AI Note Organizer Patterns
+
+### Batch Processing All Notes
+
+```typescript
+async function processAllNotes(app: App) {
+ const files = app.vault.getMarkdownFiles();
+ for (const file of files) {
+ const cache = app.metadataCache.getFileCache(file);
+ // Skip already-processed files
+ if (cache?.frontmatter?.aiProcessed) continue;
+
+ const content = await app.vault.cachedRead(file);
+ // Send to AI, get results, then update:
+ await app.fileManager.processFrontMatter(file, (fm) => {
+ fm.tags = ['ai-suggested-tag'];
+ fm.aiProcessed = new Date().toISOString();
+ });
+ }
+}
+```
+
+### Moving Notes into Organized Folders
+
+```typescript
+async function organizeNote(app: App, file: TFile, targetFolder: string) {
+ // Ensure folder exists
+ if (!app.vault.getFolderByPath(targetFolder)) {
+ await app.vault.createFolder(targetFolder);
+ }
+ // renameFile updates all links automatically
+ const newPath = `${targetFolder}/${file.name}`;
+ await app.fileManager.renameFile(file, newPath);
+}
+```
+
+### Creating AI-Generated Summary Notes
+
+```typescript
+async function createSummaryNote(vault: Vault, folder: string, title: string, content: string) {
+ const path = `${folder}/${title}.md`;
+ const existing = vault.getFileByPath(path);
+ if (existing) {
+ await vault.modify(existing, content);
+ } else {
+ await vault.create(path, content);
+ }
+}
+```
+
+### Auto-Processing on File Change
+
+```typescript
+// In onload():
+this.registerEvent(
+ this.app.vault.on('modify', async (file) => {
+ if (file instanceof TFile && file.extension === 'md') {
+ // Debounce or queue for AI processing
+ this.queueForProcessing(file);
+ }
+ })
+);
+
+// Skip initial vault load events:
+this.app.workspace.onLayoutReady(() => {
+ this.registerEvent(
+ this.app.vault.on('create', async (file) => {
+ if (file instanceof TFile && file.extension === 'md') {
+ this.queueForProcessing(file);
+ }
+ })
+ );
+});
+```
+
+### Building a Note Graph for AI Context
+
+```typescript
+function buildNoteGraph(app: App): Map<string, string[]> {
+ const graph = new Map<string, string[]>();
+ for (const [source, targets] of Object.entries(app.metadataCache.resolvedLinks)) {
+ graph.set(source, Object.keys(targets));
+ }
+ return graph;
+}
+
+function getRelatedNotes(app: App, filePath: string): string[] {
+ const related = new Set<string>();
+ // Outgoing links
+ const outgoing = app.metadataCache.resolvedLinks[filePath];
+ if (outgoing) Object.keys(outgoing).forEach(p => related.add(p));
+ // Incoming links (backlinks)
+ for (const [source, targets] of Object.entries(app.metadataCache.resolvedLinks)) {
+ if (targets[filePath]) related.add(source);
+ }
+ return [...related];
+}
+```
+
+```typescript
+const item = this.app.vault.getAbstractFileByPath('path/to/something');
+if (item instanceof TFile) {
+ // It's a file
+} else if (item instanceof TFolder) {
+ // It's a folder
+}
+```
diff --git a/.rules/docs/obsidian/workspace-api.md b/.rules/docs/obsidian/workspace-api.md
new file mode 100644
index 0000000..deedb03
--- /dev/null
+++ b/.rules/docs/obsidian/workspace-api.md
@@ -0,0 +1,221 @@
+# Obsidian Workspace & Views API
+
+## Workspace
+
+Access via `this.app.workspace`. Extends `Events`. Manages the UI layout as a tree of workspace items.
+
+### Layout Structure
+
+The workspace is a tree:
+- **WorkspaceSplit**: Lays out children side by side (vertical or horizontal)
+- **WorkspaceTabs**: Shows one child at a time with tab headers
+- **WorkspaceLeaf**: Terminal node that displays a View
+
+Three root splits: `leftSplit` (sidebar), `rootSplit` (main area), `rightSplit` (sidebar).
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `activeEditor` | `MarkdownFileInfo \| null` | Current editor component |
+| `activeLeaf` | `WorkspaceLeaf \| null` | Currently focused leaf. Avoid using directly; prefer helper methods. |
+| `layoutReady` | `boolean` | Whether layout is initialized |
+| `containerEl` | `HTMLElement` | Workspace container |
+| `leftSplit` | `WorkspaceSidedock \| WorkspaceMobileDrawer` | Left sidebar |
+| `rightSplit` | `WorkspaceSidedock \| WorkspaceMobileDrawer` | Right sidebar |
+| `rootSplit` | `WorkspaceRoot` | Main content area |
+| `leftRibbon` / `rightRibbon` | `WorkspaceRibbon` | Ribbon bars |
+| `requestSaveLayout` | `Debouncer` | Debounced layout save |
+
+### Getting Leaves & Views
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `getActiveFile()` | `TFile \| null` | Active file from current FileView, or most recent |
+| `getActiveViewOfType(type)` | `T \| null` | Get active view if it matches type |
+| `getLeavesOfType(viewType)` | `WorkspaceLeaf[]` | All leaves of a view type |
+| `getLeafById(id)` | `WorkspaceLeaf \| null` | Get leaf by ID |
+| `getLastOpenFiles()` | `string[]` | 10 most recently opened filenames |
+| `getMostRecentLeaf(root?)` | `WorkspaceLeaf \| null` | Most recent leaf in root split |
+| `iterateAllLeaves(cb)` | `void` | Iterate all leaves (main + sidebars + floating) |
+| `iterateRootLeaves(cb)` | `void` | Iterate main area leaves only |
+| `getGroupLeaves(group)` | `WorkspaceLeaf[]` | Get leaves in a link group |
+
+### Creating & Managing Leaves
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `getLeaf(newLeaf?)` | `WorkspaceLeaf` | Get/create leaf. `false`=reuse existing, `true`/`'tab'`=new tab, `'split'`=split adjacent, `'window'`=popout. |
+| `getLeaf('split', direction)` | `WorkspaceLeaf` | Split: `'vertical'` (right) or `'horizontal'` (below) |
+| `getLeftLeaf(split)` | `WorkspaceLeaf` | Create leaf in left sidebar |
+| `getRightLeaf(split)` | `WorkspaceLeaf` | Create leaf in right sidebar |
+| `ensureSideLeaf(type, side, options?)` | `WorkspaceLeaf` | Get or create sidebar leaf (v1.7.2) |
+| `createLeafBySplit(leaf, direction?, before?)` | `WorkspaceLeaf` | Split an existing leaf |
+| `createLeafInParent(parent, index)` | `WorkspaceLeaf` | Create leaf in specific parent |
+| `duplicateLeaf(leaf, leafType?, direction?)` | `Promise<WorkspaceLeaf>` | Duplicate a leaf |
+| `detachLeavesOfType(viewType)` | `void` | Remove all leaves of a type |
+
+### Navigation
+
+| Method | Description |
+|--------|-------------|
+| `setActiveLeaf(leaf, params?)` | Set the active leaf |
+| `revealLeaf(leaf)` | Bring leaf to foreground, uncollapse sidebar if needed. `await` for full load. (v1.7.2) |
+| `openLinkText(linktext, sourcePath, newLeaf?, openViewState?)` | Open internal link |
+| `moveLeafToPopout(leaf, data?)` | Move leaf to popout window (desktop only) |
+| `openPopoutLeaf(data?)` | Open new popout window with leaf (desktop only) |
+| `onLayoutReady(callback)` | Run callback when layout is ready (or immediately if already ready). (v0.11.0) |
+
+### Workspace Events
+
+| Event | Callback Args | Description |
+|-------|--------------|-------------|
+| `'file-open'` | `(file: TFile \| null)` | Active file changed (new leaf, existing leaf, or embed) |
+| `'active-leaf-change'` | `(leaf: WorkspaceLeaf \| null)` | Active leaf changed |
+| `'layout-change'` | `()` | Layout changed |
+| `'editor-change'` | `(editor: Editor, info: MarkdownView \| MarkdownFileInfo)` | Editor content changed |
+| `'editor-paste'` | `(evt: ClipboardEvent, editor: Editor, info)` | Editor paste. Check `evt.defaultPrevented`. |
+| `'editor-drop'` | `(evt: DragEvent, editor: Editor, info)` | Editor drop. Check `evt.defaultPrevented`. |
+| `'editor-menu'` | `(menu: Menu, editor: Editor, info)` | Editor context menu (v1.1.0) |
+| `'file-menu'` | `(menu: Menu, file: TAbstractFile, source: string, leaf?: WorkspaceLeaf)` | File context menu |
+| `'files-menu'` | `(menu: Menu, files: TAbstractFile[], source: string, leaf?: WorkspaceLeaf)` | Multi-file context menu (v1.4.10) |
+| `'url-menu'` | `(menu: Menu, url: string)` | External URL context menu (v1.5.1) |
+| `'quick-preview'` | `(file: TFile, data: string)` | Active markdown file modified (pre-save) |
+| `'resize'` | `()` | Window/item resized |
+| `'quit'` | `()` | App about to quit (best-effort, not guaranteed) |
+| `'window-open'` | `(win: WorkspaceWindow)` | Popout window created |
+| `'window-close'` | `(win: WorkspaceWindow)` | Popout window closed |
+| `'css-change'` | `()` | CSS changed |
+
+## WorkspaceLeaf
+
+A leaf hosts a single View.
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `view` | `View` | The view in this leaf. Check `instanceof` before casting. |
+| `parent` | `WorkspaceTabs \| WorkspaceMobileDrawer` | Parent container |
+| `isDeferred` | `boolean` (readonly) | Whether leaf is deferred/background (v1.7.2) |
+
+### Methods
+
+| Method | Description |
+|--------|-------------|
+| `openFile(file, openState?)` | Open a file in this leaf |
+| `setViewState(viewState, eState?)` | Set the view state (type, state, active) |
+| `getViewState()` | Get current view state |
+| `getDisplayText()` | Display text for tab |
+| `detach()` | Remove this leaf |
+| `setPinned(pinned)` / `togglePinned()` | Pin/unpin leaf |
+| `setGroup(group)` | Set link group |
+| `loadIfDeferred()` | Load deferred leaf. Await for full load. (v1.7.2) |
+
+## Custom Views
+
+### ItemView (for custom panels)
+
+```typescript
+import { ItemView, WorkspaceLeaf } from 'obsidian';
+
+export const VIEW_TYPE = 'my-view';
+
+export class MyView extends ItemView {
+ constructor(leaf: WorkspaceLeaf) {
+ super(leaf);
+ }
+
+ getViewType(): string { return VIEW_TYPE; }
+ getDisplayText(): string { return 'My View'; }
+
+ async onOpen() {
+ const container = this.contentEl;
+ container.empty();
+ container.createEl('h4', { text: 'Hello' });
+ }
+
+ async onClose() {
+ // Cleanup
+ }
+}
+```
+
+### View Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `app` | `App` | App reference |
+| `leaf` | `WorkspaceLeaf` | Parent leaf |
+| `containerEl` | `HTMLElement` | Container element |
+| `contentEl` | `HTMLElement` | Content area (ItemView) |
+| `icon` | `IconName` | View icon |
+| `navigation` | `boolean` | `true` if navigable (file views). `false` for static panels (explorer, calendar). |
+| `scope` | `Scope \| null` | Optional hotkey scope |
+
+### View Methods
+
+| Method | Description |
+|--------|-------------|
+| `getViewType()` | Return unique view type string (abstract) |
+| `getDisplayText()` | Return human-readable name (abstract) |
+| `onOpen()` | Build view content |
+| `onClose()` | Cleanup resources |
+| `onResize()` | Handle size changes |
+| `onPaneMenu(menu, source)` | Populate pane context menu |
+| `addAction(icon, title, callback)` | Add action button to view header |
+| `getState()` / `setState(state, result)` | Serialize/restore state |
+| `getEphemeralState()` / `setEphemeralState(state)` | Non-persistent state (scroll position, etc.) |
+
+### MarkdownView
+
+Extends `TextFileView`. The built-in markdown editor view.
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `editor` | `Editor` | The editor instance |
+| `file` | `TFile \| null` | Currently open file |
+| `data` | `string` | In-memory file content |
+| `currentMode` | `MarkdownSubView` | Current editing subview |
+| `previewMode` | `MarkdownPreviewView` | Preview renderer |
+
+| Method | Description |
+|--------|-------------|
+| `getMode()` | Get current mode |
+| `getViewData()` | Get view data |
+| `setViewData(data, clear)` | Set view data |
+| `showSearch(replace?)` | Show search (and optionally replace) |
+
+### Registering & Activating Views
+
+```typescript
+// Register in onload()
+this.registerView(VIEW_TYPE, (leaf) => new MyView(leaf));
+
+// Activate view
+async activateView() {
+ const { workspace } = this.app;
+ let leaf: WorkspaceLeaf | null = null;
+ const leaves = workspace.getLeavesOfType(VIEW_TYPE);
+
+ if (leaves.length > 0) {
+ leaf = leaves[0];
+ } else {
+ leaf = workspace.getRightLeaf(false);
+ await leaf.setViewState({ type: VIEW_TYPE, active: true });
+ }
+ workspace.revealLeaf(leaf);
+}
+```
+
+**Warning**: Never store view references in your plugin. Obsidian may call the factory multiple times. Use `getLeavesOfType()` to access views.
+
+```typescript
+this.app.workspace.getLeavesOfType(VIEW_TYPE).forEach((leaf) => {
+ if (leaf.view instanceof MyView) {
+ // Access view instance
+ }
+});
+```
+
+Plugins must remove their leaves when disabled. `detachLeavesOfType(viewType)` removes all.
diff --git a/.rules/docs/ollama/chat.md b/.rules/docs/ollama/chat.md
new file mode 100644
index 0000000..874243d
--- /dev/null
+++ b/.rules/docs/ollama/chat.md
@@ -0,0 +1,171 @@
+# Generate a chat message
+
+`POST /api/chat` — Generate the next message in a conversation between a user and an assistant.
+
+**Server:** `http://localhost:11434`
+
+## Request
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `model` | string | yes | Model name |
+| `messages` | ChatMessage[] | yes | Chat history (array of message objects) |
+| `tools` | ToolDefinition[] | no | Function tools the model may call |
+| `format` | `"json"` \| object | no | Response format — `"json"` or a JSON schema |
+| `options` | ModelOptions | no | Runtime generation options (see generate.md) |
+| `stream` | boolean | no | Stream partial responses (default: `true`) |
+| `think` | boolean \| string | no | Enable thinking output (`true`/`false` or `"high"`, `"medium"`, `"low"`) |
+| `keep_alive` | string \| number | no | Keep-alive duration (e.g. `"5m"` or `0` to unload) |
+
+### ChatMessage
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `role` | string | yes | `"system"`, `"user"`, `"assistant"`, or `"tool"` |
+| `content` | string | yes | Message text |
+| `images` | string[] | no | Base64-encoded images (multimodal) |
+| `tool_calls` | ToolCall[] | no | Tool calls from the model |
+
+### ToolDefinition
+
+```json
+{
+ "type": "function",
+ "function": {
+ "name": "function_name",
+ "description": "What the function does",
+ "parameters": { /* JSON Schema */ }
+ }
+}
+```
+
+### ToolCall
+
+```json
+{
+ "function": {
+ "name": "function_name",
+ "arguments": { /* key-value args */ }
+ }
+}
+```
+
+## Response (non-streaming, `stream: false`)
+
+| Field | Type | Description |
+|---|---|---|
+| `model` | string | Model name |
+| `created_at` | string | ISO 8601 timestamp |
+| `message.role` | string | Always `"assistant"` |
+| `message.content` | string | Assistant reply text |
+| `message.thinking` | string | Thinking trace (when `think` enabled) |
+| `message.tool_calls` | ToolCall[] | Tool calls requested by assistant |
+| `done` | boolean | Whether the response finished |
+| `done_reason` | string | Why it finished |
+| `total_duration` | integer | Total time (nanoseconds) |
+| `load_duration` | integer | Model load time (nanoseconds) |
+| `prompt_eval_count` | integer | Input token count |
+| `prompt_eval_duration` | integer | Prompt eval time (nanoseconds) |
+| `eval_count` | integer | Output token count |
+| `eval_duration` | integer | Token generation time (nanoseconds) |
+
+## Streaming Response (`stream: true`, default)
+
+Returns `application/x-ndjson`. Each chunk has `message.content` (partial text). Final chunk has `done: true` with duration/count stats.
+
+## Examples
+
+### Basic (streaming)
+```bash
+curl http://localhost:11434/api/chat -d '{
+ "model": "gemma3",
+ "messages": [
+ {"role": "user", "content": "why is the sky blue?"}
+ ]
+}'
+```
+
+### Non-streaming
+```bash
+curl http://localhost:11434/api/chat -d '{
+ "model": "gemma3",
+ "messages": [
+ {"role": "user", "content": "why is the sky blue?"}
+ ],
+ "stream": false
+}'
+```
+
+### Structured output
+```bash
+curl http://localhost:11434/api/chat -d '{
+ "model": "gemma3",
+ "messages": [
+ {"role": "user", "content": "What are the populations of the United States and Canada?"}
+ ],
+ "stream": false,
+ "format": {
+ "type": "object",
+ "properties": {
+ "countries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "country": {"type": "string"},
+ "population": {"type": "integer"}
+ },
+ "required": ["country", "population"]
+ }
+ }
+ },
+ "required": ["countries"]
+ }
+}'
+```
+
+### Tool calling
+```bash
+curl http://localhost:11434/api/chat -d '{
+ "model": "qwen3",
+ "messages": [
+ {"role": "user", "content": "What is the weather today in Paris?"}
+ ],
+ "stream": false,
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "get_current_weather",
+ "description": "Get the current weather for a location",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "The location, e.g. San Francisco, CA"
+ },
+ "format": {
+ "type": "string",
+ "description": "celsius or fahrenheit",
+ "enum": ["celsius", "fahrenheit"]
+ }
+ },
+ "required": ["location", "format"]
+ }
+ }
+ }
+ ]
+}'
+```
+
+### Thinking
+```bash
+curl http://localhost:11434/api/chat -d '{
+ "model": "gpt-oss",
+ "messages": [
+ {"role": "user", "content": "What is 1+1?"}
+ ],
+ "think": "low"
+}'
+```
diff --git a/.rules/docs/ollama/embed.md b/.rules/docs/ollama/embed.md
new file mode 100644
index 0000000..9c81ebf
--- /dev/null
+++ b/.rules/docs/ollama/embed.md
@@ -0,0 +1,56 @@
+# Generate embeddings
+
+`POST /api/embed` — Creates vector embeddings representing the input text.
+
+**Server:** `http://localhost:11434`
+
+## Request
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `model` | string | yes | Model name (e.g. `"embeddinggemma"`) |
+| `input` | string \| string[] | yes | Text or array of texts to embed |
+| `truncate` | boolean | no | Truncate inputs exceeding context window (default: `true`). If `false`, returns an error. |
+| `dimensions` | integer | no | Number of dimensions for the embedding vectors |
+| `keep_alive` | string | no | Model keep-alive duration |
+| `options` | ModelOptions | no | Runtime options (see generate.md) |
+
+## Response
+
+| Field | Type | Description |
+|---|---|---|
+| `model` | string | Model that produced the embeddings |
+| `embeddings` | number[][] | Array of embedding vectors (one per input) |
+| `total_duration` | integer | Total time (nanoseconds) |
+| `load_duration` | integer | Model load time (nanoseconds) |
+| `prompt_eval_count` | integer | Number of input tokens processed |
+
+## Examples
+
+### Single input
+```bash
+curl http://localhost:11434/api/embed -d '{
+ "model": "embeddinggemma",
+ "input": "Why is the sky blue?"
+}'
+```
+
+### Multiple inputs (batch)
+```bash
+curl http://localhost:11434/api/embed -d '{
+ "model": "embeddinggemma",
+ "input": [
+ "Why is the sky blue?",
+ "Why is the grass green?"
+ ]
+}'
+```
+
+### Custom dimensions
+```bash
+curl http://localhost:11434/api/embed -d '{
+ "model": "embeddinggemma",
+ "input": "Generate embeddings for this text",
+ "dimensions": 128
+}'
+```
diff --git a/.rules/docs/ollama/generate.md b/.rules/docs/ollama/generate.md
new file mode 100644
index 0000000..30534c2
--- /dev/null
+++ b/.rules/docs/ollama/generate.md
@@ -0,0 +1,121 @@
+# Generate a response
+
+`POST /api/generate` — Generates a response for a provided prompt.
+
+**Server:** `http://localhost:11434`
+
+## Request
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `model` | string | yes | Model name |
+| `prompt` | string | no | Text for the model to generate a response from |
+| `suffix` | string | no | Fill-in-the-middle text after the prompt, before the response |
+| `images` | string[] | no | Base64-encoded images (for multimodal models) |
+| `format` | string \| object | no | `"json"` or a JSON schema object for structured output |
+| `system` | string | no | System prompt |
+| `stream` | boolean | no | Stream partial responses (default: `true`) |
+| `think` | boolean \| string | no | Enable thinking output (`true`/`false` or `"high"`, `"medium"`, `"low"`) |
+| `raw` | boolean | no | Return raw response without prompt templating |
+| `keep_alive` | string \| number | no | Keep-alive duration (e.g. `"5m"` or `0` to unload immediately) |
+| `options` | ModelOptions | no | Runtime generation options (see below) |
+
+### ModelOptions
+
+| Field | Type | Description |
+|---|---|---|
+| `seed` | integer | Random seed for reproducible outputs |
+| `temperature` | float | Randomness (higher = more random) |
+| `top_k` | integer | Limit next token to K most likely |
+| `top_p` | float | Nucleus sampling cumulative probability threshold |
+| `min_p` | float | Minimum probability threshold |
+| `stop` | string \| string[] | Stop sequences |
+| `num_ctx` | integer | Context length (number of tokens) |
+| `num_predict` | integer | Max tokens to generate |
+
+## Response (non-streaming, `stream: false`)
+
+| Field | Type | Description |
+|---|---|---|
+| `model` | string | Model name |
+| `created_at` | string | ISO 8601 timestamp |
+| `response` | string | Generated text |
+| `thinking` | string | Thinking output (when `think` enabled) |
+| `done` | boolean | Whether generation finished |
+| `done_reason` | string | Why generation stopped |
+| `total_duration` | integer | Total time (nanoseconds) |
+| `load_duration` | integer | Model load time (nanoseconds) |
+| `prompt_eval_count` | integer | Number of input tokens |
+| `prompt_eval_duration` | integer | Prompt eval time (nanoseconds) |
+| `eval_count` | integer | Number of output tokens |
+| `eval_duration` | integer | Token generation time (nanoseconds) |
+
+## Streaming Response (`stream: true`, default)
+
+Returns `application/x-ndjson` — one JSON object per line. Each chunk has the same fields as the non-streaming response. The final chunk has `done: true` with duration/count stats.
+
+## Examples
+
+### Basic (streaming)
+```bash
+curl http://localhost:11434/api/generate -d '{
+ "model": "gemma3",
+ "prompt": "Why is the sky blue?"
+}'
+```
+
+### Non-streaming
+```bash
+curl http://localhost:11434/api/generate -d '{
+ "model": "gemma3",
+ "prompt": "Why is the sky blue?",
+ "stream": false
+}'
+```
+
+### With options
+```bash
+curl http://localhost:11434/api/generate -d '{
+ "model": "gemma3",
+ "prompt": "Why is the sky blue?",
+ "options": {
+ "temperature": 0.8,
+ "top_p": 0.9,
+ "seed": 42
+ }
+}'
+```
+
+### Structured output (JSON schema)
+```bash
+curl http://localhost:11434/api/generate -d '{
+ "model": "gemma3",
+ "prompt": "What are the populations of the United States and Canada?",
+ "stream": false,
+ "format": {
+ "type": "object",
+ "properties": {
+ "countries": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "country": {"type": "string"},
+ "population": {"type": "integer"}
+ },
+ "required": ["country", "population"]
+ }
+ }
+ },
+ "required": ["countries"]
+ }
+}'
+```
+
+### Load / Unload model
+```bash
+# Load
+curl http://localhost:11434/api/generate -d '{"model": "gemma3"}'
+# Unload
+curl http://localhost:11434/api/generate -d '{"model": "gemma3", "keep_alive": 0}'
+```
diff --git a/.rules/docs/ollama/list-models.md b/.rules/docs/ollama/list-models.md
new file mode 100644
index 0000000..f5da57f
--- /dev/null
+++ b/.rules/docs/ollama/list-models.md
@@ -0,0 +1,56 @@
+# List models
+
+`GET /api/tags` — Fetch a list of locally available models and their details.
+
+**Server:** `http://localhost:11434`
+
+## Request
+
+No parameters required.
+
+```bash
+curl http://localhost:11434/api/tags
+```
+
+## Response
+
+| Field | Type | Description |
+|---|---|---|
+| `models` | ModelSummary[] | Array of available models |
+
+### ModelSummary
+
+| Field | Type | Description |
+|---|---|---|
+| `name` | string | Model name |
+| `model` | string | Model name |
+| `modified_at` | string | Last modified (ISO 8601) |
+| `size` | integer | Size on disk (bytes) |
+| `digest` | string | SHA256 digest |
+| `details.format` | string | File format (e.g. `"gguf"`) |
+| `details.family` | string | Primary model family (e.g. `"llama"`) |
+| `details.families` | string[] | All families the model belongs to |
+| `details.parameter_size` | string | Parameter count label (e.g. `"7B"`) |
+| `details.quantization_level` | string | Quantization level (e.g. `"Q4_0"`) |
+
+### Example response
+```json
+{
+ "models": [
+ {
+ "name": "gemma3",
+ "model": "gemma3",
+ "modified_at": "2025-10-03T23:34:03.409490317-07:00",
+ "size": 3338801804,
+ "digest": "a2af6cc3eb7fa8be8504abaf9b04e88f17a119ec3f04a3addf55f92841195f5a",
+ "details": {
+ "format": "gguf",
+ "family": "gemma",
+ "families": ["gemma"],
+ "parameter_size": "4.3B",
+ "quantization_level": "Q4_K_M"
+ }
+ }
+ ]
+}
+```
diff --git a/.rules/docs/ollama/show-model.md b/.rules/docs/ollama/show-model.md
new file mode 100644
index 0000000..befbd22
--- /dev/null
+++ b/.rules/docs/ollama/show-model.md
@@ -0,0 +1,43 @@
+# Show model details
+
+`POST /api/show` — Get detailed information about a specific model.
+
+**Server:** `http://localhost:11434`
+
+## Request
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `model` | string | yes | Model name to show |
+| `verbose` | boolean | no | Include large verbose fields in the response |
+
+## Response
+
+| Field | Type | Description |
+|---|---|---|
+| `parameters` | string | Model parameter settings (text) |
+| `modified_at` | string | Last modified (ISO 8601) |
+| `template` | string | Prompt template used by the model |
+| `capabilities` | string[] | Supported features (e.g. `"completion"`, `"vision"`) |
+| `details.format` | string | File format (e.g. `"gguf"`) |
+| `details.family` | string | Model family |
+| `details.families` | string[] | All families |
+| `details.parameter_size` | string | Parameter count label (e.g. `"4.3B"`) |
+| `details.quantization_level` | string | Quantization level (e.g. `"Q4_K_M"`) |
+| `model_info` | object | Architecture metadata (context length, embedding size, etc.) |
+
+## Examples
+
+```bash
+curl http://localhost:11434/api/show -d '{
+ "model": "gemma3"
+}'
+```
+
+### Verbose
+```bash
+curl http://localhost:11434/api/show -d '{
+ "model": "gemma3",
+ "verbose": true
+}'
+```
diff --git a/.rules/docs/ollama/version.md b/.rules/docs/ollama/version.md
new file mode 100644
index 0000000..29fb757
--- /dev/null
+++ b/.rules/docs/ollama/version.md
@@ -0,0 +1,19 @@
+# Get version
+
+`GET /api/version` — Retrieve the Ollama server version.
+
+**Server:** `http://localhost:11434`
+
+## Request
+
+No parameters required.
+
+```bash
+curl http://localhost:11434/api/version
+```
+
+## Response
+
+| Field | Type | Description |
+|---|---|---|
+| `version` | string | Ollama version (e.g. `"0.12.6"`) |