From 91de3b12a1a25d1ddacbbbacd80f473d332ee082 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Wed, 13 May 2026 07:51:29 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"feat:=20=E6=96=B0=E5=A2=9E=20Skills?= =?UTF-8?q?=20Usage=20=E7=9B=91=E6=8E=A7=E7=BB=9F=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=20(#668)"=20(#670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ce08d2b05ac84fdf7e1f19a4a3e6bb2707a5991c. --- 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, 3 insertions(+), 1662 deletions(-) delete mode 100644 packages/client/src/views/hermes/SkillsUsageView.vue delete mode 100644 tests/client/skills-usage-view.test.ts delete 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 7d539b1..2543d8d 100644 --- a/packages/client/src/api/hermes/skills.ts +++ b/packages/client/src/api/hermes/skills.ts @@ -45,52 +45,11 @@ 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 4ce19d7..041e72f 100644 --- a/packages/client/src/components/layout/AppSidebar.vue +++ b/packages/client/src/components/layout/AppSidebar.vue @@ -220,14 +220,6 @@ function openChangelog() { {{ t("sidebar.usage") }} - diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 89c1540..04895cc 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -79,7 +79,6 @@ export default { memory: 'Gedachtnis', logs: 'Protokolle', usage: 'Nutzung', - skillsUsage: 'Skill-Nutzung', channels: 'Kanale', terminal: 'Terminal', files: 'Dateien', @@ -763,28 +762,6 @@ 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 4ceb543..e895170 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -83,7 +83,6 @@ export default { memory: 'Memory', logs: 'Logs', usage: 'Usage', - skillsUsage: 'Skills Usage', channels: 'Channels', gateways: 'Gateways', terminal: 'Terminal', @@ -984,28 +983,6 @@ 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 9d419dc..90e1b43 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -79,7 +79,6 @@ export default { memory: 'Memoria', logs: 'Registros', usage: 'Uso', - skillsUsage: 'Uso de habilidades', channels: 'Canales', terminal: 'Terminal', files: 'Archivos', @@ -759,28 +758,6 @@ 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 bf2da42..90b291c 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -79,7 +79,6 @@ export default { memory: 'Memoire', logs: 'Journaux', usage: 'Utilisation', - skillsUsage: 'Utilisation des compétences', channels: 'Canaux', terminal: 'Terminal', files: 'Fichiers', @@ -759,28 +758,6 @@ 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 2b1aa06..5f87a77 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -79,7 +79,6 @@ export default { memory: 'メモリ', logs: 'ログ', usage: '使用量', - skillsUsage: 'スキル使用状況', channels: 'チャンネル', terminal: 'ターミナル', files: 'ファイル', @@ -759,28 +758,6 @@ 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 c7b8092..9d42596 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -79,7 +79,6 @@ export default { memory: '메모리', logs: '로그', usage: '사용량', - skillsUsage: '스킬 사용량', channels: '채널', terminal: '터미널', files: '파일', @@ -759,28 +758,6 @@ 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 56e82d7..707e2f6 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -79,7 +79,6 @@ export default { memory: 'Memoria', logs: 'Logs', usage: 'Uso', - skillsUsage: 'Uso de habilidades', channels: 'Canais', terminal: 'Terminal', files: 'Arquivos', @@ -759,28 +758,6 @@ 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 dc11048..9bd0f31 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -83,7 +83,6 @@ export default { memory: '記憶', logs: '日誌', usage: '用量', - skillsUsage: '技能用量', channels: '頻道', gateways: '閘道', terminal: '終端機', @@ -987,28 +986,6 @@ 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 cdc5ac2..227624d 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -83,7 +83,6 @@ export default { memory: '记忆', logs: '日志', usage: '用量', - skillsUsage: '技能用量', channels: '频道', gateways: '网关', terminal: '终端', @@ -986,28 +985,6 @@ 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 2a312ce..35f022b 100644 --- a/packages/client/src/router/index.ts +++ b/packages/client/src/router/index.ts @@ -50,11 +50,6 @@ 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 deleted file mode 100644 index f959928..0000000 --- a/packages/client/src/views/hermes/SkillsUsageView.vue +++ /dev/null @@ -1,673 +0,0 @@ - - - - - diff --git a/packages/server/src/controllers/hermes/skills.ts b/packages/server/src/controllers/hermes/skills.ts index f0e4f06..c8c9767 100644 --- a/packages/server/src/controllers/hermes/skills.ts +++ b/packages/server/src/controllers/hermes/skills.ts @@ -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 { @@ -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') { diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts index bea0385..d68c126 100644 --- a/packages/server/src/db/hermes/sessions-db.ts +++ b/packages/server/src/db/hermes/sessions-db.ts @@ -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[] } }, tableName: string, @@ -828,197 +792,6 @@ 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 b5c42f7..7a1433e 100644 --- a/packages/server/src/routes/hermes/skills.ts +++ b/packages/server/src/routes/hermes/skills.ts @@ -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) diff --git a/tests/client/i18n-coverage.test.ts b/tests/client/i18n-coverage.test.ts index 19ae096..01b0729 100644 --- a/tests/client/i18n-coverage.test.ts +++ b/tests/client/i18n-coverage.test.ts @@ -39,57 +39,13 @@ function collectLiteralTranslationKeys(): string[] { return [...keys].sort() } -function getPath(messages: Record, key: string): unknown { +function hasPath(messages: Record, key: string): boolean { let current: unknown = messages for (const part of key.split('.')) { - if (!current || typeof current !== 'object' || !(part in current)) return undefined + if (!current || typeof current !== 'object' || !(part in current)) return false current = (current as Record)[part] } - 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 + return typeof current !== 'undefined' } describe('i18n locale coverage', () => { @@ -119,35 +75,6 @@ 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 deleted file mode 100644 index 5481b4d..0000000 --- a/tests/client/skills-usage-view.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -// @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 deleted file mode 100644 index 3ba78ef..0000000 --- a/tests/server/skill-usage-stats-db.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -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, - }, - ], - }) - }) -})