This reverts commit ce08d2b05a.
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
||||
} from '../../services/config-helpers'
|
||||
import { pinSkill } from '../../services/hermes/hermes-cli'
|
||||
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||
|
||||
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
||||
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
||||
@@ -240,18 +239,6 @@ export async function list(ctx: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function usageStats(ctx: any) {
|
||||
const rawDays = parseInt(String(ctx.query?.days ?? '7'), 10)
|
||||
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 7
|
||||
|
||||
try {
|
||||
ctx.body = await getSkillUsageStatsFromDb(days)
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `Failed to read skill usage stats: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggle(ctx: any) {
|
||||
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
|
||||
if (!name || typeof enabled !== 'boolean') {
|
||||
|
||||
@@ -783,42 +783,6 @@ export interface HermesUsageStats extends LocalUsageStats {
|
||||
total_api_calls: number
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageRow {
|
||||
skill: string
|
||||
view_count: number
|
||||
manage_count: number
|
||||
total_count: number
|
||||
percentage: number
|
||||
last_used_at: number | null
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageDailySkillRow {
|
||||
skill: string
|
||||
view_count: number
|
||||
manage_count: number
|
||||
total_count: number
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageDailyRow {
|
||||
date: string
|
||||
view_count: number
|
||||
manage_count: number
|
||||
total_count: number
|
||||
skills: HermesSkillUsageDailySkillRow[]
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageStats {
|
||||
period_days: number
|
||||
summary: {
|
||||
total_skill_loads: number
|
||||
total_skill_edits: number
|
||||
total_skill_actions: number
|
||||
distinct_skills_used: number
|
||||
}
|
||||
by_day: HermesSkillUsageDailyRow[]
|
||||
top_skills: HermesSkillUsageRow[]
|
||||
}
|
||||
|
||||
function tableHasColumn(
|
||||
db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } },
|
||||
tableName: string,
|
||||
@@ -828,197 +792,6 @@ function tableHasColumn(
|
||||
return columns.some(column => String(column.name || '') === columnName)
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
|
||||
if (typeof value !== 'string') return null
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type SkillUsageAction = 'view' | 'manage'
|
||||
|
||||
interface RawSkillUsageEvent {
|
||||
skill: string
|
||||
action: SkillUsageAction
|
||||
timestamp: number | null
|
||||
}
|
||||
|
||||
function extractSkillNameFromViewContent(content: string): string {
|
||||
const match = content.match(/^\[skill_view\]\s+name=(.+?)(?:\s+\(|\s*$)/)
|
||||
return match?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function extractSkillNameFromManageContent(content: string): string {
|
||||
const bracketMatch = content.match(/^\[skill_manage\]\s+name=(.+?)(?:\s+|\(|$)/)
|
||||
if (bracketMatch?.[1]) return bracketMatch[1].trim()
|
||||
|
||||
const parsed = parseJsonObject(content)
|
||||
const message = typeof parsed?.message === 'string' ? parsed.message : content
|
||||
const quotedMatch = message.match(/skill ['"]([^'"]+)['"]/i)
|
||||
if (quotedMatch?.[1]) return quotedMatch[1].trim()
|
||||
|
||||
const namedMatch = message.match(/\bname=([^\s)]+)/i)
|
||||
return namedMatch?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function mapSkillUsageEvent(row: Record<string, unknown>): RawSkillUsageEvent | null {
|
||||
const content = typeof row.content === 'string' ? row.content : ''
|
||||
const toolName = typeof row.tool_name === 'string' ? row.tool_name : ''
|
||||
const action: SkillUsageAction | null = toolName === 'skill_view' || content.startsWith('[skill_view]')
|
||||
? 'view'
|
||||
: toolName === 'skill_manage' || content.startsWith('[skill_manage]')
|
||||
? 'manage'
|
||||
: null
|
||||
|
||||
if (!action) return null
|
||||
|
||||
const skill = action === 'view'
|
||||
? extractSkillNameFromViewContent(content)
|
||||
: extractSkillNameFromManageContent(content)
|
||||
|
||||
if (!skill) return null
|
||||
|
||||
return {
|
||||
skill,
|
||||
action,
|
||||
timestamp: normalizeNullableNumber(row.timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
function formatUnixDate(timestamp: number | null): string {
|
||||
if (timestamp == null) return ''
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export async function getSkillUsageStatsFromDb(
|
||||
days = 7,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<HermesSkillUsageStats> {
|
||||
const normalizedDays = Number.isFinite(days) ? days : 7
|
||||
const safeDays = Math.max(1, Math.floor(normalizedDays))
|
||||
const since = nowSeconds - safeDays * 24 * 60 * 60
|
||||
const db = await openSessionDb()
|
||||
|
||||
try {
|
||||
const sourceFilter = tableHasColumn(db, 'sessions', 'source')
|
||||
? " AND COALESCE(s.source, '') != 'api_server'"
|
||||
: ''
|
||||
const hasStartedIndex = db.prepare("PRAGMA index_list(sessions)").all()
|
||||
.some(index => String(index.name || '') === 'idx_sessions_started')
|
||||
const hasMessagesIndex = db.prepare("PRAGMA index_list(messages)").all()
|
||||
.some(index => String(index.name || '') === 'idx_messages_session')
|
||||
const sessionsTable = hasStartedIndex ? 'sessions s INDEXED BY idx_sessions_started' : 'sessions s'
|
||||
const messagesTable = hasMessagesIndex ? 'messages m INDEXED BY idx_messages_session' : 'messages m'
|
||||
const toolPredicate = `
|
||||
m.role = 'tool'
|
||||
AND (
|
||||
m.tool_name IN ('skill_view', 'skill_manage')
|
||||
OR m.content LIKE '[skill_view]%'
|
||||
OR m.content LIKE '[skill_manage]%'
|
||||
)
|
||||
`
|
||||
const recentRows = db.prepare(`
|
||||
SELECT m.tool_name, SUBSTR(m.content, 1, 300) AS content, COALESCE(m.timestamp, s.started_at) AS timestamp
|
||||
FROM ${sessionsTable}
|
||||
JOIN ${messagesTable} ON m.session_id = s.id
|
||||
WHERE s.started_at > ?${sourceFilter}
|
||||
AND ${toolPredicate}
|
||||
`).all(since) as Record<string, unknown>[]
|
||||
const lateRows = db.prepare(`
|
||||
SELECT m.tool_name, SUBSTR(m.content, 1, 300) AS content, COALESCE(m.timestamp, s.started_at) AS timestamp
|
||||
FROM ${sessionsTable}
|
||||
JOIN ${messagesTable} ON m.session_id = s.id
|
||||
WHERE s.started_at <= ?
|
||||
AND COALESCE(m.timestamp, s.started_at) > ?${sourceFilter}
|
||||
AND ${toolPredicate}
|
||||
`).all(since, since) as Record<string, unknown>[]
|
||||
|
||||
const skillMap = new Map<string, { skill: string; view_count: number; manage_count: number; last_used_at: number | null }>()
|
||||
const dayMap = new Map<string, { date: string; view_count: number; manage_count: number }>()
|
||||
const daySkillMap = new Map<string, Map<string, { skill: string; view_count: number; manage_count: number }>>()
|
||||
|
||||
for (const row of [...recentRows, ...lateRows]) {
|
||||
const event = mapSkillUsageEvent(row)
|
||||
if (!event) continue
|
||||
|
||||
const entry = skillMap.get(event.skill) || {
|
||||
skill: event.skill,
|
||||
view_count: 0,
|
||||
manage_count: 0,
|
||||
last_used_at: null,
|
||||
}
|
||||
if (event.action === 'view') entry.view_count += 1
|
||||
else entry.manage_count += 1
|
||||
if (event.timestamp != null && (entry.last_used_at == null || event.timestamp > entry.last_used_at)) {
|
||||
entry.last_used_at = event.timestamp
|
||||
}
|
||||
skillMap.set(event.skill, entry)
|
||||
|
||||
const date = formatUnixDate(event.timestamp)
|
||||
if (date) {
|
||||
const day = dayMap.get(date) || { date, view_count: 0, manage_count: 0 }
|
||||
if (event.action === 'view') day.view_count += 1
|
||||
else day.manage_count += 1
|
||||
dayMap.set(date, day)
|
||||
|
||||
const skillsForDay = daySkillMap.get(date) || new Map<string, { skill: string; view_count: number; manage_count: number }>()
|
||||
const skillForDay = skillsForDay.get(event.skill) || { skill: event.skill, view_count: 0, manage_count: 0 }
|
||||
if (event.action === 'view') skillForDay.view_count += 1
|
||||
else skillForDay.manage_count += 1
|
||||
skillsForDay.set(event.skill, skillForDay)
|
||||
daySkillMap.set(date, skillsForDay)
|
||||
}
|
||||
}
|
||||
|
||||
const totalLoads = [...skillMap.values()].reduce((sum, skill) => sum + skill.view_count, 0)
|
||||
const totalEdits = [...skillMap.values()].reduce((sum, skill) => sum + skill.manage_count, 0)
|
||||
const totalActions = totalLoads + totalEdits
|
||||
const byDay = [...dayMap.values()]
|
||||
.map(day => ({
|
||||
...day,
|
||||
total_count: day.view_count + day.manage_count,
|
||||
skills: [...(daySkillMap.get(day.date)?.values() || [])]
|
||||
.map(skill => ({
|
||||
...skill,
|
||||
total_count: skill.view_count + skill.manage_count,
|
||||
}))
|
||||
.sort((a, b) => b.total_count - a.total_count || a.skill.localeCompare(b.skill)),
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
const topSkills = [...skillMap.values()]
|
||||
.map(skill => ({
|
||||
...skill,
|
||||
total_count: skill.view_count + skill.manage_count,
|
||||
percentage: totalActions > 0 ? (skill.view_count + skill.manage_count) / totalActions * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
b.total_count - a.total_count ||
|
||||
b.view_count - a.view_count ||
|
||||
b.manage_count - a.manage_count ||
|
||||
(b.last_used_at || 0) - (a.last_used_at || 0) ||
|
||||
a.skill.localeCompare(b.skill),
|
||||
)
|
||||
|
||||
return {
|
||||
period_days: safeDays,
|
||||
summary: {
|
||||
total_skill_loads: totalLoads,
|
||||
total_skill_edits: totalEdits,
|
||||
total_skill_actions: totalActions,
|
||||
distinct_skills_used: skillMap.size,
|
||||
},
|
||||
by_day: byDay,
|
||||
top_skills: topSkills,
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsageStatsFromDb(
|
||||
days = 30,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as ctrl from '../../controllers/hermes/skills'
|
||||
export const skillRoutes = new Router()
|
||||
|
||||
skillRoutes.get('/api/hermes/skills', ctrl.list)
|
||||
skillRoutes.get('/api/hermes/skills/usage/stats', ctrl.usageStats)
|
||||
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
|
||||
skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_)
|
||||
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
|
||||
|
||||
Reference in New Issue
Block a user