Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
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')
|
|
})
|
|
})
|