import type { App } from "obsidian"; import vaultContextTemplate from "./context/vault-context-template.json"; /** * 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(); 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, []); } const siblings = tree.get(key); if (siblings !== undefined) { siblings.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: string | undefined = children[i]; if (child === undefined) continue; 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(); 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 using the JSON template. */ export function formatVaultContext(ctx: VaultContext): string { const t = vaultContextTemplate; const lines: string[] = []; lines.push(t.prefix); lines.push(""); lines.push(t.fields.vaultName.replace("{vaultName}", ctx.vaultName)); lines.push(t.fields.totalNotes.replace("{totalNotes}", String(ctx.totalNotes))); lines.push(t.fields.totalFolders.replace("{totalFolders}", String(ctx.totalFolders))); lines.push(""); lines.push(t.sections.folderTree.replace("{folderTree}", ctx.folderTree)); lines.push(""); lines.push(t.sections.tagTaxonomy.replace("{tagTaxonomy}", ctx.tagTaxonomy)); lines.push(""); lines.push(t.sections.recentFiles.replace("{recentFiles}", ctx.recentFiles)); return lines.join("\n"); }