From ce08d2b05ac84fdf7e1f19a4a3e6bb2707a5991c Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Wed, 13 May 2026 01:43:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Skills=20Usage=20?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E7=BB=9F=E8=AE=A1=E4=B8=8E=E5=9B=BE=E8=A1=A8?= =?UTF-8?q?=20(#668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add skills usage monitoring * fix: localize Skills Usage page copy * fix: keep Skills Usage labels compact --- packages/client/src/api/hermes/skills.ts | 41 ++ .../src/components/layout/AppSidebar.vue | 8 + packages/client/src/i18n/locales/de.ts | 23 + packages/client/src/i18n/locales/en.ts | 23 + packages/client/src/i18n/locales/es.ts | 23 + packages/client/src/i18n/locales/fr.ts | 23 + packages/client/src/i18n/locales/ja.ts | 23 + packages/client/src/i18n/locales/ko.ts | 23 + packages/client/src/i18n/locales/pt.ts | 23 + packages/client/src/i18n/locales/zh-TW.ts | 23 + packages/client/src/i18n/locales/zh.ts | 23 + packages/client/src/router/index.ts | 5 + .../src/views/hermes/SkillsUsageView.vue | 673 ++++++++++++++++++ .../server/src/controllers/hermes/skills.ts | 13 + packages/server/src/db/hermes/sessions-db.ts | 227 ++++++ packages/server/src/routes/hermes/skills.ts | 1 + tests/client/i18n-coverage.test.ts | 79 +- tests/client/skills-usage-view.test.ts | 219 ++++++ tests/server/skill-usage-stats-db.test.ts | 192 +++++ 19 files changed, 1662 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/views/hermes/SkillsUsageView.vue create mode 100644 tests/client/skills-usage-view.test.ts create mode 100644 tests/server/skill-usage-stats-db.test.ts diff --git a/packages/client/src/api/hermes/skills.ts b/packages/client/src/api/hermes/skills.ts index 2543d8d..7d539b1 100644 --- a/packages/client/src/api/hermes/skills.ts +++ b/packages/client/src/api/hermes/skills.ts @@ -45,11 +45,52 @@ export interface SkillsData { archived: SkillInfo[] } +export interface SkillUsageRow { + skill: string + view_count: number + manage_count: number + total_count: number + percentage: number + last_used_at: number | null +} + +export interface SkillUsageDailySkillRow { + skill: string + view_count: number + manage_count: number + total_count: number +} + +export interface SkillUsageDailyRow { + date: string + view_count: number + manage_count: number + total_count: number + skills: SkillUsageDailySkillRow[] +} + +export interface SkillUsageStats { + period_days: number + summary: { + total_skill_loads: number + total_skill_edits: number + total_skill_actions: number + distinct_skills_used: number + } + by_day: SkillUsageDailyRow[] + top_skills: SkillUsageRow[] +} + export async function fetchSkills(): Promise { const res = await request('/api/hermes/skills') return { categories: res.categories, archived: res.archived ?? [] } } +export async function fetchSkillUsageStats(days = 7): Promise { + const params = new URLSearchParams({ days: String(days) }) + return request(`/api/hermes/skills/usage/stats?${params}`) +} + export async function fetchSkillContent(skillPath: string): Promise { const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`) return res.content diff --git a/packages/client/src/components/layout/AppSidebar.vue b/packages/client/src/components/layout/AppSidebar.vue index 041e72f..4ce19d7 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -220,6 +220,14 @@ function openChangelog() { {{ t("sidebar.usage") }} + diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 04895cc..89c1540 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -79,6 +79,7 @@ export default { memory: 'Gedachtnis', logs: 'Protokolle', usage: 'Nutzung', + skillsUsage: 'Skill-Nutzung', channels: 'Kanale', terminal: 'Terminal', files: 'Dateien', @@ -762,6 +763,28 @@ jobTriggered: 'Job ausgelost', noData: 'Keine Nutzungsdaten', }, + skillsUsage: { + title: 'Skill-Nutzung', + subtitle: 'Skill-Ladevorgänge und -Bearbeitungen aus Hermes-Sitzungen verfolgen', + refresh: 'Aktualisieren', + periodSelector: 'Zeitraum der Skill-Nutzung', + periodLabel: '{days} T', + summary: 'Zusammenfassung', + totalActions: 'Aktionen', + loads: 'Laden', + edits: 'Änd.', + distinctSkills: 'Skillzahl', + topSkills: 'Top-Skills', + dailyTrend: 'Trend', + periodSummary: 'Letzte {days} Tage', + skill: 'Fähigkeit', + share: '%', + lastUsed: 'Zuletzt', + noData: 'Keine Skill-Nutzungsdaten', + loadFailed: 'Skill-Nutzung konnte nicht geladen werden', + otherSkills: 'Andere Skills', + }, + // Anderungsprotokoll changelog: { diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index e895170..4ceb543 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -83,6 +83,7 @@ export default { memory: 'Memory', logs: 'Logs', usage: 'Usage', + skillsUsage: 'Skills Usage', channels: 'Channels', gateways: 'Gateways', terminal: 'Terminal', @@ -983,6 +984,28 @@ export default { cost: 'Cost', noData: 'No usage data', }, + skillsUsage: { + title: 'Skills Usage', + subtitle: 'Track skill loads and edits from Hermes sessions', + refresh: 'Refresh', + periodSelector: 'Skill usage period', + periodLabel: '{days}d', + summary: 'Summary', + totalActions: 'Actions', + loads: 'Loads', + edits: 'Edits', + distinctSkills: 'Skills', + topSkills: 'Top Skills', + dailyTrend: 'Daily Trend', + periodSummary: 'Last {days} days', + skill: 'Skill', + share: 'Share', + lastUsed: 'Last Used', + noData: 'No skill usage data', + loadFailed: 'Failed to load skill usage', + otherSkills: 'Other skills', + }, + // Files files: { title: 'Files', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index 90e1b43..9d419dc 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -79,6 +79,7 @@ export default { memory: 'Memoria', logs: 'Registros', usage: 'Uso', + skillsUsage: 'Uso de habilidades', channels: 'Canales', terminal: 'Terminal', files: 'Archivos', @@ -758,6 +759,28 @@ jobTriggered: 'Job ejecutado', noData: 'Sin datos de uso', }, + skillsUsage: { + title: 'Uso de habilidades', + subtitle: 'Sigue las cargas y ediciones de habilidades en sesiones de Hermes', + refresh: 'Actualizar', + periodSelector: 'Periodo de uso de habilidades', + periodLabel: '{days} d', + summary: 'Resumen', + totalActions: 'Acciones', + loads: 'Cargas', + edits: 'Ed.', + distinctSkills: 'Habs.', + topSkills: 'Top habs.', + dailyTrend: 'Tendencia diaria', + periodSummary: 'Últimos {days} días', + skill: 'Hab.', + share: '%', + lastUsed: 'Últ. uso', + noData: 'No hay datos de uso de habilidades', + loadFailed: 'No se pudo cargar el uso de habilidades', + otherSkills: 'Otras habs.', + }, + // Registro de cambios changelog: { diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index 90b291c..bf2da42 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -79,6 +79,7 @@ export default { memory: 'Memoire', logs: 'Journaux', usage: 'Utilisation', + skillsUsage: 'Utilisation des compétences', channels: 'Canaux', terminal: 'Terminal', files: 'Fichiers', @@ -758,6 +759,28 @@ jobTriggered: 'Job declenche', noData: 'Aucune donnee d\'utilisation', }, + skillsUsage: { + title: 'Utilisation des compétences', + subtitle: 'Suivre les chargements et modifications de compétences dans les sessions Hermes', + refresh: 'Actualiser', + periodSelector: 'Période d\'utilisation des compétences', + periodLabel: '{days} j', + summary: 'Résumé', + totalActions: 'Act.', + loads: 'Charg.', + edits: 'Modif.', + distinctSkills: 'Comp.', + topSkills: 'Top comp.', + dailyTrend: 'Tendance', + periodSummary: '{days} derniers jours', + skill: 'Comp.', + share: '%', + lastUsed: 'Dern. usage', + noData: 'Aucune donnée d\'utilisation des compétences', + loadFailed: 'Impossible de charger l\'utilisation des compétences', + otherSkills: 'Autres comp.', + }, + // Journal des modifications changelog: { diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index 5f87a77..2b1aa06 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -79,6 +79,7 @@ export default { memory: 'メモリ', logs: 'ログ', usage: '使用量', + skillsUsage: 'スキル使用状況', channels: 'チャンネル', terminal: 'ターミナル', files: 'ファイル', @@ -758,6 +759,28 @@ export default { noData: '使用データがありません', }, + skillsUsage: { + title: 'スキル使用状況', + subtitle: 'Hermes セッションでのスキル読み込みと編集を追跡します', + refresh: '更新', + periodSelector: 'スキル使用期間', + periodLabel: '{days}日', + summary: '概要', + totalActions: '操作数', + loads: '読み込み', + edits: '編集', + distinctSkills: 'スキル数', + topSkills: '上位', + dailyTrend: '日別', + periodSummary: '過去 {days} 日', + skill: 'スキル', + share: '割合', + lastUsed: '最終', + noData: 'スキル使用データはありません', + loadFailed: 'スキル使用状況の読み込みに失敗しました', + otherSkills: 'その他', + }, + // 更新履歴 changelog: { diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index 9d42596..c7b8092 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -79,6 +79,7 @@ export default { memory: '메모리', logs: '로그', usage: '사용량', + skillsUsage: '스킬 사용량', channels: '채널', terminal: '터미널', files: '파일', @@ -758,6 +759,28 @@ export default { noData: '사용량 데이터 없음', }, + skillsUsage: { + title: '스킬 사용량', + subtitle: 'Hermes 세션의 스킬 로드와 편집을 추적합니다', + refresh: '새로고침', + periodSelector: '스킬 사용량 기간', + periodLabel: '{days}일', + summary: '요약', + totalActions: '작업 수', + loads: '로드', + edits: '편집', + distinctSkills: '스킬 수', + topSkills: '상위', + dailyTrend: '일별', + periodSummary: '최근 {days}일', + skill: '스킬', + share: '비중', + lastUsed: '마지막', + noData: '스킬 사용량 데이터가 없습니다', + loadFailed: '스킬 사용량을 불러오지 못했습니다', + otherSkills: '기타', + }, + // 변경 이력 changelog: { diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index 707e2f6..56e82d7 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -79,6 +79,7 @@ export default { memory: 'Memoria', logs: 'Logs', usage: 'Uso', + skillsUsage: 'Uso de habilidades', channels: 'Canais', terminal: 'Terminal', files: 'Arquivos', @@ -758,6 +759,28 @@ jobTriggered: 'Job acionado', noData: 'Sem dados de uso', }, + skillsUsage: { + title: 'Uso de habilidades', + subtitle: 'Acompanhe carregamentos e edições de habilidades nas sessões Hermes', + refresh: 'Atualizar', + periodSelector: 'Período de uso de habilidades', + periodLabel: '{days} d', + summary: 'Resumo', + totalActions: 'Ações', + loads: 'Carga', + edits: 'Ed.', + distinctSkills: 'Habs.', + topSkills: 'Top habs.', + dailyTrend: 'Tendência diária', + periodSummary: 'Últimos {days} dias', + skill: 'Hab.', + share: '%', + lastUsed: 'Últ. uso', + noData: 'Nenhum dado de uso de habilidades', + loadFailed: 'Falha ao carregar o uso de habilidades', + otherSkills: 'Outras habs.', + }, + // Registro de alteracoes changelog: { diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 9bd0f31..dc11048 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -83,6 +83,7 @@ export default { memory: '記憶', logs: '日誌', usage: '用量', + skillsUsage: '技能用量', channels: '頻道', gateways: '閘道', terminal: '終端機', @@ -986,6 +987,28 @@ export default { noData: '目前無用量資料', }, + skillsUsage: { + title: '技能用量', + subtitle: '追蹤 Hermes 工作階段中的技能載入與編輯', + refresh: '重新整理', + periodSelector: '技能用量期間', + periodLabel: '{days}天', + summary: '總覽', + totalActions: '操作', + loads: '載入', + edits: '編輯', + distinctSkills: '技能數', + topSkills: '熱門', + dailyTrend: '趨勢', + periodSummary: '最近 {days} 天', + skill: '技能', + share: '占比', + lastUsed: '最近', + noData: '暫無技能用量資料', + loadFailed: '技能用量載入失敗', + otherSkills: '其他技能', + }, + // 檔案管理 files: { title: '檔案', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 227624d..cdc5ac2 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -83,6 +83,7 @@ export default { memory: '记忆', logs: '日志', usage: '用量', + skillsUsage: '技能用量', channels: '频道', gateways: '网关', terminal: '终端', @@ -985,6 +986,28 @@ export default { cost: '费用', noData: '暂无用量数据', }, + skillsUsage: { + title: '技能用量', + subtitle: '跟踪 Hermes 会话中的技能加载和编辑', + refresh: '刷新', + periodSelector: '技能用量周期', + periodLabel: '{days}天', + summary: '概览', + totalActions: '操作', + loads: '加载', + edits: '编辑', + distinctSkills: '技能数', + topSkills: '热门', + dailyTrend: '趋势', + periodSummary: '最近 {days} 天', + skill: '技能', + share: '占比', + lastUsed: '最近', + noData: '暂无技能用量数据', + loadFailed: '技能用量加载失败', + otherSkills: '其他技能', + }, + // 文件管理 files: { title: '文件', diff --git a/packages/client/src/router/index.ts b/packages/client/src/router/index.ts index 35f022b..2a312ce 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -50,6 +50,11 @@ const router = createRouter({ name: 'hermes.usage', component: () => import('@/views/hermes/UsageView.vue'), }, + { + path: '/hermes/skills-usage', + name: 'hermes.skillsUsage', + component: () => import('@/views/hermes/SkillsUsageView.vue'), + }, { path: '/hermes/skills', name: 'hermes.skills', diff --git a/packages/client/src/views/hermes/SkillsUsageView.vue b/packages/client/src/views/hermes/SkillsUsageView.vue new file mode 100644 index 0000000..f959928 --- /dev/null +++ b/packages/client/src/views/hermes/SkillsUsageView.vue @@ -0,0 +1,673 @@ + + + + + diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts index c8c9767..f0e4f06 100644 --- a/packages/server/src/controllers/hermes/skills.ts +++ b/packages/server/src/controllers/hermes/skills.ts @@ -6,6 +6,7 @@ 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 { @@ -239,6 +240,18 @@ 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') { diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts index d68c126..bea0385 100644 --- a/packages/server/src/db/hermes/sessions-db.ts +++ b/packages/server/src/db/hermes/sessions-db.ts @@ -783,6 +783,42 @@ 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[] } }, tableName: string, @@ -792,6 +828,197 @@ function tableHasColumn( return columns.some(column => String(column.name || '') === columnName) } +function parseJsonObject(value: unknown): Record | null { + if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record + if (typeof value !== 'string') return null + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : 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): 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 { + 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[] + 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[] + + const skillMap = new Map() + const dayMap = new Map() + const daySkillMap = new Map>() + + 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() + 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), diff --git a/packages/server/src/routes/hermes/skills.ts b/packages/server/src/routes/hermes/skills.ts index 7a1433e..b5c42f7 100644 --- a/packages/server/src/routes/hermes/skills.ts +++ b/packages/server/src/routes/hermes/skills.ts @@ -4,6 +4,7 @@ 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) diff --git a/tests/client/i18n-coverage.test.ts b/tests/client/i18n-coverage.test.ts index 01b0729..19ae096 100644 --- a/tests/client/i18n-coverage.test.ts +++ b/tests/client/i18n-coverage.test.ts @@ -39,13 +39,57 @@ function collectLiteralTranslationKeys(): string[] { return [...keys].sort() } -function hasPath(messages: Record, key: string): boolean { +function getPath(messages: Record, key: string): unknown { let current: unknown = messages for (const part of key.split('.')) { - if (!current || typeof current !== 'object' || !(part in current)) return false + if (!current || typeof current !== 'object' || !(part in current)) return undefined current = (current as Record)[part] } - return typeof current !== 'undefined' + return current +} + +function hasPath(messages: Record, key: string): boolean { + return typeof getPath(messages, key) !== 'undefined' +} + +const SKILLS_USAGE_LOCALIZED_KEYS = [ + 'sidebar.skillsUsage', + 'skillsUsage.title', + 'skillsUsage.subtitle', + 'skillsUsage.refresh', + 'skillsUsage.periodSelector', + 'skillsUsage.periodLabel', + 'skillsUsage.summary', + 'skillsUsage.totalActions', + 'skillsUsage.loads', + 'skillsUsage.edits', + 'skillsUsage.distinctSkills', + 'skillsUsage.topSkills', + 'skillsUsage.dailyTrend', + 'skillsUsage.periodSummary', + 'skillsUsage.skill', + 'skillsUsage.share', + 'skillsUsage.lastUsed', + 'skillsUsage.noData', + 'skillsUsage.loadFailed', + 'skillsUsage.otherSkills', +] + +const SKILLS_USAGE_COMPACT_LABEL_LIMITS: Record = { + 'skillsUsage.totalActions': 12, + 'skillsUsage.loads': 10, + 'skillsUsage.edits': 10, + 'skillsUsage.distinctSkills': 12, + 'skillsUsage.topSkills': 16, + 'skillsUsage.dailyTrend': 16, + 'skillsUsage.skill': 10, + 'skillsUsage.share': 10, + 'skillsUsage.lastUsed': 12, + 'skillsUsage.otherSkills': 16, +} + +function labelLength(value: unknown): number { + return typeof value === 'string' ? Array.from(value.replace(/\{[^}]+\}/g, '')).length : Infinity } describe('i18n locale coverage', () => { @@ -75,6 +119,35 @@ describe('i18n locale coverage', () => { expect(missing).toEqual([]) }) + it('localizes Skills Usage page copy in every non-English locale instead of falling back to English', () => { + const englishMessages = rawMessages.en + const untranslated = Object.entries(rawMessages).flatMap(([locale, localeMessages]) => { + if (locale === 'en') return [] + + return SKILLS_USAGE_LOCALIZED_KEYS.flatMap((key) => { + const localeValue = getPath(localeMessages, key) + if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`] + return localeValue === getPath(englishMessages, key) ? [`${locale}: ${key}`] : [] + }) + }) + + expect(untranslated).toEqual([]) + }) + + + it('keeps Skills Usage summary and table labels compact across locales', () => { + const oversized = Object.entries(rawMessages).flatMap(([locale, localeMessages]) => + Object.entries(SKILLS_USAGE_COMPACT_LABEL_LIMITS).flatMap(([key, maxLength]) => { + const localeValue = getPath(localeMessages, key) + return labelLength(localeValue) > maxLength + ? [`${locale}: ${key} (${labelLength(localeValue)} > ${maxLength})`] + : [] + }), + ) + + expect(oversized).toEqual([]) + }) + it('keeps the coverage scanner rooted in client source files', () => { expect(relative(process.cwd(), SOURCE_ROOT)).toBe(join('packages', 'client', 'src')) }) diff --git a/tests/client/skills-usage-view.test.ts b/tests/client/skills-usage-view.test.ts new file mode 100644 index 0000000..5481b4d --- /dev/null +++ b/tests/client/skills-usage-view.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +const fetchSkillUsageStatsMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/api/hermes/skills', () => ({ + fetchSkillUsageStats: fetchSkillUsageStatsMock, +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + if (key === 'skillsUsage.periodLabel') return `${params?.days}d` + if (key === 'skillsUsage.periodSummary') return `Last ${params?.days} days` + return key + }, + }), +})) + +vi.mock('naive-ui', async () => { + const actual = await vi.importActual('naive-ui') + return { + ...actual, + NButton: { + props: ['loading', 'type', 'size', 'quaternary', 'secondary'], + inheritAttrs: false, + template: '', + }, + } +}) + +import SkillsUsageView from '@/views/hermes/SkillsUsageView.vue' + +const sevenDayStats = { + period_days: 7, + summary: { + total_skill_loads: 3, + total_skill_edits: 1, + total_skill_actions: 4, + distinct_skills_used: 2, + }, + by_day: [ + { + date: '2026-05-10', + view_count: 1, + manage_count: 0, + total_count: 1, + skills: [ + { skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 }, + ], + }, + { + date: '2026-05-11', + view_count: 2, + manage_count: 1, + total_count: 3, + skills: [ + { skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 }, + ], + }, + ], + top_skills: [ + { skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3, percentage: 75, last_used_at: 1_700_000_000 }, + { skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1, percentage: 25, last_used_at: null }, + ], +} + +describe('SkillsUsageView', () => { + beforeEach(() => { + fetchSkillUsageStatsMock.mockReset() + fetchSkillUsageStatsMock.mockResolvedValue(sevenDayStats) + }) + + it('loads rolling 7 day skill usage and renders statistics beside a skill-colored visual trend', async () => { + const wrapper = mount(SkillsUsageView) + await flushPromises() + + expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(7) + expect(wrapper.text()).toContain('skillsUsage.title') + expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true) + expect(wrapper.findAll('.skill-bar-col')).toHaveLength(2) + expect(wrapper.findAll('.skill-bar-segment[data-skill="hermes-agent"]')).toHaveLength(1) + expect(wrapper.findAll('.skill-bar-segment[data-skill="github-pr-workflow"]')).toHaveLength(1) + expect(wrapper.find('[data-testid="skills-usage-legend"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="skills-usage-stats"]').text()).toContain('4') + expect(wrapper.text()).toContain('hermes-agent') + expect(wrapper.text()).toContain('github-pr-workflow') + expect(wrapper.text()).toContain('75.0%') + }) + + it('reloads the selected period when the period button changes', async () => { + const wrapper = mount(SkillsUsageView) + await flushPromises() + fetchSkillUsageStatsMock.mockClear() + + const thirtyDayButton = wrapper.findAll('button').find(button => button.text() === '30d') + expect(thirtyDayButton).toBeTruthy() + + await thirtyDayButton!.trigger('click') + await flushPromises() + + expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(30) + expect(thirtyDayButton!.attributes('aria-pressed')).toBe('true') + }) + + it('flips the chart tooltip away from the hovered side of the bars', async () => { + const wrapper = mount(SkillsUsageView) + await flushPromises() + + const bars = wrapper.findAll('.skill-bar-col') + expect(bars).toHaveLength(2) + + await bars[1].trigger('mouseenter') + expect(wrapper.find('.floating-tooltip.align-left').exists()).toBe(true) + expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-11') + + await bars[0].trigger('mouseenter') + expect(wrapper.find('.floating-tooltip.align-right').exists()).toBe(true) + expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-10') + }) + + it('keeps stale data visible while refreshing an already loaded period', async () => { + const wrapper = mount(SkillsUsageView) + await flushPromises() + + let resolveRefresh!: (value: unknown) => void + fetchSkillUsageStatsMock.mockReturnValueOnce(new Promise(resolve => { + resolveRefresh = resolve + })) + + const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh') + expect(refreshButton).toBeTruthy() + + await refreshButton!.trigger('click') + + expect(fetchSkillUsageStatsMock).toHaveBeenCalledTimes(2) + expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true) + expect(wrapper.find('.usage-panel.is-refreshing').exists()).toBe(true) + + resolveRefresh({ + period_days: 7, + summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 }, + by_day: [ + { + date: '2026-05-12', + view_count: 1, + manage_count: 0, + total_count: 1, + skills: [{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1 }], + }, + ], + top_skills: [ + { skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null }, + ], + }) + await flushPromises() + + expect(wrapper.text()).toContain('test-driven-development') + }) + + it('does not let an older refresh overwrite newer stats for the same period', async () => { + const wrapper = mount(SkillsUsageView) + await flushPromises() + + let resolveOlder!: (value: unknown) => void + let resolveNewer!: (value: unknown) => void + fetchSkillUsageStatsMock + .mockReturnValueOnce(new Promise(resolve => { resolveOlder = resolve })) + .mockReturnValueOnce(new Promise(resolve => { resolveNewer = resolve })) + + const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh') + expect(refreshButton).toBeTruthy() + + await refreshButton!.trigger('click') + await refreshButton!.trigger('click') + + resolveNewer({ + period_days: 7, + summary: { total_skill_loads: 2, total_skill_edits: 0, total_skill_actions: 2, distinct_skills_used: 1 }, + by_day: [ + { + date: '2026-05-13', + view_count: 2, + manage_count: 0, + total_count: 2, + skills: [{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2 }], + }, + ], + top_skills: [ + { skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2, percentage: 100, last_used_at: null }, + ], + }) + await flushPromises() + + expect(wrapper.text()).toContain('newer-skill') + + resolveOlder({ + period_days: 7, + summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 }, + by_day: [ + { + date: '2026-05-12', + view_count: 1, + manage_count: 0, + total_count: 1, + skills: [{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1 }], + }, + ], + top_skills: [ + { skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null }, + ], + }) + await flushPromises() + + expect(wrapper.text()).toContain('newer-skill') + expect(wrapper.text()).not.toContain('older-skill') + }) +}) diff --git a/tests/server/skill-usage-stats-db.test.ts b/tests/server/skill-usage-stats-db.test.ts new file mode 100644 index 0000000..3ba78ef --- /dev/null +++ b/tests/server/skill-usage-stats-db.test.ts @@ -0,0 +1,192 @@ +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, + }, + ], + }) + }) +})