Add tool telemetry tracking for LLM calls and vault searches

Introduces TelemetryManager to record tool calls, LLM token usage
(prompt_eval_count, eval_count), and vault search queries with timing.
Wires telemetry through ChatView, ToolExecutor, and OllamaClient with
configurable limits and enable/disable toggle.
This commit is contained in:
2026-05-20 22:32:15 +02:00
parent fbb744ba6b
commit f98afcd6b0
9 changed files with 698 additions and 44 deletions
+34 -2
View File
@@ -26,6 +26,7 @@ import {
import { ConversationStateManager } from './conversation-state';
import { ErrorHandler } from './error-handler';
import { StructuredMemoryManager } from './structured-memory';
import { TelemetryManager } from './tool-telemetry';
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
@@ -47,7 +48,8 @@ export class ChatView extends ItemView {
leaf: WorkspaceLeaf,
settings: PluginSettings,
vectorStore?: VaultVectorStore,
structuredMemoryManager?: StructuredMemoryManager
structuredMemoryManager?: StructuredMemoryManager,
telemetryManager?: TelemetryManager
) {
super(leaf);
this.messages = [];
@@ -72,11 +74,12 @@ export class ChatView extends ItemView {
settings.cacheConfig
);
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
this.toolExecutor = new ToolExecutor(this.app.vault, this.app);
this.toolExecutor = new ToolExecutor(this.app.vault, this.app, telemetryManager);
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
this.conversationStateManager = new ConversationStateManager();
this.structuredMemoryManager = structuredMemoryManager;
this.telemetryManager = telemetryManager;
this.workflowEngine = new WorkflowEngine(
this.app.vault,
this.app,
@@ -1036,6 +1039,9 @@ export class ChatView extends ItemView {
let fullResponse = '';
let toolCalls: OllamaToolCall[] = [];
let promptTokens = 0;
let completionTokens = 0;
const llmStartTime = Date.now();
for await (const chunk of stream) {
if (chunk.content) {
@@ -1050,8 +1056,33 @@ export class ChatView extends ItemView {
if (chunk.tool_calls) {
toolCalls = [...toolCalls, ...chunk.tool_calls];
}
// Capture token counts from final chunk when Ollama provides them
if (typeof chunk.prompt_eval_count === 'number') {
promptTokens = chunk.prompt_eval_count;
}
if (typeof chunk.eval_count === 'number') {
completionTokens = chunk.eval_count;
}
}
const llmDurationMs = Date.now() - llmStartTime;
// Fallback: estimate tokens from characters if Ollama didn't provide counts
const estimatedPromptTokens =
promptTokens > 0
? promptTokens
: completeMessages.reduce((sum, m) => sum + m.content.length, 0) / 4;
const estimatedCompletionTokens =
completionTokens > 0 ? completionTokens : fullResponse.length / 4;
this.telemetryManager?.recordLlmCall({
model: this.settings.model,
promptTokens: Math.round(estimatedPromptTokens),
completionTokens: Math.round(estimatedCompletionTokens),
totalTokens: Math.round(estimatedPromptTokens + estimatedCompletionTokens),
durationMs: llmDurationMs,
});
// Process tool calls if any
if (toolCalls.length > 0) {
await this.processToolCalls(
@@ -1152,6 +1183,7 @@ export class ChatView extends ItemView {
private workflowEngine: WorkflowEngine;
private conversationStateManager: ConversationStateManager;
private structuredMemoryManager?: StructuredMemoryManager;
private telemetryManager?: TelemetryManager;
private vectorStore?: VaultVectorStore;
private modeSelectorEl: HTMLSelectElement | null = null;
+4
View File
@@ -44,4 +44,8 @@ export const DEFAULT_SETTINGS = {
maxPreferences: 20,
maxFacts: 50,
},
toolTelemetryConfig: {
enabled: true,
maxEntries: 100,
},
};
+97 -2
View File
@@ -5,11 +5,12 @@ import { SemanticCacheService } from './semantic-cache';
import { VaultVectorStore } from './vault-vector-store';
import { VaultIndexer } from './vault-indexer';
import { AutoTagger, AutoLinker } from './auto-organizer';
import { PluginSettings, StructuredMemoryData } from './types';
import { PluginSettings, StructuredMemoryData, ToolTelemetryData } from './types';
import { Logger } from './utils';
import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes';
import { AgentMode } from './types';
import { StructuredMemoryManager, createDefaultStructuredMemoryData } from './structured-memory';
import { TelemetryManager, createDefaultToolTelemetryData } from './tool-telemetry';
export default class OllamaPlugin extends Plugin {
settings: PluginSettings = DEFAULT_SETTINGS;
@@ -18,6 +19,7 @@ export default class OllamaPlugin extends Plugin {
autoTagger?: AutoTagger;
autoLinker?: AutoLinker;
structuredMemoryManager?: StructuredMemoryManager;
telemetryManager?: TelemetryManager;
private indexingAbortController?: AbortController;
private currentIndexingPromise?: Promise<void>;
@@ -33,7 +35,13 @@ export default class OllamaPlugin extends Plugin {
this.registerView(
'ollama-chat-view',
(leaf: WorkspaceLeaf) =>
new ChatView(leaf, this.settings, this.vaultVectorStore, this.structuredMemoryManager)
new ChatView(
leaf,
this.settings,
this.vaultVectorStore,
this.structuredMemoryManager,
this.telemetryManager
)
);
// Add a ribbon icon in the left sidebar
@@ -117,6 +125,17 @@ export default class OllamaPlugin extends Plugin {
new Notice('Structured memory cleared.');
},
});
// Add a command to clear tool telemetry
this.addCommand({
id: 'clear-tool-telemetry',
name: 'Clear Tool Telemetry',
callback: async () => {
this.telemetryManager?.clear();
await this.saveSettings();
new Notice('Tool telemetry cleared.');
},
});
this.addSettingTab(new OllamaSettingTab(this.app, this));
// Initialize the semantic cache
@@ -162,6 +181,10 @@ export default class OllamaPlugin extends Plugin {
this.settings.structuredMemoryConfig,
memoryData
);
const telemetryData: ToolTelemetryData =
(data.toolTelemetry as ToolTelemetryData | undefined) ?? createDefaultToolTelemetryData();
this.telemetryManager = new TelemetryManager(this.settings.toolTelemetryConfig, telemetryData);
}
async saveSettings() {
@@ -169,6 +192,7 @@ export default class OllamaPlugin extends Plugin {
settings: this.settings,
structuredMemory:
this.structuredMemoryManager?.getData() ?? createDefaultStructuredMemoryData(),
toolTelemetry: this.telemetryManager?.getData() ?? createDefaultToolTelemetryData(),
});
}
@@ -972,6 +996,77 @@ class OllamaSettingTab extends PluginSettingTab {
new Notice('Structured memory cleared.');
})
);
// Tool Telemetry Settings
containerEl.createEl('h3', { text: 'Tool Telemetry' });
containerEl.createEl('p', {
text: 'Track which tools were called, which notes were searched, and LLM token usage.',
});
new Setting(containerEl)
.setName('Enable Tool Telemetry')
.setDesc('Record tool calls, searches, and LLM token counts for analysis.')
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.toolTelemetryConfig.enabled)
.onChange(async (value) => {
this.plugin.settings.toolTelemetryConfig.enabled = value;
this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig);
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Max Telemetry Entries')
.setDesc('Maximum number of telemetry events to retain (default: 100).')
.addText((text) =>
text
.setValue(String(this.plugin.settings.toolTelemetryConfig.maxEntries))
.onChange(async (value) => {
const parsed = parseInt(value);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1000) {
this.plugin.settings.toolTelemetryConfig.maxEntries = parsed;
this.plugin.telemetryManager?.updateConfig(this.plugin.settings.toolTelemetryConfig);
await this.plugin.saveSettings();
} else {
new Notice('Max entries must be between 0 and 1000.');
}
})
);
new Setting(containerEl)
.setName('Clear Tool Telemetry')
.setDesc('Delete all recorded tool telemetry.')
.addButton((button) =>
button.setButtonText('Clear Telemetry').onClick(async () => {
this.plugin.telemetryManager?.clear();
await this.plugin.saveSettings();
new Notice('Tool telemetry cleared.');
})
);
// Recent Telemetry Summary
const recentEntries = this.plugin.telemetryManager?.getRecentEntries(10) ?? [];
if (recentEntries.length > 0) {
containerEl.createEl('h4', { text: 'Recent Activity' });
const telemetryList = containerEl.createEl('ul');
for (const entry of recentEntries) {
const li = telemetryList.createEl('li');
if (entry.type === 'tool_call') {
li.setText(
`${new Date(entry.timestamp).toLocaleString()}: Tool "${entry.toolName}" — ${entry.success ? 'success' : 'failed'} (${entry.durationMs}ms)`
);
} else if (entry.type === 'llm_call') {
li.setText(
`${new Date(entry.timestamp).toLocaleString()}: LLM call — ${entry.totalTokens} tokens (${entry.durationMs}ms)`
);
} else if (entry.type === 'vault_search') {
li.setText(
`${new Date(entry.timestamp).toLocaleString()}: Search "${entry.query}" — ${entry.resultsCount} results`
);
}
}
}
}
hide() {
+13 -4
View File
@@ -8,6 +8,9 @@ import { SemanticCacheService } from './semantic-cache';
interface OllamaChatResponse {
message?: Partial<OllamaMessage>;
error?: string;
done?: boolean;
prompt_eval_count?: number;
eval_count?: number;
}
export class OllamaClient {
@@ -206,7 +209,7 @@ export class OllamaClient {
throw new Error(`Ollama error: ${errorMsg}`);
}
yield this.normalizeMessage(parsed.message);
yield this.normalizeMessage(parsed.message, parsed.prompt_eval_count, parsed.eval_count);
}
}
@@ -229,7 +232,7 @@ export class OllamaClient {
}
if (parsed?.message) {
yield this.normalizeMessage(parsed.message);
yield this.normalizeMessage(parsed.message, parsed.prompt_eval_count, parsed.eval_count);
}
}
} catch (error) {
@@ -289,7 +292,7 @@ export class OllamaClient {
if (!this.isChatResponse(data)) {
return this.normalizeMessage();
}
return this.normalizeMessage(data.message);
return this.normalizeMessage(data.message, data.prompt_eval_count, data.eval_count);
} catch (error) {
if (retryCount < this.maxRetries && this.isRetryableError(error, controller)) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -309,12 +312,18 @@ export class OllamaClient {
}
}
private normalizeMessage(message?: Partial<OllamaMessage>): OllamaMessage {
private normalizeMessage(
message?: Partial<OllamaMessage>,
promptEvalCount?: number,
evalCount?: number
): OllamaMessage {
return {
role: message?.role ?? 'assistant',
content: message?.content ?? '',
tool_calls: message?.tool_calls ?? [],
tool_call_id: message?.tool_call_id,
prompt_eval_count: promptEvalCount,
eval_count: evalCount,
};
}
+45 -14
View File
@@ -3,6 +3,7 @@
import { Vault, App, TFile } from 'obsidian';
import type { ToolCall, ToolResult } from './types';
import { safeParseJson } from './utils';
import { TelemetryManager } from './tool-telemetry';
// Disallow characters that are invalid in file paths
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
@@ -12,10 +13,12 @@ const FORBIDDEN_DIRS = ['.obsidian', '.git'];
export class ToolExecutor {
private vault: Vault;
private app: App;
private telemetryManager?: TelemetryManager;
constructor(vault: Vault, app: App) {
constructor(vault: Vault, app: App, telemetryManager?: TelemetryManager) {
this.vault = vault;
this.app = app;
this.telemetryManager = telemetryManager;
}
private isSafePath(path: string): boolean {
@@ -87,8 +90,13 @@ export class ToolExecutor {
}
async handleToolCall(toolCall: ToolCall): Promise<ToolResult> {
const startTime = Date.now();
const toolName = toolCall.function?.name ?? 'unknown';
let parsedArgs: Record<string, unknown> = {};
let result: ToolResult = { success: false, message: 'No result' };
let success = false;
try {
const toolName = toolCall.function?.name;
const rawArgs = toolCall.function?.arguments;
if (!toolName) {
@@ -96,7 +104,6 @@ export class ToolExecutor {
}
// Parse arguments whether they're a string or object
let parsedArgs: Record<string, unknown>;
if (typeof rawArgs === 'string') {
try {
parsedArgs = safeParseJson(rawArgs) as Record<string, unknown>;
@@ -113,31 +120,55 @@ export class ToolExecutor {
switch (toolName) {
case 'create_file':
case 'create_note':
return await this.handleCreateNote(parsedArgs);
result = await this.handleCreateNote(parsedArgs);
break;
case 'read_vault_file':
return await this.handleReadVaultFile(parsedArgs);
result = await this.handleReadVaultFile(parsedArgs);
break;
case 'search_vault_files':
return this.handleSearchVaultFiles(parsedArgs);
result = this.handleSearchVaultFiles(parsedArgs);
break;
case 'append_to_note':
return await this.handleAppendToNote(parsedArgs);
result = await this.handleAppendToNote(parsedArgs);
break;
case 'replace_note_section':
return await this.handleReplaceNoteSection(parsedArgs);
result = await this.handleReplaceNoteSection(parsedArgs);
break;
case 'update_frontmatter':
return await this.handleUpdateFrontmatter(parsedArgs);
result = await this.handleUpdateFrontmatter(parsedArgs);
break;
case 'rename_note':
return await this.handleRenameNote(parsedArgs);
result = await this.handleRenameNote(parsedArgs);
break;
case 'move_note':
return await this.handleMoveNote(parsedArgs);
result = await this.handleMoveNote(parsedArgs);
break;
case 'delete_note':
return await this.handleDeleteNote(parsedArgs);
result = await this.handleDeleteNote(parsedArgs);
break;
case 'insert_link':
return await this.handleInsertLink(parsedArgs);
result = await this.handleInsertLink(parsedArgs);
break;
default:
return { success: false, message: `Unknown tool: ${toolName}` };
result = { success: false, message: `Unknown tool: ${toolName}` };
}
success = result.success;
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
success = false;
result = { success: false, message: errorMessage };
throw new Error(errorMessage);
} finally {
const durationMs = Date.now() - startTime;
this.telemetryManager?.recordToolCall({
toolName,
args: parsedArgs,
success,
resultSummary: result?.message ?? 'No result',
durationMs,
});
}
}
+121
View File
@@ -0,0 +1,121 @@
import {
ToolTelemetryEntry,
LlmTelemetryEntry,
SearchTelemetryEntry,
TelemetryEntry,
ToolTelemetryData,
ToolTelemetryConfig,
} from './types';
export {
ToolTelemetryEntry,
LlmTelemetryEntry,
SearchTelemetryEntry,
TelemetryEntry,
ToolTelemetryData,
ToolTelemetryConfig,
};
export function createDefaultToolTelemetryData(): ToolTelemetryData {
return {
entries: [],
};
}
function generateId(): string {
return crypto.randomUUID?.() ?? `id-${Date.now()}-${Math.random()}`;
}
export class TelemetryManager {
private config: ToolTelemetryConfig;
private data: ToolTelemetryData;
constructor(config: ToolTelemetryConfig, initialData?: ToolTelemetryData) {
this.config = config;
this.data = initialData
? { entries: [...initialData.entries] }
: createDefaultToolTelemetryData();
}
loadData(data: ToolTelemetryData): void {
this.data = {
entries: [...data.entries],
};
this.enforceLimits();
}
getData(): ToolTelemetryData {
return {
entries: [...this.data.entries],
};
}
updateConfig(config: ToolTelemetryConfig): void {
this.config = config;
this.enforceLimits();
}
recordToolCall(entry: Omit<ToolTelemetryEntry, 'id' | 'timestamp' | 'type'>): void {
if (!this.config.enabled) {
return;
}
const fullEntry: ToolTelemetryEntry = {
...entry,
id: generateId(),
timestamp: Date.now(),
type: 'tool_call',
};
this.data.entries.push(fullEntry);
this.enforceLimits();
}
recordLlmCall(entry: Omit<LlmTelemetryEntry, 'id' | 'timestamp' | 'type'>): void {
if (!this.config.enabled) {
return;
}
const fullEntry: LlmTelemetryEntry = {
...entry,
id: generateId(),
timestamp: Date.now(),
type: 'llm_call',
};
this.data.entries.push(fullEntry);
this.enforceLimits();
}
recordSearch(entry: Omit<SearchTelemetryEntry, 'id' | 'timestamp' | 'type'>): void {
if (!this.config.enabled) {
return;
}
const fullEntry: SearchTelemetryEntry = {
...entry,
id: generateId(),
timestamp: Date.now(),
type: 'vault_search',
};
this.data.entries.push(fullEntry);
this.enforceLimits();
}
getRecentEntries(limit?: number): TelemetryEntry[] {
const sorted = [...this.data.entries].sort((a, b) => b.timestamp - a.timestamp);
if (limit !== undefined) {
return sorted.slice(0, limit);
}
return sorted;
}
getEntriesByType(type: TelemetryEntry['type']): TelemetryEntry[] {
return [...this.data.entries.filter((entry) => entry.type === type)];
}
clear(): void {
this.data.entries = [];
}
private enforceLimits(): void {
if (this.data.entries.length > this.config.maxEntries) {
this.data.entries = this.data.entries.slice(-this.config.maxEntries);
}
}
}
+50
View File
@@ -90,6 +90,8 @@ export interface OllamaMessage {
content: string;
tool_calls?: OllamaToolCall[];
tool_call_id?: string;
prompt_eval_count?: number;
eval_count?: number;
}
export interface OllamaToolCall {
@@ -243,6 +245,53 @@ export interface StructuredMemoryConfig {
maxFacts: number;
}
// ============================================================
// Tool Telemetry
// ============================================================
export interface ToolTelemetryEntry {
id: string;
timestamp: number;
type: 'tool_call';
toolName: string;
args: Record<string, unknown>;
success: boolean;
resultSummary: string;
durationMs: number;
}
export interface LlmTelemetryEntry {
id: string;
timestamp: number;
type: 'llm_call';
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
durationMs: number;
}
export interface SearchTelemetryEntry {
id: string;
timestamp: number;
type: 'vault_search';
query: string;
resultsCount: number;
resultPaths: string[];
durationMs: number;
}
export type TelemetryEntry = ToolTelemetryEntry | LlmTelemetryEntry | SearchTelemetryEntry;
export interface ToolTelemetryData {
entries: TelemetryEntry[];
}
export interface ToolTelemetryConfig {
enabled: boolean;
maxEntries: number;
}
// ============================================================
// Plugin Configuration
// ============================================================
@@ -291,6 +340,7 @@ export interface PluginSettings {
dryRun: boolean;
};
structuredMemoryConfig: StructuredMemoryConfig;
toolTelemetryConfig: ToolTelemetryConfig;
}
// ============================================================
+22 -22
View File
@@ -76,6 +76,10 @@ const mockSettings: PluginSettings = {
maxPreferences: 20,
maxFacts: 50,
},
toolTelemetryConfig: {
enabled: true,
maxEntries: 100,
},
};
describe('ChatView', () => {
@@ -360,17 +364,15 @@ describe('ChatView', () => {
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
const searchSpy = jest
.spyOn(view['noteContextBuilder'], 'buildContext')
.mockResolvedValue({
explicitMentions: [],
openNote: undefined,
selectedText: undefined,
backlinks: [],
outlinks: [],
relatedNotes: [],
searchResults: [],
});
const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
explicitMentions: [],
openNote: undefined,
selectedText: undefined,
backlinks: [],
outlinks: [],
relatedNotes: [],
searchResults: [],
});
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
@@ -815,17 +817,15 @@ describe('ChatView', () => {
view['inputEl'] = document.createElement('textarea');
(view['inputEl'] as HTMLTextAreaElement).value = 'search query';
const searchSpy = jest
.spyOn(view['noteContextBuilder'], 'buildContext')
.mockResolvedValue({
explicitMentions: [],
openNote: undefined,
selectedText: undefined,
backlinks: [],
outlinks: [],
relatedNotes: [],
searchResults: [],
});
const searchSpy = jest.spyOn(view['noteContextBuilder'], 'buildContext').mockResolvedValue({
explicitMentions: [],
openNote: undefined,
selectedText: undefined,
backlinks: [],
outlinks: [],
relatedNotes: [],
searchResults: [],
});
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockReturnValue(
(async function* () {
yield { role: 'assistant', content: 'response' };
+312
View File
@@ -0,0 +1,312 @@
import { TelemetryManager, createDefaultToolTelemetryData } from '../src/tool-telemetry';
import type { ToolTelemetryConfig } from '../src/types';
describe('createDefaultToolTelemetryData', () => {
it('should return empty entries array', () => {
const data = createDefaultToolTelemetryData();
expect(data.entries).toEqual([]);
});
});
describe('TelemetryManager', () => {
const defaultConfig: ToolTelemetryConfig = {
enabled: true,
maxEntries: 3,
};
let manager: TelemetryManager;
beforeEach(() => {
manager = new TelemetryManager(defaultConfig);
});
describe('constructor', () => {
it('should initialize with empty data when no initial data provided', () => {
expect(manager.getData().entries).toEqual([]);
});
it('should initialize with provided data', () => {
const initialData = createDefaultToolTelemetryData();
initialData.entries.push({
id: 'e1',
timestamp: 1,
type: 'tool_call',
toolName: 'read_vault_file',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
const m = new TelemetryManager(defaultConfig, initialData);
expect(m.getData().entries).toHaveLength(1);
});
});
describe('loadData and getData', () => {
it('should load and return data round-trip', () => {
manager.recordToolCall({
toolName: 'test',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 5,
});
const loaded = manager.getData();
expect(loaded.entries).toHaveLength(1);
const newManager = new TelemetryManager(defaultConfig);
newManager.loadData(loaded);
expect(newManager.getData().entries).toHaveLength(1);
});
});
describe('updateConfig', () => {
it('should enforce new limits after config update', () => {
for (let i = 0; i < 5; i++) {
manager.recordToolCall({
toolName: `tool-${i}`,
args: {},
success: true,
resultSummary: 'ok',
durationMs: i,
});
}
expect(manager.getData().entries).toHaveLength(3);
manager.updateConfig({ ...defaultConfig, maxEntries: 2 });
expect(manager.getData().entries).toHaveLength(2);
});
it('should disable writes when enabled becomes false', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.recordToolCall({
toolName: 'test',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 5,
});
expect(manager.getData().entries).toHaveLength(0);
});
});
describe('recordToolCall', () => {
it('should add a tool call entry', () => {
manager.recordToolCall({
toolName: 'create_note',
args: { path: 'test.md' },
success: true,
resultSummary: 'Created',
durationMs: 100,
});
const entries = manager.getEntriesByType('tool_call');
expect(entries).toHaveLength(1);
expect((entries[0] as any).toolName).toBe('create_note');
expect((entries[0] as any).success).toBe(true);
});
it('should enforce maxEntries limit keeping newest', () => {
for (let i = 0; i < 5; i++) {
manager.recordToolCall({
toolName: `tool-${i}`,
args: {},
success: true,
resultSummary: 'ok',
durationMs: i,
});
}
const entries = manager.getData().entries;
expect(entries).toHaveLength(3);
});
it('should not add when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.recordToolCall({
toolName: 'test',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 5,
});
expect(manager.getData().entries).toHaveLength(0);
});
});
describe('recordLlmCall', () => {
it('should add an LLM call entry', () => {
manager.recordLlmCall({
model: 'llama3',
promptTokens: 100,
completionTokens: 50,
totalTokens: 150,
durationMs: 2000,
});
const entries = manager.getEntriesByType('llm_call');
expect(entries).toHaveLength(1);
expect((entries[0] as any).model).toBe('llama3');
expect((entries[0] as any).totalTokens).toBe(150);
});
it('should not add when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.recordLlmCall({
model: 'llama3',
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
durationMs: 100,
});
expect(manager.getData().entries).toHaveLength(0);
});
});
describe('recordSearch', () => {
it('should add a search entry', () => {
manager.recordSearch({
query: 'test',
resultsCount: 3,
resultPaths: ['a.md', 'b.md'],
durationMs: 50,
});
const entries = manager.getEntriesByType('vault_search');
expect(entries).toHaveLength(1);
expect((entries[0] as any).query).toBe('test');
expect((entries[0] as any).resultsCount).toBe(3);
});
it('should not add when disabled', () => {
manager.updateConfig({ ...defaultConfig, enabled: false });
manager.recordSearch({
query: 'test',
resultsCount: 0,
resultPaths: [],
durationMs: 10,
});
expect(manager.getData().entries).toHaveLength(0);
});
});
describe('getRecentEntries', () => {
it('should return entries sorted by timestamp descending', async () => {
manager.recordToolCall({
toolName: 'first',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
await new Promise((r) => setTimeout(r, 5));
manager.recordToolCall({
toolName: 'second',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
const recent = manager.getRecentEntries();
expect((recent[0] as any).toolName).toBe('second');
expect((recent[1] as any).toolName).toBe('first');
});
it('should respect limit parameter', () => {
manager.recordToolCall({
toolName: 'first',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
manager.recordToolCall({
toolName: 'second',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
expect(manager.getRecentEntries(1)).toHaveLength(1);
});
});
describe('getEntriesByType', () => {
it('should filter by type', () => {
manager.recordToolCall({
toolName: 'test',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
manager.recordLlmCall({
model: 'llama3',
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
durationMs: 100,
});
expect(manager.getEntriesByType('tool_call')).toHaveLength(1);
expect(manager.getEntriesByType('llm_call')).toHaveLength(1);
expect(manager.getEntriesByType('vault_search')).toHaveLength(0);
});
});
describe('clear', () => {
it('should remove all entries', () => {
manager.recordToolCall({
toolName: 'test',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
manager.clear();
expect(manager.getData().entries).toHaveLength(0);
});
});
describe('immutability', () => {
it('getData should return a copy', () => {
manager.recordToolCall({
toolName: 'test',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
const data = manager.getData();
data.entries.push({
id: 'x',
timestamp: 1,
type: 'tool_call',
toolName: 'injected',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 1,
});
expect(manager.getData().entries).toHaveLength(1);
});
it('getEntriesByType should return a copy', () => {
manager.recordToolCall({
toolName: 'test',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 10,
});
const entries = manager.getEntriesByType('tool_call');
entries.push({
id: 'x',
timestamp: 1,
type: 'tool_call',
toolName: 'injected',
args: {},
success: true,
resultSummary: 'ok',
durationMs: 1,
});
expect(manager.getEntriesByType('tool_call')).toHaveLength(1);
});
});
});