import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' const profileMock = vi.hoisted(() => ({ getActiveProfileDir: vi.fn(), })) vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ getActiveProfileDir: profileMock.getActiveProfileDir, getProfileDir: vi.fn(), })) function createStateDb(): string { const dir = mkdtempSync(join(tmpdir(), 'hermes-skill-usage-')) const db = new DatabaseSync(join(dir, 'state.db')) db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, source TEXT, started_at INTEGER ); CREATE INDEX idx_sessions_started ON sessions(started_at); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp INTEGER ); CREATE INDEX idx_messages_session ON messages(session_id, timestamp); `) db.close() return dir } function insertSession(dir: string, row: { id: string; source?: string; started_at: number }) { const db = new DatabaseSync(join(dir, 'state.db')) db.prepare('INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)') .run(row.id, row.source ?? 'cli', row.started_at) db.close() } function insertToolResult(dir: string, row: { sessionId: string timestamp: number toolName?: string | null content: string }) { const db = new DatabaseSync(join(dir, 'state.db')) db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)') .run(row.sessionId, 'tool', row.content, row.toolName ?? null, row.timestamp) db.close() } function insertAssistantToolCalls(dir: string, sessionId: string, timestamp: number, toolCalls: unknown) { const db = new DatabaseSync(join(dir, 'state.db')) db.prepare('INSERT INTO messages (session_id, role, tool_calls, timestamp) VALUES (?, ?, ?, ?)') .run(sessionId, 'assistant', JSON.stringify(toolCalls), timestamp) db.close() } describe('Hermes skill usage analytics DB aggregation', () => { let profileDir: string | null = null beforeEach(() => { vi.resetModules() profileMock.getActiveProfileDir.mockReset() }) afterEach(() => { if (profileDir) rmSync(profileDir, { recursive: true, force: true }) profileDir = null }) it('counts completed skill loads and edits from compact tool result rows inside the requested period', async () => { const now = 1_700_000_000 profileDir = createStateDb() profileMock.getActiveProfileDir.mockReturnValue(profileDir) insertSession(profileDir, { id: 'recent-cli', source: 'cli', started_at: now - 60 }) insertToolResult(profileDir, { sessionId: 'recent-cli', timestamp: now - 50, content: '[skill_view] name=hermes-agent (64,764 chars)', }) insertToolResult(profileDir, { sessionId: 'recent-cli', timestamp: now - 45, toolName: 'skill_view', content: '[skill_view] name=hermes-agent (64,764 chars)', }) insertToolResult(profileDir, { sessionId: 'recent-cli', timestamp: now - 40, toolName: 'skill_manage', content: JSON.stringify({ success: true, message: "Patched SKILL.md in skill 'hermes-agent' (1 replacement)." }), }) insertToolResult(profileDir, { sessionId: 'recent-cli', timestamp: now - 35, content: '[skill_view] name=github-pr-workflow (22,106 chars)', }) insertAssistantToolCalls(profileDir, 'recent-cli', now - 30, [ { function: { name: 'skill_view', arguments: JSON.stringify({ name: 'planned-but-not-counted' }) } }, ]) insertToolResult(profileDir, { sessionId: 'recent-cli', timestamp: now - 25, toolName: 'terminal', content: 'noop', }) insertSession(profileDir, { id: 'web-local-copy', source: 'api_server', started_at: now - 30 }) insertToolResult(profileDir, { sessionId: 'web-local-copy', timestamp: now - 20, content: '[skill_view] name=ignored-local-copy (1 chars)', }) insertSession(profileDir, { id: 'old-cli', source: 'cli', started_at: now - 10 * 86400 }) insertToolResult(profileDir, { sessionId: 'old-cli', timestamp: now - 10 * 86400, content: '[skill_view] name=old-skill (1 chars)', }) insertSession(profileDir, { id: 'long-running-cli', source: 'cli', started_at: now - 10 * 86400 }) insertToolResult(profileDir, { sessionId: 'long-running-cli', timestamp: now - 40, content: '[skill_view] name=late-session-skill (1 chars)', }) const mod = await import('../../packages/server/src/db/hermes/sessions-db') const result = await mod.getSkillUsageStatsFromDb(7, now) expect(result).toEqual({ period_days: 7, summary: { total_skill_loads: 4, total_skill_edits: 1, total_skill_actions: 5, distinct_skills_used: 3, }, by_day: [ { date: '2023-11-14', view_count: 4, manage_count: 1, total_count: 5, skills: [ { skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 }, { skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 }, { skill: 'late-session-skill', view_count: 1, manage_count: 0, total_count: 1 }, ], }, ], top_skills: [ { skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3, percentage: 60, last_used_at: now - 40, }, { skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1, percentage: 20, last_used_at: now - 35, }, { skill: 'late-session-skill', view_count: 1, manage_count: 0, total_count: 1, percentage: 20, last_used_at: now - 40, }, ], }) }) })