Add list_vault_tags and get_vault_stats tools to all agent modes
Extend the tool registry to support vault-wide tag listing and statistical overview tools, adding them to READ, EDIT, ORGANIZE, and RESEARCH agent modes. Includes implementations for tag aggregation, folder structure reporting, and metadataCache integration in VaultIndexer.
This commit is contained in:
@@ -3124,10 +3124,17 @@ var ALL_AGENT_MODES = ["ask", "edit", "organize", "research", "workflow"];
|
|||||||
function filterToolsByName(tools, allowed) {
|
function filterToolsByName(tools, allowed) {
|
||||||
return tools.filter((t) => allowed.has(t.function.name));
|
return tools.filter((t) => allowed.has(t.function.name));
|
||||||
}
|
}
|
||||||
var READ_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]);
|
var READ_TOOLS = /* @__PURE__ */ new Set([
|
||||||
|
"read_vault_file",
|
||||||
|
"search_vault_files",
|
||||||
|
"list_vault_tags",
|
||||||
|
"get_vault_stats"
|
||||||
|
]);
|
||||||
var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([
|
var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([
|
||||||
"read_vault_file",
|
"read_vault_file",
|
||||||
"search_vault_files",
|
"search_vault_files",
|
||||||
|
"list_vault_tags",
|
||||||
|
"get_vault_stats",
|
||||||
"update_frontmatter",
|
"update_frontmatter",
|
||||||
"rename_note",
|
"rename_note",
|
||||||
"move_note",
|
"move_note",
|
||||||
@@ -3136,6 +3143,8 @@ var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([
|
|||||||
var EDIT_TOOLS = /* @__PURE__ */ new Set([
|
var EDIT_TOOLS = /* @__PURE__ */ new Set([
|
||||||
"read_vault_file",
|
"read_vault_file",
|
||||||
"search_vault_files",
|
"search_vault_files",
|
||||||
|
"list_vault_tags",
|
||||||
|
"get_vault_stats",
|
||||||
"create_note",
|
"create_note",
|
||||||
"append_to_note",
|
"append_to_note",
|
||||||
"replace_note_section",
|
"replace_note_section",
|
||||||
@@ -3145,7 +3154,12 @@ var EDIT_TOOLS = /* @__PURE__ */ new Set([
|
|||||||
"delete_note",
|
"delete_note",
|
||||||
"insert_link"
|
"insert_link"
|
||||||
]);
|
]);
|
||||||
var RESEARCH_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]);
|
var RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
||||||
|
"read_vault_file",
|
||||||
|
"search_vault_files",
|
||||||
|
"list_vault_tags",
|
||||||
|
"get_vault_stats"
|
||||||
|
]);
|
||||||
var AGENT_MODE_CONFIGS = {
|
var AGENT_MODE_CONFIGS = {
|
||||||
ask: {
|
ask: {
|
||||||
label: "Ask",
|
label: "Ask",
|
||||||
@@ -3182,8 +3196,9 @@ When editing notes:
|
|||||||
label: "Organize",
|
label: "Organize",
|
||||||
description: "Tag, rename, move, and link notes to keep the vault tidy.",
|
description: "Tag, rename, move, and link notes to keep the vault tidy.",
|
||||||
systemPrompt: `You are an assistant that helps organize the user's Obsidian vault.
|
systemPrompt: `You are an assistant that helps organize the user's Obsidian vault.
|
||||||
You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links.
|
You can search notes, read them, list all vault tags, get vault structure stats, update frontmatter tags, rename files, move files to folders, and insert wiki-links.
|
||||||
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool.
|
IMPORTANT: When the user asks about tags or folder structure, use list_vault_tags or get_vault_stats FIRST instead of searching blindly.
|
||||||
|
When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool.
|
||||||
Only respond to the user after you have received and analyzed the tool results.
|
Only respond to the user after you have received and analyzed the tool results.
|
||||||
When organizing:
|
When organizing:
|
||||||
- Suggest consistent tag vocabularies.
|
- Suggest consistent tag vocabularies.
|
||||||
@@ -3199,7 +3214,8 @@ When organizing:
|
|||||||
description: "Deep vault search and synthesis across multiple notes.",
|
description: "Deep vault search and synthesis across multiple notes.",
|
||||||
systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault.
|
systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault.
|
||||||
Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries.
|
Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries.
|
||||||
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file.
|
IMPORTANT: When the user asks about tags or vault structure, use list_vault_tags or get_vault_stats FIRST before searching.
|
||||||
|
When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file.
|
||||||
Only respond to the user after you have received and analyzed the tool results.
|
Only respond to the user after you have received and analyzed the tool results.
|
||||||
Search broadly, read key sources, and cross-reference information.
|
Search broadly, read key sources, and cross-reference information.
|
||||||
Cite specific notes and quotes where possible.
|
Cite specific notes and quotes where possible.
|
||||||
@@ -8565,11 +8581,14 @@ var VaultIndexer = class {
|
|||||||
setVectorStore(vectorStore) {
|
setVectorStore(vectorStore) {
|
||||||
this.vectorStore = vectorStore;
|
this.vectorStore = vectorStore;
|
||||||
}
|
}
|
||||||
|
setApp(app) {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
tokenize(text) {
|
tokenize(text) {
|
||||||
return text.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((token) => token.length > 1 && !STOP_WORDS.has(token));
|
return text.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((token) => token.length > 1 && !STOP_WORDS.has(token));
|
||||||
}
|
}
|
||||||
tokenizeContent(content, file) {
|
tokenizeContent(content, file) {
|
||||||
const parsed = this.parseMarkdown(content);
|
const parsed = this.parseMarkdown(content, file);
|
||||||
const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, "");
|
const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, "");
|
||||||
const paragraphs = bodyWithoutFrontmatter.split(/\n\n+/).map((p) => p.trim()).filter((p) => p && !p.startsWith("#"));
|
const paragraphs = bodyWithoutFrontmatter.split(/\n\n+/).map((p) => p.trim()).filter((p) => p && !p.startsWith("#"));
|
||||||
const firstParagraph = paragraphs[0] || "";
|
const firstParagraph = paragraphs[0] || "";
|
||||||
@@ -8629,7 +8648,7 @@ var VaultIndexer = class {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const content = typeof this.vault.cachedRead === "function" ? await this.vault.cachedRead(file) : await this.vault.read(file);
|
const content = typeof this.vault.cachedRead === "function" ? await this.vault.cachedRead(file) : await this.vault.read(file);
|
||||||
const parsed = this.parseMarkdown(content);
|
const parsed = this.parseMarkdown(content, file);
|
||||||
entries.push({
|
entries.push({
|
||||||
file,
|
file,
|
||||||
title: parsed.frontmatter.title || file.basename,
|
title: parsed.frontmatter.title || file.basename,
|
||||||
@@ -8777,32 +8796,62 @@ var VaultIndexer = class {
|
|||||||
const queryStem = this.stemToken(queryLower);
|
const queryStem = this.stemToken(queryLower);
|
||||||
return textLower.includes(queryLower) || textLower.includes(queryStem);
|
return textLower.includes(queryLower) || textLower.includes(queryStem);
|
||||||
}
|
}
|
||||||
parseMarkdown(content) {
|
parseMarkdown(content, file) {
|
||||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
let frontmatter = {};
|
||||||
const frontmatterMatch = content.match(frontmatterRegex);
|
let title = "";
|
||||||
const frontmatter = {};
|
let headings = [];
|
||||||
if (frontmatterMatch) {
|
if (this.app && file) {
|
||||||
try {
|
try {
|
||||||
const lines = frontmatterMatch[1].trim().split("\n");
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
for (const line of lines) {
|
if (cache?.frontmatter) {
|
||||||
const [key, ...valueParts] = line.split(":");
|
const fm = cache.frontmatter;
|
||||||
if (!key) continue;
|
if (typeof fm.title === "string") frontmatter.title = fm.title;
|
||||||
const value = valueParts.join(":").trim();
|
const rawTags = fm.tags;
|
||||||
if (key.trim() === "title" && value) frontmatter.title = value;
|
if (Array.isArray(rawTags)) {
|
||||||
else if (key.trim() === "tags" && value) frontmatter.tags = value;
|
frontmatter.tags = rawTags.map(String).join(", ");
|
||||||
|
} else if (typeof rawTags === "string") {
|
||||||
|
frontmatter.tags = rawTags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cache?.headings) {
|
||||||
|
headings = cache.headings.map((h) => h.heading);
|
||||||
|
}
|
||||||
|
if (headings.length > 0) {
|
||||||
|
title = headings[0];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Logger.warn("Failed to parse frontmatter", "vault-indexer");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const titleMatch = content.match(/^# (.+)$/m);
|
if (Object.keys(frontmatter).length === 0) {
|
||||||
const title = titleMatch ? titleMatch[1] : "";
|
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
||||||
const headings = [];
|
const frontmatterMatch2 = content.match(frontmatterRegex);
|
||||||
const headingRegex = /^#{1,6} (.+)$/gm;
|
if (frontmatterMatch2) {
|
||||||
let headingMatch;
|
try {
|
||||||
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
const lines = frontmatterMatch2[1].trim().split("\n");
|
||||||
headings.push(headingMatch[1]);
|
for (const line of lines) {
|
||||||
|
const [key, ...valueParts] = line.split(":");
|
||||||
|
if (!key) continue;
|
||||||
|
const value = valueParts.join(":").trim();
|
||||||
|
if (key.trim() === "title" && value) frontmatter.title = value;
|
||||||
|
else if (key.trim() === "tags" && value) frontmatter.tags = value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Logger.warn("Failed to parse frontmatter", "vault-indexer");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!title) {
|
||||||
|
const titleMatch = content.match(/^# (.+)$/m);
|
||||||
|
title = titleMatch ? titleMatch[1] : "";
|
||||||
|
}
|
||||||
|
if (headings.length === 0) {
|
||||||
|
const headingRegex = /^#{1,6} (.+)$/gm;
|
||||||
|
let headingMatch;
|
||||||
|
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
||||||
|
headings.push(headingMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||||
const bodyWithoutFrontmatter = frontmatterMatch ? content.substring(frontmatterMatch[0].length) : content;
|
const bodyWithoutFrontmatter = frontmatterMatch ? content.substring(frontmatterMatch[0].length) : content;
|
||||||
const bodyText = bodyWithoutFrontmatter.replace(/#{1,6} .+/g, "").replace(/^\s*[\r\n]/gm, "").trim();
|
const bodyText = bodyWithoutFrontmatter.replace(/#{1,6} .+/g, "").replace(/^\s*[\r\n]/gm, "").trim();
|
||||||
return { frontmatter, title, headings, content: bodyText };
|
return { frontmatter, title, headings, content: bodyText };
|
||||||
@@ -8923,6 +8972,12 @@ var ToolExecutor = class {
|
|||||||
case "insert_link":
|
case "insert_link":
|
||||||
result = await this.handleInsertLink(parsedArgs);
|
result = await this.handleInsertLink(parsedArgs);
|
||||||
break;
|
break;
|
||||||
|
case "list_vault_tags":
|
||||||
|
result = await this.handleListVaultTags(parsedArgs);
|
||||||
|
break;
|
||||||
|
case "get_vault_stats":
|
||||||
|
result = await this.handleGetVaultStats(parsedArgs);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
result = { success: false, message: `Unknown tool: ${toolName}` };
|
result = { success: false, message: `Unknown tool: ${toolName}` };
|
||||||
}
|
}
|
||||||
@@ -9217,6 +9272,105 @@ ${lines.join("\n")}
|
|||||||
await this.vault.delete(file);
|
await this.vault.delete(file);
|
||||||
return { success: true, message: `Note ${path} deleted successfully` };
|
return { success: true, message: `Note ${path} deleted successfully` };
|
||||||
}
|
}
|
||||||
|
async handleListVaultTags(args) {
|
||||||
|
const sortBy = args.sortBy === "count" ? "count" : "name";
|
||||||
|
const files = this.vault.getMarkdownFiles();
|
||||||
|
const tagMap = /* @__PURE__ */ new Map();
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const rawTags = cache?.frontmatter?.tags;
|
||||||
|
const tagList = [];
|
||||||
|
if (Array.isArray(rawTags)) {
|
||||||
|
tagList.push(...rawTags.map(String));
|
||||||
|
} else if (typeof rawTags === "string") {
|
||||||
|
tagList.push(
|
||||||
|
...rawTags.split(/[,\n]+/).map((t) => t.trim()).filter((t) => t.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const tag of tagList) {
|
||||||
|
const existing = tagMap.get(tag);
|
||||||
|
if (existing) {
|
||||||
|
existing.count++;
|
||||||
|
if (existing.notes.length < 5) existing.notes.push(file.path);
|
||||||
|
} else {
|
||||||
|
tagMap.set(tag, { count: 1, notes: [file.path] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entries = Array.from(tagMap.entries()).map(([tag, data]) => ({
|
||||||
|
tag,
|
||||||
|
count: data.count,
|
||||||
|
sampleNotes: data.notes.slice(0, 3)
|
||||||
|
}));
|
||||||
|
if (sortBy === "count") {
|
||||||
|
entries.sort((a, b) => b.count - a.count);
|
||||||
|
} else {
|
||||||
|
entries.sort((a, b) => a.tag.localeCompare(b.tag));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Found ${entries.length} unique tags across ${files.length} notes`,
|
||||||
|
data: entries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async handleGetVaultStats(args) {
|
||||||
|
const files = this.vault.getMarkdownFiles();
|
||||||
|
const folderSet = /* @__PURE__ */ new Set();
|
||||||
|
let totalLength = 0;
|
||||||
|
let taggedCount = 0;
|
||||||
|
let untaggedCount = 0;
|
||||||
|
const tagMap = /* @__PURE__ */ new Map();
|
||||||
|
const recentFiles = [];
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const folder = file.path.split("/").slice(0, -1).join("/") || "(root)";
|
||||||
|
folderSet.add(folder);
|
||||||
|
const content = await this.vault.cachedRead(file);
|
||||||
|
totalLength += content.length;
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const rawTags = cache?.frontmatter?.tags;
|
||||||
|
let hasTags = false;
|
||||||
|
if (Array.isArray(rawTags) && rawTags.length > 0) {
|
||||||
|
hasTags = true;
|
||||||
|
for (const tag of rawTags.map(String)) {
|
||||||
|
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
} else if (typeof rawTags === "string" && rawTags.trim().length > 0 && rawTags.trim() !== "[]") {
|
||||||
|
hasTags = true;
|
||||||
|
for (const tag of rawTags.split(/[,\n]+/).map((t) => t.trim()).filter((t) => t.length > 0)) {
|
||||||
|
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasTags) {
|
||||||
|
taggedCount++;
|
||||||
|
} else {
|
||||||
|
untaggedCount++;
|
||||||
|
}
|
||||||
|
if (file.stat?.mtime) {
|
||||||
|
recentFiles.push({ path: file.path, mtime: file.stat.mtime });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recentFiles.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Vault has ${files.length} notes in ${folderSet.size} folders`,
|
||||||
|
data: {
|
||||||
|
totalNotes: files.length,
|
||||||
|
totalFolders: folderSet.size,
|
||||||
|
folders: Array.from(folderSet).sort(),
|
||||||
|
taggedNotes: taggedCount,
|
||||||
|
untaggedNotes: untaggedCount,
|
||||||
|
topTags: Array.from(tagMap.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([tag, count]) => ({ tag, count })),
|
||||||
|
avgNoteLength: files.length > 0 ? Math.round(totalLength / files.length) : 0,
|
||||||
|
recentFiles: recentFiles.slice(0, 10).map((f) => f.path)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
async handleInsertLink(args) {
|
async handleInsertLink(args) {
|
||||||
const sourcePath = args.sourcePath;
|
const sourcePath = args.sourcePath;
|
||||||
const targetPath = args.targetPath;
|
const targetPath = args.targetPath;
|
||||||
@@ -9700,6 +9854,7 @@ var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g;
|
|||||||
var WorkflowEngine = class _WorkflowEngine {
|
var WorkflowEngine = class _WorkflowEngine {
|
||||||
constructor(vault, app, ollamaUrl, model, options) {
|
constructor(vault, app, ollamaUrl, model, options) {
|
||||||
this.vaultIndexer = new VaultIndexer(vault);
|
this.vaultIndexer = new VaultIndexer(vault);
|
||||||
|
this.vaultIndexer.setApp(app);
|
||||||
this.toolExecutor = new ToolExecutor(vault, app, void 0, this.vaultIndexer);
|
this.toolExecutor = new ToolExecutor(vault, app, void 0, this.vaultIndexer);
|
||||||
this.ollamaClient = new OllamaClient(ollamaUrl, model, void 0, options?.cacheConfig);
|
this.ollamaClient = new OllamaClient(ollamaUrl, model, void 0, options?.cacheConfig);
|
||||||
this.conversationStateManager = new ConversationStateManager();
|
this.conversationStateManager = new ConversationStateManager();
|
||||||
@@ -10748,6 +10903,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
|||||||
this.ollamaClient = this.createOllamaClient(settings.chatModel ?? settings.model, settings);
|
this.ollamaClient = this.createOllamaClient(settings.chatModel ?? settings.model, settings);
|
||||||
this.agentOllamaClient = (settings.agentModel ?? settings.model) === (settings.chatModel ?? settings.model) ? this.ollamaClient : this.createOllamaClient(settings.agentModel ?? settings.model, settings);
|
this.agentOllamaClient = (settings.agentModel ?? settings.model) === (settings.chatModel ?? settings.model) ? this.ollamaClient : this.createOllamaClient(settings.agentModel ?? settings.model, settings);
|
||||||
this.vaultIndexer = new VaultIndexer(this.app.vault, void 0, vectorStore);
|
this.vaultIndexer = new VaultIndexer(this.app.vault, void 0, vectorStore);
|
||||||
|
this.vaultIndexer.setApp(this.app);
|
||||||
this.toolExecutor = new ToolExecutor(
|
this.toolExecutor = new ToolExecutor(
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
this.app,
|
this.app,
|
||||||
@@ -11272,6 +11428,34 @@ var ChatView = class extends import_obsidian5.ItemView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "list_vault_tags",
|
||||||
|
description: "Lists all unique tags used across the vault with usage counts. Use this when the user asks about tags, tag organization, or wants to see all tags. Do NOT use search_vault_files for tag listing.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sortBy: {
|
||||||
|
type: "string",
|
||||||
|
description: 'Sort by "name" (alphabetical) or "count" (most used first). Default: "name"',
|
||||||
|
enum: ["name", "count"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_vault_stats",
|
||||||
|
description: "Returns an overview of the vault: total notes, folder structure, tag distribution, average note length, and recent files. Use this when the user asks about vault structure, folder organization, or wants a high-level overview before making organizational suggestions.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
@@ -13182,6 +13366,7 @@ var OllamaPlugin = class extends import_obsidian7.Plugin {
|
|||||||
);
|
);
|
||||||
if (!this.autoLinker) {
|
if (!this.autoLinker) {
|
||||||
const vaultIndexer = new VaultIndexer(this.app.vault, void 0, this.vaultVectorStore);
|
const vaultIndexer = new VaultIndexer(this.app.vault, void 0, this.vaultVectorStore);
|
||||||
|
vaultIndexer.setApp(this.app);
|
||||||
this.autoLinker = new AutoLinker(
|
this.autoLinker = new AutoLinker(
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
vaultIndexer,
|
vaultIndexer,
|
||||||
|
|||||||
+21
-5
@@ -25,11 +25,18 @@ function filterToolsByName(tools: OllamaTool[], allowed: Set<string>): OllamaToo
|
|||||||
return tools.filter((t) => allowed.has(t.function.name));
|
return tools.filter((t) => allowed.has(t.function.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
const READ_TOOLS = new Set(['read_vault_file', 'search_vault_files']);
|
const READ_TOOLS = new Set([
|
||||||
|
'read_vault_file',
|
||||||
|
'search_vault_files',
|
||||||
|
'list_vault_tags',
|
||||||
|
'get_vault_stats',
|
||||||
|
]);
|
||||||
|
|
||||||
const ORGANIZE_TOOLS = new Set([
|
const ORGANIZE_TOOLS = new Set([
|
||||||
'read_vault_file',
|
'read_vault_file',
|
||||||
'search_vault_files',
|
'search_vault_files',
|
||||||
|
'list_vault_tags',
|
||||||
|
'get_vault_stats',
|
||||||
'update_frontmatter',
|
'update_frontmatter',
|
||||||
'rename_note',
|
'rename_note',
|
||||||
'move_note',
|
'move_note',
|
||||||
@@ -39,6 +46,8 @@ const ORGANIZE_TOOLS = new Set([
|
|||||||
const EDIT_TOOLS = new Set([
|
const EDIT_TOOLS = new Set([
|
||||||
'read_vault_file',
|
'read_vault_file',
|
||||||
'search_vault_files',
|
'search_vault_files',
|
||||||
|
'list_vault_tags',
|
||||||
|
'get_vault_stats',
|
||||||
'create_note',
|
'create_note',
|
||||||
'append_to_note',
|
'append_to_note',
|
||||||
'replace_note_section',
|
'replace_note_section',
|
||||||
@@ -49,7 +58,12 @@ const EDIT_TOOLS = new Set([
|
|||||||
'insert_link',
|
'insert_link',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const RESEARCH_TOOLS = new Set(['read_vault_file', 'search_vault_files']);
|
const RESEARCH_TOOLS = new Set([
|
||||||
|
'read_vault_file',
|
||||||
|
'search_vault_files',
|
||||||
|
'list_vault_tags',
|
||||||
|
'get_vault_stats',
|
||||||
|
]);
|
||||||
|
|
||||||
export const AGENT_MODE_CONFIGS: Record<AgentMode, AgentModeConfig> = {
|
export const AGENT_MODE_CONFIGS: Record<AgentMode, AgentModeConfig> = {
|
||||||
ask: {
|
ask: {
|
||||||
@@ -89,8 +103,9 @@ When editing notes:
|
|||||||
label: 'Organize',
|
label: 'Organize',
|
||||||
description: 'Tag, rename, move, and link notes to keep the vault tidy.',
|
description: 'Tag, rename, move, and link notes to keep the vault tidy.',
|
||||||
systemPrompt: `You are an assistant that helps organize the user's Obsidian vault.
|
systemPrompt: `You are an assistant that helps organize the user's Obsidian vault.
|
||||||
You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links.
|
You can search notes, read them, list all vault tags, get vault structure stats, update frontmatter tags, rename files, move files to folders, and insert wiki-links.
|
||||||
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool.
|
IMPORTANT: When the user asks about tags or folder structure, use list_vault_tags or get_vault_stats FIRST instead of searching blindly.
|
||||||
|
When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool.
|
||||||
Only respond to the user after you have received and analyzed the tool results.
|
Only respond to the user after you have received and analyzed the tool results.
|
||||||
When organizing:
|
When organizing:
|
||||||
- Suggest consistent tag vocabularies.
|
- Suggest consistent tag vocabularies.
|
||||||
@@ -107,7 +122,8 @@ When organizing:
|
|||||||
description: 'Deep vault search and synthesis across multiple notes.',
|
description: 'Deep vault search and synthesis across multiple notes.',
|
||||||
systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault.
|
systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault.
|
||||||
Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries.
|
Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries.
|
||||||
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file.
|
IMPORTANT: When the user asks about tags or vault structure, use list_vault_tags or get_vault_stats FIRST before searching.
|
||||||
|
When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file.
|
||||||
Only respond to the user after you have received and analyzed the tool results.
|
Only respond to the user after you have received and analyzed the tool results.
|
||||||
Search broadly, read key sources, and cross-reference information.
|
Search broadly, read key sources, and cross-reference information.
|
||||||
Cite specific notes and quotes where possible.
|
Cite specific notes and quotes where possible.
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export class ChatView extends ItemView {
|
|||||||
? this.ollamaClient
|
? this.ollamaClient
|
||||||
: this.createOllamaClient(settings.agentModel ?? settings.model, settings);
|
: this.createOllamaClient(settings.agentModel ?? settings.model, settings);
|
||||||
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
|
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
|
||||||
|
this.vaultIndexer.setApp(this.app);
|
||||||
this.toolExecutor = new ToolExecutor(
|
this.toolExecutor = new ToolExecutor(
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
this.app,
|
this.app,
|
||||||
@@ -666,6 +667,37 @@ export class ChatView extends ItemView {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'list_vault_tags',
|
||||||
|
description:
|
||||||
|
'Lists all unique tags used across the vault with usage counts. Use this when the user asks about tags, tag organization, or wants to see all tags. Do NOT use search_vault_files for tag listing.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
sortBy: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Sort by "name" (alphabetical) or "count" (most used first). Default: "name"',
|
||||||
|
enum: ['name', 'count'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_vault_stats',
|
||||||
|
description:
|
||||||
|
'Returns an overview of the vault: total notes, folder structure, tag distribution, average note length, and recent files. Use this when the user asks about vault structure, folder organization, or wants a high-level overview before making organizational suggestions.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export default class OllamaPlugin extends Plugin {
|
|||||||
|
|
||||||
if (!this.autoLinker) {
|
if (!this.autoLinker) {
|
||||||
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
|
const vaultIndexer = new VaultIndexer(this.app.vault, undefined, this.vaultVectorStore);
|
||||||
|
vaultIndexer.setApp(this.app);
|
||||||
this.autoLinker = new AutoLinker(
|
this.autoLinker = new AutoLinker(
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
vaultIndexer,
|
vaultIndexer,
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ export class ToolExecutor {
|
|||||||
case 'insert_link':
|
case 'insert_link':
|
||||||
result = await this.handleInsertLink(parsedArgs);
|
result = await this.handleInsertLink(parsedArgs);
|
||||||
break;
|
break;
|
||||||
|
case 'list_vault_tags':
|
||||||
|
result = await this.handleListVaultTags(parsedArgs);
|
||||||
|
break;
|
||||||
|
case 'get_vault_stats':
|
||||||
|
result = await this.handleGetVaultStats(parsedArgs);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
result = { success: false, message: `Unknown tool: ${toolName}` };
|
result = { success: false, message: `Unknown tool: ${toolName}` };
|
||||||
}
|
}
|
||||||
@@ -535,6 +541,133 @@ export class ToolExecutor {
|
|||||||
return { success: true, message: `Note ${path} deleted successfully` };
|
return { success: true, message: `Note ${path} deleted successfully` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleListVaultTags(args: Record<string, unknown>): Promise<ToolResult> {
|
||||||
|
const sortBy = args.sortBy === 'count' ? 'count' : 'name';
|
||||||
|
const files = this.vault.getMarkdownFiles();
|
||||||
|
const tagMap = new Map<string, { count: number; notes: string[] }>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const rawTags: unknown = cache?.frontmatter?.tags;
|
||||||
|
const tagList: string[] = [];
|
||||||
|
if (Array.isArray(rawTags)) {
|
||||||
|
tagList.push(...rawTags.map(String));
|
||||||
|
} else if (typeof rawTags === 'string') {
|
||||||
|
tagList.push(
|
||||||
|
...rawTags
|
||||||
|
.split(/[,\n]+/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const tag of tagList) {
|
||||||
|
const existing = tagMap.get(tag);
|
||||||
|
if (existing) {
|
||||||
|
existing.count++;
|
||||||
|
if (existing.notes.length < 5) existing.notes.push(file.path);
|
||||||
|
} else {
|
||||||
|
tagMap.set(tag, { count: 1, notes: [file.path] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unreadable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Array.from(tagMap.entries()).map(([tag, data]) => ({
|
||||||
|
tag,
|
||||||
|
count: data.count,
|
||||||
|
sampleNotes: data.notes.slice(0, 3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (sortBy === 'count') {
|
||||||
|
entries.sort((a, b) => b.count - a.count);
|
||||||
|
} else {
|
||||||
|
entries.sort((a, b) => a.tag.localeCompare(b.tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Found ${entries.length} unique tags across ${files.length} notes`,
|
||||||
|
data: entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGetVaultStats(args: Record<string, unknown>): Promise<ToolResult> {
|
||||||
|
const files = this.vault.getMarkdownFiles();
|
||||||
|
const folderSet = new Set<string>();
|
||||||
|
let totalLength = 0;
|
||||||
|
let taggedCount = 0;
|
||||||
|
let untaggedCount = 0;
|
||||||
|
const tagMap = new Map<string, number>();
|
||||||
|
const recentFiles: { path: string; mtime: number }[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const folder = file.path.split('/').slice(0, -1).join('/') || '(root)';
|
||||||
|
folderSet.add(folder);
|
||||||
|
|
||||||
|
const content = await this.vault.cachedRead(file);
|
||||||
|
totalLength += content.length;
|
||||||
|
|
||||||
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
|
const rawTags: unknown = cache?.frontmatter?.tags;
|
||||||
|
let hasTags = false;
|
||||||
|
if (Array.isArray(rawTags) && rawTags.length > 0) {
|
||||||
|
hasTags = true;
|
||||||
|
for (const tag of rawTags.map(String)) {
|
||||||
|
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
typeof rawTags === 'string' &&
|
||||||
|
rawTags.trim().length > 0 &&
|
||||||
|
rawTags.trim() !== '[]'
|
||||||
|
) {
|
||||||
|
hasTags = true;
|
||||||
|
for (const tag of rawTags
|
||||||
|
.split(/[,\n]+/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length > 0)) {
|
||||||
|
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTags) {
|
||||||
|
taggedCount++;
|
||||||
|
} else {
|
||||||
|
untaggedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.stat?.mtime) {
|
||||||
|
recentFiles.push({ path: file.path, mtime: file.stat.mtime });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unreadable files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recentFiles.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Vault has ${files.length} notes in ${folderSet.size} folders`,
|
||||||
|
data: {
|
||||||
|
totalNotes: files.length,
|
||||||
|
totalFolders: folderSet.size,
|
||||||
|
folders: Array.from(folderSet).sort(),
|
||||||
|
taggedNotes: taggedCount,
|
||||||
|
untaggedNotes: untaggedCount,
|
||||||
|
topTags: Array.from(tagMap.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(([tag, count]) => ({ tag, count })),
|
||||||
|
avgNoteLength: files.length > 0 ? Math.round(totalLength / files.length) : 0,
|
||||||
|
recentFiles: recentFiles.slice(0, 10).map((f) => f.path),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async handleInsertLink(args: Record<string, unknown>): Promise<ToolResult> {
|
private async handleInsertLink(args: Record<string, unknown>): Promise<ToolResult> {
|
||||||
const sourcePath = args.sourcePath;
|
const sourcePath = args.sourcePath;
|
||||||
const targetPath = args.targetPath;
|
const targetPath = args.targetPath;
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface OllamaTool {
|
|||||||
[key: string]: {
|
[key: string]: {
|
||||||
type: string;
|
type: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
enum?: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
required?: string[];
|
required?: string[];
|
||||||
|
|||||||
+66
-24
@@ -1,6 +1,6 @@
|
|||||||
// src/vault-indexer.ts
|
// src/vault-indexer.ts
|
||||||
|
|
||||||
import { Vault, TFile } from 'obsidian';
|
import { Vault, TFile, App } from 'obsidian';
|
||||||
import { Logger } from './utils';
|
import { Logger } from './utils';
|
||||||
import { Cache } from './cache';
|
import { Cache } from './cache';
|
||||||
import { VaultVectorStore } from './vault-vector-store';
|
import { VaultVectorStore } from './vault-vector-store';
|
||||||
@@ -110,6 +110,7 @@ const DEFAULT_RECENCY_HALF_LIFE = 30; // 30 days
|
|||||||
|
|
||||||
export class VaultIndexer {
|
export class VaultIndexer {
|
||||||
private vault: Vault;
|
private vault: Vault;
|
||||||
|
private app?: App;
|
||||||
private cache?: Cache;
|
private cache?: Cache;
|
||||||
private vectorStore?: VaultVectorStore;
|
private vectorStore?: VaultVectorStore;
|
||||||
private readonly SCORING_WEIGHTS = {
|
private readonly SCORING_WEIGHTS = {
|
||||||
@@ -134,6 +135,10 @@ export class VaultIndexer {
|
|||||||
this.vectorStore = vectorStore;
|
this.vectorStore = vectorStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setApp(app: App | undefined): void {
|
||||||
|
this.app = app;
|
||||||
|
}
|
||||||
|
|
||||||
tokenize(text: string): string[] {
|
tokenize(text: string): string[] {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -143,7 +148,7 @@ export class VaultIndexer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenizeContent(content: string, file: TFile): TokenizedContent {
|
tokenizeContent(content: string, file: TFile): TokenizedContent {
|
||||||
const parsed = this.parseMarkdown(content);
|
const parsed = this.parseMarkdown(content, file);
|
||||||
const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, '');
|
const bodyWithoutFrontmatter = content.replace(/^---[\s\S]*?---\n/, '');
|
||||||
const paragraphs = bodyWithoutFrontmatter
|
const paragraphs = bodyWithoutFrontmatter
|
||||||
.split(/\n\n+/)
|
.split(/\n\n+/)
|
||||||
@@ -221,7 +226,7 @@ export class VaultIndexer {
|
|||||||
typeof this.vault.cachedRead === 'function'
|
typeof this.vault.cachedRead === 'function'
|
||||||
? await this.vault.cachedRead(file)
|
? await this.vault.cachedRead(file)
|
||||||
: await this.vault.read(file);
|
: await this.vault.read(file);
|
||||||
const parsed = this.parseMarkdown(content);
|
const parsed = this.parseMarkdown(content, file);
|
||||||
entries.push({
|
entries.push({
|
||||||
file,
|
file,
|
||||||
title: parsed.frontmatter.title || file.basename,
|
title: parsed.frontmatter.title || file.basename,
|
||||||
@@ -404,35 +409,72 @@ export class VaultIndexer {
|
|||||||
return textLower.includes(queryLower) || textLower.includes(queryStem);
|
return textLower.includes(queryLower) || textLower.includes(queryStem);
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseMarkdown(content: string) {
|
private parseMarkdown(content: string, file?: TFile) {
|
||||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
let frontmatter: ParsedFrontmatter = {};
|
||||||
const frontmatterMatch = content.match(frontmatterRegex);
|
let title = '';
|
||||||
const frontmatter: ParsedFrontmatter = {};
|
let headings: string[] = [];
|
||||||
if (frontmatterMatch) {
|
|
||||||
|
// Use metadataCache when available for accurate frontmatter and headings parsing
|
||||||
|
if (this.app && file) {
|
||||||
try {
|
try {
|
||||||
const lines = frontmatterMatch[1].trim().split('\n');
|
const cache = this.app.metadataCache.getFileCache(file);
|
||||||
for (const line of lines) {
|
if (cache?.frontmatter) {
|
||||||
const [key, ...valueParts] = line.split(':');
|
const fm = cache.frontmatter as Record<string, unknown>;
|
||||||
if (!key) continue;
|
if (typeof fm.title === 'string') frontmatter.title = fm.title;
|
||||||
const value = valueParts.join(':').trim();
|
const rawTags = fm.tags;
|
||||||
if (key.trim() === 'title' && value) frontmatter.title = value;
|
if (Array.isArray(rawTags)) {
|
||||||
else if (key.trim() === 'tags' && value) frontmatter.tags = value;
|
frontmatter.tags = rawTags.map(String).join(', ');
|
||||||
|
} else if (typeof rawTags === 'string') {
|
||||||
|
frontmatter.tags = rawTags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cache?.headings) {
|
||||||
|
headings = cache.headings.map((h) => h.heading);
|
||||||
|
}
|
||||||
|
if (headings.length > 0) {
|
||||||
|
title = headings[0];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
// Fall back to regex parsing below
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleMatch = content.match(/^# (.+)$/m);
|
// Fallback regex parsing for frontmatter when metadataCache is unavailable
|
||||||
const title = titleMatch ? titleMatch[1] : '';
|
if (Object.keys(frontmatter).length === 0) {
|
||||||
|
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/;
|
||||||
const headings: string[] = [];
|
const frontmatterMatch = content.match(frontmatterRegex);
|
||||||
const headingRegex = /^#{1,6} (.+)$/gm;
|
if (frontmatterMatch) {
|
||||||
let headingMatch;
|
try {
|
||||||
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
const lines = frontmatterMatch[1].trim().split('\n');
|
||||||
headings.push(headingMatch[1]);
|
for (const line of lines) {
|
||||||
|
const [key, ...valueParts] = line.split(':');
|
||||||
|
if (!key) continue;
|
||||||
|
const value = valueParts.join(':').trim();
|
||||||
|
if (key.trim() === 'title' && value) frontmatter.title = value;
|
||||||
|
else if (key.trim() === 'tags' && value) frontmatter.tags = value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Logger.warn('Failed to parse frontmatter', 'vault-indexer');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback regex parsing for title
|
||||||
|
if (!title) {
|
||||||
|
const titleMatch = content.match(/^# (.+)$/m);
|
||||||
|
title = titleMatch ? titleMatch[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback regex parsing for headings
|
||||||
|
if (headings.length === 0) {
|
||||||
|
const headingRegex = /^#{1,6} (.+)$/gm;
|
||||||
|
let headingMatch;
|
||||||
|
while ((headingMatch = headingRegex.exec(content)) !== null) {
|
||||||
|
headings.push(headingMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
||||||
const bodyWithoutFrontmatter = frontmatterMatch
|
const bodyWithoutFrontmatter = frontmatterMatch
|
||||||
? content.substring(frontmatterMatch[0].length)
|
? content.substring(frontmatterMatch[0].length)
|
||||||
: content;
|
: content;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export class WorkflowEngine {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
this.vaultIndexer = new VaultIndexer(vault);
|
this.vaultIndexer = new VaultIndexer(vault);
|
||||||
|
this.vaultIndexer.setApp(app);
|
||||||
this.toolExecutor = new ToolExecutor(vault, app, undefined, this.vaultIndexer);
|
this.toolExecutor = new ToolExecutor(vault, app, undefined, this.vaultIndexer);
|
||||||
this.ollamaClient = new OllamaClient(ollamaUrl, model, undefined, options?.cacheConfig);
|
this.ollamaClient = new OllamaClient(ollamaUrl, model, undefined, options?.cacheConfig);
|
||||||
this.conversationStateManager = new ConversationStateManager();
|
this.conversationStateManager = new ConversationStateManager();
|
||||||
|
|||||||
@@ -1295,6 +1295,141 @@ describe('ToolExecutor', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('list_vault_tags tool', () => {
|
||||||
|
it('should list all tags sorted by name', async () => {
|
||||||
|
class MockTFile extends TFile {
|
||||||
|
path: string;
|
||||||
|
basename: string;
|
||||||
|
extension: string;
|
||||||
|
name: string;
|
||||||
|
constructor(path: string) {
|
||||||
|
super();
|
||||||
|
this.path = path;
|
||||||
|
this.basename = path.split('/').pop() || path;
|
||||||
|
this.extension = this.basename.split('.').pop() || '';
|
||||||
|
this.name = this.basename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = [new MockTFile('a.md'), new MockTFile('b.md'), new MockTFile('c.md')];
|
||||||
|
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||||
|
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
|
||||||
|
if (f.path === 'a.md') return { frontmatter: { tags: ['project', 'alpha'] } };
|
||||||
|
if (f.path === 'b.md') return { frontmatter: { tags: 'project, beta' } };
|
||||||
|
return { frontmatter: {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
const call: ToolCall = {
|
||||||
|
id: 'call_lt1',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'list_vault_tags',
|
||||||
|
arguments: JSON.stringify({ sortBy: 'name' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await executor.handleToolCall(call);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect((result.data as any[]).length).toBe(3);
|
||||||
|
expect((result.data as any[])[0].tag).toBe('alpha');
|
||||||
|
expect((result.data as any[])[1].tag).toBe('beta');
|
||||||
|
expect((result.data as any[])[2].tag).toBe('project');
|
||||||
|
expect((result.data as any[])[2].count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort tags by count', async () => {
|
||||||
|
class MockTFile extends TFile {
|
||||||
|
path: string;
|
||||||
|
basename: string;
|
||||||
|
extension: string;
|
||||||
|
name: string;
|
||||||
|
constructor(path: string) {
|
||||||
|
super();
|
||||||
|
this.path = path;
|
||||||
|
this.basename = path.split('/').pop() || path;
|
||||||
|
this.extension = this.basename.split('.').pop() || '';
|
||||||
|
this.name = this.basename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = [new MockTFile('a.md'), new MockTFile('b.md')];
|
||||||
|
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||||
|
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
|
||||||
|
if (f.path === 'a.md') return { frontmatter: { tags: ['common', 'rare'] } };
|
||||||
|
if (f.path === 'b.md') return { frontmatter: { tags: ['common'] } };
|
||||||
|
return { frontmatter: {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
const call: ToolCall = {
|
||||||
|
id: 'call_lt2',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'list_vault_tags',
|
||||||
|
arguments: JSON.stringify({ sortBy: 'count' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await executor.handleToolCall(call);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const data = result.data as any[];
|
||||||
|
expect(data[0].tag).toBe('common');
|
||||||
|
expect(data[0].count).toBe(2);
|
||||||
|
expect(data[1].tag).toBe('rare');
|
||||||
|
expect(data[1].count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get_vault_stats tool', () => {
|
||||||
|
it('should return vault overview stats', async () => {
|
||||||
|
class MockTFile extends TFile {
|
||||||
|
path: string;
|
||||||
|
basename: string;
|
||||||
|
extension: string;
|
||||||
|
name: string;
|
||||||
|
constructor(path: string, mtime?: number) {
|
||||||
|
super();
|
||||||
|
this.path = path;
|
||||||
|
this.basename = path.split('/').pop() || path;
|
||||||
|
this.extension = this.basename.split('.').pop() || '';
|
||||||
|
this.name = this.basename;
|
||||||
|
if (mtime) {
|
||||||
|
(this as any).stat = { mtime, ctime: mtime, size: 100 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = [
|
||||||
|
new MockTFile('Projects/alpha.md', 1000),
|
||||||
|
new MockTFile('Projects/beta.md', 2000),
|
||||||
|
new MockTFile('notes/daily.md', 1500),
|
||||||
|
];
|
||||||
|
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(files);
|
||||||
|
mockVault.cachedRead = jest.fn().mockResolvedValue('content');
|
||||||
|
mockApp.metadataCache.getFileCache = jest.fn().mockImplementation((f: MockTFile) => {
|
||||||
|
if (f.path === 'Projects/alpha.md') return { frontmatter: { tags: ['project'] } };
|
||||||
|
if (f.path === 'Projects/beta.md') return { frontmatter: { tags: ['project', 'done'] } };
|
||||||
|
return { frontmatter: {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
const call: ToolCall = {
|
||||||
|
id: 'call_vs1',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'get_vault_stats',
|
||||||
|
arguments: JSON.stringify({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await executor.handleToolCall(call);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const data = result.data as any;
|
||||||
|
expect(data.totalNotes).toBe(3);
|
||||||
|
expect(data.totalFolders).toBe(2);
|
||||||
|
expect(data.folders).toContain('Projects');
|
||||||
|
expect(data.folders).toContain('notes');
|
||||||
|
expect(data.taggedNotes).toBe(2);
|
||||||
|
expect(data.untaggedNotes).toBe(1);
|
||||||
|
expect(data.topTags).toHaveLength(2);
|
||||||
|
expect(data.topTags[0].tag).toBe('project');
|
||||||
|
expect(data.topTags[0].count).toBe(2);
|
||||||
|
expect(data.recentFiles[0]).toBe('Projects/beta.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('telemetry integration', () => {
|
describe('telemetry integration', () => {
|
||||||
let telemetryManager: TelemetryManager;
|
let telemetryManager: TelemetryManager;
|
||||||
let telemetryExecutor: ToolExecutor;
|
let telemetryExecutor: ToolExecutor;
|
||||||
|
|||||||
@@ -489,4 +489,78 @@ describe('VaultIndexer', () => {
|
|||||||
expect(key2).toContain('norecency');
|
expect(key2).toContain('norecency');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with metadataCache', () => {
|
||||||
|
it('should parse YAML array tags from metadataCache', async () => {
|
||||||
|
const file = {
|
||||||
|
basename: 'Note A',
|
||||||
|
path: 'projects/note-a.md',
|
||||||
|
};
|
||||||
|
const mockApp = {
|
||||||
|
metadataCache: {
|
||||||
|
getFileCache: jest.fn().mockReturnValue({
|
||||||
|
frontmatter: {
|
||||||
|
title: 'Project Alpha',
|
||||||
|
tags: ['project', 'alpha', 'urgent'],
|
||||||
|
},
|
||||||
|
headings: [{ heading: 'Project Alpha' }, { heading: 'Overview' }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const indexedWithApp = new VaultIndexer(mockVault as unknown as any);
|
||||||
|
indexedWithApp.setApp(mockApp as unknown as any);
|
||||||
|
|
||||||
|
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||||
|
mockVault.read = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue('# Project Alpha\n\nSome content here.\n\n## Overview\n\nMore text.');
|
||||||
|
|
||||||
|
const results = await indexedWithApp.searchVault('alpha', 5);
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
expect(results[0].title).toBe('Project Alpha');
|
||||||
|
expect(results[0].tags).toBe('project, alpha, urgent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to regex parsing when metadataCache is unavailable', async () => {
|
||||||
|
const file = {
|
||||||
|
basename: 'Note B',
|
||||||
|
path: 'note-b.md',
|
||||||
|
};
|
||||||
|
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([file]);
|
||||||
|
mockVault.read = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
'---\ntitle: Legacy Note\ntags: legacy, old\n---\n\n# Legacy Note\n\nContent here.'
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await indexer.searchVault('legacy', 5);
|
||||||
|
expect(results.length).toBe(1);
|
||||||
|
expect(results[0].title).toBe('Legacy Note');
|
||||||
|
expect(results[0].tags).toBe('legacy, old');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use metadataCache headings when available', () => {
|
||||||
|
const file = {
|
||||||
|
basename: 'Note C',
|
||||||
|
path: 'note-c.md',
|
||||||
|
};
|
||||||
|
const mockApp = {
|
||||||
|
metadataCache: {
|
||||||
|
getFileCache: jest.fn().mockReturnValue({
|
||||||
|
frontmatter: {},
|
||||||
|
headings: [{ heading: 'First Heading' }, { heading: 'Second Heading' }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const indexedWithApp = new VaultIndexer(mockVault as unknown as any);
|
||||||
|
indexedWithApp.setApp(mockApp as unknown as any);
|
||||||
|
|
||||||
|
const tokenized = (indexedWithApp as any).tokenizeContent(
|
||||||
|
'Some content\n\n# First Heading\n\n# Second Heading\n\nBody.',
|
||||||
|
file
|
||||||
|
);
|
||||||
|
expect(tokenized.headings).toEqual(['First Heading', 'Second Heading']);
|
||||||
|
expect(tokenized.title).toBe('First Heading');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user