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:
@@ -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', () => {
|
||||
let telemetryManager: TelemetryManager;
|
||||
let telemetryExecutor: ToolExecutor;
|
||||
|
||||
@@ -489,4 +489,78 @@ describe('VaultIndexer', () => {
|
||||
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