summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-24 19:57:16 +0900
committerAdam Malczewski <[email protected]>2026-03-24 19:57:16 +0900
commit0d7e2758d28bb37c9d724f79008239a5e29e6ce4 (patch)
treecdd294a7a45e712d86c39e15340cadf2a9a1bc91 /src
parenta5f54269f6b7ace71c4509fb8105993a7f064e63 (diff)
downloadai-pulse-obsidian-plugin-0d7e2758d28bb37c9d724f79008239a5e29e6ce4.tar.gz
ai-pulse-obsidian-plugin-0d7e2758d28bb37c9d724f79008239a5e29e6ce4.zip
Add vault context injection, frontmatter tool, vision idea
Diffstat (limited to 'src')
-rw-r--r--src/chat-view.ts29
-rw-r--r--src/ollama-client.ts23
-rw-r--r--src/settings-modal.ts54
-rw-r--r--src/settings.ts4
-rw-r--r--src/tools.ts151
-rw-r--r--src/vault-context.ts168
6 files changed, 421 insertions, 8 deletions
diff --git a/src/chat-view.ts b/src/chat-view.ts
index 7975cd2..b76fb8e 100644
--- a/src/chat-view.ts
+++ b/src/chat-view.ts
@@ -6,6 +6,7 @@ import { SettingsModal } from "./settings-modal";
import { ToolModal } from "./tool-modal";
import { TOOL_REGISTRY } from "./tools";
import type { OllamaToolDefinition } from "./tools";
+import { collectVaultContext, formatVaultContext } from "./vault-context";
export const VIEW_TYPE_CHAT = "ai-pulse-chat";
@@ -236,6 +237,16 @@ export class ChatView extends ItemView {
}
}
+ // Build vault context if enabled
+ let vaultContext: string | undefined;
+ if (this.plugin.settings.injectVaultContext) {
+ const ctx = collectVaultContext(
+ this.plugin.app,
+ this.plugin.settings.vaultContextRecentFiles,
+ );
+ vaultContext = formatVaultContext(ctx);
+ }
+
try {
const enabledTools = this.getEnabledTools();
const hasTools = enabledTools.length > 0;
@@ -291,6 +302,7 @@ export class ChatView extends ItemView {
num_predict: this.plugin.settings.numPredict,
},
userSystemPrompt,
+ vaultContext,
onChunk,
onToolCall: hasTools ? onToolCall : undefined,
onApprovalRequest: hasTools ? onApprovalRequest : undefined,
@@ -486,7 +498,7 @@ export class ChatView extends ItemView {
container.createDiv({ text: event.message, cls: "ai-pulse-approval-message" });
// Show details for edit_file so the user can review the change
- if (event.toolName === "edit_file" || event.toolName === "create_file") {
+ if (event.toolName === "edit_file" || event.toolName === "create_file" || event.toolName === "set_frontmatter") {
const collapse = container.createDiv({ cls: "ai-pulse-collapse ai-pulse-collapse-arrow" });
const collapseId = `approval-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const checkbox = collapse.createEl("input", {
@@ -498,7 +510,9 @@ export class ChatView extends ItemView {
const titleEl = collapse.createEl("label", {
cls: "ai-pulse-collapse-title",
attr: { for: collapseId },
- text: event.toolName === "create_file" ? "Review content" : "Review changes",
+ text: event.toolName === "create_file" ? "Review content"
+ : event.toolName === "set_frontmatter" ? "Review properties"
+ : "Review changes",
});
void titleEl;
@@ -520,6 +534,17 @@ export class ChatView extends ItemView {
text: newText,
cls: "ai-pulse-tool-call-result",
});
+ } else if (event.toolName === "set_frontmatter") {
+ const props = event.args.properties;
+ const propsStr = typeof props === "object" && props !== null
+ ? JSON.stringify(props, null, 2)
+ : typeof props === "string" ? props : "{}";
+
+ contentInner.createEl("div", { text: "Properties to set:", cls: "ai-pulse-tool-call-label" });
+ contentInner.createEl("pre", {
+ text: propsStr,
+ cls: "ai-pulse-tool-call-result",
+ });
} else {
// create_file
const content = typeof event.args.content === "string" ? event.args.content : "";
diff --git a/src/ollama-client.ts b/src/ollama-client.ts
index f1288e7..c01b5bc 100644
--- a/src/ollama-client.ts
+++ b/src/ollama-client.ts
@@ -68,6 +68,7 @@ interface AgentLoopOptions {
tools?: OllamaToolDefinition[];
app?: App;
userSystemPrompt?: string;
+ vaultContext?: string;
onToolCall?: (event: ToolCallEvent) => void;
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>;
sendRequest: ChatRequestStrategy;
@@ -108,6 +109,12 @@ const TOOL_SYSTEM_PROMPT =
"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.";
@@ -117,21 +124,25 @@ const TOOL_SYSTEM_PROMPT =
* text response or the iteration cap is reached.
*/
async function chatAgentLoop(opts: AgentLoopOptions): Promise<string> {
- const { messages, tools, app, userSystemPrompt, onToolCall, onApprovalRequest, sendRequest } = opts;
+ const { messages, tools, app, userSystemPrompt, vaultContext, onToolCall, onApprovalRequest, sendRequest } = opts;
const maxIterations = 10;
let iterations = 0;
const workingMessages = messages.map((m) => ({ ...m }));
- // Build combined system prompt from tool instructions + user custom prompt
+ // Build combined system prompt from tool instructions + vault context + user custom prompt
const hasTools = tools !== undefined && tools.length > 0;
const hasUserPrompt = userSystemPrompt !== undefined && userSystemPrompt.trim() !== "";
+ const hasVaultContext = vaultContext !== undefined && vaultContext.trim() !== "";
- if (hasTools || hasUserPrompt) {
+ if (hasTools || hasUserPrompt || hasVaultContext) {
const parts: string[] = [];
if (hasTools) {
parts.push(TOOL_SYSTEM_PROMPT);
}
+ if (hasVaultContext) {
+ parts.push(vaultContext);
+ }
if (hasUserPrompt) {
parts.push("USER INSTRUCTIONS:\n" + userSystemPrompt.trim());
}
@@ -341,6 +352,7 @@ export async function sendChatMessage(
onToolCall?: (event: ToolCallEvent) => void,
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>,
userSystemPrompt?: string,
+ vaultContext?: string,
): Promise<string> {
const sendRequest: ChatRequestStrategy = async (workingMessages) => {
const body: Record<string, unknown> = {
@@ -384,6 +396,7 @@ export async function sendChatMessage(
tools,
app,
userSystemPrompt,
+ vaultContext,
onToolCall,
onApprovalRequest,
sendRequest,
@@ -405,6 +418,7 @@ export interface StreamingChatOptions {
app?: App;
options?: ModelOptions;
userSystemPrompt?: string;
+ vaultContext?: string;
onChunk: (text: string) => void;
onToolCall?: (event: ToolCallEvent) => void;
onApprovalRequest?: (event: ApprovalRequestEvent) => Promise<boolean>;
@@ -457,7 +471,7 @@ async function* readNdjsonStream(
export async function sendChatMessageStreaming(
opts: StreamingChatOptions,
): Promise<string> {
- const { ollamaUrl, model, tools, app, options, userSystemPrompt, onChunk, onToolCall, onApprovalRequest, onCreateBubble, abortSignal } = opts;
+ const { ollamaUrl, model, tools, app, options, userSystemPrompt, vaultContext, onChunk, onToolCall, onApprovalRequest, onCreateBubble, abortSignal } = opts;
const sendRequest: ChatRequestStrategy = Platform.isMobile
? buildMobileStrategy(ollamaUrl, model, tools, options, onChunk, onCreateBubble)
@@ -468,6 +482,7 @@ export async function sendChatMessageStreaming(
tools,
app,
userSystemPrompt,
+ vaultContext,
onToolCall,
onApprovalRequest,
sendRequest,
diff --git a/src/settings-modal.ts b/src/settings-modal.ts
index 9a4218c..8ffc3e7 100644
--- a/src/settings-modal.ts
+++ b/src/settings-modal.ts
@@ -123,6 +123,60 @@ export class SettingsModal extends Modal {
// Set initial state
updateFileSettingState(this.plugin.settings.useSystemPromptFile);
+ // --- Vault Context ---
+
+ const ctxHeader = contentEl.createEl("h4", { text: "Vault Context" });
+ ctxHeader.style.marginTop = "16px";
+ ctxHeader.style.marginBottom = "4px";
+
+ // Recent files count (disabled/enabled based on toggle)
+ let recentFilesInputEl: HTMLInputElement | null = null;
+ const recentFilesSetting = new Setting(contentEl)
+ .setName("Recent Files Count")
+ .setDesc("Number of recently modified files to include in the context.")
+ .addText((text) => {
+ text.inputEl.type = "number";
+ text.inputEl.min = "0";
+ text.inputEl.max = "100";
+ text.inputEl.step = "5";
+ text.setValue(String(this.plugin.settings.vaultContextRecentFiles));
+ text.onChange(async (value) => {
+ const num = parseInt(value, 10);
+ if (!isNaN(num) && num >= 0) {
+ this.plugin.settings.vaultContextRecentFiles = num;
+ await this.plugin.saveSettings();
+ }
+ });
+ text.inputEl.style.width = "60px";
+ recentFilesInputEl = text.inputEl;
+ });
+
+ const updateVaultContextState = (enabled: boolean): void => {
+ recentFilesSetting.settingEl.toggleClass("ai-pulse-setting-disabled", !enabled);
+ if (recentFilesInputEl !== null) {
+ recentFilesInputEl.disabled = !enabled;
+ }
+ };
+
+ const vaultContextToggle = new Setting(contentEl)
+ .setName("Inject Vault Context")
+ .setDesc("Automatically inject a summary of the vault (folders, tags, recent files) into each conversation.")
+ .addToggle((toggle) => {
+ toggle
+ .setValue(this.plugin.settings.injectVaultContext)
+ .onChange(async (value) => {
+ this.plugin.settings.injectVaultContext = value;
+ await this.plugin.saveSettings();
+ updateVaultContextState(value);
+ });
+ });
+
+ // Move toggle above count in the DOM
+ contentEl.insertBefore(vaultContextToggle.settingEl, recentFilesSetting.settingEl);
+
+ // Set initial state
+ updateVaultContextState(this.plugin.settings.injectVaultContext);
+
// --- Generation Parameters ---
const paramHeader = contentEl.createEl("h4", { text: "Generation Parameters" });
diff --git a/src/settings.ts b/src/settings.ts
index eb42c3f..c61af9d 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -9,6 +9,8 @@ export interface AIPulseSettings {
numPredict: number;
useSystemPromptFile: boolean;
systemPromptFile: string;
+ injectVaultContext: boolean;
+ vaultContextRecentFiles: number;
}
export const DEFAULT_SETTINGS: AIPulseSettings = {
@@ -20,4 +22,6 @@ export const DEFAULT_SETTINGS: AIPulseSettings = {
numPredict: -1,
useSystemPromptFile: false,
systemPromptFile: "agent.md",
+ injectVaultContext: false,
+ vaultContextRecentFiles: 20,
};
diff --git a/src/tools.ts b/src/tools.ts
index 7e13d26..70deb0e 100644
--- a/src/tools.ts
+++ b/src/tools.ts
@@ -68,7 +68,8 @@ async function executeSearchFiles(app: App, args: Record<string, unknown>): Prom
/**
* Execute the "read_file" tool.
- * Returns the full text content of a file by its vault path.
+ * Returns the full text content of a file by its vault path,
+ * plus parsed frontmatter as a JSON block if present.
*/
async function executeReadFile(app: App, args: Record<string, unknown>): Promise<string> {
const filePath = typeof args.file_path === "string" ? args.file_path : "";
@@ -83,6 +84,20 @@ async function executeReadFile(app: App, args: Record<string, unknown>): Promise
try {
const content = await app.vault.cachedRead(file);
+
+ // Include parsed frontmatter as JSON if available
+ const cache = app.metadataCache.getFileCache(file);
+ if (cache?.frontmatter !== undefined) {
+ const fm: Record<string, unknown> = {};
+ for (const [key, value] of Object.entries(cache.frontmatter)) {
+ if (key !== "position") {
+ fm[key] = value;
+ }
+ }
+ const fmJson = JSON.stringify(fm, null, 2);
+ return `--- FRONTMATTER (parsed) ---\n${fmJson}\n--- END FRONTMATTER ---\n\n--- FILE CONTENT ---\n${content}\n--- END FILE CONTENT ---`;
+ }
+
return content;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Unknown error";
@@ -320,6 +335,77 @@ async function executeEditFile(app: App, args: Record<string, unknown>): Promise
}
/**
+ * Execute the "set_frontmatter" tool.
+ * Atomically sets or updates frontmatter properties using processFrontMatter().
+ * The `properties` argument is a JSON object whose keys are set/overwritten in the YAML block.
+ * To remove a property, set its value to null.
+ */
+async function executeSetFrontmatter(app: App, args: Record<string, unknown>): Promise<string> {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ if (filePath === "") {
+ return "Error: file_path parameter is required.";
+ }
+
+ let properties = args.properties;
+
+ // The model may pass properties as a JSON string — parse it
+ if (typeof properties === "string") {
+ try {
+ properties = JSON.parse(properties) as unknown;
+ } catch {
+ return "Error: properties must be a valid JSON object. Failed to parse the string.";
+ }
+ }
+
+ if (typeof properties !== "object" || properties === null || Array.isArray(properties)) {
+ return "Error: properties must be a JSON object with key-value pairs.";
+ }
+
+ const propsObj = properties as Record<string, unknown>;
+ if (Object.keys(propsObj).length === 0) {
+ return "Error: properties object is empty. Provide at least one key to set.";
+ }
+
+ const file = app.vault.getAbstractFileByPath(filePath);
+ if (file === null || !(file instanceof TFile)) {
+ return `Error: File not found at path "${filePath}".`;
+ }
+
+ try {
+ const keysSet: string[] = [];
+ const keysRemoved: string[] = [];
+
+ await app.fileManager.processFrontMatter(file, (fm) => {
+ for (const [key, value] of Object.entries(propsObj)) {
+ if (value === null) {
+ // Remove the property
+ if (key in fm) {
+ delete fm[key];
+ keysRemoved.push(key);
+ }
+ } else {
+ fm[key] = value;
+ keysSet.push(key);
+ }
+ }
+ });
+
+ const parts: string[] = [];
+ if (keysSet.length > 0) {
+ parts.push(`Set: ${keysSet.join(", ")}`);
+ }
+ if (keysRemoved.length > 0) {
+ parts.push(`Removed: ${keysRemoved.join(", ")}`);
+ }
+
+ return `Frontmatter updated for "${filePath}". ${parts.join(". ")}.`;
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ return `Error updating frontmatter: ${msg}`;
+ }
+}
+
+/**
* All available tools for the plugin.
*/
export const TOOL_REGISTRY: ToolEntry[] = [
@@ -383,7 +469,7 @@ export const TOOL_REGISTRY: ToolEntry[] = [
type: "function",
function: {
name: "read_file",
- description: "Read the full text content of a file in the Obsidian vault. The file_path must be an exact path as returned by search_files.",
+ 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"],
@@ -663,6 +749,67 @@ export const TOOL_REGISTRY: ToolEntry[] = [
},
execute: executeMoveFile,
},
+ {
+ id: "set_frontmatter",
+ label: "Set Frontmatter",
+ description: "Add or update YAML frontmatter properties (requires approval).",
+ friendlyName: "Set Frontmatter",
+ requiresApproval: true,
+ approvalMessage: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "unknown";
+ const props = typeof args.properties === "object" && args.properties !== null
+ ? Object.keys(args.properties as Record<string, unknown>)
+ : [];
+ return `Update frontmatter in "${filePath}"? Properties: ${props.join(", ")}`;
+ },
+ summarize: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ const props = typeof args.properties === "object" && args.properties !== null
+ ? Object.keys(args.properties as Record<string, unknown>)
+ : [];
+ return `"/${filePath}" — ${props.join(", ")}`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) {
+ return result;
+ }
+ if (result.includes("declined")) {
+ return "Declined by user";
+ }
+ 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,
+ },
];
/**
diff --git a/src/vault-context.ts b/src/vault-context.ts
new file mode 100644
index 0000000..80afa03
--- /dev/null
+++ b/src/vault-context.ts
@@ -0,0 +1,168 @@
+import type { App } from "obsidian";
+
+/**
+ * Collected vault context summary injected into the AI system prompt.
+ */
+export interface VaultContext {
+ vaultName: string;
+ totalNotes: number;
+ totalFolders: number;
+ folderTree: string;
+ tagTaxonomy: string;
+ recentFiles: string;
+}
+
+/**
+ * Build a folder tree string from the vault.
+ * Produces an indented tree like:
+ * /
+ * ├── folder-a/
+ * │ ├── subfolder/
+ * ├── folder-b/
+ */
+function buildFolderTree(app: App): string {
+ const folders = app.vault.getAllFolders(true);
+ // Build a map of parent → children folder names
+ const tree = new Map<string, string[]>();
+
+ for (const folder of folders) {
+ if (folder.isRoot()) continue;
+ const parentPath = folder.parent?.path ?? "/";
+ const key = parentPath === "/" || parentPath === "" ? "/" : parentPath;
+ if (!tree.has(key)) {
+ tree.set(key, []);
+ }
+ tree.get(key)!.push(folder.path);
+ }
+
+ const lines: string[] = [];
+
+ function walk(path: string, prefix: string): void {
+ const children = tree.get(path) ?? [];
+ children.sort();
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+ const isLast = i === children.length - 1;
+ const connector = isLast ? "└── " : "├── ";
+ const childPrefix = isLast ? " " : "│ ";
+ // Show just the folder name, not the full path
+ const name = child.split("/").pop() ?? child;
+ lines.push(`${prefix}${connector}${name}/`);
+ walk(child, prefix + childPrefix);
+ }
+ }
+
+ lines.push("/");
+ walk("/", "");
+
+ return lines.join("\n");
+}
+
+/**
+ * Collect all tags in the vault with their usage counts.
+ * Returns a formatted string like: #tag1 (12), #tag2 (8), ...
+ */
+function buildTagTaxonomy(app: App): string {
+ const tagCounts = new Map<string, number>();
+ const files = app.vault.getMarkdownFiles();
+
+ for (const file of files) {
+ const cache = app.metadataCache.getFileCache(file);
+ if (cache === null) continue;
+
+ // Inline tags
+ if (cache.tags !== undefined) {
+ for (const tagEntry of cache.tags) {
+ const tag = tagEntry.tag.toLowerCase();
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
+ }
+ }
+
+ // Frontmatter tags
+ if (cache.frontmatter?.tags !== undefined) {
+ const fmTags = cache.frontmatter.tags;
+ if (Array.isArray(fmTags)) {
+ for (const raw of fmTags) {
+ const tag = typeof raw === "string"
+ ? (raw.startsWith("#") ? raw.toLowerCase() : `#${raw.toLowerCase()}`)
+ : "";
+ if (tag !== "") {
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
+ }
+ }
+ }
+ }
+ }
+
+ if (tagCounts.size === 0) {
+ return "No tags in vault.";
+ }
+
+ // Sort by count descending
+ const sorted = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);
+
+ // Cap at 100 tags to avoid overwhelming context
+ const maxTags = 100;
+ const limited = sorted.slice(0, maxTags);
+ const lines = limited.map(([tag, count]) => `${tag} (${count})`);
+ const suffix = sorted.length > maxTags
+ ? `\n...and ${sorted.length - maxTags} more tags.`
+ : "";
+
+ return lines.join(", ") + suffix;
+}
+
+/**
+ * Get the most recently modified files.
+ */
+function buildRecentFiles(app: App, maxFiles: number): string {
+ const files = app.vault.getMarkdownFiles();
+
+ // Sort by modification time descending
+ const sorted = [...files].sort((a, b) => b.stat.mtime - a.stat.mtime);
+ const limited = sorted.slice(0, maxFiles);
+
+ if (limited.length === 0) {
+ return "No notes in vault.";
+ }
+
+ return limited.map((f) => f.path).join("\n");
+}
+
+/**
+ * Collect the full vault context summary.
+ * This is cheap — all data comes from the metadata cache and vault indexes.
+ */
+export function collectVaultContext(app: App, maxRecentFiles: number): VaultContext {
+ const markdownFiles = app.vault.getMarkdownFiles();
+ const allFolders = app.vault.getAllFolders(false);
+
+ return {
+ vaultName: app.vault.getName(),
+ totalNotes: markdownFiles.length,
+ totalFolders: allFolders.length,
+ folderTree: buildFolderTree(app),
+ tagTaxonomy: buildTagTaxonomy(app),
+ recentFiles: buildRecentFiles(app, maxRecentFiles),
+ };
+}
+
+/**
+ * Format the vault context into a system prompt block.
+ */
+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
+ );
+}