mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-11 03:00:49 +00:00
c712e10f2a
* fix: read skills usage from Hermes session DB * fix: avoid profile fallback in session db reads
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { mkdtempSync, mkdirSync, rmSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
|
|
let hermesHome = ''
|
|
|
|
function createStateDb(profileDir = hermesHome, withIndexes = true): DatabaseSync {
|
|
mkdirSync(profileDir, { recursive: true })
|
|
const db = new DatabaseSync(join(profileDir, 'state.db'))
|
|
db.exec(`
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT,
|
|
started_at INTEGER
|
|
);
|
|
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
|
|
);
|
|
`)
|
|
if (withIndexes) {
|
|
db.exec(`
|
|
CREATE INDEX idx_sessions_started ON sessions(started_at);
|
|
CREATE INDEX idx_messages_session ON messages(session_id);
|
|
`)
|
|
}
|
|
return db
|
|
}
|
|
|
|
function createProfileStateDb(profile: string): DatabaseSync {
|
|
const profileDir = join(hermesHome, 'profiles', profile)
|
|
return createStateDb(profileDir)
|
|
}
|
|
|
|
function insertSession(db: DatabaseSync, row: { id: string; source?: string; started_at: number }) {
|
|
db.prepare('INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)')
|
|
.run(row.id, row.source ?? 'api_server', row.started_at)
|
|
}
|
|
|
|
function insertToolResult(db: DatabaseSync, row: {
|
|
sessionId: string
|
|
timestamp: number
|
|
toolName?: string | null
|
|
toolCallId?: string | null
|
|
content: string
|
|
}) {
|
|
db.prepare('INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, timestamp) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(row.sessionId, 'tool', row.content, row.toolCallId ?? null, row.toolName ?? null, row.timestamp)
|
|
}
|
|
|
|
function insertAssistantToolCalls(db: DatabaseSync, sessionId: string, timestamp: number, toolCalls: unknown) {
|
|
db.prepare('INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)')
|
|
.run(sessionId, 'assistant', '', JSON.stringify(toolCalls), timestamp)
|
|
}
|
|
|
|
describe('Hermes skill usage analytics DB aggregation', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
hermesHome = mkdtempSync(join(tmpdir(), 'wui-skill-usage-'))
|
|
process.env.HERMES_HOME = hermesHome
|
|
})
|
|
|
|
afterEach(() => {
|
|
delete process.env.HERMES_HOME
|
|
if (hermesHome) rmSync(hermesHome, { recursive: true, force: true })
|
|
hermesHome = ''
|
|
})
|
|
|
|
it('counts completed skill loads and edits from canonical Hermes state.db by event timestamp', async () => {
|
|
const now = 1_700_000_000
|
|
const db = createStateDb()
|
|
|
|
insertSession(db, { id: 'recent-chat', started_at: now - 60 })
|
|
insertToolResult(db, {
|
|
sessionId: 'recent-chat',
|
|
timestamp: now - 50,
|
|
content: '[skill_view] name=hermes-agent (64,764 chars)',
|
|
})
|
|
insertToolResult(db, {
|
|
sessionId: 'recent-chat',
|
|
timestamp: now - 45,
|
|
toolName: 'skill_view',
|
|
content: '[skill_view] name=hermes-agent (64,764 chars)',
|
|
})
|
|
insertToolResult(db, {
|
|
sessionId: 'recent-chat',
|
|
timestamp: now - 40,
|
|
toolName: 'skill_manage',
|
|
content: JSON.stringify({ success: true, message: "Patched SKILL.md in skill 'hermes-agent' (1 replacement)." }),
|
|
})
|
|
insertToolResult(db, {
|
|
sessionId: 'recent-chat',
|
|
timestamp: now - 32,
|
|
toolName: 'skill_view',
|
|
content: JSON.stringify({
|
|
success: true,
|
|
name: 'github-project-analysis',
|
|
description: 'x'.repeat(512),
|
|
}),
|
|
})
|
|
insertAssistantToolCalls(db, 'recent-chat', now - 30, [
|
|
{ function: { name: 'skill_view', arguments: JSON.stringify({ name: 'planned-but-not-counted' }) } },
|
|
])
|
|
insertToolResult(db, {
|
|
sessionId: 'recent-chat',
|
|
timestamp: now - 25,
|
|
toolName: 'terminal',
|
|
content: 'noop',
|
|
})
|
|
insertToolResult(db, {
|
|
sessionId: 'recent-chat',
|
|
timestamp: now - 10 * 86400,
|
|
content: '[skill_view] name=old-message-inside-recent-session (1 chars)',
|
|
})
|
|
|
|
insertSession(db, { id: 'web-api-session', started_at: now - 30 })
|
|
insertAssistantToolCalls(db, 'web-api-session', now - 22, [
|
|
{
|
|
id: 'call_api_skill_view',
|
|
call_id: 'call_api_skill_view',
|
|
type: 'function',
|
|
function: { name: 'skill_view', arguments: JSON.stringify({ name: 'api-server-skill' }) },
|
|
},
|
|
])
|
|
insertToolResult(db, {
|
|
sessionId: 'web-api-session',
|
|
timestamp: now - 20,
|
|
toolCallId: 'call_api_skill_view',
|
|
content: JSON.stringify({ success: true, description: 'API-server JSON tool result without skill name' }),
|
|
})
|
|
|
|
insertSession(db, { id: 'old-chat', started_at: now - 10 * 86400 })
|
|
insertToolResult(db, {
|
|
sessionId: 'old-chat',
|
|
timestamp: now - 10 * 86400,
|
|
content: '[skill_view] name=old-skill (1 chars)',
|
|
})
|
|
|
|
insertSession(db, { id: 'long-running-chat', started_at: now - 10 * 86400 })
|
|
insertToolResult(db, {
|
|
sessionId: 'long-running-chat',
|
|
timestamp: now - 40,
|
|
content: '[skill_view] name=late-session-skill (1 chars)',
|
|
})
|
|
|
|
db.close()
|
|
|
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
|
const result = await mod.getSkillUsageStatsFromDb(7, now, 'default')
|
|
|
|
expect(result).toEqual({
|
|
period_days: 7,
|
|
summary: {
|
|
total_skill_loads: 5,
|
|
total_skill_edits: 1,
|
|
total_skill_actions: 6,
|
|
distinct_skills_used: 4,
|
|
},
|
|
by_day: [
|
|
{
|
|
date: '2023-11-14',
|
|
view_count: 5,
|
|
manage_count: 1,
|
|
total_count: 6,
|
|
skills: [
|
|
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
|
|
{ skill: 'api-server-skill', view_count: 1, manage_count: 0, total_count: 1 },
|
|
{ skill: 'github-project-analysis', 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: 3 / 6 * 100,
|
|
last_used_at: now - 40,
|
|
},
|
|
{
|
|
skill: 'api-server-skill',
|
|
view_count: 1,
|
|
manage_count: 0,
|
|
total_count: 1,
|
|
percentage: 1 / 6 * 100,
|
|
last_used_at: now - 20,
|
|
},
|
|
{
|
|
skill: 'github-project-analysis',
|
|
view_count: 1,
|
|
manage_count: 0,
|
|
total_count: 1,
|
|
percentage: 1 / 6 * 100,
|
|
last_used_at: now - 32,
|
|
},
|
|
{
|
|
skill: 'late-session-skill',
|
|
view_count: 1,
|
|
manage_count: 0,
|
|
total_count: 1,
|
|
percentage: 1 / 6 * 100,
|
|
last_used_at: now - 40,
|
|
},
|
|
],
|
|
})
|
|
})
|
|
|
|
it('returns empty stats when the requested Hermes profile has no state.db yet', async () => {
|
|
const now = 1_700_000_000
|
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
|
const result = await mod.getSkillUsageStatsFromDb(7, now, 'default')
|
|
|
|
expect(result).toEqual({
|
|
period_days: 7,
|
|
summary: {
|
|
total_skill_loads: 0,
|
|
total_skill_edits: 0,
|
|
total_skill_actions: 0,
|
|
distinct_skills_used: 0,
|
|
},
|
|
by_day: [],
|
|
top_skills: [],
|
|
})
|
|
})
|
|
|
|
it('does not fall back to default usage when a named Hermes profile is missing', async () => {
|
|
const now = 1_700_000_000
|
|
const db = createStateDb()
|
|
insertSession(db, { id: 'default-chat', started_at: now - 60 })
|
|
insertToolResult(db, {
|
|
sessionId: 'default-chat',
|
|
timestamp: now - 50,
|
|
content: '[skill_view] name=default-skill (1 chars)',
|
|
})
|
|
db.close()
|
|
|
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
|
const result = await mod.getSkillUsageStatsFromDb(7, now, 'deleted-profile')
|
|
|
|
expect(result).toEqual({
|
|
period_days: 7,
|
|
summary: {
|
|
total_skill_loads: 0,
|
|
total_skill_edits: 0,
|
|
total_skill_actions: 0,
|
|
distinct_skills_used: 0,
|
|
},
|
|
by_day: [],
|
|
top_skills: [],
|
|
})
|
|
})
|
|
|
|
it('falls back when a readable Hermes state.db lacks optional performance indexes', async () => {
|
|
const now = 1_700_000_000
|
|
const db = createStateDb(hermesHome, false)
|
|
insertSession(db, { id: 'indexless-chat', started_at: now - 60 })
|
|
insertToolResult(db, {
|
|
sessionId: 'indexless-chat',
|
|
timestamp: now - 50,
|
|
content: '[skill_view] name=indexless-skill (1 chars)',
|
|
})
|
|
db.close()
|
|
|
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
|
const result = await mod.getSkillUsageStatsFromDb(7, now, 'default')
|
|
|
|
expect(result.summary).toMatchObject({
|
|
total_skill_loads: 1,
|
|
total_skill_edits: 0,
|
|
total_skill_actions: 1,
|
|
distinct_skills_used: 1,
|
|
})
|
|
expect(result.top_skills[0]?.skill).toBe('indexless-skill')
|
|
})
|
|
|
|
it('uses the requested Hermes profile state.db instead of a Web UI-local profile column', async () => {
|
|
const now = 1_700_000_000
|
|
const defaultDb = createStateDb()
|
|
insertSession(defaultDb, { id: 'default-chat', started_at: now - 60 })
|
|
insertToolResult(defaultDb, {
|
|
sessionId: 'default-chat',
|
|
timestamp: now - 50,
|
|
content: '[skill_view] name=default-skill (1 chars)',
|
|
})
|
|
defaultDb.close()
|
|
|
|
const testerDb = createProfileStateDb('tester')
|
|
insertSession(testerDb, { id: 'tester-chat', started_at: now - 60 })
|
|
insertToolResult(testerDb, {
|
|
sessionId: 'tester-chat',
|
|
timestamp: now - 50,
|
|
content: '[skill_view] name=tester-skill (1 chars)',
|
|
})
|
|
testerDb.close()
|
|
|
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
|
|
|
const defaultResult = await mod.getSkillUsageStatsFromDb(7, now, 'default')
|
|
expect(defaultResult.summary).toMatchObject({
|
|
total_skill_loads: 1,
|
|
total_skill_edits: 0,
|
|
total_skill_actions: 1,
|
|
distinct_skills_used: 1,
|
|
})
|
|
expect(defaultResult.top_skills[0]?.skill).toBe('default-skill')
|
|
|
|
const testerResult = await mod.getSkillUsageStatsFromDb(7, now, 'tester')
|
|
expect(testerResult.summary).toMatchObject({
|
|
total_skill_loads: 1,
|
|
total_skill_edits: 0,
|
|
total_skill_actions: 1,
|
|
distinct_skills_used: 1,
|
|
})
|
|
expect(testerResult.top_skills[0]?.skill).toBe('tester-skill')
|
|
})
|
|
})
|